很多人第一次接触云函数,都会觉得它挺神奇。你不用买服务器,不用处理半夜的磁盘告警,只需要把一段代码扔上去,配一个触发条件,剩下的好像都交给云厂商了。尤其是当系统里有大量零零碎碎的事件冒出来时,云函数这种按次计费、自动扩缩容的玩法,听着确实是天作之合。但实际用起来,麻烦事也不少。今天咱们就聊聊在事件驱动的场景里,把业务跑在无服务器服务上会遇到哪些坑,又是怎么慢慢填上的。

一、事件驱动场景里,Serverless 到底解决什么问题

1.1 传统模式下的事件处理有多别扭

传统做法往往是一个常驻进程,或者一台专门跑定时任务的机器。比如用户上传图片之后,你希望生成一张缩略图。如果靠定时任务扫描,可能五分钟才扫一次,用户传完图要等好一阵子才能看到效果。如果靠一个消息队列加消费者,你需要自己部署消费者程序,还得盯着消费者挂了没有、队列积压多不多。这些活儿听起来都不难,但一旦公司规模变大,各种服务之间都要互相通知,麻烦就成倍增加。

1.2 Serverless 带来的改变

用了云函数这类无服务器计算之后,你可以把每个事件单独对应成一个函数。图片一上传,对象存储自动发出一个事件,函数被拉起来,生成缩略图,然后自己消失。没有常驻进程,也没有需要手动管理的中间件集群。关键是,它在没人调用的时候不收费,消息突然增多的时候又能自动横向扩容,这种灵敏的身手,让事件驱动架构一下子变得亲民了很多。

二、实际应用里的常见痛点

2.1 冷启动延迟

函数长时间没被触发,云平台会把实例回收。下一事件到来时,平台需要重新下载代码、启动运行时、初始化容器,这个过程就是冷启动。对线上用户来说,可能就是一次额外的几百毫秒甚至一两秒的延迟。如果函数里还连了数据库、加载了机器学习模型,那冷启动会更明显。

2.2 事件重复送达

很多消息系统为了不丢数据,承诺的是“至少一次”。这意味着同一个事件可能被投递两遍。好比快递员把一个包裹送到你家门口,敲了两次门。如果你的业务逻辑不是“幂等”的,比如处理一次就扣一次钱,那第二遍会被活活扣出双倍账单。

2.3 失败重试与消息丢失

函数处理事件时,可能因为下游服务抖动、代码出 Bug 等原因抛错。云平台默认会重试几次。这本来是好意,但重试的间隔和次数可能是固定的,有时候下游已经出故障了,重试只会添乱。如果重试完了还是不行,消息就有可能被悄悄丢掉。对很多需要可靠处理的业务来说,这就是大问题。

2.4 并发控制与资源限制

云函数虽然能自动扩容,但扩容不是无限度的。如果你的事件流量太高,函数实例可能瞬间开出一大堆,把数据库连接数打满,或者把下游 API 打得喘不过气。另一方面,函数的内存、CPU、磁盘大小都有限制,用惯了高配服务器的人,会觉得这日子过得太紧巴。

2.5 调试与本地开发困难

本地开发时,你很难完整模拟云端的事件源。对象存储刚放一个对象进来,消息队列刚投递一条消息,这些真实环境里的“手感”,本地并不方便复现。出了错也只能去翻日志,没有断点,没有单步执行,排查一个由不同事件拼在一起的连锁问题,简直像在迷宫里找出口。

2.6 供应商锁定

每个云厂商的实现细节都不一样。在 AWS 上好用的触发器,搬到别家可能就得改函数入口、改事件格式、改权限配置。你的业务代码里如果到处直接调用了厂商的 SDK,那将来想换一家云,几乎等于重写一遍。

三、解决痛点的具体思路和示例

说明:下面所有代码统一使用 Node.js 18 与 AWS Lambda,配合 DynamoDB 和 SQS 作为辅助服务。如果读者用的是别的运行时,思路是一样的,代码照猫画虎改一改就行。

3.1 冷启动:留温度、减重量

对付冷启动有两个常用办法。一个是从平台层面申请“预留并发”,让一定数量的函数实例一直热着;另一个是把函数包尽量做薄,减少启动时要加载的内容。比如把不必要的依赖从代码里剔出去,或者把初始化逻辑放到全局变量里,让实例在生命周期内只做一次。

