我用 Lambda Durable Functions 把五个 Lambda 缩成了一个,代码量砍半

9 阅读4分钟

Lambda 15 分钟超时,搞不了长流程——这事儿我相信不少人都遇到过。

之前接了个需求:订单处理流水线,要过库存校验、支付扣款、等仓库拣货(两小时起步)、发物流。我一开始用 SQS + DynamoDB + 五个 Lambda 拼了一套状态机。能跑,但五个函数之间传状态、处理异常、保证幂等……折腾半天,光胶水代码就写了几百行。

现在亚马逊云科技出了 Lambda Durable Functions,我重写了一版——一个函数搞定,代码量直接砍半。这篇文章分享下实战过程和踩的坑。

啥是 Durable Functions

说白了,就是让 Lambda 支持断点续跑

核心机制叫检查点(Checkpoint)。你把业务逻辑拆成一个个 step,每个 step 执行完 SDK 自动存结果。如果中间函数被打断(超时、OOM、平台回收),下次唤醒时 SDK 会"回放"——已经完成的 step 直接返回存好的结果,没完成的接着跑。

几个亮点:

  • ctx.wait() 可以挂起函数等几小时甚至几天,不产生计算费用
  • 内置重试,支持指数退避 + 随机抖动
  • ctx.map() 并发处理集合,单个元素失败不影响其他
  • ctx.createCallback() 等外部事件,比如人工审批
  • 函数可以跑到一年

SDK 是 Apache 2.0 开源的:github.com/aws/aws-dur…

实战:订单处理工作流

Maven 依赖

<dependency>
    <groupId>software.amazon.lambda.durable</groupId>
    <artifactId>aws-durable-execution-sdk-java</artifactId>
    <version>VERSION</version>
</dependency>

Java 17+,这是硬性要求。

核心代码

public class OrderProcessor extends DurableHandler<Order, OrderResult> {

    private final InventoryService inventoryService = new InventoryService();
    private final PaymentService paymentService = new PaymentService();
    private final ShippingService shippingService = new ShippingService();

    @Override
    protected OrderResult handleRequest(Order order, DurableContext ctx) {
        // 扣库存
        var reservation = ctx.step("reserve-inventory", Reservation.class,
            stepCtx -> inventoryService.reserve(order.getItems()));

        // 扣款(带重试 + 至多一次语义)
        var payment = ctx.step("process-payment", Payment.class,
            stepCtx -> paymentService.charge(
                order.getPaymentMethod(), order.getTotal()),
            StepConfig.builder()
                .retryStrategy(RetryStrategies.exponentialBackoff(
                    3, Duration.ofSeconds(2),
                    Duration.ofSeconds(15), 2.0,
                    JitterStrategy.FULL))
                .semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY)
                .build());

        // 等仓库处理 2 小时(零成本挂起)
        ctx.wait("wait-for-warehouse", Duration.ofHours(2));

        // 发货
        var shipment = ctx.step("confirm-shipment", Shipment.class,
            stepCtx -> shippingService.ship(
                reservation, order.getAddress()));

        return new OrderResult(order.getId(), shipment.getTrackingNumber());
    }
}

这代码看着就是普通的顺序执行逻辑,但背后 SDK 帮你做了检查点、回放、重试、挂起恢复。跟之前五个 Lambda + SQS 的方案比,维护成本不在一个量级。

踩坑记录

坑 1:扣款被执行了两次

这个坑比较隐蔽。默认的执行语义是 AT_LEAST_ONCE_PER_RETRY——如果 step 执行成功了但检查点没存住(运行环境突然被回收),回放时会再执行一次。

扣款这种操作绝对不能重复。解决方案:

var payment = ctx.step("charge-payment", Payment.class,
    stepCtx -> paymentService.charge(amount),
    StepConfig.builder()
        .semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY)
        .retryStrategy(RetryStrategies.Presets.NO_RETRY)
        .build());

AT_MOST_ONCE_PER_RETRY 会在执行前先记录一个检查点。如果执行完但结果没存上,回放时不会重新执行,而是抛 StepInterruptedException。你需要自己查外部状态来处理。

