一、先聊聊函数里的"临时工"状态

很多人刚开始接触Serverless的时候,都会发现一个反直觉的现象:明明我在代码里写了变量,为什么下一次调用就不见了?其实这不怪你,也不怪代码,而是Serverless的设计就是这样。它不像传统服务器,一个进程一直在后台跑着,你想放什么就放什么。Serverless函数是一个"用完即走"的临时工,没活干的时候,平台就把它的工位收回去,省电省内存。等新的请求来了,再重新开一个工位。工位都没了,你之前写在白板上的数据自然也保不住。

1.1 一个最典型的翻车现场

我见过很多新手写过这样一个计数器函数,看起来像这样:

// 技术栈:Node.js
let count = 0; // 一个全局变量,想着每次调用加1

exports.handler = async (event) => {
  count += 1;
  return { count };
};

这段代码在本地跑,每次调用count都会累加。但是放到Serverless上,你会发现它跟个失忆症患者一样。第一次调用返回1,第二次可能还是1,甚至变成0。原因就是:你的函数可能被多个实例同时运行,每个实例里的count是独立的一份。更惨的是,实例闲置几秒钟就被回收了,等下一次请求,平台又拉起一个新实例,新实例里的count永远从0开始。所以,千万别在函数代码里保存"有记忆"的东西。

1.2 内存不可靠,那磁盘呢?

有些同学灵机一动,我不写内存,我写到本地文件里总行了吧?很遗憾,也不行。Serverless给函数挂载的磁盘是一个临时目录,生命周期和实例一样。实例没了,目录也没了。而且多个实例之间根本看不到对方的文件,你在这个实例写了一个文件,另一个实例根本读不到。所以,本地文件只适合放一些临时的、可再生的数据,比如缓存一个模型文件,而不是你业务上非要不可的数据。

说白了,函数本身就是一个"无状态的计算单元",它的身体里只能放"计算逻辑",不能放"业务状态"。那业务状态放哪?答案就是下面要说的——外部存储。

其实平台这么做也是迫不得已。为了省钱,它必须把闲置资源租给其他人用。要是每个函数都占用一部分内存不释放,平台就没法高效调度。所以你理解了这一点,就不会再抱怨Serverless限制多,而是顺着它设计。

二、外部存储:绕不开的救兵

既然函数肚子里存不了,那我们就把它搬到外面去。外部存储就好比一个公共储物柜,任何实例任何时候都能来存取东西。但储物柜也分很多种,你得根据你要装的东西选对柜子,否则不是放不下,就是拿不出来。

2.1 先想清楚你要存什么

在选型之前,先回答自己几个问题:

  • 这份数据是给谁看的?用户?还是别的函数?
  • 需要多快读出来?毫秒级还是秒级?
  • 会不会被多个函数同时改?改乱了怎么办?
  • 能不能接受数据丢失?丢多少没关系?

这些问题背后,对应的是数据的"一致性"、"持久性"和"访问模式"。

比如你存一个购物车,丢了用户可能骂你;但存一个访问日志,丢几条也没关系。这种不同决定了你要不要为它付出更多成本。

2.2 常见外部存储的优缺点

我拿最常见的三类存储来聊聊。

第一类:Redis。它是一个住在内存里的数据库,读写特别快,通常用来存临时状态、缓存、会话、计数器。它有很多数据结构,比如字符串、哈希、列表。但它也有弱点:数据量太大时,内存会爆,持久化能力也比不上磁盘型数据库。所以Redis适合存"丢了还能重建"的数据,不适合存"唯一的账单记录"。

第二类:关系型数据库(比如MySQL、PostgreSQL)。它擅长存结构化数据,支持事务,能保证数据的完整性。比如订单、用户信息、余额这些,丢不起也错不得的数据,放在这里最稳。但它的扩展性要费点心思,高并发下容易成为瓶颈,需要读写分离、分库分表等操作。

第三类:对象存储(比如AWS S3、阿里云OSS)。它专门存文件,比如图片、视频、备份。上传下载方便,成本低,而且无限容量。但它不适合频繁更新一个文件里的某个字段,因为每次更新都是整文件覆盖,不仅慢还浪费钱。

下面我用一个表格对比一下,大家看着更清楚:

存储类型 典型代表 擅长的事 不擅长的事
Redis Redis云服务 缓存、临时状态、计数器、排行榜 大文件、强事务、长期持久化
关系型数据库 MySQL、PostgreSQL 交易、订单、用户资料 海量流量、大对象存储
对象存储 S3、OSS 图片、视频、日志归档 高频改动、复杂查询