下面这个 Node.js 函数演示了一种让数据库连接“热着”的写法。技术栈:Node.js 18 + AWS Lambda。

// 这个模块在函数实例启动时只会加载一次
const AWS = require('aws-sdk');

// 把连接池放在函数外面,这样实例没有被回收时,连接可以复用
let dbClient = null;

async function getDbClient() {
  if (dbClient) {
    return dbClient; // 已经初始化过了,直接返回
  }
  // 冷启动时才会走到这里,同时避免每次都重新建连接
  dbClient = new AWS.DynamoDB.DocumentClient();
  console.log('数据库客户端已初始化');
  return dbClient;
}

// 事件处理入口
exports.handler = async (event) => {
  const client = await getDbClient();
  // 用这个客户端去操作数据,这里就不写业务了
  return { status: 'ok' };
};

这里要注意,函数实例在一段时间没有事件后会被回收,连接池跟着一起消失,所以做不到完全消除冷启动,只能尽量降低冷启动带来的影响。预留并发需要花钱买“温暖”,但换来的是更稳定的延迟,交易是否合算,得看你的业务对延迟有多敏感。

3.2 幂等:给每次事件一个独一无二的“指纹”

处理重复事件,最简单的方式是给事件一个唯一 ID,然后把这个 ID 作为主键写到数据库里。如果之前已经处理过,写入就会失败,这时直接返回成功,不给下游造成二次影响。这个思路在很多场景下都够用。

比如电商订单支付的回调事件里,每个事件都有一个 orderId 和 paymentId。下面这个示例用 DynamoDB 做去重。技术栈:Node.js 18 + AWS Lambda + DynamoDB。

const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();

exports.handler = async (event) => {
  // 假设 event 已经是被云服务解析过的对象
  const { orderId, paymentId } = event;

  // 幂等主键:从真实业务里选一个不会重复的字段
  const id = `order_${orderId}_pay_${paymentId}`;

  const putParams = {
    TableName: 'order_payment_events',
    Item: {
      eventId: id,
      createdAt: new Date().toISOString(),
      processed: true,
      // 把原始事件也存下来,方便以后排查问题
      rawEvent: JSON.stringify(event)
    },
    // 如果这个 id 已经存在,下面的条件表达式会失败
    ConditionExpression: 'attribute_not_exists(eventId)'
  };

  try {
    await dynamodb.put(putParams).promise();
    // 第一次处理,真正执行业务逻辑,比如更新订单状态
    console.log(`开始处理订单 ${orderId} 的支付...`);
    return { status: 'processed' };
  } catch (error) {
    // 如果是条件表达式失败,说明之前已经处理过
    if (error.code === 'ConditionalCheckFailedException') {
      console.log(`重复事件 ${id},直接忽略`);
      return { status: 'duplicate' };
    }
    // 其他错误继续往外抛,让 Lambda 自动重试
    throw error;
  }
};

这段代码的关键在 ConditionExpression。数据库里一旦有了 eventId 相同的记录,下一次 put 就会失败,我们便知道这是一次重复投递。注意,put 的幂等逻辑要和真正的业务逻辑放在同一个事务里才是完全安全的,否则可能这边刚刚查完没有,那边事件又到了,两边都开始处理。对于要求极高的场景,可以把写记录和改状态放到事务里去。

3.3 失败:用死信队列兜底

当函数反复处理不了一个事件,云平台默认的重试机制往往会让你很难受。你可以打开“死信队列”功能,把超过重试次数也没处理掉的消息自动塞进一个专门的队列里,等开发者慢慢排查。也可以自己在函数里捕捉一部分异常,手动把消息转移到死信队列。

下面展示一个手动转移的完整示例,技术栈:Node.js 18 + AWS Lambda + SQS。

const AWS = require('aws-sdk');
const sqs = new AWS.SQS();

// 模拟发送短信,如果手机号为空则抛错
async function sendSms(phone, text) {
  if (!phone) {
    throw new Error('手机号不能为空');
  }
  // 真实环境里会调用短信服务商 API
  console.log(`向 ${phone} 发送短信:${text}`);
}