坑 2:泛型返回值反序列化炸了

我有一个 step 返回 List<Order>,直接写 List.class

// ❌ 这样不行,回放时反序列化会出错
var orders = ctx.step("fetch-orders", List.class,
    stepCtx -> orderService.getOrders(userId));

Java 类型擦除的老问题。要用 TypeToken

// ✅ 正确写法
var orders = ctx.step("fetch-orders", new TypeToken<List<Order>>() {},
    stepCtx -> orderService.getOrders(userId));

坑 3:改了 step 顺序导致回放失败

有一次我重构代码,把两个 step 的顺序调了一下。结果线上正在运行的 durable function 回放时直接报 NonDeterministicExecutionException

原因:回放是从头开始跑的,它按顺序匹配 step 名称。你改了顺序,它就对不上了。

教训:对已经在运行的 durable function,不要改 step 的名称和顺序。 要改就等所有运行中的实例跑完再改。

坑 4:map 用了 HashSet 导致回放不一致

// ❌ HashSet 迭代顺序不确定
var items = new HashSet<>(Arrays.asList("a", "b", "c"));
ctx.map("process", items, String.class, fn);
// 抛 IllegalArgumentException

ctx.map() 要求输入集合必须有确定的迭代顺序。用 List 就行:

// ✅ List 有确定顺序
var items = List.of("a", "b", "c");
ctx.map("process", items, String.class, fn);

批量处理:ctx.map() 实战

真实场景里经常要批量处理数据。比如批量发送通知:

var userIds = List.of("user-1", "user-2", "user-3",
    "user-4", "user-5");

var result = ctx.map("send-notifications", userIds, 
    NotifyResult.class,
    (userId, index, childCtx) -> {
        return childCtx.step("notify-" + index, 
            NotifyResult.class,
            stepCtx -> notificationService.send(userId));
    },
    MapConfig.builder()
        .maxConcurrency(3)
        .completionConfig(
            CompletionConfig.toleratedFailureCount(2))
        .build());

System.out.println("成功: " + result.succeeded().size());
System.out.println("失败: " + result.failed().size());

toleratedFailureCount(2) 表示最多容忍 2 个失败。超过 2 个就停止。每个元素跑在隔离的子上下文里,一个炸了不影响其他。

等外部事件:审批场景

大额订单需要主管审批:

DurableCallbackFuture<String> callback = ctx.createCallback(
    "manager-approval", String.class,
    CallbackConfig.builder()
        .timeout(Duration.ofHours(24))
        .build());

ctx.step("request-approval", String.class,
    stepCtx -> {
        approvalService.requestApproval(
            callback.callbackId(), orderDetails);
        return "requested";
    });

try {
    String decision = callback.get();
    if ("rejected".equals(decision)) {
        // 走拒绝逻辑
    }
} catch (CallbackTimeoutException e) {
    // 24 小时没审批,自动取涊
}

函数在 callback.get() 处挂起,不占计算资源。审批系统通过 API 把结果发回来,函数继续跑。

异步并行执行

多个互不依赖的操作可以并行:

DurableFuture<User> userFuture = ctx.stepAsync(
    "fetch-user", User.class,
    stepCtx -> userService.getUser(userId));

DurableFuture<List<Order>> ordersFuture = ctx.stepAsync(
    "fetch-orders", new TypeToken<List<Order>>() {},
    stepCtx -> orderService.getOrders(userId));

// 并行执行,这里等结果
User user = userFuture.get();
List<Order> orders = ordersFuture.get();

和之前方案的对比

SQS + 多 Lambda 状态机Lambda Durable Functions
代码分布5-6 个函数 + 胶水代码1 个函数,顺序写
状态管理自己写 DynamoDB 读写SDK 自动检查点
长时间等待SQS 延迟消息(上限 15 分钟)ctx.wait() 挂起,等多久都行
重试逻辑自己实现内置指数退避
错误处理每个函数单独处理统一 try-catch

参考资源


如果你也在用多个 Lambda 拼状态机,建议试试 Durable Functions。开发体验好太多了。