2.3 一个完整的购物车例子

说了半天,我们来点实际的。假设你要做购物车,用户往里面加商品,随时能查。购物车的特点是:读取频繁、更新频繁、数据量不大、丢了也不至于倾家荡产(顶多重新加一遍)。这种场景用Redis最合适。

我写一段Node.js代码,用Redis来存购物车:

// 技术栈:Node.js + Redis(ioredis)
const Redis = require('ioredis');

// 创建Redis连接,建议放到全局,不要让每个请求都新建连接
const redis = new Redis({
  host: 'your-redis-host',   // Redis地址
  port: 6379,                // 端口
  password: 'your-password', // 密码,如果没有可以去掉
  // 断线自动重试,避免网络抖动导致连接失效
  retryStrategy(times) {
    return Math.min(times * 50, 2000); // 重试间隔,从50ms开始,最大2秒
  }
});

// 云函数入口
exports.handler = async (event) => {
  const userId = event.userId;    // 用户ID
  const itemId = event.itemId;    // 商品ID
  const quantity = Number(event.quantity || 1); // 加购数量,默认1

  // 参数校验
  if (!userId || !itemId) {
    return { code: 400, msg: '缺少userId或itemId' };
  }

  try {
    // 用Redis的哈希结构存储购物车,key是 cart:{userId}
    // hincrby 表示对哈希中的某个字段做加法,如果字段不存在会先创建
    await redis.hincrby(`cart:${userId}`, itemId, quantity);

    // 读取整个购物车
    const cart = await redis.hgetall(`cart:${userId}`);

    // hgetall返回的是对象,值是字符串,这里转换成数组
    const items = Object.entries(cart).map(([id, qty]) => ({
      itemId: id,
      quantity: Number(qty)   // 把字符串转成数字
    }));

    return { code: 200, data: items };
  } catch (err) {
    // 记录错误日志,方便排查问题
    console.error('购物车操作失败:', err);
    return { code: 500, msg: '服务内部错误' };
  }
};

这段代码里用到的hincrby是Redis的原子操作,两个请求同时加同一件商品,结果也是对的,不会出现你加一件我也加一件,最后只加了一件的情况。而且这个函数本身是无状态的,它唯一的依赖就是外部的Redis,所以实例随便怎么扩容都行。

三、无状态思维的转换:把状态踢出函数

有了外部存储,看起来问题解决了。但你要注意,外部存储只是工具,真正的核心是转变你的思维方式。如果你还是老想着"我能不能在函数里缓存一下",那就很容易写出各种奇奇怪怪的问题。

3.1 到底什么是"无状态"?

很多人一听"无状态"就以为是"没有状态",其实不是。状态依然存在,只是不在函数里。函数就像一条流水线上的工人,他不需要记住上一个零件长什么样,因为零件和图纸都是从一个公共管道送过来的。他只需要干完手上的活,交给下一个人就行。这样干的好处是:只要工人能干活,来多少工人都不怕,因为大家共享同一套资料,谁干都一样。

3.2 用事件驱动来串联业务

举个实际场景:用户上传一个视频,你需要转码成多个清晰度,然后通知用户。如果这一切都在一个函数里做,可能要好几分钟,函数早就超时了。正确做法是把流程拆成两个函数:第一个函数只负责接收上传并往队列里发一条消息,第二个函数监听队列里的消息,然后去转码。这样每个函数都干一件简单的事,谁也不依赖谁。

我写一段示意代码:

// 技术栈:Node.js(示例:结合AWS SQS)
// 第一个函数:上传完成后,把文件信息发送到队列
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({ region: 'us-east-1' }); // 创建SQS客户端

exports.uploadHandler = async (event) => {
  // 假设文件已经上传到对象存储,这里拿到文件路径
  const message = {
    fileKey: event.fileKey,   // 文件在对象存储中的key
    userId: event.userId      // 用户ID
  };

  // 把消息发送到队列
  await sqs.send(new SendMessageCommand({
    QueueUrl: 'https://sqs.us-east-1.amazonaws.com/1234567890/my-queue',
    MessageBody: JSON.stringify(message) // 消息体需要是字符串
  }));

  return { code: 200, msg: '上传成功,准备转码' };
};

// 第二个函数:从队列事件中获取消息并处理
exports.processHandler = async (event) => {
  // SQS触发函数时,消息在event.Records中
  for (const record of event.Records) {
    const msg = JSON.parse(record.body);  // 解析消息
    console.log(`开始转码: ${msg.fileKey}`);
    // 这里执行转码逻辑,转码结果可以存到对象存储或数据库
  }
};

