什么是幂等性?为什么会计与支付 API 需要它
重试请求如何导致发票重复、幂等键的作用保证、它必须处理的故障模式,以及如何正确实现幂等性。
如果执行两次某项操作的效果与执行一次相同,那么该操作就是幂等的。读取记录本质上是幂等的。而创建发票则不然,调用它两次就会产生两张发票、两笔应收账款,以及一个询问为何被重复收费的客户。
这种差距至关重要,因为网络会以一种非常特定且令人头疼的方式失效:请求成功了,但响应丢失了。客户端看到了超时,无法分辨请求是“从未到达”还是“已经到达并处理、只是回复丢失了”。它看起来最安全的做法是重试,而重试恰恰就是产生重复的原因。
为什么会计业务会让情况更糟
在大多数系统中,重复只是一个小麻烦。但在总账中,它会演变成一个复合问题:
- 重复的发票会消耗不间断序列 (gapless sequence) 中的一个编号,因此无法直接将其删除。由于不可变性的要求,纠正它需要一张红字发票(贷记单,credit note),而这是您的客户能够看到的单据。
- 重复的发票会过账日记账分录,从而导致收入、增值税和应收账款全部虚高,直到有人发现。
- 它会进入增值税申报表,在立陶宛还会进入 i.SAF 登记册。
- 如果它触发了付款,资金实际上就已经转移了。
“直接检查重复项”也是行不通的,因为在同一天向同一客户开具两张金额相同且完全独立的合法的发票是再正常不过的事情。系统无法从内容中推断意图,必须由客户端明确声明。
幂等键是如何工作的
客户端为每个逻辑操作生成一个唯一的键,并将其随请求一起发送:
POST /v1/sales/invoices/create
Idempotency-Key: 8f14e45f-ea1b-4c2b-9f3a-2d7c1e0b5a91
随后,服务器将保证:
- 使用此键的首次请求 - 正常处理,并根据该键存储处理结果。
- 使用相同键和相同有效负载的重试 - 不重新执行;返回已存储的响应。
- 相同的键,不同的有效负载 - 拒绝。该键代表对“这是同一操作”的断言;不同的请求体意味着它并非同一操作,而静默返回旧结果的危害远大于报错。
- 在原请求仍在运行时的重试 - 返回可重试的错误予以拒绝,而不是并发执行。
第 4 点是许多朴素的实现容易忽略的地方。客户端在 5 秒时超时并在 6 秒时重试,这会与它自己正在进行中的请求发生冲突;如果没有并发保护,两者的请求都会继续执行,你就会得到该机制原本旨在防止的重复数据。
正确生成键
键必须标识操作,而不是尝试(attempt):
- 正确: 用户点击“开具发票”时生成一次 UUID,并在该点击的每一次重试中复用。
- 正确: 从您自己的业务域派生的确定性键,例如
order-1041-invoice。 - 错误: 每次 HTTP 尝试都使用一个新的 UUID。每次重试都会获得一个新键,因此每次重试都是一个新操作,这就等于什么也没实现。
- 错误: 有效负载的哈希值。这样一来,两个完全合法且相同的操作就会发生冲突,第二个操作会被静默吞掉。
键需要有一定的保留期(retention period),即足以涵盖实际的重试窗口和客户端故障时间,但不需要永久保留。
幂等性无法解决的问题
幂等性防止的是同一请求的重复执行。它无法防止:
- 两个不同的用户从不同的会话创建相同的发票。这是一个业务规则问题,需要通过唯一性约束(例如每笔订单只能有一张发票)来解决。
- 使用不同键的逻辑重复。
- 操作内部的非确定性。如果处理程序生成时间戳或编号,存储的响应会捕获第一个生成的值。这是期望的行为,但值得了解。
幂等性与数据库约束相辅相成,而不是替代它们。Nordlet 的总账两者兼用:幂等键阻止重复的请求,而唯一索引则阻止每个结算批次出现重复的打款 ID、每个供应商和单据编号出现重复的采购发票、以及每个资产和期间出现重复的折旧运行。
Nordlet 是如何实现幂等性的
在任何更改状态的请求中发送 Idempotency-Key 请求头,它是一个最多 255 个字符的非空字符串。
使用给定键的第一个请求会执行,并存储其结果。使用相同键和相同有效负载的重试会返回存储的响应而不会重新执行,因此无论调用重复多少次,发票都只会被创建且仅创建一次。
有两种故障模式是显式拒绝的,而不是静默处理:
- 相同的键,不同的有效负载 → 拒绝:“Idempotency-Key 已被用于不同的请求有效负载。” (Idempotency-Key was already used with a different request payload.) 这捕获了真正危险的情况,即客户端在更改金额的同时复用了键。
- 相同的键,原请求仍在进行中 → 作为可重试错误拒绝:“带有此 Idempotency-Key 的请求仍在处理中,请稍后重试。” (A request with this Idempotency-Key is still being processed, retry shortly.) 从而防止并发双重执行。
键的作用域按公司划分,因此两个生成相同 UUID 的租户永远不会发生冲突。
互补的保证则反向运行。Webhook 传递是至少一次(at-least-once)的,这意味着您的端点偶尔会接收到两次相同的事件,例如在实际传递成功的超时之后,或者在重新投递时。每个事件都带有 ID,您的处理程序应当记录已处理的 ID 并忽略重复项。幂等性是集成双方都需要具备的特性;API 惯例指南详细介绍了请求端的内容。
FAQ
什么是幂等键?
幂等键是客户端生成的、随请求发送的标识符,它使服务器能够识别对同一逻辑操作的重试。第一个请求会执行;使用相同键的重试会返回存储的结果,而不会再次执行。
为什么会计 API 需要幂等性?
因为重复的写入会产生具有实际后果的真实单据,例如消耗顺序编号、过账到总账并进入增值税申报表的第二张发票。与重复的读取不同,它不能简单地删除,必须通过红字发票(贷记单)予以纠正。
我应该为每次重试生成一个新的键吗?
不应该,那会彻底破坏该机制。每个逻辑操作生成一个键,并在该操作的每一次重试中复用它。每次尝试都使用新键意味着每次尝试都被视为一个新操作。
如果我使用不同的有效负载复用一个键会发生什么?
请求将被拒绝。该键断言这是同一操作,因此请求体改变意味着出错了,这很可能是客户端上的键复用漏洞。静默返回早期的结果会更糟糕。
幂等性能防止所有重复吗?
不能。它防止的是同一请求的重复执行。两个不同的用户独立创建相同的发票,产生了两个不同的请求,这是需要通过唯一性约束来解决的业务规则问题,超出了幂等键所能识别的范围。