exports.handler = async (event) => {
  // 拿到 SQS 消息里的内容
  const record = event.Records[0];
  const messageBody = JSON.parse(record.body);

  try {
    await sendSms(messageBody.phone, messageBody.text);
    console.log('短信已发送');
    // 成功时返回 ok 即可
    return { status: 'success' };
  } catch (error) {
    // 发送失败,先把错误信息记下来
    console.error('短信发送失败,准备放入死信队列', error);

    const deadLetterQueueUrl = process.env.DEAD_LETTER_QUEUE_URL;
    await sqs.sendMessage({
      QueueUrl: deadLetterQueueUrl,
      MessageBody: JSON.stringify(messageBody),
      MessageAttributes: {
        errorInfo: { DataType: 'String', StringValue: error.message }
      }
    }).promise();

    // 返回成功,避免当前消息被 Lambda 反复重试。
    // 因为已经“救”到死信队列里了,这里就当任务完成。
    return { status: 'movedToDlq' };
  }
};

注意,这里选择在函数内部手动转移,是因为你可能希望把更完整的业务上下文一起带进死信队列。如果你不想写这些代码,也可以直接在事件源映射上配置平台自带的死信队列,让重试耗尽后自动转移。两种方式各有好处,手动方式适合做精细控制,自动方式适合图省事。

3.4 并发:给函数上枷锁

云函数并发太高容易压垮下游。你可以通过给函数配置“保留并发”来限制同时运行的实例数量。也可以用事件源映射的批量大小,让每次投递给函数的事件数量固定下来,减少对下游的冲击。下面演示用 SDK 更新函数并发上限,技术栈:Node.js 18 + AWS Lambda。

const AWS = require('aws-sdk');

// 创建 Lambda 客户端
const lambda = new AWS.Lambda({ region: process.env.AWS_REGION });

async function setConcurrencyLimit(functionName, maxConcurrency) {
  // 给指定的函数设置保留并发上限
  const params = {
    FunctionName: functionName,
    ReservedConcurrentExecutions: maxConcurrency
  };

  try {
    const result = await lambda.putFunctionConcurrency(params).promise();
    console.log(`函数 ${functionName} 的并发上限设置为 ${maxConcurrency}`);
    return result;
  } catch (error) {
    console.error('设置并发失败', error);
    throw error;
  }
}

// 这个函数不是事件入口,而是管理函数,可以单独被调用
exports.handler = async () => {
  await setConcurrencyLimit('orderProcess', 10);
  return { status: 'done' };
};

这段代码展示了如何用代码去控制并发,但实际项目中,这种配置多半是用基础设施即代码的工具去管理。关键是,你要想清楚:到底把并发限制在哪一层?是限制整个函数,还是限制函数访问数据库时的连接池?限制整个函数最简单,但代价是流量高峰时部分事件会被拒绝,所以你要搭配重试机制使用。

3.5 调试:让本地环境离云端更近

如果觉得本地调试难,可以给函数做一个“本地可运行”的外壳。也就是把业务逻辑和事件源解析分开,本地写一个 main 去模拟事件输入。这样不用真的上云,也能在终端里看输出。下面是一个简单的示例,技术栈:Node.js 18。

// 业务处理函数:只关心业务,不关心事件来源
async function processEvent(event) {
  if (!event.userId) {
    throw new Error('缺少 userId');
  }
  console.log(`为用户 ${event.userId} 生成优惠券`);
  return { success: true, userId: event.userId };
}

// 本地运行的入口,模拟云函数被触发
async function localMain() {
  // 一个很像真实 SQS 消息的事件对象
  const fakeEvent = {
    body: JSON.stringify({ userId: 'u_123456' })
  };

  try {
    // 解析出业务数据
    const payload = JSON.parse(fakeEvent.body);
    await processEvent(payload);
  } catch (error) {
    console.error('处理失败', error);
    process.exit(1);
  }
}

// 如果这个文件被直接运行,就执行 localMain
if (require.main === module) {
  localMain();
}

在这个例子里,processEvent 是纯粹的业务逻辑,localMain 是本地模拟器。上了云之后,你只需要在 AWS Lambda 的入口函数里把 event.Records[0].body 解析出来,然后调用同一份 processEvent 就好了。这样,本地能跑的,云端也能跑,调试体验就上来了。

3.6 避免锁定:把云细节关进仓库

为了不让业务代码绑死在某一家云上,你可以把事件解析、发送结果这些“云接口”隔离在模块边界之外。业务模块只管处理纯数据,不直接调用 AWS SDK。下面演示一个简单的依赖注入式写法,技术栈:Node.js 18。

// 业务模块:不依赖任何云服务
class OrderService {
  constructor({ orderRepo, notifier }) {
    this.orderRepo = orderRepo;   // 数据访问接口
    this.notifier = notifier;     // 消息通知接口
  }