这里面,函数A和函数B的唯一联系就是队列。函数A不关心函数B什么时候跑,函数B也不关心函数A是谁。状态(文件路径、用户ID)都放在消息里,谁处理都一样。这就是无状态思维。

这种模式也被称为事件驱动架构。它不只是为了用队列而用队列,而是为了让每个函数都能独立伸缩。想象一下,如果两个函数各自需要不同的资源,绑在一起就会互相拖累。

3.3 状态放在数据里,而不是放在代码里

你可能听说过一句话:代码是死的,数据是活的。在Serverless里尤其明显。你的函数代码不应该包含任何"业务状态",比如当前用户是谁、余额多少、文件处理到哪一步了。这些都应该作为输入参数或者从外部存储读取。函数就像一个纯函数:同样的输入,永远得到同样的输出。这样测起来也方便,部署起来也放心。

四、实际踩坑和注意事项

思想转变了,但动手写的时候还是有很多坑。我把最常见的几个列出来,你最好拿小本本记一下。

4.1 不要在函数里频繁创建数据库连接

这是新手最容易犯的错。很多人写代码,习惯在函数内部创建一个连接,用完再关。在传统服务里没问题,但Serverless的实例会被频繁拉起,如果你每次请求都new一个连接,数据库连接数会蹭蹭往上蹿,最后直接把数据库拖垮。

正确的做法是在全局初始化连接,让同一个实例的所有请求复用这个连接。我来写个对比:

// 技术栈:Node.js + mysql2

// 错误示范:每次请求都创建新连接
const mysql = require('mysql2/promise');

exports.badHandler = async (event) => {
  // 每次调用都新建连接,高并发下会炸掉
  const conn = await mysql.createConnection({
    host: 'db-host',
    user: 'root',
    password: 'secret',
    database: 'app'
  });
  const [rows] = await conn.query('SELECT * FROM users WHERE id = ?', [event.id]);
  await conn.end(); // 用完就关
  return rows;
};

// 正确示范:把连接池放到全局
const pool = mysql.createPool({
  host: 'db-host',
  user: 'root',
  password: 'secret',
  database: 'app',
  connectionLimit: 10 // 连接池最大连接数,别设太大
});

exports.goodHandler = async (event) => {
  // 从全局连接池拿连接,查询完毕后连接会自动回到池中
  const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [event.id]);
  return rows;
};

注意,连接池的大小要和函数并发数匹配。如果并发很高,连接池设太小会导致排队;设太大又会拖垮数据库。这个需要压测来调。

4.2 冷启动时,别干重活

函数冷启动本身要花时间,如果再在初始化阶段去连接各种外部服务,那第一次请求就会特别慢。所以尽量把初始化工作放到全局,并且保持轻量。还有,不要在初始化阶段去加载超大模型或做重量级计算,否则你的首呼延迟会飙到几秒。

4.3 幂等性:重复请求不是Bug,不处理就是Bug

Serverless平台为了保证消息到达,可能会重试同一个请求。比如一个支付回调函数,平台网络超时后会再调一次。如果你在函数里直接扣款,用户就会被扣两次。解决办法是给数据做一个"已处理"标记,用外部存储的原子操作来保证只处理一次。

我用Redis的setnx来做这个标记:

// 技术栈:Node.js + Redis(ioredis)
const Redis = require('ioredis');
const redis = new Redis();

exports.handler = async (event) => {
  const orderId = event.orderId; // 订单号

  // 尝试设置一个标记,如果之前已经存在,说明这个订单已经处理过了
  // NX表示只有key不存在时才设置成功,EX表示过期时间
  const done = await redis.set(`processed:${orderId}`, '1', 'EX', 3600, 'NX');

  if (done !== 'OK') {
    // 已经有标记,说明是重复请求,直接返回成功
    return { code: 200, msg: '重复请求,已忽略' };
  }

  // 下面是真正的业务逻辑:比如扣款、发货
  await processPayment(orderId); // 假设这个函数实现扣款

  return { code: 200, msg: '处理成功' };
};

注意,这个标记要在业务逻辑之前设置,但业务逻辑失败时需要把标记删除,否则下一次调用会被当成"已处理"而跳过。这个细节要记牢。

4.4 分布式锁:解决并发打架

