33.3 Serverless / FaaS

一句话

FaaS 把部署单元从“实例”换成“调用”,代价是冷启动的概率成本:决定冷启动比例的不是函数启动多快,而是 请求率 × 空闲回收时间——P(冷启动) = e^(−λT),流量越稀、回收越快,冷启动越多。

先认清换掉了什么

维度 常驻部署(容器) FaaS
部署单元 一个长期运行的进程 一次调用(平台替你起进程)
空闲成本 一直付费 不调用不付费
扩缩粒度 副本(秒级到分钟级) 并发实例(毫秒级到秒级)
状态 可放内存 只能外置
最贵的故障 实例挂(有副本兜底) 冷启动(首请求变慢)
计费与责任边界 按实例时长 按调用次数 + 执行时长 × 内存

计费模型、FaaS 的五个硬约束(时长上限、无状态、并发配额、事件源绑定、冷启动)、以及“峰均比决定胜负”的算法,主场在 5.1 服务模型:IaaS / PaaS / FaaS。本节只算部署形态特有的那笔账:冷启动概率。

冷启动的概率模型

设请求是泊松流、速率 λ(次/秒),实例空闲 T 秒后被回收。一次请求遇到冷启动,等价于“它与上一次请求的间隔 > T”,其概率是:

P(冷启动) = e^(−λT)
平均延迟  = P × 冷启动耗时 + (1−P) × 热请求耗时

于是有两个反直觉结论:

  1. 冷启动不是“函数慢”的问题,是“流量稀”的问题:同一个函数,在 10 次/秒时冷启动几乎为零,在 1 次/小时时几乎每次都冷启动。
  2. 把 T 从 10 分钟调到 15 分钟,冷启动概率会指数下降:因为 e^(−λT) 对 T 是指数关系,T 是最便宜的调节旋钮。
package main

import (
	"fmt"
	"math"
)

const (
	idleTimeoutSec = 600.0 // 实例空闲 10 分钟后被回收
	coldStartMs    = 800.0 // 冷启动耗时
	warmMs         = 12.0  // 热请求耗时
)

func human(sec float64) string {
	switch {
	case sec >= 3600:
		return fmt.Sprintf("%.1f 小时", sec/3600)
	case sec >= 60:
		return fmt.Sprintf("%.1f 分钟", sec/60)
	default:
		return fmt.Sprintf("%.1f 秒", sec)
	}
}

func main() {
	fmt.Printf("=== 冷启动概率 vs 请求率(单实例、泊松到达)===\n")
	fmt.Printf("空闲回收 %.0f 分钟,冷启动 %.0f ms,热请求 %.0f ms\n\n", idleTimeoutSec/60, coldStartMs, warmMs)
	fmt.Printf("%-12s %-12s %-12s %-14s %s\n", "请求率(次/秒)", "平均间隔", "冷启动概率", "平均延迟(ms)", "结论")

	for _, l := range []float64{0.001, 0.005, 0.01, 0.05, 0.1, 1, 5} {
		p := math.Exp(-l * idleTimeoutSec)
		avg := p*coldStartMs + (1-p)*warmMs
		tip := ""
		switch {
		case p > 0.5:
			tip = "过半请求冷启动:FaaS 不适合这个流量"
		case p > 0.05:
			tip = "预置 1 个并发就能消掉大部分"
		case p > 0.001:
			tip = "可接受,配合定时预热"
		default:
			tip = "冷启动可忽略"
		}
		fmt.Printf("%-12.3f %-12s %-12s %-14.1f %s\n",
			l, human(1/l), fmt.Sprintf("%.2f%%", p*100), avg, tip)
	}

	// 反推:把冷启动概率压到 5% 需要多大请求率
	need := -math.Log(0.05) / idleTimeoutSec
	fmt.Printf("\n冷启动概率 ≤ 5%% 需要 λ ≥ %.4f 次/秒(约每 %s 一次请求)\n", need, human(1/need))
	fmt.Printf("把空闲回收时间从 %.0f 分钟调到 15 分钟,同一个函数在 λ=0.001 时冷启动概率从 %.0f%% 降到 %.0f%%\n",
		idleTimeoutSec/60,
		math.Exp(-0.001*idleTimeoutSec)*100,
		math.Exp(-0.001*900)*100)
}

