3、函数即服务(FaaS)核心概念:函数生命周期、触发方式、冷启动与热启动

聊到 Serverless,FaaS 绝对是绕不开的核心。很多人觉得 FaaS 就是「写个函数扔上去跑」,这话没错,但太糙了。你想想看,一个函数从你写完代码到真正被调用,中间经历了什么?为什么有时候响应快得像闪电,有时候又慢得让人抓狂?

这一讲,我们就来扒开 FaaS 的壳,看看它的生命周期、触发方式,以及那个让所有 Serverless 开发者又爱又恨的——冷启动。

3.1 函数的生命周期:从代码到运行实例

一个 FaaS 函数,说白了就是一个事件驱动的处理单元。它的生命周期可以拆成三个阶段:构建与部署实例化与运行销毁与回收

3.1.1 构建与部署

你写完代码,执行 serverless deploy 或者点一下云厂商的控制台按钮。这时候平台会做几件事:

  • 把你的代码打包,连同依赖一起塞进一个容器镜像或者 ZIP 包。
  • 注册到函数服务中,分配一个唯一的 ARN(Amazon Resource Name)或类似标识。
  • 配置好触发器、环境变量、内存大小、超时时间等元数据。

这个阶段,函数还只是一个「蓝图」。它还没跑起来,不占任何计算资源。嗯,这里要注意,部署本身是异步的,有些平台会做代码校验,有些不会。我曾经遇到过部署成功但调用时才发现依赖缺失的情况,那叫一个尴尬。

3.1.2 实例化与运行

当第一个请求到来,平台才会真正拉起一个运行环境。这个过程包括:

  • 分配一个沙箱容器(通常是 Docker 容器)。
  • 下载你的代码包并解压。
  • 启动运行时(比如 Node.js、Python、Java 的 JVM)。
  • 加载你的函数代码,执行全局初始化代码(比如建立数据库连接池)。
  • 最后,调用你的 handler 函数处理事件。

这个实例一旦创建,平台会尽量让它「活着」,以便处理后续请求。这就是所谓的「热启动」状态。

3.1.3 销毁与回收

如果函数实例空闲了一段时间(通常是几分钟到十几分钟,各家策略不同),平台就会把它回收掉。释放 CPU、内存,销毁容器。下次再有请求,就得重新走一遍实例化流程。

核心要点:FaaS 的生命周期是「按需分配,用完即走」。这种设计带来了极致弹性,但也引入了冷启动这个老大难问题。

3.2 触发方式:谁在叫醒你的函数?

函数不能自己主动跑,它得等一个「事件」来叫醒它。触发方式决定了你的函数在什么场景下被调用。我个人习惯把触发方式分成三大类:

3.2.1 同步触发

调用方发起请求,同步等待函数返回结果。典型场景:

  • HTTP 触发器:通过 API Gateway 或 ALB 暴露 RESTful 接口。客户端发一个 HTTP 请求,函数处理完返回 JSON。
  • 直接调用:用 SDK 或 CLI 调用 Invoke 接口,指定同步模式。

同步触发对延迟敏感。你想想看,用户点了个按钮,结果等了 5 秒才响应,这体验能好吗?所以同步场景下,冷启动优化是重中之重。

3.2.2 异步触发

调用方把事件扔给平台,平台负责调度函数执行,调用方不关心结果。典型场景:

  • 对象存储事件:比如文件上传到 S3 或 OSS,自动触发函数做图片处理、视频转码。
  • 消息队列:Kafka、RabbitMQ、SQS 等消息队列里的消息,驱动函数消费。
  • 定时任务:Cron 表达式触发,比如每天凌晨跑一次数据清洗。

异步触发对延迟容忍度高一些。就算冷启动多花 1 秒,用户也感知不到。我在项目中经常把耗时任务(比如生成报表)放到异步触发里,前端只管返回「任务已提交」就行。

3.2.3 流式触发

这是比较新的模式。函数不再是处理一个完整事件,而是处理一个数据流中的片段。比如 AWS Lambda 支持从 Kinesis Data Streams 或 DynamoDB Streams 中读取记录。函数每次被调用,处理一批记录,然后继续下一批。

流式触发对「幂等性」要求极高。我曾经踩过一个坑:函数处理到一半崩溃了,重启后重复处理了部分数据,导致数据重复。后来加了去重逻辑才解决。

触发类型 典型场景 延迟敏感度 幂等要求
同步触发 HTTP API、RPC 调用
异步触发 文件处理、消息消费
流式触发 数据管道、日志处理

3.3 冷启动与热启动:性能的分水岭