如果两个函数实例同时去修改同一个资源,比如扣减库存,就可能出现超卖。这时候需要一把"分布式锁"来保证同一时间只有一个实例在操作。Redis的setnx同样可以当锁用。

// 技术栈:Node.js + Redis(ioredis)
const Redis = require('ioredis');
const redis = new Redis();

// 获取锁:只有key不存在时才能设置成功
async function acquireLock(lockKey, ownerId, ttlSeconds = 10) {
  const result = await redis.set(lockKey, ownerId, 'EX', ttlSeconds, 'NX');
  return result === 'OK';
}

// 释放锁:用Lua脚本保证原子性,只有持有者才能释放
async function releaseLock(lockKey, ownerId) {
  const script = `
    if redis.call('get', KEYS[1]) == ARGV[1] then
      return redis.call('del', KEYS[1])
    else
      return 0
    end
  `;
  return await redis.eval(script, 1, lockKey, ownerId);
}

// 示例:扣减库存
exports.handler = async (event) => {
  const skuId = event.skuId; // 商品SKU ID
  const lockKey = `lock:stock:${skuId}`; // 锁的key,每个SKU一把锁
  const ownerId = `${process.env.AWS_LAMBDA_FUNCTION_NAME}-${Date.now()}-${Math.random()}`; // 当前实例的唯一标识

  // 尝试加锁
  if (!(await acquireLock(lockKey, ownerId))) {
    // 没拿到锁,说明有其他实例在操作
    return { code: 400, msg: '操作太频繁,请稍后再试' };
  }

  try {
    // 拿到了锁,开始业务逻辑
    // 比如:读取库存 -> 判断是否大于0 -> 减1 -> 写回
    // ...
    return { code: 200, msg: '扣减成功' };
  } finally {
    // 无论成功失败,都要释放锁
    await releaseLock(lockKey, ownerId);
  }
};

注意锁的过期时间要比业务执行时间长,否则业务还没执行完锁就过期了,别人就能进来,还是会造成并发问题。设短了不行,设长了又会影响性能,需要根据实际业务耗时来设置。

五、应用场景归纳

聊了这么多,我们来整理一下:哪些场景适合用外部存储,哪些场景应该绕道走。

5.1 适合用外部存储的场景

  • 用户会话信息:登录状态、购物车、临时偏好。这些数据要跨请求共享,而且变化快,用Redis很合适。
  • 热点数据缓存:比如商品详情、配置项、排行榜。数据库扛不住高并发,把热点数据缓存到Redis,能大大减轻压力。
  • 海量文件:图片、视频、备份,放对象存储最安全,成本低,还不用担心容量。
  • 跨函数协作:比如一个上传流程,用队列或数据库传递中间状态,函数之间解耦。

5.2 不适合用外部存储的场景

  • 数据量极大且访问极频繁:每次函数调用都去访问外部存储,网络IO的开销不可忽视。如果数据是GB级别,最好用专门的缓存或数据库,而不是让函数直接怼。
  • 强事务场景:跨多个外部存储的更新很难做到原子性,比如用户下订单需要同时扣库存和生成订单,用两个存储可能出问题。这种场景还是用一个支持事务的关系型数据库更稳。
  • 纯原型验证:只是本地跑着玩,或者写个一次性脚本,可以不用外部存储,直接在函数里存变量。但你要明白,投产前必须改。

六、技术优缺点总结

外部存储的优点很明显:数据不随函数实例消失,多个函数可以共享,而且可以按需扩容。缺点也很直接:每一次读写都多了一次网络调用,延迟比内存高一个数量级;还要处理连接、权限、费用,运维复杂度上来了。

无状态思维的优点,是让函数变得简单、纯粹,可以尽情地横向扩展,不用考虑数据迁移和状态同步。缺点是需要你改变习惯,不能随手把东西写在全局变量里,很多逻辑要拆成更小的单元,代码会显得更"啰嗦"。

但你要知道,这个"啰嗦"是值得的。它换来了系统的弹性和稳定性。当你的服务突然被流量冲进来,外部存储帮你扛住数据,无状态函数帮你扛住计算,两者配合,才能让Serverless真正发挥威力。

七、写在最后

状态持久化这个事在Serverless里确实是个坎儿,但绕过去以后,你会发现前面是一片开阔地。思路其实就是两句话:别把状态放到函数里,让状态住在外部存储里;把函数当做一个"来一件干一件"的机器人,而不是一个"记仇"的老人。希望这篇文章能帮你少踩几个坑,让你的Serverless之路走得顺一点。