本机实测(macOS,Apple M1,Go 1.27):

=== 冷启动概率 vs 请求率(单实例、泊松到达)===
空闲回收 10 分钟,冷启动 800 ms,热请求 12 ms

请求率(次/秒)     平均间隔         冷启动概率        平均延迟(ms)       结论
0.001        16.7 分钟      54.88%       444.5          过半请求冷启动:FaaS 不适合这个流量
0.005        3.3 分钟       4.98%        51.2           可接受,配合定时预热
0.010        1.7 分钟       0.25%        14.0           可接受,配合定时预热
0.050        20.0 秒       0.00%        12.0           冷启动可忽略
0.100        10.0 秒       0.00%        12.0           冷启动可忽略
1.000        1.0 秒        0.00%        12.0           冷启动可忽略
5.000        0.2 秒        0.00%        12.0           冷启动可忽略

冷启动概率 ≤ 5% 需要 λ ≥ 0.0050 次/秒(约每 3.3 分钟 一次请求)
把空闲回收时间从 10 分钟调到 15 分钟,同一个函数在 λ=0.001 时冷启动概率从 55% 降到 41%

读数:

读数 含义 动作
1 次/1000 秒:冷启动 54.9% 每两个请求就有一个要等 800ms 这个流量下 FaaS 的延迟不可接受,要么常驻,要么预置并发
1 次/200 秒:冷启动 5% 刚好在可接受边界 配合“每 5 分钟一次预热请求”即可消掉
1 次/100 秒:0.25% 已经很稳 可以只用 FaaS
冷启动 ≤5% 的门槛是 λ ≥ 3/T e^(−3) ≈ 5%,这是模型给的常数,与平台无关 用 3/T 快速判断“这个函数该不该用 FaaS”
把 T 从 10 分钟调到 15 分钟,λ=0.001 时从 55% 降到 41% T 是指数旋钮,但改 T 只能缓解,不能根治稀流量 稀流量场景只有“预置并发/常驻”能根治

🔑 判据:先算 λ × T。λT ≥ 3 → 冷启动可忽略,FaaS 放心用;λT 在 0.1–3 → 靠预热/预置并发;λT < 0.1 → 这个函数的调用密度撑不起 FaaS 的延迟要求,改常驻或合并到别的服务里。

冷启动的六个缓解手段

手段 原理 成本 什么时候用
预置并发(provisioned concurrency) 平台保持 N 个已初始化实例 按“常驻”付费 关键路径、λT 小但必须快
定时预热 每 N 分钟打一次空请求,保住实例不回收 几乎为零 λT 在 0.1–1 之间
缩小包体 / 减依赖 初始化时间变短 工程量 所有场景都值得做
快照启动(snapshot) 从初始化后的内存快照恢复 平台相关 运行时初始化重的语言(JVM/Python)
换轻运行时 Go/Node 冷启动远低于 JVM 语言约束 新函数
把初始化挪到全局作用域 复用容器内的初始化结果 注意别把请求态放进去 所有场景

⚠️ “把初始化挪到全局作用域”有一个坑:如果初始化依赖环境变量或密钥,而这些在每个请求都可能不同(多租户),全局初始化会串数据。判断方法是问“这份初始化结果对所有请求都相同吗”。

事件源决定部署边界

同一个函数,挂在不同事件源上,并发与重试语义完全不同:

事件源 并发模型 失败重试 对函数的要求
HTTP(API 网关) 每请求一个并发 通常不自动重试(由调用方决定) 快速失败、幂等
队列(SQS/Kafka) 按分片/批量拉取 自动重试 + 死信队列 必须幂等,单批处理要控制时长
定时(Cron) 单实例 平台可能补跑 必须幂等 + 加锁(避免重叠执行)
对象存储事件 每对象一个事件 可能重复投递 幂等 + 大文件要拆分处理

🔑 这解释了为什么“FaaS 函数要幂等”不是风格问题:队列与对象存储事件天然至少一次,而函数实例随时会被回收,重试是平台行为,不是你写的代码。

