33.3 Serverless / FaaS
一句话
FaaS 把部署单元从“实例”换成“调用”,代价是冷启动的概率成本:决定冷启动比例的不是函数启动多快,而是
请求率 × 空闲回收时间——P(冷启动) = e^(−λT),流量越稀、回收越快,冷启动越多。
先认清换掉了什么
| 维度 | 常驻部署(容器) | FaaS |
|---|---|---|
| 部署单元 | 一个长期运行的进程 | 一次调用(平台替你起进程) |
| 空闲成本 | 一直付费 | 不调用不付费 |
| 扩缩粒度 | 副本(秒级到分钟级) | 并发实例(毫秒级到秒级) |
| 状态 | 可放内存 | 只能外置 |
| 最贵的故障 | 实例挂(有副本兜底) | 冷启动(首请求变慢) |
| 计费与责任边界 | 按实例时长 | 按调用次数 + 执行时长 × 内存 |
计费模型、FaaS 的五个硬约束(时长上限、无状态、并发配额、事件源绑定、冷启动)、以及“峰均比决定胜负”的算法,主场在 5.1 服务模型:IaaS / PaaS / FaaS。本节只算部署形态特有的那笔账:冷启动概率。
冷启动的概率模型
设请求是泊松流、速率 λ(次/秒),实例空闲 T 秒后被回收。一次请求遇到冷启动,等价于“它与上一次请求的间隔 > T”,其概率是:
P(冷启动) = e^(−λT)
平均延迟 = P × 冷启动耗时 + (1−P) × 热请求耗时
于是有两个反直觉结论:
- 冷启动不是“函数慢”的问题,是“流量稀”的问题:同一个函数,在 10 次/秒时冷启动几乎为零,在 1 次/小时时几乎每次都冷启动。
- 把
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,冷启动会让延迟翻倍。正确做法是先把“一个业务请求内的多次读写”合并在一个函数里(减少调用次数与冷启动次数),只在独立扩缩或独立权限的边界上拆函数。