幂等引擎:确保事务完整性

SchemaBridge Team · 2025-12-22 · Idempotency, Consistency, Transactions

at-least-once 执行、入站去重与目标端密钥:在分布式工作流中,重复究竟在哪里被拦截,哪一部分是你的职责。

“重复扣款”噩梦:为什么分布式系统痛恨重试

在网络不稳定、云资源转瞬即逝的世界里,故障不仅频繁,而且是常态。每位资深工程师都经历过“孤儿操作”的噩梦:一个始于轻微网络抖动、终于数据损坏或财务责任的场景。这是经典的分布式系统故障场景:

1. 请求:你的编排器向支付 API 发送“扣款”请求,或向承运商发送“发货”请求。

2. 成功:第三方 API 成功处理了请求,从客户的卡上扣了款,或打印了运单。

3. 分区:返回路径上出现短暂的网络故障。API 的响应——那份至关重要的确认——始终没有到达你的 worker 机器。

4. 重试:你的 worker 看到超时,按照惯例认为处理失败了。它遵循重试策略,再次发送请求。

5. 重复:由于 API 没有为这个特定的意图获得唯一身份,它会再次处理该请求,把它当作一笔新交易。客户被扣款两次,或者同一订单生成了两张运单。

这不是代码逻辑的失败,而是身份的失败。如果无法跨越时间与空间唯一地标识一个特定的意图,你的系统每次重试连接时,都是在拿客户的数据和金钱做赌注。

分布式事务已死:幂等性万岁

在单体架构中,我们依赖 Two-Phase Commit (2PC) 或全局分布式锁来保证一致性。这些工具让我们能够把多个操作视为单一的真相单元。但在由 SaaS API、Serverless worker 和多语言微服务构成的碎片化世界里,全局事务只是幻想。它们无法扩展,会带来巨大的延迟,而且大多数第三方提供商并不支持。它们需要对资源进行“锁定”,而这在跨组织边界时根本无法实现。

实现分布式一致性唯一可行的路径是幂等性。从数学上讲,如果一个操作可以被多次应用,而结果在第一次应用之后不再改变,那么它就是幂等的。用代数表示:f(x) = f(f(x))。用工程语言说,就是你的系统可以失败并重试任意多次,最终结果始终是正确的。

诚实的契约:带防护的 at-least-once

我们来精确地说明编排器能承诺什么、不能承诺什么。SchemaBridge 以 at-least-once 方式交付工作。worker 可能在 HTTP 调用成功之后、记录结果之前崩溃;消息可能被重新投递;claim 租约可能过期并被接管。在上述每种情况下,安全的做法都是重新执行该步骤而不是丢失它——这意味着一个步骤在极少数情况下可能会执行两次。

平台所做的,是在两个层面上让这种“极少数情况”尽可能少:

第 1 层:入站触发去重

入口点的每次触发都会获得一个执行 ID,默认情况下这个 ID 由内容派生:对工作空间、入口点 vertex 和请求载荷计算出的确定性 SHA-256。同一次 webhook 投递到达两次时,会解析到同一个执行,而不会启动第二个——这正是发送方重试时你想要的行为。你可以通过三个开关控制身份:

| 开关 | 位置 | 适用场景 |

| :--- | :--- | :--- |

| idempotencyFields | 入口点 vertex 配置 | 你希望按业务标识符(例如 order.id)而不是整个请求体去重 |

| clientIdempotencyId | 触发调用的查询参数 | 调用方掌握去重键 |

| forceNewExecution | 仅限测试运行和流式执行调用(在这些场景下默认开启) | 生产触发器不可用——生产中请使用 idempotencyFields 或每次调用唯一的 clientIdempotencyId |

{
  "type": "ENTRY_POINT",
  "config": {
    "idempotencyFields": "order.id"
  }
}

反过来看,这也是一个真实的陷阱:如果两次合法的运行可能携带逐字节相同的载荷(“用空请求体运行夜间报表”),除非你主动退出,否则它们会被合并为一次。定时(cron)触发是例外——每次触发都会获得一个全新的随机 ID,因为调度本来就不应被去重。还要注意,显式去重是一次先读后写的查找,而不是数据库唯一性约束,因此两个真正并发、键相同的触发可能都会通过。

第 2 层:步骤级防护

在一次运行内部,已经处于 COMPLETED 状态的步骤不会被再次执行;worker 在执行步骤之前必须原子地认领它(DISPATCHED → EXECUTING);终态转换受到保护;claim 纪元会隔离租约已被接管的 worker。这些防护拦住了常见的重复路径——但它们都无法深入第三方 API 去撤销一次已经发生的调用。

第 3 层归你负责:目标端密钥

正是因为最后这个缺口,平台自身的行为契约才明确指出:要让副作用在目标端具备幂等性,不要依赖编排器恰好执行一次。

好消息是,大多数严肃的 API(支付、物流、消息)都接受幂等键,而 Gateway 步骤可以发送一个由运行数据派生的键。在 SchemaBridge 中,header 的值可以是基于步骤输入的表达式,因此键由稳定的业务标识符计算得出——绝不能来自时间戳或随机值:

{
  "type": "GATEWAY",
  "config": {
    "method": "POST",
    "url": "https://api.payments.example/v1/charges",
    "headers": {
      "Idempotency-Key": "= 'charge-' & payload.order.id",
      "Authorization": "Bearer {{ secrets.PAYMENTS_KEY }}"
    }
  }
}

