深入理解 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/                 # 示例

核心类的层次:

  1. DurableHandler<I, O> — 函数入口,继承 RequestStreamHandler。通过反射提取输入类型,委托给 DurableExecutor
  2. DurableExecutor — 创建 ExecutionManagerDurableContext,协调执行流程
  3. DurableContext — 面向用户的 API,提供 step()wait()createCallback()invoke()map() 等持久化操作
  4. 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());

几个设计要点:

  1. 错误隔离 — 一个元素的失败不影响其他元素。失败信息记录在 MapResult 对应索引的 MapError
  2. 并发控制maxConcurrency 限制同时执行的元素数。超出限制的排队等待
  3. 提前终止CompletionConfig 支持多种策略:全部完成、N 个成功即停、失败容忍度等
  4. 确定性 — 输入集合必须有确定的迭代顺序(List OK,HashSet 不行)
  5. 检查点持久化 — 小结果(< 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 里。代码更内聚,调试更简单。如果你的场景需要多步骤协调、长时间等待或外部事件驱动,可以认真考虑下。

posted @ 2026-03-26 11:00  亚马逊云开发者  阅读(2)  评论(0)    收藏  举报