Serverless 的分布式系统挑战
一个后端工程师写了一段 50 行的 Node.js 函数:接收 HTTP 请求,从数据库查一条记录,做一次格式转换,返回 JSON。他把这段代码上传到 AWS Lambda,配好 API Gateway,五分钟后就拿到了一个能承受每秒数万请求的线上接口。不需要配置服务器,不需要操心容量规划,不需要写自动伸缩策略,甚至不需要关心底层运行的是什么操作系统。
但这个看似简单的部署动作背后,平台要解决一系列经典的分布式系统问题。这段函数部署到了哪台机器上?如果同时有一万个请求到达,平台如何在毫秒级内启动一万个隔离的执行环境?每个执行环境之间如何保证安全隔离,使得一个租户的恶意代码不会影响另一个租户?函数执行完毕后状态如何持久化?如果某个节点宕机,正在执行的请求怎么办?
这些问题中的每一个,单独拿出来都是分布式系统领域的研究课题。而 Serverless 平台需要同时解决所有这些问题,并且让开发者对此完全无感。本文将深入剖析这些挑战的技术细节:从执行模型的本质约束,到冷启动的优化方案;从有状态 Serverless 的设计哲学,到一致性保证的工程实现;从 Firecracker microVM 的安全隔离机制,到 WebAssembly 运行时在边缘计算场景下的突破。
一、一个函数调用背后的分布式系统
1.1 从函数到分布式平台
当开发者将一个函数部署到 FaaS(Function as a Service)平台时,平台的工作才刚刚开始。以 AWS Lambda 为例,一次函数调用的完整链路至少涉及以下组件:
- 前端调用层:API Gateway 或 SDK 接收到调用请求,进行认证、限流、请求路由。
- 调度层:Lambda 的内部调度器(Invoke Service)根据函数标识,决定将请求发往哪个工作节点。如果该函数已经有空闲的执行环境(Execution Environment),直接路由过去;如果没有,则触发新环境的创建。
- 放置层:Placement Service 在数千台物理服务器中选择一台来承载新的执行环境。选择依据包括:该服务器的 CPU 和内存余量、该函数的安全隔离需求、数据局部性(是否靠近函数需要访问的数据存储)等。
- 隔离层:在选定的物理服务器上,创建一个隔离的执行环境。AWS Lambda 使用 Firecracker microVM 提供硬件级隔离,确保不同租户的函数互不干扰。
- 执行层:在隔离环境中加载运行时(如 Node.js 运行时)、加载函数代码、加载依赖、初始化全局变量,然后执行函数逻辑。
- 结果返回:函数执行完成后,结果通过调度层返回给调用方。执行环境可能被保留一段时间以应对后续请求(热复用),也可能被销毁以释放资源。
整个过程涉及的分布式系统挑战包括:负载均衡(如何在数千台服务器间分配请求)、资源调度(如何在毫秒级完成放置决策)、故障恢复(调度器或工作节点宕机时如何保证请求不丢失)、安全隔离(如何在多租户环境下保证数据安全)、状态管理(函数本身无状态,但应用的状态必须可靠地存储在某处)。
1.2 简单性的幻象
Serverless
的核心价值主张是”开发者只需关注业务逻辑”。但这种简单性是一种刻意构造的幻象——平台通过承担巨大的复杂性,将开发者从运维工作中解放出来。这与操作系统对硬件的抽象如出一辙:应用程序调用
write()
系统调用时,不需要知道磁盘控制器的寄存器地址、文件系统的
B-tree 布局、RAID
控制器的条带化策略。操作系统把这些复杂性隐藏了。
然而,抽象会泄漏(Leaky Abstraction)。当开发者遇到冷启动延迟、并发限制、执行超时、状态管理困难、调试困难时,底层分布式系统的复杂性就会暴露出来。理解这些底层机制,不是为了自己实现一个 Serverless 平台,而是为了在使用平台时做出正确的架构决策。
1.3 FaaS 的定位
FaaS 是 Serverless 计算的主要形态,但 Serverless 的范畴更广。Serverless 数据库(如 Aurora Serverless、PlanetScale)、Serverless 消息队列(如 SQS)、Serverless 存储(如 S3)都属于 Serverless 生态。这些服务的共同特征是:不需要预置容量、按使用量计费、自动伸缩、运维全托管。
本文聚焦于 FaaS 作为计算层面临的分布式系统挑战,但也会讨论这些挑战如何与 Serverless 生态中的其他组件产生交互。
二、无状态、短暂、按调用计费的执行模型
2.1 执行模型的三个核心特征
Serverless 函数的执行模型有三个根本性的特征,它们直接决定了 Serverless 架构面临的分布式系统挑战:
无状态(Stateless)。每次函数调用在逻辑上是独立的。函数实例不保证在两次调用之间保持任何本地状态。虽然在实现层面,平台可能会复用同一个执行环境来处理多个请求(热复用),但开发者不能依赖这种行为。全局变量在两次调用之间可能保持,也可能被清除——取决于平台是否回收了这个执行环境。这意味着所有需要持久化的状态必须写入外部存储。
短暂(Ephemeral)。函数的执行环境是临时创建的,用完即弃。AWS Lambda 的最大执行时间是 15 分钟,Google Cloud Functions 是 9 分钟(第一代)或 60 分钟(第二代),Azure Functions 在消费计划下默认 5 分钟、最大 10 分钟。这意味着函数不适合执行长时间运行的任务,如视频转码、大规模数据处理、长轮询等——至少不能在单个函数调用中完成。
按调用计费(Pay-per-invocation)。用户按请求次数和执行时长付费。AWS Lambda 的计费粒度是每毫秒、每 GB 内存。没有请求时不产生费用。这与传统的按服务器/按小时计费模型有本质区别:传统模型下,即使服务器空闲也要付费;Serverless 模型下,空闲时成本为零。
2.2 与传统模型的本质差异
传统的服务器部署模型中,应用进程是长驻(Long-lived)的。一个 Spring Boot 应用启动后,可能运行数周甚至数月而不重启。进程在内存中维护连接池、缓存、会话状态、预热的 JIT 编译结果。应用可以启动后台线程执行定时任务,可以打开 WebSocket 连接保持长连接,可以在本地磁盘上读写临时文件。
Serverless 模型打破了这些假设。以下是一个对照表:
| 特性 | 传统服务器模型 | Serverless 模型 |
|---|---|---|
| 进程生命周期 | 数天到数月 | 毫秒到 15 分钟 |
| 本地状态 | 可靠持久 | 不可靠,随时可能丢失 |
| 本地磁盘 | 持久化可用 | 临时目录(如 /tmp),容量有限 |
| 并发模型 | 单进程多线程 | 单请求单实例(或有限并发) |
| 启动开销 | 一次性,可忽略 | 每次冷启动都会产生 |
| 网络连接 | 长连接,连接池 | 短连接,每次调用可能新建 |
| 扩缩容 | 手动或基于规则 | 自动,按请求级别 |
| 计费 | 按资源预留 | 按实际使用 |
2.3 编程模型的约束
Serverless 的执行模型对开发者施加了一系列约束,这些约束直接影响分布式系统的设计方式:
执行时间限制。Lambda 的 15 分钟上限看似充裕,但对于某些工作负载来说远远不够。一个需要处理 100 GB 数据文件的 ETL 任务,在 15 分钟内可能只能处理其中一小部分。应对方案是将大任务分解为多个小任务(Map-Reduce 模式),通过消息队列或 Step Functions 进行编排。
内存限制。Lambda 的内存上限是 10,240 MB(10 GB),CPU 算力按内存成比例分配。对于内存密集型任务(如大规模矩阵运算、图像处理),这个限制可能成为瓶颈。
无直接通信。函数实例之间不能直接通信。没有共享内存,没有进程间通信(IPC)机制,甚至不知道彼此的存在。如果两个函数需要协调,必须通过外部中介——消息队列、数据库、分布式锁服务。
无本地持久存储。Lambda 的 /tmp 目录最大 10 GB,且仅在函数实例存活期间有效。一旦实例被回收,/tmp 中的数据就消失了。所有需要持久化的数据必须写入 S3、DynamoDB、RDS 等外部存储服务。
2.4 对分布式系统设计的影响
这些约束重新定义了分布式系统的设计范式:
状态外化(State Externalization)。由于函数本身无状态,所有应用状态必须存储在外部服务中。这使得外部存储成为系统的核心组件——它的延迟、吞吐量、一致性保证直接决定了整个系统的性能和正确性。一个 Lambda 函数调用 DynamoDB 的网络延迟通常在 5-10 毫秒,这意味着一个涉及 10 次数据库操作的函数至少需要 50-100 毫秒的网络开销,即使计算本身只需要 1 毫秒。
协调困难。传统的分布式系统可以通过 Leader 选举、分布式锁、两阶段提交等机制进行协调。但在 Serverless 环境下,没有长驻进程来担任协调者。Leader 选举需要持久的心跳,但函数可能随时被回收;分布式锁需要持锁者持续续约,但函数的执行时间有限。这些传统协调机制需要重新设计才能适用于 Serverless 场景。
幂等性要求。平台保证的是至少一次(At-least-once)执行语义——函数可能被重试。这要求每个函数必须是幂等(Idempotent)的:执行一次和执行多次的效果必须相同。我们会在第五节详细讨论这个问题。
下面的代码示例展示了一个典型的 Serverless 函数结构,体现了状态外化和短暂执行的特征:
// AWS Lambda 函数示例:处理订单
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { SQS } = require('@aws-sdk/client-sqs');
// 全局变量:可能在热复用时被保留,但不能依赖
const dynamodb = new DynamoDB({ region: 'us-east-1' });
const sqs = new SQS({ region: 'us-east-1' });
exports.handler = async (event) => {
const order = JSON.parse(event.body);
// 幂等性检查:用 orderId 作为幂等键
const existing = await dynamodb.getItem({
TableName: 'Orders',
Key: { orderId: { S: order.orderId } }
});
if (existing.Item) {
// 已处理过,直接返回(幂等)
return { statusCode: 200, body: JSON.stringify({ status: 'already_processed' }) };
}
// 写入数据库(状态外化)
await dynamodb.putItem({
TableName: 'Orders',
Item: {
orderId: { S: order.orderId },
userId: { S: order.userId },
amount: { N: String(order.amount) },
status: { S: 'pending' },
createdAt: { S: new Date().toISOString() }
},
ConditionExpression: 'attribute_not_exists(orderId)' // 防止并发写入
});
// 通过消息队列触发下游处理(无直接通信)
await sqs.sendMessage({
QueueUrl: process.env.FULFILLMENT_QUEUE_URL,
MessageBody: JSON.stringify({ orderId: order.orderId }),
MessageGroupId: order.userId // FIFO 队列保证同一用户的订单顺序
});
return { statusCode: 201, body: JSON.stringify({ orderId: order.orderId }) };
};这段代码不到 40 行,却体现了 Serverless
编程模型的核心特征:状态存储在 DynamoDB(外化),通过 SQS
与下游通信(无直接通信),使用
ConditionExpression
保证幂等性(应对重试),函数本身不保持任何持久状态。
三、冷启动问题与解决方案
3.1 冷启动的本质
冷启动(Cold Start)是 Serverless 领域最广为人知的性能问题。当一个函数没有可用的空闲执行环境时,平台必须从零开始创建一个新的环境,这个过程引入的额外延迟就是冷启动延迟。
下图展示了一个 Serverless 函数从请求到达到最终销毁的完整生命周期:
sequenceDiagram
participant R as 请求
participant S as 调度器
participant W as 工作节点
participant E as 执行环境
R->>S: 请求到达
alt 存在空闲热容器
S->>W: 路由到热实例
W->>E: 直接执行函数
else 无空闲容器(冷启动)
S->>W: 选择目标服务器
W->>W: 创建 microVM / 容器
W->>E: 拉取函数代码包
E->>E: 初始化运行时(JVM/Node/Python)
E->>E: 初始化函数(加载依赖/全局变量)
end
E->>E: 执行函数逻辑
E-->>R: 返回响应
Note over E: 空闲等待期
alt 空闲超时(5-15分钟内有新请求)
Note over E: 冻结状态,等待复用
else 长时间空闲
E->>E: 销毁执行环境
Note over E: 释放资源
end
该序列清晰展示了热路径与冷路径的差异:热路径仅包含调度和执行两步,延迟在毫秒级;冷路径则需要经历容器创建、代码拉取、运行时初始化等多个阶段,总延迟可达数百毫秒甚至数秒。执行完成后,环境进入空闲状态等待复用,超时后被销毁回收资源。
冷启动的过程可以分解为以下阶段:
flowchart LR
subgraph ColdStart["冷启动延迟分解"]
direction LR
CS1["调度决策<br/>~1-5ms"] --> CS2["容器/microVM 创建<br/>~50-125ms"]
CS2 --> CS3["Guest OS 启动<br/>~50-100ms"]
CS3 --> CS4["代码下载<br/>~10-200ms"]
CS4 --> CS5["运行时初始化<br/>~50-500ms"]
CS5 --> CS6["依赖加载<br/>~10-2000ms"]
CS6 --> CS7["函数初始化<br/>~1-100ms"]
end
CS7 --> Total["总冷启动<br/>~170ms - 3s+"]
从分解图可以看出,运行时初始化和依赖加载两个阶段的延迟变化范围最大,是冷启动优化的主要靶点。轻量级运行时(Go、Rust)在这两个阶段的耗时极短,而重量级运行时(Java + Spring Boot)可能在此消耗数秒。
- 调度决策(~1-5ms):调度器选择目标服务器。
- microVM 创建(~50-125ms):Firecracker 创建一个新的 microVM,包括分配内存、设置虚拟 CPU、配置网络和块设备。
- Guest OS 启动(~50-100ms):Linux 内核在 microVM 内启动。
- 运行时初始化(~50-500ms):加载语言运行时,如 JVM 启动、Python 解释器初始化。
- 代码加载(~10-200ms):从内部存储下载并加载函数代码包。
- 依赖初始化(~10-2000ms):加载第三方库、建立数据库连接等应用级初始化。
各阶段延迟差异巨大,其中运行时初始化和依赖初始化通常是最大的瓶颈。
3.2 冷启动延迟的实际测量
不同语言运行时的冷启动延迟差异显著。以 AWS Lambda(分配 1024 MB 内存)为参考:
| 运行时 | 最小冷启动 | 典型冷启动 | 最大冷启动 |
|---|---|---|---|
| Node.js 18 | ~80ms | ~150-250ms | ~500ms |
| Python 3.11 | ~90ms | ~200-300ms | ~600ms |
| Go 1.x (AL2) | ~50ms | ~100-150ms | ~300ms |
| Java 17 (Corretto) | ~300ms | ~800ms-2s | ~5s+ |
| .NET 6 | ~200ms | ~400-800ms | ~2s |
| Rust (AL2) | ~40ms | ~80-120ms | ~200ms |
Java 的冷启动之所以远高于其他语言,原因在于 JVM 的启动开销:类加载、字节码验证、JIT 编译器初始化、Spring 框架的依赖注入容器初始化等。一个使用 Spring Boot 的 Java Lambda 函数,冷启动可能超过 5 秒。
依赖包的大小也是关键因素。一个仅包含运行时标准库的 Python 函数冷启动约 200ms,但引入 NumPy + Pandas 后可能增加到 2-3 秒,因为这些库的二进制依赖需要额外的加载时间。
3.3 解决方案:预热与复用
3.3.1 预留并发(Provisioned Concurrency)
AWS Lambda 的预留并发功能允许用户预先指定一个函数应保持多少个已初始化的执行环境。这些环境始终处于热状态,请求到达时可以立即执行,无需冷启动。
# serverless.yml 配置示例
functions:
processOrder:
handler: src/handler.processOrder
runtime: nodejs18.x
memorySize: 1024
timeout: 30
provisionedConcurrency: 10 # 始终保持 10 个热实例
events:
- http:
path: /orders
method: post预留并发的代价是:即使没有请求,预留的实例也会按时间计费。这本质上是用固定成本换取延迟的确定性,牺牲了 Serverless “按使用付费”的核心优势。
3.3.2 快照与恢复(Snapshot and Restore)
AWS Lambda SnapStart 基于 CRIU(Checkpoint/Restore In Userspace)技术。其原理是:
- 在函数首次部署时,平台创建一个执行环境并完成完整的初始化过程(包括 JVM 启动、类加载、Spring 容器初始化)。
- 初始化完成后,平台将整个进程的内存状态做一个快照(Snapshot),包括堆内存、栈、文件描述符、网络连接状态等。
- 后续的冷启动不再从零开始初始化,而是从这个快照恢复(Restore)。恢复一个内存快照比重新初始化 JVM 快得多。
SnapStart 可以将 Java 函数的冷启动从数秒降低到约 200ms。但它有一些限制:快照中的网络连接在恢复后失效(需要重新建立);快照中的随机数种子在恢复后相同(存在安全隐患,需要在恢复后重新初始化随机数生成器)。
// Lambda SnapStart 的使用注意事项
public class OrderHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
// 在 init 阶段初始化(会被快照)
private static final DynamoDbClient dynamoDb = DynamoDbClient.builder()
.region(Region.US_EAST_1)
.build();
// CRaC(Coordinated Restore at Checkpoint)接口
// 用于在快照恢复后执行必要的重初始化
@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// 快照前:关闭不能被快照的资源
// 如数据库连接、SSL 会话等
}
@Override
public void afterRestore(Context<? extends Resource> context) {
// 恢复后:重新初始化随机数生成器、重建连接
SecureRandom random = new SecureRandom();
random.nextBytes(new byte[64]); // 强制重新播种
}
@Override
public APIGatewayProxyResponseEvent handleRequest(
APIGatewayProxyRequestEvent event, com.amazonaws.services.lambda.runtime.Context context) {
// 业务逻辑...
return new APIGatewayProxyResponseEvent().withStatusCode(200);
}
}3.3.3 GraalVM 原生镜像
GraalVM Native Image 将 Java 应用预先编译为原生可执行文件,消除了 JVM 启动和类加载的开销。编译后的二进制文件启动时间可以从数秒降低到数十毫秒,接近 Go 和 Rust 的水平。
代价是编译时间长(一个中型项目可能需要 10-30 分钟),且 GraalVM 的 AOT 编译不支持 Java 的所有动态特性(如反射、动态代理),许多框架需要特殊适配。Quarkus 和 Micronaut 等框架对 GraalVM 原生编译提供了良好的支持。
3.4 调度优化:最小化冷启动
3.4.1 一致性哈希与函数放置
调度器的核心问题是:如何将请求路由到已有热实例的服务器,避免不必要的冷启动?
一种方案是使用一致性哈希(Consistent Hashing)。将函数标识作为哈希键,映射到服务器环上的某个位置。同一函数的请求总是被路由到同一组服务器,提高了实例复用的概率。
# 简化的一致性哈希调度器
import hashlib
from bisect import bisect_right
class FunctionScheduler:
def __init__(self, servers, virtual_nodes=150):
self.ring = []
self.server_map = {}
for server in servers:
for i in range(virtual_nodes):
key = f"{server}:{i}"
h = int(hashlib.sha256(key.encode()).hexdigest(), 16)
self.ring.append(h)
self.server_map[h] = server
self.ring.sort()
def get_server(self, function_id):
h = int(hashlib.sha256(function_id.encode()).hexdigest(), 16)
idx = bisect_right(self.ring, h) % len(self.ring)
return self.server_map[self.ring[idx]]
def route_request(self, function_id, servers_with_warm_instances):
# 优先路由到已有热实例的服务器
preferred = self.get_server(function_id)
if preferred in servers_with_warm_instances.get(function_id, set()):
return preferred, "warm"
# 没有热实例,使用一致性哈希选择服务器
return preferred, "cold"3.4.2 预测性预热
更高级的方案是基于历史调用模式进行预测性预热(Predictive Warming)。如果某个函数每天上午 9 点流量激增,平台可以在 8:55 开始预热实例。
AWS 内部的 Lambda 调度系统会跟踪每个函数的调用频率和模式。对于周期性调用的函数(如 CloudWatch 定时触发),平台可以准确预测下一次调用时间并提前准备实例。对于突发流量,平台采用指数增长策略:先启动少量实例,如果请求继续增加则快速倍增。
3.5 冷启动影响的实际场景
冷启动对不同类型应用的影响截然不同。对于异步批处理任务(如 S3 事件触发的图片处理),几百毫秒的冷启动完全可以接受。但对于同步 API 调用(如用户登录接口),200ms 的冷启动加上 100ms 的业务逻辑就意味着 P99 延迟达到 300ms,可能超出 SLA 要求。
对于延迟敏感的场景,常见的工程实践是使用”保温调用”——用 CloudWatch Events 每 5 分钟触发一次函数,保持实例活跃。这是一种廉价但不可靠的方案:它只能保持一个实例的热度,无法应对并发突增。
四、有状态 Serverless
4.1 根本矛盾
Serverless 的执行模型是无状态的,但绝大多数真实应用需要状态。一个聊天室需要维护在线用户列表和消息历史;一个电商购物车需要在多次操作之间保持商品列表;一个协同编辑器需要实时同步多个用户的编辑操作。
传统做法是将所有状态存入外部数据库。但这引入了两个问题:延迟和复杂性。每次状态读写都需要一次网络往返(5-10ms),对于需要频繁读写状态的应用(如实时游戏),这个延迟不可接受。同时,开发者需要手动处理并发控制、缓存一致性、连接管理等问题,大幅增加了开发复杂度。
有状态 Serverless(Stateful Serverless)试图在 Serverless 的自动伸缩和运维托管优势与有状态应用的需求之间找到平衡。其核心思路是:将状态与计算绑定,但由平台自动管理状态的持久化、迁移和恢复。
4.2 Cloudflare Durable Objects
Cloudflare Durable Objects 是有状态 Serverless 领域最具创新性的设计之一。它的核心思想是:每个 Durable Object 是一个单线程的实体(Actor),绑定到一个全局唯一的 ID。在任意时刻,全世界只有一个该 ID 的 Durable Object 实例在运行。
核心特性:
- 单线程执行:每个 Durable Object 一次只处理一个请求,不存在并发竞争。这从根本上消除了分布式锁和并发控制的需求。
- 自动放置:平台决定 Durable Object 运行在哪个数据中心。通常选择距离访问者最近的位置,以降低延迟。
- 持久化存储:每个 Durable Object 有自己的 KV 存储(基于 SQLite),数据持久化在磁盘上。读写延迟极低(通常 < 1ms),因为数据就在本地。
- 自动休眠与唤醒:没有请求时,Durable Object 自动进入休眠状态,释放内存。有新请求到达时自动唤醒。状态从磁盘恢复到内存的过程对调用方透明。
- WebSocket 支持:Durable Object 可以持有 WebSocket 连接,实现服务器推送。这使得它特别适合实时应用。
下面是一个使用 Durable Objects 实现的聊天室示例:
// Cloudflare Durable Object:聊天室
export class ChatRoom {
constructor(state, env) {
this.state = state;
this.sessions = []; // 当前连接的 WebSocket 会话
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/websocket') {
// 升级为 WebSocket 连接
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.push(server);
// 发送历史消息
const history = await this.state.storage.get('messages') || [];
for (const msg of history.slice(-50)) {
server.send(JSON.stringify(msg));
}
// 处理新消息
server.addEventListener('message', async (event) => {
const data = JSON.parse(event.data);
const message = {
user: data.user,
text: data.text,
timestamp: Date.now()
};
// 持久化消息(本地 KV,延迟 < 1ms)
const messages = await this.state.storage.get('messages') || [];
messages.push(message);
if (messages.length > 1000) messages.splice(0, messages.length - 1000);
await this.state.storage.put('messages', messages);
// 广播给所有在线用户(单线程,无竞争条件)
const payload = JSON.stringify(message);
this.sessions = this.sessions.filter(s => {
try {
s.send(payload);
return true;
} catch {
return false; // 连接已断开
}
});
});
return new Response(null, { status: 101, webSocket: client });
}
return new Response('Use WebSocket endpoint', { status: 400 });
}
}这个聊天室的所有状态(消息历史、在线用户)都在 Durable Object 内部管理,不需要外部数据库。单线程模型保证了消息顺序和状态一致性——不需要分布式锁,不需要乐观并发控制,不需要冲突解决。
权衡:Durable Object 的单线程模型意味着单个对象的吞吐量有限。如果一个聊天室有 10 万用户同时发消息,单个 Durable Object 可能成为瓶颈。解决方案是分片:将聊天室分成多个子房间,每个子房间由一个 Durable Object 管理。
4.3 Azure Durable Functions
Azure Durable Functions 从另一个角度解决有状态 Serverless 问题。它不是提供有状态的计算单元,而是在无状态函数之上构建有状态的工作流编排层。
编排器函数(Orchestrator Functions):定义工作流逻辑,可以调用其他函数、等待外部事件、设置定时器。编排器看起来像普通的命令式代码,但底层使用事件溯源(Event Sourcing)来实现持久化。编排器的每一步操作都被记录为一个事件,如果编排器中途崩溃,平台可以从事件日志中恢复状态并继续执行。
实体函数(Entity Functions):提供虚拟 Actor 模型。每个实体有唯一标识、持久化状态和一组操作。与 Durable Objects 类似,实体保证单线程访问。
下面展示一个使用 Durable Functions 实现的订单处理工作流:
# Azure Durable Functions:订单处理编排器
import azure.functions as func
import azure.durable_functions as df
def orchestrator_function(context: df.DurableOrchestrationContext):
order = context.get_input()
# 步骤 1:验证库存
inventory_result = yield context.call_activity('CheckInventory', order)
if not inventory_result['available']:
yield context.call_activity('NotifyUser', {
'orderId': order['orderId'],
'message': '库存不足,订单已取消'
})
return {'status': 'cancelled', 'reason': 'out_of_stock'}
# 步骤 2:扣款(带重试策略)
retry_options = df.RetryOptions(
first_retry_interval_in_milliseconds=5000,
max_number_of_attempts=3
)
payment_result = yield context.call_activity_with_retry(
'ProcessPayment', retry_options, order
)
if not payment_result['success']:
yield context.call_activity('NotifyUser', {
'orderId': order['orderId'],
'message': '支付失败'
})
return {'status': 'failed', 'reason': 'payment_failed'}
# 步骤 3:扣减库存 + 创建物流单(并行扇出)
tasks = [
context.call_activity('DeductInventory', order),
context.call_activity('CreateShipment', order)
]
results = yield context.task_all(tasks) # Fan-out/Fan-in
# 步骤 4:通知用户
yield context.call_activity('NotifyUser', {
'orderId': order['orderId'],
'message': '订单处理成功,等待发货'
})
return {'status': 'completed', 'shipmentId': results[1]['shipmentId']}
main = df.Orchestrator.create(orchestrator_function)这段代码看起来像普通的同步流程控制,但底层是完全异步和持久化的。每个
yield
点都是一个持久化检查点——如果编排器在执行到第 3
步时崩溃,恢复后会从事件日志中回放前两步的结果,然后从第 3
步继续执行,而不是重新开始。
4.4 AWS Step Functions
AWS Step Functions 采用声明式的状态机模型来定义工作流。与 Durable Functions 的代码优先风格不同,Step Functions 使用 JSON(Amazon States Language)描述状态机的状态转换图。
{
"Comment": "订单处理状态机",
"StartAt": "CheckInventory",
"States": {
"CheckInventory": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456:function:checkInventory",
"Next": "InventoryAvailable?",
"Retry": [{ "ErrorEquals": ["States.TaskFailed"], "MaxAttempts": 2 }]
},
"InventoryAvailable?": {
"Type": "Choice",
"Choices": [
{ "Variable": "$.available", "BooleanEquals": true, "Next": "ProcessPayment" }
],
"Default": "OrderCancelled"
},
"ProcessPayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456:function:processPayment",
"Next": "ParallelFulfillment"
},
"ParallelFulfillment": {
"Type": "Parallel",
"Branches": [
{ "StartAt": "DeductInventory", "States": { "DeductInventory": { "Type": "Task", "Resource": "arn:aws:lambda:us-east-1:123456:function:deductInventory", "End": true } } },
{ "StartAt": "CreateShipment", "States": { "CreateShipment": { "Type": "Task", "Resource": "arn:aws:lambda:us-east-1:123456:function:createShipment", "End": true } } }
],
"Next": "NotifySuccess"
},
"NotifySuccess": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456:function:notifyUser",
"End": true
},
"OrderCancelled": {
"Type": "Fail",
"Cause": "库存不足"
}
}
}Step Functions 的优势在于可视化——状态机可以在控制台上以图形方式展示,每个状态的执行历史清晰可查。其劣势是表达能力受限——复杂的条件逻辑在 JSON 中难以表达,且每次状态转换都有延迟开销。
4.5 Actor 模型的连接
Durable Objects 和 Azure Entity Functions 的设计与 Actor 模型(Actor Model)有深刻的联系。Actor 模型由 Carl Hewitt 在 1973 年提出,其核心原则是:
- 每个 Actor 有唯一标识和私有状态。
- Actor 之间通过消息传递通信,不共享内存。
- Actor 一次只处理一条消息(单线程语义)。
- Actor 可以创建新的 Actor。
Durable Objects 本质上就是跨数据中心的分布式 Actor。与 Erlang/OTP 或 Akka 的 Actor 不同,Durable Objects 的 Actor 持久化在磁盘上,可以跨机器迁移,且由平台自动管理生命周期。这是 Actor 模型在云原生时代的一种演化。
4.6 权衡分析
有状态 Serverless 的各种方案在延迟、一致性、分区容忍性之间做出了不同的权衡:
| 方案 | 状态访问延迟 | 一致性保证 | 扩展性 | 适用场景 |
|---|---|---|---|---|
| 外部数据库 | 5-10ms(网络) | 取决于数据库 | 高 | 通用 |
| Durable Objects | <1ms(本地) | 单对象强一致 | 中(受单线程限制) | 实时协作、游戏 |
| Durable Functions | 取决于存储后端 | 编排级别强一致 | 高 | 工作流编排 |
| Step Functions | ~50ms(状态转换) | 工作流级别强一致 | 高 | 业务流程 |
五、一致性挑战
5.1 Serverless 环境下的一致性问题
在 Serverless 环境中,一致性问题比传统分布式系统更加复杂,原因有三:
无序调用。当多个请求并发到达时,平台可能为每个请求创建独立的函数实例。这些实例并行执行,访问同一个外部数据库。如果两个函数同时修改同一条记录,就会产生写写冲突。传统的服务器应用可以通过进程内的互斥锁来序列化访问,但 Serverless 函数实例之间完全隔离,无法使用进程内锁。
至少一次执行语义。Serverless 平台保证的是至少一次执行(At-least-once Execution)。如果一个函数在执行完成但返回结果之前发生故障(如网络超时、进程崩溃),平台会重新调度这个请求。这意味着同一个请求可能被执行两次或多次。如果函数的操作不是幂等的——例如”扣款 100 元”——重试就会导致重复扣款。
外部状态瓶颈。由于所有状态都外化到了数据库或对象存储中,这些外部存储成为了一致性保证的核心。但外部存储的一致性模型各异:DynamoDB 默认提供最终一致性读(Eventually Consistent Read),需要显式请求强一致性读(Strongly Consistent Read);S3 从 2020 年 12 月开始对新对象提供读后写一致性(Read-after-write Consistency),但 LIST 操作仍然是最终一致的。
5.2 幂等性设计
幂等性(Idempotency)是 Serverless 系统正确性的基石。一个幂等操作执行一次和执行多次的效果完全相同。
实现幂等性的核心技术:
幂等键(Idempotency Key)。每个请求携带一个全局唯一的标识符。函数在执行前先检查这个标识符是否已经被处理过,如果是则直接返回上次的结果,不重复执行。
// 使用 DynamoDB 条件写入实现幂等性
async function processPayment(event) {
const { idempotencyKey, userId, amount } = event;
try {
// 尝试写入幂等记录(条件:该 key 不存在)
await dynamodb.putItem({
TableName: 'IdempotencyStore',
Item: {
pk: { S: idempotencyKey },
status: { S: 'IN_PROGRESS' },
createdAt: { N: String(Date.now()) },
ttl: { N: String(Math.floor(Date.now() / 1000) + 86400) } // 24 小时过期
},
ConditionExpression: 'attribute_not_exists(pk)'
});
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') {
// 已经处理过,查询并返回上次结果
const existing = await dynamodb.getItem({
TableName: 'IdempotencyStore',
Key: { pk: { S: idempotencyKey } },
ConsistentRead: true
});
return JSON.parse(existing.Item.result.S);
}
throw err;
}
// 执行实际业务逻辑
const result = await chargeUser(userId, amount);
// 更新幂等记录为已完成
await dynamodb.updateItem({
TableName: 'IdempotencyStore',
Key: { pk: { S: idempotencyKey } },
UpdateExpression: 'SET #s = :s, #r = :r',
ExpressionAttributeNames: { '#s': 'status', '#r': 'result' },
ExpressionAttributeValues: {
':s': { S: 'COMPLETED' },
':r': { S: JSON.stringify(result) }
}
});
return result;
}条件写入(Conditional
Write)。利用数据库的条件表达式确保写入操作的幂等性。例如,DynamoDB
的 ConditionExpression、PostgreSQL 的
INSERT ... ON CONFLICT DO NOTHING。
版本号(Version Number)。在记录中维护一个版本号,每次更新时递增。更新操作附带”当前版本号必须等于 N”的条件,如果版本号已经改变(说明有其他操作先行更新),则拒绝本次更新。
5.3 精确一次语义
精确一次语义(Exactly-once Semantics)意味着每个请求恰好被处理一次——不多也不少。在分布式系统中,这通常被认为是不可能严格实现的(因为无法区分”操作失败”和”操作成功但确认消息丢失”两种情况)。但在工程实践中,可以通过”至少一次执行 + 幂等性”来近似实现等效的效果。
事务性发件箱(Transactional Outbox)模式是一个典型方案。当函数需要同时更新数据库和发送消息时,不能分别执行这两个操作(可能一个成功一个失败)。正确的做法是:
- 在同一个数据库事务中,更新业务记录并将待发送的消息写入一个”发件箱”表。
- 一个独立的进程(Change Data Capture 或轮询)从发件箱表中读取未发送的消息,发送到消息队列,然后标记为已发送。
-- 事务性发件箱示例(PostgreSQL)
BEGIN;
-- 业务操作:更新订单状态
UPDATE orders SET status = 'paid' WHERE order_id = '12345';
-- 写入发件箱(同一事务)
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (
gen_random_uuid(),
'Order',
'12345',
'OrderPaid',
'{"orderId": "12345", "amount": 199.99}',
NOW()
);
COMMIT;5.4 分布式事务
传统的两阶段提交(Two-Phase Commit,2PC)在 Serverless 环境中不可用,因为 2PC 需要一个持久化的协调者(Coordinator)来记录事务状态。Serverless 函数是短暂的,无法充当可靠的协调者。
Saga 模式是 Serverless 环境下分布式事务的主要替代方案。Saga 将一个长事务分解为一系列局部事务,每个局部事务都有对应的补偿操作(Compensating Action)。如果某个步骤失败,通过执行前面步骤的补偿操作来回滚已完成的修改。
Saga 有两种协调方式:
编排式(Orchestration):由一个中心编排器(如 Step Functions)控制 Saga 的执行流程。编排器依次调用各个步骤,如果某步失败则按逆序调用补偿操作。优势是流程清晰,便于监控和调试;劣势是编排器成为单点,且引入额外延迟。
协同式(Choreography):没有中心编排器。每个步骤完成后发布事件,下游服务监听事件并执行自己的操作。如果某步失败,发布失败事件,上游服务监听失败事件并执行补偿。优势是去中心化,没有单点瓶颈;劣势是流程分散在各个服务中,难以追踪全局状态。
在 Serverless 场景下,编排式 Saga 更为常见,因为 Step Functions 和 Durable Functions 天然支持这种模式。
六、Firecracker microVM:安全隔离的基石
6.1 多租户隔离的挑战
Serverless 平台的核心挑战之一是多租户隔离。一台物理服务器可能同时运行数百个不同客户的函数。如果一个恶意用户的函数能够逃逸出隔离边界,读取其他用户的内存、访问其他用户的文件系统或网络,后果不堪设想。
传统的容器(Docker)使用 Linux 命名空间(Namespaces)和控制组(Cgroups)实现隔离。命名空间隔离了进程的视图——PID 命名空间让容器内的进程看不到容器外的进程,网络命名空间让容器有独立的网络栈,挂载命名空间让容器有独立的文件系统视图。Cgroups 限制了容器能使用的 CPU、内存、磁盘 I/O 等资源。
但容器的隔离依赖于 Linux 内核的正确性。如果内核存在漏洞(如 Dirty COW、OverlayFS 提权等),容器内的恶意代码可能利用漏洞突破隔离。Linux 内核有约 3000 万行代码,系统调用接口有 300 多个,每个系统调用的实现都可能存在安全漏洞。对于多租户的公有云 Serverless 平台来说,这个攻击面太大了。
6.2 Firecracker 的设计
Firecracker 是 AWS 为 Lambda 和 Fargate 开发的轻量级虚拟机监控器(Virtual Machine Monitor,VMM)。它基于 KVM(Kernel-based Virtual Machine)提供硬件级隔离,但极度精简了传统虚拟机的开销。
核心设计原则:
- 最小化攻击面:Firecracker 只实现了必要的虚拟设备——一个块存储设备(virtio-block)、一个网络设备(virtio-net)和一个串口控制台。没有 USB 控制器、没有 PCI 总线模拟、没有图形适配器。传统 VMM(如 QEMU)模拟了数百种硬件设备,每种设备的实现都是潜在的攻击面。Firecracker 将设备模型压缩到了最小集合。
- Rust 实现:Firecracker 使用 Rust 编写,利用 Rust 的内存安全保证消除了缓冲区溢出、使用后释放(Use-after-free)等常见的安全漏洞。
- 极致轻量:每个 Firecracker microVM 的内存开销约 5MB(Guest 最小内存 + VMM 自身开销)。启动时间约 125ms(从 API 调用到 Guest OS 开始执行 init 进程)。相比之下,QEMU 启动一个虚拟机通常需要数秒。
- API 驱动:Firecracker 通过 RESTful API 进行管理。创建 microVM、配置网络、加载内核、启动 Guest——所有操作都通过 HTTP API 完成。
以下是 Firecracker 的基本配置示例:
{
"boot-source": {
"kernel_image_path": "/var/lib/firecracker/vmlinux",
"boot_args": "console=ttyS0 reboot=k panic=1 pci=off"
},
"drives": [
{
"drive_id": "rootfs",
"path_on_host": "/var/lib/firecracker/rootfs.ext4",
"is_root_device": true,
"is_read_only": false
}
],
"machine-config": {
"vcpu_count": 2,
"mem_size_mib": 256
},
"network-interfaces": [
{
"iface_id": "eth0",
"guest_mac": "06:00:AC:10:00:02",
"host_dev_name": "tap0"
}
]
}6.3 Lambda Worker 架构
AWS Lambda 的工作节点(Worker)架构围绕 Firecracker 构建:
- Worker Manager:管理整台物理服务器上的所有 microVM。接收调度层的创建/销毁指令,跟踪每个 microVM 的状态(空闲、忙碌、待回收)。
- Slot 管理:每个 microVM 被称为一个 Slot。当一个函数的请求到达时,Worker Manager 首先查找是否有空闲的、属于该函数的 Slot。如果有,直接复用(热路径);如果没有,创建新 Slot(冷路径)。
- Slot 复用:函数执行完成后,Slot 不会立即销毁,而是保持一段时间(通常 5-15 分钟)。如果在此期间有新请求到达,可以复用这个已经初始化好的 Slot,避免冷启动。
- 安全边界:每个客户的每个函数使用独立的 microVM。即使同一个客户的不同函数也运行在不同的 microVM 中,提供了严格的隔离保证。
6.4 替代方案对比
除了 Firecracker microVM,业界还有其他隔离方案:
gVisor(Google):gVisor 采用用户态内核的方式。它在用户空间实现了一个兼容 Linux 的内核(称为 Sentry),拦截 Guest 应用的系统调用,由 Sentry 判断是否允许并代为执行。这样,即使 Guest 应用试图利用系统调用漏洞,也只会影响到 Sentry,而不会直接接触宿主机内核。gVisor 用于 Google Cloud Run 和 Google Cloud Functions。
Unikernel:将应用和最小化的操作系统内核编译为一个单一的二进制镜像,直接在 hypervisor 上运行。启动极快(微秒级),但开发体验差,生态有限。
V8 Isolate(Cloudflare Workers):不使用虚拟机或容器,而是在同一个 V8 进程中创建多个隔离的 JavaScript 执行上下文(Isolate)。启动时间极短(<1ms),但隔离强度低于 microVM——所有 Isolate 共享同一个操作系统进程,理论上存在旁路攻击(Side-channel Attack)的风险。
| 方案 | 启动时间 | 内存开销 | 隔离强度 | 实现复杂度 |
|---|---|---|---|---|
| Docker 容器 | ~300-500ms | ~30-50MB | 中(共享内核) | 低 |
| Firecracker microVM | ~125ms | ~5MB | 高(硬件隔离) | 中 |
| gVisor | ~150-300ms | ~15-30MB | 中高(用户态内核) | 高 |
| V8 Isolate | <1ms | <1MB | 低(进程内) | 低 |
| Unikernel | <10ms | ~1-5MB | 高(独立内核) | 极高 |
Firecracker 在启动时间、内存开销和隔离强度之间取得了独特的平衡:它提供了接近 hypervisor 级别的隔离强度,同时启动时间和内存开销远低于传统虚拟机。这是它成为 AWS Lambda 和 AWS Fargate 底层隔离技术的原因。
七、WASM 运行时与边缘计算
7.1 WebAssembly 作为 Serverless 运行时
WebAssembly(WASM)最初为浏览器设计,但其核心特性——接近原生的性能、确定性执行、内存安全的沙箱、语言无关性——使其成为 Serverless 运行时的有力竞争者。
WASM 作为 Serverless 运行时的核心优势:
微秒级冷启动。一个 WASM 模块的实例化时间通常在微秒级(10-100 微秒),远低于容器(百毫秒级)或 microVM(百毫秒级)。这是因为 WASM 模块已经是编译好的二进制格式,不需要解析源代码、不需要 JIT 编译,只需分配线性内存并调用初始化函数。
语言无关。任何能编译到 WASM 的语言(Rust、C、C++、Go、AssemblyScript、Kotlin 等)都可以在 WASM 运行时中执行。开发者可以选择最适合任务的语言,而不受运行时限制。
内存安全沙箱。WASM 模块运行在线性内存(Linear Memory)中,不能直接访问宿主进程的内存。所有与外部世界的交互(文件系统、网络、环境变量)必须通过显式导入的宿主函数(Host Functions)完成。如果宿主不提供某个能力,WASM 模块就无法使用它。
可预测的性能。WASM 的执行模型是确定性的——给定相同的输入,总是产生相同的输出。没有垃圾回收(GC)暂停,没有 JIT 编译引起的性能波动。这对于延迟敏感的 Serverless 场景尤为重要。
7.2 WASI 标准
WASI(WebAssembly System Interface)是一套标准化的系统接口规范,旨在让 WASM 模块能够以可移植的方式访问操作系统功能。WASI 借鉴了 CloudABI 和 Capsicum 的能力(Capability)安全模型:WASM 模块不能直接打开任意文件,而是只能操作宿主显式授予的文件描述符。
// Rust 代码编译为 WASM + WASI 目标
use std::fs;
use std::io::Read;
fn main() {
// WASI 只允许访问宿主预授权的目录
// 运行时通过 --dir 参数授权
let mut file = fs::File::open("/data/input.json")
.expect("无法打开文件(需要宿主授权)");
let mut content = String::new();
file.read_to_string(&mut content).unwrap();
// 处理数据...
let result = process_data(&content);
// 写入结果
fs::write("/output/result.json", result)
.expect("无法写入文件");
}
fn process_data(input: &str) -> String {
let data: serde_json::Value = serde_json::from_str(input).unwrap();
// 业务逻辑处理...
serde_json::to_string_pretty(&data).unwrap()
}编译和运行:
# 编译为 WASI 目标
cargo build --target wasm32-wasi --release
# 使用 Wasmtime 运行,显式授权目录访问
wasmtime --dir /data --dir /output target/wasm32-wasi/release/processor.wasm7.3 Cloudflare Workers
Cloudflare Workers 是 WASM 和 V8 Isolate 技术在 Serverless 领域最成功的商业应用。它不使用容器或虚拟机,而是在 V8 引擎的 Isolate 中运行用户代码。
架构特点:
- V8 Isolate:每个 Worker 运行在一个独立的 V8 Isolate 中。Isolate 是 V8 引擎的轻量级执行上下文,拥有独立的堆内存和全局作用域。创建一个 Isolate 的开销极低——通常不到 1 毫秒。
- 亚毫秒冷启动:由于不需要创建容器、启动操作系统或初始化重量级运行时,Workers 的冷启动时间通常在亚毫秒级。这使得”冷启动问题”在 Workers 平台上几乎不存在。
- 全球边缘部署:Cloudflare 在全球 310+ 个城市有边缘节点(PoP),用户的请求被路由到最近的节点执行。对于一个中国用户访问的 API,如果使用 AWS Lambda(部署在 us-east-1),请求需要跨越太平洋往返,延迟可能达到 200-300ms。如果使用 Cloudflare Workers,请求在中国大陆的边缘节点就能处理,延迟可能低于 10ms。
- Workers KV 和 R2:Workers KV 是全球分布式的键值存储,提供最终一致性读取。R2 是兼容 S3 API 的对象存储。这些存储服务与 Workers 运行时深度集成,调用延迟极低。
// Cloudflare Worker 示例:边缘 API 代理与缓存
export default {
async fetch(request, env) {
const url = new URL(request.url);
const cacheKey = `api:${url.pathname}`;
// 先查本地 KV 缓存
const cached = await env.API_CACHE.get(cacheKey, { type: 'json' });
if (cached) {
return new Response(JSON.stringify(cached), {
headers: { 'Content-Type': 'application/json', 'X-Cache': 'HIT' }
});
}
// 缓存未命中,请求源站
const originResponse = await fetch(`https://api.origin.com${url.pathname}`, {
headers: { 'Authorization': `Bearer ${env.API_TOKEN}` }
});
if (!originResponse.ok) {
return new Response('Origin error', { status: 502 });
}
const data = await originResponse.json();
// 写入 KV 缓存(60 秒过期)
await env.API_CACHE.put(cacheKey, JSON.stringify(data), {
expirationTtl: 60
});
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json', 'X-Cache': 'MISS' }
});
}
};7.4 Fastly Compute
Fastly 的 Compute 平台采用了不同的技术路线——基于 Wasmtime 运行时直接执行 WASM 模块。与 Cloudflare Workers 的 V8 Isolate 方案相比,Wasmtime 方案的优势在于更广泛的语言支持(不限于 JavaScript 和 WASM,任何编译到 WASM 的语言都可以)和更确定的性能特征(没有 JIT 编译的不确定性)。
7.5 边缘计算与 Serverless 的融合
边缘计算(Edge Computing)和 Serverless 的融合是一个重要趋势。传统的 Serverless 平台(如 Lambda)部署在中心化的云区域(Region),用户请求需要先到达该区域。边缘 Serverless 将函数推送到离用户最近的网络边缘节点执行。
这带来了显著的延迟优势,但也引入了新的分布式系统挑战:
状态一致性。如果函数在全球 300 多个边缘节点执行,且需要读写共享状态,如何保证一致性?全局强一致性需要跨洲际的分布式共识,延迟开销可能高达数百毫秒,完全抵消了边缘部署的延迟优势。因此,边缘场景通常采用最终一致性或因果一致性(Causal Consistency)模型。
代码分发。当开发者更新函数代码时,如何将新版本快速分发到全球 300 多个节点?Cloudflare 使用全球 Anycast 网络和增量更新机制,通常可以在 30 秒内完成全球部署。
冷缓存(Cold Cache)。即使函数本身没有冷启动问题,边缘节点的缓存可能是冷的。一个不常被访问的边缘节点上的 KV 缓存可能是空的,第一次请求需要回源获取数据。
7.6 WASM 组件模型
WASM 组件模型(Component Model)是 WebAssembly 规范的下一个重要演进。它定义了 WASM 模块之间的互操作标准——不同语言编译的 WASM 模块可以通过定义良好的接口直接互相调用,无需通过宿主中转。
这对 Serverless 的意义在于:开发者可以用不同语言编写函数的不同部分(如用 Rust 写性能关键的解析逻辑,用 Python 写胶水代码),将它们编译为 WASM 组件并组合在一起运行。组件模型还支持能力安全——每个组件只能访问其显式声明的能力,无法越权访问其他组件的资源。
八、Serverless 对分布式系统设计范式的影响
8.1 范式转变
Serverless 正在改变分布式系统的设计思维。以下是几个核心范式转变:
从”设计服务器”到”设计函数”。传统分布式系统的设计单元是服务(Service)——一个长驻的进程,有明确的启动和关闭流程,有状态管理策略,有负载均衡配置。Serverless 的设计单元是函数(Function)——一个短暂的、无状态的、事件驱动的计算片段。这要求开发者从”我需要多少服务器”转变为”我需要处理哪些事件”。
从”手动伸缩”到”自动伸缩”。传统分布式系统需要开发者显式地配置自动伸缩策略——设置 CPU 利用率阈值、最小/最大实例数、伸缩步长等。Serverless 的伸缩是隐式的:每个请求自动获得一个函数实例,没有请求时实例为零。这消除了容量规划的需求,但也意味着开发者失去了对伸缩行为的精细控制。
从”长连接”到”短连接”。传统应用依赖数据库连接池、TCP 长连接、HTTP/2 多路复用等技术来减少连接建立的开销。Serverless 函数每次调用可能在不同的实例中执行,连接池无法跨实例共享。这导致数据库连接风暴(Connection Storm)问题:当流量突增时,数千个新实例同时尝试建立数据库连接,可能超过数据库的连接数上限。AWS RDS Proxy 等连接代理服务就是为了解决这个问题。
8.2 新的架构模式
事件驱动架构(Event-Driven Architecture,EDA)。Serverless 天然适合事件驱动架构。函数由事件触发——HTTP 请求、消息队列消息、数据库变更流(Change Stream)、文件上传、定时任务等。各个函数通过事件解耦,形成松散耦合的事件处理管道。
CQRS 和事件溯源。命令查询职责分离(Command Query Responsibility Segregation,CQRS)将读操作和写操作分离到不同的模型中。写操作通过命令(Command)触发,产生事件;读操作查询预先构建的物化视图(Materialized View)。事件溯源(Event Sourcing)将系统状态表示为事件的有序序列,而非当前状态的快照。这两种模式与 Serverless 高度契合:写操作由 Lambda 函数处理事件并写入事件存储(如 DynamoDB Streams 或 EventBridge);读操作由另一组 Lambda 函数消费事件并更新物化视图(如 ElasticSearch 或 Redis 缓存)。
编排与协同。在 Serverless 环境中,复杂的业务流程需要在多个函数之间协调。编排式(Orchestration)使用中心协调器(如 Step Functions)显式控制流程;协同式(Choreography)通过事件发布和订阅实现隐式协调。两种方式各有优劣——编排式更容易理解和调试,协同式更灵活和可扩展。实践中常常混合使用:在一个限界上下文(Bounded Context)内部使用编排,在限界上下文之间使用协同。
8.3 反模式与陷阱
Lambda Pinball。过度的函数链式调用(Function Chaining)——A 调 B,B 调 C,C 调 D——导致延迟累加、调试困难、成本翻倍。每个函数调用都有调度开销(~5-10ms),一个经过 10 个函数的链路总延迟至少 50-100ms,而这些函数可能合并为一个函数在 5ms 内完成。解决方案是合并紧密耦合的函数,只在真正需要独立伸缩或独立部署的边界处拆分。
状态管理复杂性爆炸。随着系统规模增长,外部状态存储的数量、类型和一致性需求变得越来越复杂。一个中等规模的 Serverless 应用可能同时使用 DynamoDB(事务数据)、S3(文件存储)、ElastiCache(缓存)、SQS(消息队列)、EventBridge(事件总线),每个存储都有不同的一致性模型、延迟特征和故障模式。开发者需要在脑中同时推理所有这些存储的交互行为。
测试和调试挑战。Serverless 的事件驱动、分布式特性使得传统的测试方法不再适用。单元测试可以测试单个函数的逻辑,但无法测试函数之间的交互。集成测试需要模拟完整的云环境,成本高昂。端到端测试中的非确定性(函数执行顺序、网络延迟变化)使得测试结果不稳定。分布式追踪(Distributed Tracing)工具(如 AWS X-Ray、Datadog APM)对于 Serverless 系统的可观测性至关重要。
供应商锁定(Vendor Lock-in)。Serverless 函数的代码通常可以移植,但其依赖的配套服务(API Gateway 的路由规则、DynamoDB 的数据模型、Step Functions 的状态机定义、IAM 权限策略)是高度供应商特定的。将一个基于 AWS Lambda + DynamoDB + Step Functions + SQS 的应用迁移到 Google Cloud Platform 或 Azure,几乎等于重写整个应用。Serverless Framework、Terraform 等基础设施即代码(Infrastructure as Code,IaC)工具可以降低但不能消除锁定风险。
8.4 未来展望
Serverless 会成为默认的部署模型吗?从技术趋势来看,答案是有可能的。WASM 运行时的成熟正在消除冷启动瓶颈;有状态 Serverless(Durable Objects、Durable Functions)正在消除状态管理的障碍;边缘计算的普及正在消除中心化部署的延迟问题。
但也有一些场景在可预见的未来仍然不适合 Serverless:需要 GPU 计算的机器学习训练;需要持久化大量内存状态的内存数据库;需要亚毫秒延迟保证的高频交易系统;需要自定义内核或特殊硬件驱动的嵌入式系统。
更可能的未来不是”全面 Serverless”或”永远不用 Serverless”,而是混合架构——在适合的地方使用 Serverless(API 接口、事件处理、数据管道),在需要精细控制的地方使用容器或虚拟机(有状态服务、性能关键路径),两者通过事件和 API 连接。
从分布式系统研究的角度,Serverless 提出了许多有趣的开放问题:如何在极短生命周期的计算单元之间实现高效的分布式共识?如何在全球边缘网络上保证有意义的一致性?如何让开发者在享受 Serverless 简单性的同时,不被底层分布式系统的复杂性所困扰?这些问题的答案将塑造下一个十年的云计算形态。
参考文献
- E. Jonas et al. “Cloud Programming Simplified: A Berkeley View on Serverless Computing.” Technical Report No. UCB/EECS-2019-3, UC Berkeley, 2019. https://www2.eecs.berkeley.edu/Pubs/TechRpts/2019/EECS-2019-3.html
- J. Schleier-Smith et al. “What Serverless Computing Is and Should Become: The Next Phase of Cloud Computing.” Communications of the ACM, Vol. 64, No. 5, 2021. https://doi.org/10.1145/3406011
- A. Agache et al. “Firecracker: Lightweight Virtualization for Serverless Applications.” NSDI, 2020. https://www.usenix.org/conference/nsdi20/presentation/agache
- Cloudflare. “Durable Objects Documentation.” https://developers.cloudflare.com/durable-objects/
- Microsoft. “Azure Durable Functions Documentation.” https://docs.microsoft.com/azure/azure-functions/durable/
- S. Hendrickson et al. “Serverless Computation with OpenLambda.” HotCloud, 2016. https://www.usenix.org/conference/hotcloud16/workshop-program/presentation/hendrickson
- T. Warszawski and P. Bailis. “ACIDRain: Concurrency-Related Attacks on Database-Backed Web Applications.” SIGMOD, 2017. https://doi.org/10.1145/3035918.3064028
- E. Boutin et al. “Bridging the Gap Between Serverless and its State with Storage Functions.” SoCC, 2020. https://doi.org/10.1145/3419111.3421275
- A. Jangda et al. “Not So Fast: Analyzing the Performance of WebAssembly vs. Native Code.” USENIX ATC, 2019. https://www.usenix.org/conference/atc19/presentation/jangda
- S. Shillaker and P. Shermon. “Faasm: Lightweight Isolation for Efficient Stateful Serverless Computing.” USENIX ATC, 2020. https://www.usenix.org/conference/atc20/presentation/shillaker
上一篇:存算分离架构
下一篇:可验证分布式系统的未来
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】Serverless 架构:冷启动、成本模型与适用场景
2023 年,Datadog 发布的年度 Serverless 报告显示,超过 70% 的 AWS 用户已在生产环境中使用 Lambda,平均每个组织部署了超过 1000 个 Lambda 函数。然而,同一份报告也指出,冷启动(Cold Start)仍然是开发者最关注的性能问题——在 Java 运行时中,P99 冷启动…
【从零造容器】容器 vs microVM:Firecracker 凭什么 125ms 启动
容器用 namespace 隔离,microVM 用硬件虚拟化。AWS Lambda 背后的 Firecracker 去掉了 BIOS、ACPI、PCI,只用 virtio-mmio,125ms 启动一个 VM。两种隔离模型到底差在哪?安全性差多少?开销差多少?