由于键只取决于订单,这个步骤的重新执行——无论是在重新投递之后、操作员点击 Retry,还是过载时的自动重试——都会发送同一个键,目标端会返回第一次的结果,而不是再次扣款。需要注意一点:Start over(重放整个工作流)会创建一个新的执行,但如果你的键是从业务数据而不是执行 ID 派生的,即使在这种情况下它也保持稳定。这正是业务标识符比引擎标识符更适合作为键的原因。

应对遗留 API:“Read-Verify-Write”模式

遗憾的是,许多遗留系统和小众 SaaS 提供商并不支持幂等键。它们没有 Idempotency-Key header。对于这些“非幂等”端点,你可以用普通步骤组合出一个 Read-Verify-Write 序列:

1. 校验步骤(Read):Gateway 步骤首先查询下游系统,查看记录是否已存在(例如 GET /orders?external_id=123,使用的正是你本会用作键的那个业务标识符)。

2. 条件分支:一条条件 edge 检查响应。如果订单已存在,运行走“已完成”路径;否则继续。

3. 操作步骤(Write):只有当校验步骤什么也没找到时,运行才会进入真正的写入(POST /orders)。

了解窗口

Read-Verify-Write 缩小了重复窗口,但并没有关闭它。如果 worker 在写入之后、记录成功之前崩溃,写入步骤会被单独重新执行——它不会先回头重新校验。因此,对于完全不支持幂等性的目标端,还应寻找目标端天然的唯一性约束(例如在冲突时会被拒绝的 external_id),并把冲突响应视为“已完成”。默认情况下,4xx 会让 Gateway 步骤失败;将 clientErrorMode 设置为 complete,可以把客户端错误响应变成下一个步骤可据以路由的数据。

“密钥管理”陷阱:为什么自建幂等方案在规模化时会失败

许多工程团队尝试在主数据库中构建一张“幂等表”。这会带来三个关键问题,最终拖垮开发速度和可靠性:

1. 写入瓶颈:现在每一次 API 调用都需要一次数据库写入来记录令牌。在高负载下,你的幂等表会成为争用点。

2. 清理复杂性——垃圾问题:幂等键并不需要永久保存。你需要一个后台进程或 TTL(Time-To-Live)来清除旧键。清理过于激进,就可能让缓慢且被重试的任务出现重复扣款;清理过慢,表就会无限增长。

3. 分布式状态不一致:如果数据库写入成功而 API 调用失败怎么办?或者你的 worker 在 API 调用之后、数据库更新为“已完成”之前崩溃了怎么办?你最终会得到一个需要人工介入才能解决的不一致状态。

出路不是在你这边建一张更聪明的表,而是让执行副作用的系统负责去重。一个遵守幂等键的目标端,已经为它自己的操作解决了存储、过期和崩溃窗口的问题——你的工作就是给它发送一个稳定的键。

客户端生成令牌 vs. 服务端生成令牌

令牌应该在哪里生成?

在 SchemaBridge 工作流中,意图的源头就是运行本身的数据。在那里——在 Gateway 步骤的 header 中——派生键,就能让它在引擎可能采取的每一条重试路径上都保持稳定。

对比表:重复在哪里被拦截

| 重复来源 | 拦截机制 | 由谁配置 |

| :--- | :--- | :--- |

| 发送方重试同一个 webhook | 内容派生的执行 ID / idempotencyFields / clientIdempotencyId | SchemaBridge,键由你选择 |

| 运行内的步骤被重新投递 | 步骤级防护(已完成短路、原子认领、claim 纪元) | SchemaBridge |

| 调用已成功后步骤被重新执行 | 目标端幂等键 | 你,在 Gateway 步骤上 |

| 目标端不支持幂等性 | Read-Verify-Write 加上目标端唯一性约束 | 你 |

专家提示:幂等性检查清单

1. 绝不使用时间戳:你的键必须基于数据,而不是时间。

2. 为键划定作用域:按操作为键加前缀(charge-、ship-),这样即使共享同一个订单 ID,“发货”的键也绝不会与“计费”的键冲突。

3. 处理 409 冲突:如果 API 对一个它已经见过的键或标识符返回 409(Conflict),在输入一致的情况下,你的工作流通常应将其视为“已完成”。

4. 事先决定如何处理重复载荷:如果两次真实的运行可能携带相同的请求体,请设置 idempotencyFields,或在每次调用时传入唯一的 clientIdempotencyId——否则第二次运行会被去重掉。(forceNewExecution 只存在于测试运行和流式执行调用中。)

5. 让键成为模板的一部分:如果开发者必须记得添加幂等键,他们迟早会忘记。把它放进你为该 API 复用的 Gateway 配置中。

结论:身份是真相的支柱

在分布式系统中,你无法信任网络,无法信任时钟,也无法信任响应。你唯一真正能信任的是身份。

SchemaBridge 在它所控制的边界上为你提供身份——内容派生的执行 ID、显式去重键和步骤级防护——并且坦诚地说明它无法控制的边界:第三方的副作用。从业务数据中派生一个稳定的键,在每次调用时都发送它,“重复扣款噩梦”就会变成你一次性做出的设计决策,而不是日后才发现的事故。

本文属于 Building the Bridge 系列。另请参阅:精通合并,介绍如何在并行分支之间同步状态。

深入了解