深入理解 Lambda Durable Functions 的检查点回放机制与持久执行模型
我最近在研究亚马逊云科技新推出的 Lambda Durable Functions。这东西解决了一个 Lambda 用户长期面对的问题——如何在无服务器函数里跑长时间、多步骤的工作流。
以前要跑一个超过 15 分钟的流程,通常得自己搞一套状态机:多个 Lambda + SQS + DynamoDB 存状态。Durable Functions 把这套逻辑内化到了 SDK 里,用检查点 + 回放的方式实现了持久执行。这篇文章我从原理层面聊聊它是怎么运作的。
核心模型:检查点与回放
Durable Functions 的核心是一个确定性回放模型(Deterministic Replay)。
你的业务逻辑写在一个 DurableHandler<I, O> 的 handleRequest 方法里。执行过程中,每调用一次 ctx.step(),SDK 就会把这个 step 的结果序列化后写入检查点日志(Checkpoint Log)。
当函数因任何原因被中断——超时、运行环境回收、ctx.wait() 挂起后恢复——SDK 会从函数的开头重新执行代码。但遇到已有检查点的 step 时,它不会再执行 step 里的用户代码,而是直接返回序列化好的结果。
第一次执行:
step("A") → 执行用户代码 → 保存检查点
step("B") → 执行用户代码 → 保存检查点
wait(2h) → 函数挂起
恢复后回放:
step("A") → 检测到检查点 → 直接返回存好的结果(不执行用户代码)
step("B") → 检测到检查点 → 直接返回存好的结果(不执行用户代码)
wait(2h) → 检测到已完成 → 跳过
step("C") → 没有检查点 → 执行用户代码 → 保存检查点
这个模型要求一个前提:代码执行路径必须是确定性的。每次回放时,step 的名称和调用顺序必须和第一次执行时一样。否则 SDK 无法正确匹配检查点,会抛 NonDeterministicExecutionException。
SDK 架构
从模块结构看:
aws-durable-execution-sdk-java/
├── sdk/ # 核心运行时
├── sdk-testing/ # 测试工具
├── sdk-integration-tests/ # 集成测试
└── examples/ # 示例
核心类的层次:
- DurableHandler<I, O> — 函数入口,继承
RequestStreamHandler。通过反射提取输入类型,委托给DurableExecutor - DurableExecutor — 创建
ExecutionManager和DurableContext,协调执行流程 - DurableContext — 面向用户的 API,提供
step()、wait()、createCallback()、invoke()、map()等持久化操作 - ExecutionManager — 管理检查点日志,处理回放逻辑
线程模型
SDK 内部用了两个独立的线程池:
用户线程池(DurableConfig.executorService):
- 运行用户代码(
ctx.step()和ctx.stepAsync()里的 lambda) - 可配置,默认是 cached daemon thread pool
内部线程池(InternalExecutor.INSTANCE):
- 运行 SDK 协调任务:检查点批量写入、wait 完成轮询
- 不可配置,daemon 线程,名称前缀
durable-sdk-internal-*
这种隔离设计的好处是:用户代码不会饿死 SDK 内部任务,反过来也不会。用户关闭了自己的线程池,SDK 的协调工作照样跑。
@Override
protected DurableConfig createConfiguration() {
var executor = new ThreadPoolExecutor(
4, 10,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadFactoryBuilder()
.setNameFormat("order-processor-%d")
.setDaemon(true)
.build());
return DurableConfig.builder()
.withExecutorService(executor)
.build();
}
Step 的执行语义
这是我觉得设计得比较精巧的地方。
SDK 提供两种执行语义:
AT_LEAST_ONCE_PER_RETRY(默认)
step 执行成功 → 保存检查点。如果 step 成功了但检查点没存住(比如 sandbox 崩了),回放时会重新执行 step。
适合幂等操作,比如数据库 upsert、带幂等键的 API 调用。
AT_MOST_ONCE_PER_RETRY
执行前先写一个"占位"检查点 → 执行 step → 成功后更新检查点。如果执行后检查点没更新上,回放时不会重新执行,而是抛 StepInterruptedException。
适合非幂等操作,比如发邮件、扣款。
// 非幂等操作:至多一次 + 不重试
var result = ctx.step("send-email", Result.class,
stepCtx -> emailService.send(notification),
StepConfig.builder()
.semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY)
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
.build());
注意这里的"per retry"修饰词。AT_MOST_ONCE_PER_RETRY 保证的是每次重试尝试至多执行一次。如果你配了重试策略(比如重试 3 次),step 可能执行多次(每次重试各一次)。要实现真正的全局至多一次,需要同时配 NO_RETRY。
Wait 的实现
ctx.wait() 和 ctx.waitAsync() 让函数挂起指定时长。挂起期间不占计算资源,不产生费用。
// 同步 wait — 函数在此处挂起
ctx.wait("cooling-period", Duration.ofDays(7));
// 异步 wait — 启动计时器,继续执行其他操作
DurableFuture<Void> timer = ctx.waitAsync("min-delay",
Duration.ofSeconds(5));
var result = ctx.step("do-work", String.class,
stepCtx -> doWork());
timer.get(); // 如果 5 秒还没到,在这里挂起
异步 wait 的妙处在于可以和其他操作并行。计时器在后台跑,你继续执行其他 step。最后 get() 时如果计时器还没到,函数才会挂起。
Map 的并发模型
ctx.map() 对集合中的每个元素创建独立的子上下文(Child Context),每个子上下文有自己的检查点日志。
var result = ctx.map("batch", items, Result.class,
(item, index, childCtx) -> {
return childCtx.step("process-" + index, Result.class,
stepCtx -> process(item));
},
MapConfig.builder()
.maxConcurrency(5)
.completionConfig(CompletionConfig.minSuccessful(3))
.build());
几个设计要点:
- 错误隔离 — 一个元素的失败不影响其他元素。失败信息记录在
MapResult对应索引的MapError里 - 并发控制 —
maxConcurrency限制同时执行的元素数。超出限制的排队等待 - 提前终止 —
CompletionConfig支持多种策略:全部完成、N 个成功即停、失败容忍度等 - 确定性 — 输入集合必须有确定的迭代顺序(
ListOK,HashSet不行) - 检查点持久化 — 小结果(< 256KB)直接存,大结果从子上下文的检查点重建
回放时,已完成的元素直接返回缓存结果,未完成的从各自的检查点恢复,从未启动的重新执行。
Callback 机制
Callback 用于等待外部事件,比如人工审批、Webhook。
DurableCallbackFuture<String> callback = ctx.createCallback(
"approval", String.class,
CallbackConfig.builder()
.timeout(Duration.ofHours(24))
.heartbeatTimeout(Duration.ofHours(1))
.build());
callbackId 是一个标识符,发给外部系统。外部系统处理完后通过 Lambda Durable Functions API 把结果发回来。函数在 callback.get() 处挂起,收到结果后恢复。
心跳机制(heartbeatTimeout)可以检测外部系统是否还在处理。如果超过指定时间没收到心跳,SDK 可以做相应处理。
错误处理体系
SDK 的异常层次设计得比较清晰:
DurableExecutionException
├── NonDeterministicExecutionException — 回放时 step 不匹配
├── SerDesException — 序列化/反序列化失败
└── DurableOperationException
├── StepFailedException — step 重试耗尽
├── StepInterruptedException — AT_MOST_ONCE step 被中断
├── InvokeFailedException — 链式调用失败
├── InvokeTimedoutException — 链式调用超时
├── CallbackFailedException — 回调收到错误响应
├── CallbackTimeoutException — 回调超时
├── WaitForConditionFailedException — 条件轮询超时
└── ChildContextFailedException — 子上下文失败
NonDeterministicExecutionException 值得特别注意。它通常说明你在 durable function 运行期间修改了代码,导致 step 的名称或顺序和检查点日志不匹配。这是不可恢复的错误。
序列化
默认用 Jackson。可以全局配置,也可以 per-step 配置:
// 全局
DurableConfig.builder()
.withSerDes(new CustomSerDes())
.build();
// per-step
ctx.step("my-step", Result.class, stepCtx -> doWork(),
StepConfig.builder()
.serDes(new StepSpecificSerDes())
.build());
泛型类型要用 TypeToken:
var orders = ctx.step("get-orders",
new TypeToken<List<Order>>() {},
stepCtx -> orderService.getOrders(userId));
这是因为 Java 的类型擦除。运行时 List<Order>.class 就是 List.class,丢失了元素类型信息。TypeToken 通过匿名内部类的方式保留了泛型参数。
相关资源
从架构角度看,Durable Functions 把之前需要外部基础设施(消息队列 + 状态存储)才能实现的工作流编排,收敛到了一个 SDK 里。代码更内聚,调试更简单。如果你的场景需要多步骤协调、长时间等待或外部事件驱动,可以认真考虑下。

浙公网安备 33010602011771号