什么时候别用 FaaS

情况 替代
长连接(WebSocket、gRPC 流) 常驻容器 / 网关维持连接
单次执行 > 平台上限(常见 5–15 分钟) 拆成编排 + 多个函数,或用 Job/容器(5.1)
需要本地状态(缓存、会话、临时文件大) 常驻 + 本地缓存,或用外置存储换冷启动
高频且延迟敏感(λ 高、p99 要求 < 50ms) 常驻,FaaS 的调度与冷启动抖动不可控
需要固定出口 IP / 特殊网络 常驻在 VPC 内,FaaS 的出口往往共享且受限

FaaS 的弹性由平台接管:你不需要配 HPA,也拿不到节点;代价是配额与冷启动都不可控。伸缩单元的选择、配额天花板与“预留 + 弹性”的成本组合,见 5.4 弹性伸缩与自动扩缩容。

反模式

反模式 后果
用 FaaS 部署低频管理后台(每天几十次调用) 每次点击都等冷启动,体验比常驻差一个数量级
把一个大单体直接搬成“一个巨型函数” 包体大、初始化慢、冷启动更糟,且失去按调用计费的粒度
函数里做长事务/跨服务写 超时被强杀后留下中间态,且重试会重复写
依赖本地 /tmp 存中间结果 实例回收即丢失,重试时读到半个文件
用 FaaS 跑定时任务却不加锁 平台补跑 + 重叠执行,数据被写两遍
不设并发上限 突发流量把下游数据库打挂(FaaS 会替你“无限扩容”)

自检

Q1 一个函数每天被调用 200 次(约 0.0023 次/秒),平台空闲回收 10 分钟。用本节模型估算冷启动比例,并给出方案。

▸ 答案:λT = 0.0023 × 600 ≈ 1.4,P = e^(−1.4) ≈ 24.7%——约每四个请求有一个要等冷启动。方案:① 定时预热(每 5 分钟一次空请求)把 T 的效果抵消,成本几乎为零;② 若必须保证 p99,预置 1 个并发(等于为 1 个常驻实例付费,但仍比整台容器便宜);③ 如果这个后台按钮对延迟不敏感(用户点一下等 800ms 可接受),可以什么都不做——先确认 SLO,再决定要不要优化。

Q2 为什么“把空闲回收时间从 10 分钟调到 15 分钟”只能缓解冷启动,不能根治?

▸ 答案:因为 P = e^(−λT),T 在指数上,调大 T 确实让概率下降,但下降的幅度取决于 λT 的绝对值:λ=0.001 时从 e^(−0.6)=55% 到 e^(−0.9)=41%,只是从“一半”变“四成”;而 λ=0.01 时从 0.25% 到 0.011%,本来就可以忽略。根治只能靠提高 λ(合并请求/批量)或消除冷启动(预置并发/快照),调 T 是在“少回收”和“多付费”之间挪一点点。

Q3 同样是 FaaS,为什么“HTTP 触发”可以不做幂等,“队列触发”必须做?

▸ 答案:因为重试的主体不同。HTTP 触发时,重试由调用方决定,函数本身失败就是失败,不会自动重放;队列触发时,重试由平台决定:消息处理失败会被重新投递(至少一次),函数可能在“已经写完数据库但还没 ack”时被回收,于是同一条消息被处理两次。对象存储事件同理(重复投递)。所以幂等要求来自投递语义,不是来自“用了 FaaS”。

Q4 一个团队把单体拆成 30 个函数,每个函数只做一次数据库读写,结果账单更高、延迟更差。最可能踩了什么?

▸ 答案:把“按调用计费”误当成“更便宜”,同时把调用密度拆稀了。30 个函数意味着一次业务请求要串 5–10 次函数调用(每次都有调度开销与冷启动风险),而单次调用的固定开销(调度、初始化检查、日志写入)在低流量下被放大;如果每个函数的 λT 都小于 3,冷启动会让延迟翻倍。正确做法是先把“一个业务请求内的多次读写”合并在一个函数里(减少调用次数与冷启动次数),只在独立扩缩或独立权限的边界上拆函数。

关联