一、引言
Serverless 的弹性来自「用完即回收」,代价就是冷启动:当没有实例存活时,第一个请求必须等待运行时拉起、代码加载、依赖初始化,才能开始处理。在高 QPS 稳定场景下冷启动被并发实例掩藏,但在突发流量、定时任务、多地域扩缩、低流量长尾场景,冷启动直接表现为第一字节延迟(TTFB)的尖刺,摧毁 P95 体验。
不少团队把冷启动当成「平台玄学」,但它的每个环节都可度量、可优化。本文从成因出发:冷启动到底冷在哪、各运行时启动性能如何、函数怎么合并与分层、预启动/Keepalive/预留并发怎么用,最后给出 Cloudflare / Vercel / AWS Lambda 的对比矩阵与一份可执行的优化清单。相关平台背景见 Cloudflare Workers 入门 与 Vercel Serverless Function。
二、冷启动成因与度量
2.1 一次冷启动的生命周期
请求到达 → 平台发现无存活实例
→ ① 调度/下载代码(容器或沙箱拉起)
→ ② 运行时启动(Node/Deno/Bun 进程初始化)
→ ③ 加载依赖(require/import 全部模块)
→ ④ 执行初始化逻辑(连接 DB、读配置、建连接池)
→ ⑤ 执行处理函数 → 返回
热启动只经过 ⑤,冷启动 = ①+②+③+④+⑤
各环节占比经验值(Node.js Lambda 类平台):
| 环节 | 占比 | 优化空间 |
|---|---|---|
| ① 调度/代码下载 | 20–30% | 小代码包、分层缓存 |
| ② 运行时启动 | 15–25% | 选轻量运行时(Bun/Edge) |
| ③ 模块加载 | 25–40% | 裁剪依赖、Tree-shaking、按需加载 |
| ④ 初始化逻辑 | 10–25% | 懒连接、跳过冷连接池 |
关键认知:③ 模块加载通常是最贵的,很多「优化运行时」的方案只优化了 ②,效果有限。
2.2 如何度量冷启动
用首字节延迟(TTFB)分布观察,冷启动是分布右侧的长尾:
# 压测:连续空载后单发,观察首请求延迟
hey -n 200 -c 1 https://your-fn.example.com/ping
# 统计 P99 / 首批请求(前 10 个)延迟
更精确:平台会提供「初始化时长」指标,如 AWS Lambda 的 Init Duration、Cloudflare 的请求日志字段:
# AWS CloudWatch Logs Insights
filter @type = "REPORT"
| fields @initDuration, @duration, @maxMemoryUsed
| stats max(@initDuration) as maxInit, p90(@initDuration) as p90Init
// 自测:在函数内打印初始化耗时(一次性的)
let coldStartMs = 0
if (!globalThis.__warmed) {
coldStartMs = performance.now() - __importTime
globalThis.__warmed = true
}
console.log(JSON.stringify({ cold: coldStartMs }))
三、运行时选择:Node / Edge / Bun
3.1 运行时启动速度对比
| 运行时 | 进程/沙箱启动 | 模块加载 | 适用平台 | 典型冷启动 |
|---|---|---|---|---|
| Node.js(AWS Lambda) | ~数百 ms | require 同步加载 | AWS/GCP/Azure | 200–1000ms |
| Node.js(Vercel) | 容器预置优化 | 同上 | Vercel | 100–500ms |
| Edge Runtime(V8 isolates) | <10ms | 编译后快照 | Cloudflare/Vercel Edge | 0–50ms |
| Bun | 启动极快(~5ms 进程) | 快速加载 | 本地/部分平台 | 取决于平台 |
| Deno | 中等 | 按需 | Deno Deploy / Cloudflare | 10–80ms |
3.2 Edge Runtime:把「运行时启动」压到近零
Cloudflare Workers 使用 V8 Isolates:代码编译为字节码并常驻内存,新请求复用同一 isolate 进程,冷启动不是「拉起进程」而是「激活一个 isolate」,量级从百毫秒降到亚毫秒:
传统 Serverless:请求 → 拉容器 → 起 Node → 加载模块 → 处理 (300-800ms)
Edge(Workers) :请求 → 复用 V8 isolate → 执行 handler (0-10ms)
选择 Edge Runtime 的代价是约束:无 Node 生态全量 API(fs、原生模块受限)、长任务超时更严格、部分包需兼容 Edge。适合「边缘计算 + 低延迟敏感 + 无重型依赖」的场景。
3.3 Bun:启动与加载的双优
Bun 内置打包器 + 极快启动,作为函数运行时能同时压缩 ②③:
// Bun 作为 Lambda 自定义运行时(示例)
// 部署:把入口 bundle 成单文件,减少代码包体积与加载路径
bun build ./handler.ts --outdir=dist --minify --target=bun
Bun 的冷启动优势来自两点:启动快(进程创建 <10ms)与模块解析快(内置 JIT)。但平台支持度不如 Node/Edge 广泛,选型前确认目标平台(部分平台已原生支持 Bun 运行时)。
3.4 运行时决策矩阵
| 场景 | 推荐运行时 | 理由 |
|---|---|---|
| 纯边缘逻辑 / 中间件 / 分流 | Edge Runtime | 冷启动近零、全球就近 |
| Node 生态重度依赖 / ORM / 第三方 SDK | Node.js | 兼容性最大 |
| 冷启动敏感 + 单文件小型函数 | Bun | 启动与加载双快 |
| 大数据处理 / CPU 密集 | 常驻容器或 Worker | Serverless 不适合长任务 |
四、函数合并与分层
4.1 函数合并:减少「冷启动个数」
冷启动的痛点是每个函数各冷各的。把多个端点合并进一个函数,共享一个实例,冷启动被复用分摊:
// 反例:10 个路由 → 10 个独立函数 → 各自冷启动
// 正例:单一 handler 内按路径分发
export async function handler(req: Request) {
const url = new URL(req.url)
switch (url.pathname) {
case '/api/user': return handleUser(req)
case '/api/posts': return handlePosts(req)
case '/api/search': return handleSearch(req)
default: return new Response('Not Found', { status: 404 })
}
}
// next.config.js — 合并 API 路由为一个函数(Vercel)
module.exports = {
async rewrites() { /* 同 prefix 路由聚合 */ },
experimental: { serverComponentsExternalPackages: [] },
}
// 或在 serverless.yml 里把多个 http event 绑到同一 function
权衡:合并过度会让「单点函数」变得庞大、冷启动反而更久、扩缩不再按路由独立。合并的目标是「热点路由共享实例」,不是把全站塞进一个函数。
4.2 函数分层:热点与冷点分离
| 层 | 特征 | 策略 |
|---|---|---|
| 热点(/api/user、首页数据) | 高频、冷启动影响大 | 小函数 + 预留并发 + 缓存 |
| 中温(/api/search) | 偶发突发 | 合并进热点函数或独立小函数 |
| 冷点(管理后台、Cron) | 低频 | 独立大函数,容忍冷启动 |
| 边缘 | 中间件/静态 | 全部走 Edge Runtime |
分层示意:
浏览器 → Edge Runtime(路由/鉴权/静态缓存) [冷启动 ≈ 0]
├→ 热点函数(预热池常驻) [冷启动被吸收]
├→ 中温函数(合并复用) [偶发冷启动]
└→ 冷点函数(管理后台) [接受冷启动]
五、预启动与 Keepalive
5.1 预留并发(Provisioned Concurrency)
AWS Lambda 的预留并发是「预启动 n 个实例并常驻」:流量到来直接命中热实例,冷启动从 P95 消失,代价是始终计费(即使空闲):
# 预留并发:为关键函数常驻 5 个实例
aws lambda put-provisioned-concurrency-config \
--function-name my-hot-fn \
--qualifier prod \
--provisioned-concurrent-executions 5
适用:核心漏斗 API、首发大促、定时任务前预热。只给热点函数预留,否则成本失控。
5.2 定期 Keepalive:用「假请求」保温
平台不保证保活,但可用低频请求维持实例存活(多数平台空闲约 5–15 分钟回收)。两种实现:
// 方案一:外部 Cron 定时打 Warmup 端点
// GitHub Actions / Vercel Cron / Cloudflare Cron Triggers
// 每 4 分钟请求一次 /api/warmup
async function warmup() {
await fetch('https://app.example.com/api/warmup')
// 内部可再做一次真正重活,保持连接池、缓存热
}
// 方案二:函数内部维持状态,warmup 时执行初始化
let pool: Pool | null = null
export async function handler() {
if (!pool) {
pool = new Pool() // 首次冷启动建池
}
return handle(pool)
}
// warmup 请求 → 触发 handler → pool 被建起并常驻
⚠️ Keepalive 是对平台回收机制的对抗:AWS 现网实测并发足够时,即使不保温,流量分布也会自然维持部分实例。Keepalive 更适合「定时任务 + 间歇流量」的确定性场景。
5.3 预热触发时机
| 时机 | 做法 |
|---|---|
| 定时 | 每 4–5 分钟 warmup 一次 |
| 发布后 | 部署后立即跑一轮 warmup,填充新版本实例 |
| 大促前 | 提前 30 分钟拉高并发 + 预留并发 |
| 分地域 | 每个活跃区域各发一个 warmup 请求 |
六、渐进式加载与代码体积优化
6.1 代码包越小,冷启动越快
冷启动时长与代码包大小与模块数量强相关(AWS 上打包 50MB vs 5MB 可差数倍)。核心优化:
// ① 裁剪依赖:只 import 需要的子路径,避免整包
// 坏:import _ from 'lodash'
// 好:import { map } from 'lodash-es' 或 直接手写
// ② 延迟加载重依赖:只在真正用到时 require
// 坏:顶部 require('aws-sdk') 全量加载
// 好:在 handler 内按需加载
let s3
export async function handler() {
if (!s3) s3 = await import('@aws-sdk/client-s3')
// ...
}
// next.config.js — 把重依赖排除到 externals,减小函数 bundle
const nextConfig = {
serverExternalPackages: ['prisma', 'pg', 'sharp'],
}
# 检查产物体积
du -sh .next/standalone .vercel/output/functions/*
6.2 渐进式加载:先响应,后初始化
把「初始化重活」从请求关键路径移出:首请求先返回(或先做轻量路径),连接池、模型加载放后台:
export async function handler() {
// 先响应外壳/轻量数据
const reply = await handleLight()
// 重活异步执行(waitUntil 语义:平台会等待后台 Promise)
ctx.waitUntil(preloadHeavyModel()) // 下次请求命中热模型
return reply
}
Cloudflare Workers 的 waitUntil 与 Vercel 的 waitUntil 都支持这种「响应先行、后台加热」模式,让冷启动的第一发请求先走,同时把该重活做完,第二发请求就是热的。
6.3 Snapshot / 提前编译
平台侧优化(无需代码改动):
| 平台机制 | 原理 | 效果 |
|---|---|---|
| Vercel 函数缓存 | 构建产物预编译 | 省 ①调度下载 |
| Lambda SnapStart | JVM 内存快照恢复 | 冷启动从秒级降到 ~100ms |
| Workers 编译缓存 | 字节码常驻 | 冷启动亚毫秒 |
| 容器镜像分层 | 只拉差异层 | 省代码下载 |
七、三平台冷启动对比矩阵
| 维度 | Cloudflare Workers | Vercel Functions | AWS Lambda |
|---|---|---|---|
| 运行时 | V8 Isolates | Node.js / Edge | Node/Python/Java/Go 等 |
| 冷启动量级 | <10ms(Edge) | ~100–500ms | ~200–1000ms(Node) |
| 保温手段 | 高频复用天然低冷 | Cron warmup / 流量维持 | 预留并发 / SnapStart |
| 代码大小影响 | 影响编译与内存 | 影响下载与加载 | 显著(zip/镜像) |
| 地域 | 300+ PoP 就近 | 全球边缘 | 需按 Region 预置 |
| 适合 | 边缘逻辑、中间件 | Next.js 全栈 | 重型数据处理 |
| 计费 | 请求数 + 时长 | 请求 + 时长 + 带宽 | 请求 + 时长 + 预留费 |
选择提示:
- 延迟敏感 + 边缘路由 → Cloudflare Workers(冷启动近零)。
- Next.js 全栈 + 中等流量 → Vercel(内置优化,冷启动可控)。
- 重型任务 + 强治理 → AWS Lambda(预留并发 + SnapStart 精确控冷)。
- 突发不可预测 → 预留并发 + warmup 组合拳,但先评估成本。
八、优化优先级清单
| 优先级 | 动作 | 收益 |
|---|---|---|
| P0 | 裁剪代码包、Tree-shake、只 import 子路径 | 降 ③ 模块加载(最大头) |
| P0 | 懒加载重依赖(DB SDK、模型) | 冷启动第一发减负 |
| P1 | 热点函数预留并发 | 关键路径冷启动归零 |
| P1 | 定期 warmup + 发布后立即预热 | 确定性地维持实例 |
| P2 | 路由合并进热点函数 | 分摊冷启动复用 |
| P2 | 边缘化可边缘的部分(鉴权/中间件) | 把这部分冷启动变近零 |
| P3 | 采用 Bun/SnapStart/编译缓存 | 平台级启动加速 |
| P3 | 用 P95/P99 TTFB 持续度量 | 验证优化效果、防回归 |
九、总结
冷启动不是「要不要优化」,而是「冷在哪、值不值得优化」:
- 先度量:用 TTFB 分布与平台 Init Duration 定位冷启动尖峰的真实占比。
- 再动手:按成本从「代码体积 → 懒加载 → 预留并发 → 边缘化」逐级优化。
- 看场景:边缘逻辑上 Edge Runtime,热点函数上预留并发,低频任务接受冷启动。
把冷启动当作「可预算的成本项」而非「玄学」,用 P95 指标闭环验证,就能让 Serverless 的弹性收益不被首字节延迟尖刺抵消。相关实践可继续阅读 边缘缓存策略(缓存吸收冷启动)与 前端监控 RUM(度量冷启动对用户体验的影响)。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。