  async handlePayment(paymentInfo) {
    // 这里只用抽象接口,不知道底层是 DynamoDB 还是 MySQL
    await this.orderRepo.updateOrder(paymentInfo.orderId, { paid: true });

    // 通知用户,不关心具体是发短信还是发邮件
    await this.notifier.notify(paymentInfo.userId, '支付成功');
  }
}

// 云适配模块:负责把 Lambda 事件转成业务对象,并实现接口
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
const sns = new AWS.SNS();

async function lambdaHandler(event) {
  // 把 AWS 事件转成业务数据
  const paymentInfo = {
    orderId: event.orderId,
    userId: event.userId
  };

  // 接口实现:orderRepo 用 DynamoDB 实现
  const orderRepo = {
    async updateOrder(orderId, changes) {
      await dynamodb.update({
        TableName: 'orders',
        Key: { orderId },
        UpdateExpression: 'set paid = :paid',
        ExpressionAttributeValues: { ':paid': changes.paid }
      }).promise();
    }
  };

  // 接口实现:notifier 用 SNS 实现
  const notifier = {
    async notify(userId, message) {
      await sns.publish({
        Message: message,
        TopicArn: process.env.NOTIFY_TOPIC_ARN
      }).promise();
    }
  };

  const service = new OrderService({ orderRepo, notifier });
  await service.handlePayment(paymentInfo);
}

module.exports.handler = lambdaHandler;

这样,核心业务不知道自己跑在云上,将来换一家云,只需要重写底下的适配器,业务模块原封不动。当然,这不是银弹,有些云厂商特有的服务本来就很难完全抽象,但你至少可以把决策权留到自己手上。

四、注意事项与最佳实践

使用无服务器架构时,有几个容易被忽略的点。

第一,监控和日志一定要一开始就规划好。函数是短命的,实例随时会消失,你不能像管传统服务器那样登录上去看问题。哪怕只是一个小函数,也要把关键日志打完整,把业务埋点加上。等出了事故再去补,往往会漏掉很多线索。

第二,权限要最小化。云函数天生适合用角色来授权,不要给函数挂一个能操作所有资源的超级权限。因为一旦函数被攻击,黑客能拿到的权限越大,危害就越大。每个函数只为它需要访问的资源开一条窄窄的门。

第三,成本账要精打细算。按次计费听起来便宜,但如果你写了一个死循环,或者配了错误的重试策略,账单可能嗖嗖地涨。事件量大的时候,函数调用次数和持续时间都会推高成本,所以要在设计阶段就估算好。

第四,测试不能偷懒。由于云函数的运行环境和本地不一样,尽量做一次线上环境的冒烟测试。尤其要测一测冷启动、并发突增、事件重复这些边界条件,可别只测了顺风顺水的路径。

第五,控制依赖的大小。一个 Node.js 函数里如果装了几百兆的依赖,冷启动时间和部署时间都会变长。能不引入的库就别引入,能裁剪就裁剪,这也是对运维最实在的贡献。

第六,不要把函数当成常驻服务。函数应该设计成一次只做一件事,如果一个函数里要干十件事,复杂度会急剧上升,你也会很快体会到什么叫改一处动全身的感觉。尽量保持函数短小,把多个步骤用状态机或者消息链串起来,这样每条链路都清晰可控。

第七,及时清理不再使用的事件源和函数。很多时候,线上会残留一些测试用的触发器和定时器,它们在后台悄悄运行,产生让你意外的费用和日志噪音。定期梳理资源,给每个函数打上标签,长期来看能省下不少心力。

五、文章总结

事件驱动场景天然适合无服务器计算,因为它把“处理一件事”的粒度做得非常灵活,让开发者从服务器维护里解放出来。但自由是有代价的,冷启动、重复事件、失败重试、并发限制、调试麻烦、供应商锁定,这些都是要过的坎。好在每种坎都有对应的梯子:预留并发和轻量化抹平了冷启动,幂等键挡住了重复事件,死信队列兜住了失败,并发限制保护了下游,本地化的外层让调试不再抓瞎,而对云服务的使用做一层隔离,会让你将来挪窝的时候从容一些。

无服务器不是银弹,但当你真正理解了它的脾气,学会在上面和“无常”共处,事件驱动的那股轻盈劲儿,才算是真正发挥出来了。