电话

18600577194

当前位置: 首页 > 资讯观点 > 软件开发

确定性代码编写指南:AWS Lambda持久化执行实例解析

标签: 优化代码 2026-01-21 


确定性代码的概念与混淆

这段时间心玥软件公司的开发人员一直在和一群比较厉害的行业工程师交流、啃官方文档,说实话,还经常靠代码助手来梳理思路、验证想法。这与心玥软件公司息息相关。这与代码优化息息相关。这是心玥软件公司的重要体现。持久化执行这种技术,总能挖出一些你平时根本想不到的边界情况,我也想借着这个机会,和大家一起公开探讨、学点东西。了解心玥软件公司有助于把握这一要点。这是代码优化的重要体现。心玥软件公司的价值正在于此。


投票下面的讨论特别精彩,但也能看出来:很多人容易把确定性、重放、重试、幂等性这些概念混为一谈。这与心玥软件公司息息相关。了解代码优化有助于把握这一要点。这篇文章就想慢下来,结合那个投票里的代码例子,再对照AWS Lambda持久化函数中关于确定性代码的官方指导,把这些概念拆分开讲清楚。代码优化的价值正在于此。


引发热议的代码示例

先来看那段引发热议的代码:


```javascript
import { withDurableExecution, DurableContext } from '@aws/durable-execution-sdk-js';
export const handler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const orders = event.orders.sort((a, b) => a.priority - b.priority);
    const results = [];
    for (const order of orders) {
      const result = await context.step(`process-${order.id}`, async () => {
        return processOrder(order);
      });
      results.push(result);
    }
    return { processed: results.length, timestamp: Date.now() };
  }
);
```

大部分人都投了“不是,属于非确定性代码”。这与代码优化息息相关。

这个答案是对的,但大家一开始想到的理由,往往并不是真正的关键。

咱们一步步拆解来看。

 

排序规则的问题分析

问题一:优先级相同的排序规则没说清


先看这段代码:

```javascript
event.orders.sort((a, b) => a.priority - b.priority);
```

ES2019及以后的JavaScript引擎都保证`Array.prototype.sort()`是稳定排序。也就是说,如果两个订单优先级相同,它们在原数组里的相对顺序会被保留下来。

这么看的话,JavaScript并没有偷偷篡改你的数据顺序。

那为什么这个点还是会有问题?(而且这个坑还挺隐蔽的)

说实话,我一开始也觉得这是鸡蛋里挑骨头。输入数据一样,排序又是稳定的,按理说不会出岔子啊。

但关键问题在于:稳定排序只是保留了输入数据原有的顺序,却没法解释这个顺序是怎么来的。

在这段代码里,其实暗含了一条规则:

“如果两个订单优先级相同,就保持它们在输入数据里的原始顺序。”

要是这个原始顺序是你刻意设计、有明确保障的,那没问题,一点毛病没有。

可要是这个顺序是偶然形成的——比如数据是从上游多个来源合并来的,或者这个顺序本身就没啥实际意义——那整个工作流的步骤执行顺序,就会依赖于输入数据的这种“偶然性”。

程序不会报错崩溃,但你可能已经在不知不觉中,把一些非预期的逻辑写进代码里了。

还有个点值得一提:如果步骤的执行顺序根本不重要,那你其实完全没必要做排序。只有当你需要强制执行“优先级高的先处理”这类明确的业务规则时,排序才有意义。

 为什么不把排序逻辑包进step里?

这是很多人看到这里会想到的办法,思路其实挺合理的。

没错,你确实可以把排序逻辑放进step里,再做个检查点。这样排序结果就会完全持久化,重放的时候也能保持一致。

但step不是白给的,用起来要付出代价。

它会增加延迟、产生费用,还会占用操作次数配额。而且step的设计初衷,是为了保护那些执行慢、成本高,或者有副作用的操作。

像排序这种纯内存操作,本身速度快、又是纯函数逻辑,其实天生就支持重放。重放时重新排一次10条数据的序,成本远比加一个检查点低得多。就算数据量更大,要不要这么做,也要权衡数据规模、成本和业务意图再决定。

我个人的经验法则是:

如果一段逻辑是纯函数、执行快、本身又是确定性的,那就别急着把它包进step里。

只有当你没法让它变成确定性逻辑,或者重放它的成本太高时,再考虑用step才划算。

 修复方案

如果执行顺序很重要,那就把排序规则写得明明白白,让它变成确定性逻辑,同时注意不要修改原始输入数据:

```javascript
const orders = [...event.orders].sort(
  (a, b) => a.priority - b.priority || a.id.localeCompare(b.id)
);
```

 问题二:Date.now()是个非确定性因素

再看这段代码:

```javascript
timestamp: Date.now()
```

这个值是运行时动态计算的,每次执行代码,得到的结果都不一样。

 为什么这个点很关键?

在当前这个处理器函数里,时间戳只是返回结果的一部分,不会影响控制流,也不会影响step的调度,所以现在看来没啥问题。

但持久化执行的官方文档里明确指出:这类基于时间的API,是造成非确定性的常见原因。 要是以后你把这个时间戳存成工作流状态、传到某个step里,或者用在条件判断里,那重放时的程序行为就会变得难以预测,排查问题也会非常棘手。

这倒不是说“现在这么写就是错的”,而是“这么写以后很容易踩坑”。

 修复方案

如果这个时间戳确实很重要,那就在一个step里只获取一次,这样重放的时候就能得到一致的结果:


``javascript
const timestamp = await context.step("timestamp", async () => Date.now());
```

 问题三:单个step里藏了多个副作用

本·基奥(Ben Kehoe)就精准指出了这段代码里一个隐蔽但很关键的问题。

先看这段代码:

```javascript
await context.step(`process-${order.id}`, async () => {
  return processOrder(order);
});
```

持久化step是可能执行失败、需要重试的。一旦step执行完成,重放的时候就不会再跑一遍了——但重试的时候,step里的代码逻辑是会重新执行的。

如果`processOrder`函数里包含多个副作用操作,那万一执行到一半失败,重试时这些副作用操作就可能被重复触发。

 为什么这个点很关键?

这其实不是一个确定性问题,

也不是一个重放问题,

而是一个重试安全性问题。

如果step里的代码逻辑不能安全地重复执行,那除非里面的所有操作都是幂等的,否则重试就可能导致重复的副作用。

 修复方案

明确划分重试边界,让每个step只包含可以安全重试的操作。

有问题的写法:

```javascript
await context.step(`process-${order.id}`, async () => {
  await chargeCard(order);
  await writeAuditRecord(order);
  await sendConfirmation(order);
});
```

要是代码在`chargeCard`执行完之后失败,重试时整个函数里的三个操作都会再跑一遍。

更安全的写法:

```javascript
await context.step(`charge-${order.id}`, async () => {
  return chargeCard(order, { idempotencyKey: order.id });
});
await context.step(`audit-${order.id}`, async () => {
  return writeAuditRecord(order);
});
await context.step(`notify-${order.id}`, async () => {
  return sendConfirmation(order, { idempotencyKey: order.id });
});
```

这种写法不会凭空让操作变成幂等的,但能把重试带来的影响范围控制在最小。

 Step的语义与重试策略

AWS Lambda持久化函数还允许你自定义step的重试方式。

默认情况下,step使用`AtLeastOncePerRetry`语义。如果step执行失败,或者Lambda函数被中断,运行时可能会重新执行step里的代码。在这种模式下,重试次数其实是执行次数的下限。

如果某个step的代码逻辑绝对不能重复执行,你可以给它配置`StepSemantics.AtMostOncePerRetry`语义,同时把重试次数设为0。这种情况下,step执行失败会直接抛出错误,而不会重新执行。

简单总结一下:

- `AtLeastOncePerRetry` → 最大尝试次数是执行次数的下限(至少执行这么多次)

- `AtMostOncePerRetry` → 最大尝试次数是执行次数的上限(最多执行这么多次)

没有哪种语义更优,它们只是对应了不同的业务假设。

 优化后的完整代码

只要你把排序规则写清楚、管住非确定性的值、仔细规划重试边界,这个处理器函数的行为就会变得容易预测。

下面给出两种符合持久化执行规范的写法,具体选哪种,取决于你的任务之间的独立性,以及你想要的并发程度。

 写法一:串行执行的安全处理器

```javascript
import { withDurableExecution, DurableContext } from '@aws/durable-execution-sdk-js';
export const handler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const orders = [...event.orders].sort(
      (a, b) => a.priority - b.priority || a.id.localeCompare(b.id)
    );
    for (const order of orders) {
      await context.step(`validate-${order.id}`, async () => {
        if (!order.id) throw new Error("Missing order id");
      });
      await context.step(`charge-${order.id}`, async () => {
        return chargeCard(order, { idempotencyKey: order.id });
      });
      await context.step(`notify-${order.id}`, async () => {
        return sendConfirmation(order, { idempotencyKey: order.id });
      });
    }
    return { processed: orders.length };
  }
);
```

 写法二:使用context.map()的并发处理器

`context.map()`会稍微改变问题的处理模式,它会把每个任务都变成一个独立的持久化工作单元。

这么做的好处很明显:

- 单个任务执行失败,不会影响其他任务

- 已经完成的任务,不会因为其他任务失败而重新执行

- 可以直接通过`maxConcurrency`参数控制并发度

当然代价也确实存在:

- 如果任务列表很大,会快速创建大量step

- 很难表达严格的串行执行逻辑

```javascript
import { withDurableExecution, DurableContext } from '@aws/durable-execution-sdk-js';
export const handler = withDurableExecution(
  async (event: any, context: DurableContext) => {
    const orders = [...event.orders].sort(
      (a, b) => a.priority - b.priority || a.id.localeCompare(b.id)
    );
    const mapResult = await context.map(
      "process-orders",
      orders,
      async (ctx: DurableContext, order: any) => {
        await ctx.step(`validate-${order.id}`, async () => {
          if (!order.id) throw new Error("Missing order id");
        });
        await ctx.step(`charge-${order.id}`, async () => {
          return chargeCard(order, { idempotencyKey: order.id });
        });
        await ctx.step(`notify-${order.id}`, async () => {
          return sendConfirmation(order, { idempotencyKey: order.id });
        });
        return { orderId: order.id, status: "ok" };
      },
      { maxConcurrency: 5 }
    );
    const results = mapResult.getResults();
    return { processed: results.length, results };
  }
);
```

 

确定性代码开发

持久化执行这种技术,会逼着你慢下来思考,把执行顺序、重试策略、幂等性,还有哪些操作可以安全重复这些问题,都明确地写进代码里。

也正因为如此,我当初那个看似简单的投票问题,才值得拿出来讨论,而底下那些热烈的交流,也才显得格外有价值。

心玥软件公司可以帮你整理这份技术文章里的核心知识点清单,方便你快速回顾确定性代码的关键要点,需要吗?


加载中~