这是 FaaS 里最常被讨论的话题。说白了,冷启动就是函数实例从零开始创建的过程,热启动就是复用已有实例的过程。

3.3.1 冷启动到底慢在哪?

一个完整的冷启动链路,我拆解给你看:

  1. 调度延迟:平台收到请求,决定要创建一个新实例。这个过程通常很快,1-5ms。
  2. 容器启动:分配沙箱,拉起容器。Docker 容器启动本身很快,但如果镜像很大,下载时间就长了。我记得有一次项目里用了 Python 的机器学习库,镜像 500MB,冷启动直接飙到 8 秒。
  3. 运行时初始化:启动 Node.js 的 V8 引擎、Python 解释器、Java 的 JVM。Java 的 JVM 启动最慢,动辄几百毫秒到几秒。
  4. 代码加载:加载你的函数代码,执行全局初始化。如果你在全局作用域里建立了数据库连接池,那这一步也会耗时。
  5. Handler 执行:终于开始执行你的业务逻辑了。

前四步加起来,就是冷启动的额外开销。热启动则直接跳过前四步,只执行 Handler。

避坑指南:我曾经在全局作用域里用 await 建立数据库连接,结果冷启动时连接超时,整个函数直接报错。后来改成惰性初始化,第一次调用时才建立连接,问题就解决了。

3.3.2 冷启动的典型时长

不同运行时,冷启动差异很大。我整理了一个大概的数据(基于 AWS Lambda 实测):

运行时 冷启动时长(典型值) 备注
Node.js 200-500ms 启动快,依赖少时更优
Python 300-800ms 依赖包大小影响明显
Go 100-300ms 编译型,启动极快
Java 1-5s JVM 启动是硬伤
.NET 500ms-2s 取决于运行时版本

你想想看,如果你的函数是 Java 写的,冷启动 3 秒,用户每次点击都要等 3 秒,这谁受得了?所以 Java 在 FaaS 里一直不太受欢迎,除非你用 GraalVM 做 Native Image 编译。

3.3.3 热启动:平台如何复用实例?

平台会尽量保持实例「温暖」。当一个实例处理完请求后,它不会被立即销毁,而是进入空闲状态。如果下一个请求在几分钟内到来,平台会直接复用这个实例。

热启动的响应时间几乎等于你的 Handler 执行时间。比如一个简单的 Hello World,热启动下可能只要 5ms,冷启动却要 500ms。差了 100 倍。

但热启动也有坑。因为实例是复用的,全局变量会保留。如果你在函数里修改了全局状态,下一个请求可能会读到脏数据。嗯,这里要注意,函数应该是无状态的,别依赖全局变量。

3.3.4 冷启动优化策略

既然冷启动躲不掉,那就想办法优化。我总结了几条实战经验:

  • 选对运行时:对延迟敏感的场景,优先选 Go、Node.js、Python。Java 和 .NET 慎用。
  • 精简依赖:只打包真正需要的库。别把整个框架塞进去。我见过有人把整个 node_modules 打包进去,里面一堆没用到的包,冷启动直接多 1 秒。
  • 代码包瘦身:把代码包控制在 10MB 以内。超过 50MB,下载时间就会明显增加。
  • 预留并发:这是云厂商提供的「作弊」手段。你可以预先分配一定数量的实例,让它们一直处于热启动状态。代价就是多花点钱。
  • 惰性初始化:把耗时的初始化操作(比如建立连接池、加载模型)推迟到第一次调用时做,而不是在全局作用域里做。
  • 使用 SnapStart:AWS Lambda 的 SnapStart 功能,可以把初始化后的内存快照保存下来,下次冷启动时直接恢复快照,能省掉 JVM 启动和代码加载的时间。Java 用户强烈推荐。

注意:预留并发虽然能消除冷启动,但会占用你的并发配额。如果预留太多,其他函数可能没并发可用。要合理规划。

3.4 小结

FaaS 的核心概念其实不复杂。生命周期就是「创建-运行-销毁」的循环,触发方式决定了谁来叫醒函数,冷启动和热启动则直接决定了你的用户体验。

我个人觉得,理解冷启动是掌握 FaaS 的关键一步。你不需要完全消除它,但要知道它什么时候会发生,以及如何把影响降到最低。说白了,FaaS 就是用「冷启动的代价」换「零管理的便利」。这个 trade-off 值不值,取决于你的业务场景。

下一讲,我们会深入 FaaS 的编程模型,聊聊 handler 怎么写、上下文对象怎么用、以及如何优雅地处理错误。到时候见。