超时、重试、幂等与 Exactly-once:结果未知时,系统还能相信什么

从一次创建订单超时出发,解释超时为何不等于失败,怎样设计可重试接口,以及 Exactly-once 在端到端语境中的边界。

一次 HTTP 或 RPC 调用超时,只能说明调用方在截止时间前没有拿到响应。服务端可能还没收到请求,可能已经提交并且响应丢了,也可能仍在执行。把超时直接翻译成失败,再自动重试写操作,是重复扣款、重复发货和重复发送消息最常见的起点。

本文用 POST /orders 说明超时、重试与幂等各自负责什么,并区分“消息只投递一次”“消费者只处理一次”和“业务效果只发生一次”。HTTP 方法的幂等语义参照 RFC 9110,Exactly-once 的讨论聚焦端到端业务效果,而非某个中间件的单项开关。

一次写请求从超时到确认的状态

一、先承认第三种结果:UNKNOWN

用户点击提交订单。网关将请求发到订单服务,订单服务写入数据库后响应 201;就在响应返回的路上,连接被重置。客户端看到的是超时,但世界可能已经多了一张订单。

客户端 ── create(o-1) ──> 订单服务 ── commit ──> 数据库
客户端 <── response lost ── 订单服务

客户端观察:UNKNOWN
服务端事实:可能 COMMITTED / NOT_FOUND / RUNNING

因此,写操作的结果至少应区分 SUCCEEDED、FAILED 和 UNKNOWN。只读查询可以在有限次数内重试;有副作用的操作先要回答:重复执行会不会改变业务事实。如果会,客户端在 UNKNOWN 后应该用同一个业务键查询,而不是生成新请求再赌一次。

二、超时是预算,不是故障证明

超时控制资源占用和尾延迟。没有截止时间,线程、连接和队列会等待到级联耗尽;截止时间太短,则把拥塞、GC、跨地域抖动都变成大量无意义的重试。一次请求的总预算应从入口向下游传播,而不是每一层各自等待 1 秒,最后把用户请求拖成数十秒。

超时还要区分连接、首字节、读取和服务端执行。客户端 500ms 超时并不一定取消服务端事务;服务端收到取消信号也不一定能撤销已经提交给支付渠道的请求。取消是一种协作请求,不是时光倒流。

实践中应记录请求的 deadline、实际耗时、超时位置和业务键。只有“请求超时”这一条日志,无法区分网络丢包、排队、下游慢还是服务端已经成功。

从入口 deadline 反推每一跳预算

假设页面愿意等待 2 秒,网关、订单、库存、支付不能各自设置 2 秒超时。订单服务排队和计算已经花掉 300ms,调用库存时剩余预算只有 1.7 秒;库存再访问数据库时要扣掉已经消耗的时间,并保留返回与序列化空间。deadline 应沿调用链传播,服务端收到一个已经过期或不可能完成的请求时尽早拒绝。

连接超时、TLS 握手、连接池等待、请求写入、首字节和响应读取是不同阶段。把它们折成一个模糊的 socket timeout,排障时只能看到总时间。连接池耗尽时,请求甚至还没发到网络;首字节超时可能是服务端排队或下游慢;读取中断则可能发生在响应已经开始之后。分阶段指标能决定是扩容、限流,还是修复下游。

超时值也不能只看平均耗时。应基于同一网络区域与请求类型的尾延迟分布,再考虑可以接受的误超时率。冷连接、证书握手、首次 DNS 解析和部署后连接重建会产生不同分布,混在一起设一个阈值容易在发布时制造尖峰。

取消只能节省后续工作

客户端取消表达“我不再等待这个结果”。服务端收到信号后可以停止尚未开始的计算、取消下游查询和释放资源;已经提交的事务、已经发往第三方的支付请求或已经被消费者读取的消息不会随之回滚。gRPC 的取消说明也要求服务端处理函数主动配合停止工作。

因此,长任务要把可取消阶段与不可撤销阶段分开。图片生成可以在推理前取消;付款进入渠道后要保存渠道请求 ID,并把调用状态转成确认中。取消后的客户端仍可通过业务键查询终态,后台也必须继续完成对账。

三、重试只适合有证据的暂时性失败

重试应有明确触发条件、次数上限和退避。常见可重试信号包括连接尚未建立、明确的过载响应、可判定的临时网络错误;认证失败、参数校验失败、余额不足、幂等键与参数不匹配等永久错误不应重试。

固定间隔重试会让大量客户端在同一秒再次冲向刚恢复的服务。指数退避加 jitter 会把尝试分散开;重试次数也必须受总 deadline 约束。一个简单的原则是:如果下一次尝试已经不可能在用户或上游的时间预算内完成,就不该继续发起。

重试不是免费可用性。它会增加下游负载、放大队列积压,并把短暂问题变成雪崩。对非关键读取,缓存、降级结果或稍后刷新有时比三次同步重试更合适;对关键写入,应优先提供状态查询和异步确认路径。

错误分类决定重试层级

错误 例子 默认动作 原因
参数永久错误 INVALID_ARGUMENT、格式非法 不重试 相同输入不会自行变好
前置条件不满足 余额不足、订单已关闭 修正业务状态后再决定 需要上层重新规划
并发冲突 版本 CAS 失败、事务 abort 重读后重做完整操作 只重放最后一步可能基于旧状态
暂时不可用 过载、短暂网络故障 退避后有限重试 服务可能恢复
deadline 到期 未收到终态 先判断幂等与查询能力 原操作可能已生效

UNAVAILABLE 常被配置为可重试,但它只说明服务或连接暂时不可用,不证明写操作没有发生。gRPC 的状态码指南也提醒,非幂等操作不一定能安全重试。应用需要把传输错误与业务动作的语义结合起来。

并发冲突通常应在更高层重试。读取版本 7、计算新库存、条件写失败后,不能只重发条件写;调用方应重新读取当前版本,再执行完整 read-modify-write。否则它会反复提交基于旧状态的结果。

backoff、jitter 与 retry budget

指数退避让连续尝试间隔逐步增加,jitter 让不同客户端错开。只有这两项仍不足以防止重试风暴,因为大量新请求和历史重试会共同争抢容量。服务可以为重试流量设置 retry budget,例如只允许总请求中的一小部分是重试,超出后快速失败或转异步队列。

每一跳都重试会乘法放大。五层调用链每层最多尝试三次,底层最坏可能收到 243 次请求。通常只让一个靠近业务边界的层负责重试,底层传输库只做能够证明未交给应用处理的 transparent retry。gRPC 的重试文档区分了透明重试与显式策略,也在收到响应 header 后把 RPC 视为 committed,不再由框架重试。

Hedging 是另一种策略:第一份请求尚未失败就向另一个副本发送第二份,取最快响应。它能降低尾延迟,却会增加正常流量,也会让非幂等写产生并发执行。适合可取消、无副作用且副本独立的读取,不应默认用于创建订单。

四、幂等键把“同一次意图”固定下来

幂等不是“接口重复调用返回 200”,而是同一个逻辑动作被重复提交时只产生一次业务效果。创建订单可让客户端生成 Idempotency-Key: o-1,服务端把键、请求摘要和处理结果持久化。

POST /orders
Idempotency-Key: o-1
body: { sku: "sku-42", quantity: 1 }

第一次:原子写入幂等记录 + 创建订单 → 201 { orderId, status }
同键同参数:返回保存的 201 结果
同键不同参数:409,拒绝把两个意图混为一谈
处理中:返回 PROCESSING 或引导查询,不再并发创建

幂等记录要与业务写入处于同一原子边界,或通过可靠状态机保证二者不会分离。先创建订单、再异步写幂等表会留下窗口:进程在两步之间崩溃,重试仍会创建第二张订单。键也需要合理的保留期,至少覆盖客户端、网关和消息系统可能重放的时间;过期后再次使用同一键应有明确语义。

数据库唯一索引能实现部分幂等,例如 UNIQUE(user_id, client_order_id);但它不能替代返回历史结果、参数摘要和处理中状态。支付、发券、发送邮件等外部动作还要向下游传递同一个业务键,不能只在入口去重。

幂等键的作用域、所有者与生命周期

键必须有租户或用户作用域。不同商户都可能生成 order-1,服务端若只对裸字符串做全局唯一,会互相冲突;若只在单实例内存去重,流量切换后又会重复。常见唯一约束是 (tenant_id, operation_type, idempotency_key)。

客户端负责为一次逻辑意图复用同一键。用户修改商品数量后再提交,已经是新意图,应生成新键;网络超时、页面刷新和 SDK 重试则继续使用旧键。服务端保存请求摘要,遇到同键不同参数时明确拒绝,防止调用方误把两个意图合并。

保留期要覆盖所有可能晚到的请求。移动端离线数小时、消息队列延迟数天、人工重放历史任务,都会超过普通 HTTP 重试窗口。永久保存所有键成本很高,可以把已完成键与业务记录绑定,或根据资源生命周期归档;删除前要明确晚到请求会被当作新动作还是过期错误。

并发首次请求需要一个原子胜者

两个相同键可能同时首次到达不同实例。正确实现不能先查“键不存在”,再各自创建资源;这会出现经典 check-then-act 竞态。可以用唯一约束插入幂等记录,只有一个事务成功成为 owner,另一个读取现有记录;也可以使用条件写把状态从空推进到 PROCESSING。

PROCESSING 记录要有 lease 与 fencing version。处理者崩溃后,新处理者在 lease 过期时接管,并递增 fencing version;旧处理者恢复后若继续写入,持久层拒绝旧版本。仅靠锁过期无法阻止暂停进程复活后提交。

保存完整 HTTP 响应很方便,但要考虑响应中的短期 token、时间戳和隐私字段。另一种做法是保存资源 ID 与稳定结果码,再根据当前资源重新构造响应。二者语义不同:前者严格重放原结果,后者可能反映资源后续变化;接口要选择并记录。

五、Exactly-once 要先问“哪一段”

消息系统说 exactly-once,可能指 producer 不重复写入一个 topic,可能指 consumer 的 offset 与本地状态一起提交,也可能指流式计算在崩溃恢复后不重复计算。它们都很有价值,但不自动覆盖数据库、第三方支付和邮件服务。

一次“订单创建后发送确认邮件”跨越订单库、消息 broker 和邮件供应商。即使 broker 保证消费者只拿到一次消息,消费者在调用邮件服务后、提交消费位置前崩溃,恢复后仍可能再次发送。反过来,先提交位置再发邮件,崩溃会导致邮件丢失。

端到端的业务 exactly-once 通常不能凭传输层单独获得。可行的工程目标是:至少一次投递,加上稳定业务键、消费者去重、可查询的外部回执和补偿/对账。对用户而言,重复请求返回同一订单、重复事件不重复扣库存,比承诺一个无法跨所有边界证明的“全局 exactly-once”更可靠。

at-most-once 与 at-least-once 各自丢掉什么

At-most-once 常通过“不重试”或先记录已处理来避免重复,代价是崩溃窗口可能丢失动作。At-least-once 持续重试直到确认,避免静默丢失,却允许重复。Exactly-once 若限定在一个事务系统内部,可以通过原子提交输入位置与输出状态实现;一旦效果跨到系统外部,边界外仍需幂等或对账。

以消费者为例:先提交 offset 再写数据库,二者之间崩溃会丢消息;先写数据库再提交 offset,崩溃会重复消费。若数据库能在同一事务里保存业务结果和 processed_event,重复消费可以被去重;若还要调用短信供应商,供应商是否支持业务键又成为新的边界。

“effectively once”依赖可证明的不变量

工程团队有时用 effectively-once 表示底层可能重复,但最终业务效果等价于一次。这个说法只有在效果与不变量明确时才有意义。计数器使用 eventId 去重后只增加一次;订单状态机拒绝版本倒退;付款渠道以 paymentId 返回同一笔交易。这些机制可以逐项验证。

发送邮件很难撤回,供应商又可能不提供幂等键。系统最多记录已请求发送并减少重复概率,不能诚实承诺收件箱中只出现一封。遇到这类不可控效果,应缩小承诺范围,并保留供应商 messageId 和人工处理方式。

六、Outbox 把本地提交与事件发布接起来

订单创建时,把订单行和 OrderCreated Outbox 行写进同一数据库事务。后台 publisher 读取未发布 Outbox,投递到 broker,并以 eventId 作为去重键。publisher 可能重复投递,消费者必须按 eventId 或业务版本幂等处理;但它不会再出现“订单已提交、崩溃导致事件永远没发”的双写窗口。

数据库事务:orders.insert + outbox.insert(eventId=e-18)
                        ↓
publisher:至少一次 publish(e-18)
                        ↓
consumer:记录 e-18 已处理 + 执行业务更新

Outbox 不是万能事务协调器。它处理的是本地事实与待发布事件的一致性;跨服务的库存、支付和物流仍需要状态机、超时处理、补偿与对账。它也需要监控:未发布 Outbox 积压、发布失败次数、消费者去重命中率和长时间未完成的业务键。

publisher 与 consumer 都可能重复

publisher 把事件发送成功后,在标记 Outbox 已发布前崩溃,恢复后会再次发送。broker 也可能在确认丢失时让 producer 重发。Outbox 解决丢失窗口,默认并不消除重复。事件必须有稳定 eventId、聚合根 ID 和业务版本。

消费者可以把去重记录与本地业务修改放进同一事务:先尝试插入 processed_events(eventId),唯一冲突说明已处理;插入成功则更新业务表并一起提交。若去重记录先提交、业务更新后失败,会丢效果;若业务先提交、去重后失败,会重复效果,原子边界必须覆盖二者。

事件顺序也不能只靠 eventId。订单版本 9 可能先于版本 8 到达。消费者可以拒绝小于当前版本的事件,对版本缺口暂存并请求重放,或使用状态型事件让新版本覆盖旧投影。选择取决于中间事件是否都必须执行。

七、一次订单超时怎样走完整恢复链

用户提交 orderId=o-1,网关 deadline 为 2 秒。订单服务成功写入订单和 Outbox,用时 900ms;响应经过网关时连接断开,页面显示“确认中”。此时订单已经存在,客户端观察仍是 UNKNOWN。

页面使用同一个 o-1 查询。请求命中幂等记录 SUCCEEDED,服务端返回原 orderId,不会重新创建。若查询发生在首次事务提交前,记录可能是 PROCESSING;页面继续轮询,后台不会让第二个 worker 并发执行。

Outbox publisher 发布 OrderCreated(e-18) 后在更新 published 标记前崩溃,于是事件发送两次。库存消费者把 e-18 与库存预占写进同一事务,第二次投递命中唯一约束,只返回已经存在的预占结果。它随后发布 InventoryReserved(version=1)。

订单消费者收到结果后把订单推进到 PENDING_PAYMENT。如果版本 1 的消息重复或晚到,状态机不会重复创建支付。整条链没有依赖网络“只传一次”,而是让每个持久化边界都能识别同一业务意图。

若库存服务请求外部仓库系统后超时,而仓库不支持幂等查询,订单进入 PENDING_CONFIRMATION,自动重试暂停。对账任务根据请求时间、SKU 与仓库流水查询证据;无法确认时进入人工队列。系统没有伪造 exactly-once,而是把无法证明的部分显式隔离。

这条恢复链的完成条件包括:同一 orderId 只有一张订单;同一 eventId 只改变库存一次;任何 UNKNOWN 都能查询;超过时限的处理中状态进入告警;对账操作本身也幂等。每一项都能通过故障注入验证。

八、一个可重试创建订单接口的状态机

状态 调用方下一步 服务端需要保存什么
NEW 可接受首次请求 键、参数摘要、创建时间
PROCESSING 查询或短暂等待 处理者、租约、步骤进度
SUCCEEDED 返回历史结果 orderId、最终响应、版本
FAILED_FINAL 返回确定业务错误 错误码、可展示原因
UNKNOWN 用业务键查询和对账 外部请求 ID、已知证据

将 UNKNOWN 暴露给上层不代表把复杂度推给用户。页面可以显示“正在确认订单”,后台根据订单键查询库存和支付结果;超过阈值进入人工队列。真正危险的是前端把超时当作失败并换一个键重提,从而把一次意图变成两次订单。

九、处理中的请求也需要恢复协议

幂等键第一次抵达时,服务端常先占有一条 PROCESSING 记录。处理节点崩溃后,这条记录不能永久阻塞后续查询。记录应带有开始时间、处理者和租约;租约到期后,恢复任务检查订单、库存或支付的现有事实,再决定继续、标记成功、标记最终失败或进入 UNKNOWN 对账。直接让第二个请求夺取执行权会形成并发双执行。

恢复过程必须使用同一个业务键。假如处理者已经把 o-1 提交给支付渠道,只是没有更新本地结果,接管者应先按渠道请求 ID 查询,而不是再次扣款。租约只解决“谁暂时负责推进”,不证明外部动作没有发生。

十、重试风暴怎样形成,又怎样止住

下游变慢时,入口超时触发重试,重试又把更多请求压进同一个连接池和队列;服务端排队更久,于是更多客户端超时。这种正反馈常让原本能恢复的服务被额外流量拖垮。退避和 jitter 只能减缓同步冲刺,还需要并发上限、熔断、队列隔离和过载时的明确拒绝。

调用链上应指定一层拥有重试权。客户端、API Gateway 和服务 SDK 同时各重试三次,最坏会把一次请求放大到二十七次。通常由最了解业务语义的一层处理有副作用的重试;底层库只重试尚未写出字节或能确定没有送达的连接建立失败。指标要记录每次尝试、最终结果和放大倍率,而不只看成功率。

对于异步任务,重试还要区分业务等待和系统等待。库存暂时不足不是网络暂态错误,隔几毫秒重试没有意义;支付渠道限流可能需要按其 Retry-After 节奏延后;用户取消订单后,仍在队列里的旧任务必须通过订单版本发现自己已过期。把重试原因编码成状态和错误码,才能让调度器使用不同的退避、过期和人工接管策略。

SDK、网关和业务服务只能有一个主要重试者

通用 SDK 看到的是连接与状态码,业务服务知道操作是否幂等,网关掌握入口总 deadline。三层信息各有价值,但不能各自独立重试。一个可操作的分工是:SDK 只做能够证明未进入服务端应用的透明重试;业务服务根据方法语义决定是否重放;网关传播 deadline、限制总尝试次数并记录 attempt。

对于 GET 等安全读取,网关可以在同区域另一个健康实例上做一次快速重试。对于 CreateOrder,网关只有在请求携带稳定 idempotency key 且下游声明支持时才能重试。支付确认可能要求业务编排器先查询渠道,再决定重放;这一判断不应藏在通用 HTTP 拦截器里。

服务端的错误码也要服务于这套分工。INVALID_ARGUMENT 表示同样输入永远不会成功;FAILED_PRECONDITION 要求先改变系统状态;ABORTED 可能要求重做完整读改写流程;UNAVAILABLE 表示暂时不可用,却仍需结合操作幂等性。所有内部异常统一映射为 500,会迫使调用方猜测。

过载时重试信息也是流控协议

服务端知道自己何时可能恢复,可以返回 Retry-After、pushback 或明确的限流码。客户端应尊重该信息,并对最大等待设上限。若服务端已经进入保护状态,客户端忽略 pushback 按本地 100ms 周期冲击,退避策略形同虚设。

令牌桶可以同时约束首次请求与重试,也可以为重试单独保留很小预算。熔断器在连续失败后暂停新尝试,半开状态只放少量探测请求。熔断保护的是调用方资源和下游恢复空间,并不会让业务自动成功;被熔断的写操作仍要进入可查询、可恢复的状态。

十一、把证据留给排障和对账

每一次可能产生副作用的调用至少应关联请求 ID、业务幂等键、尝试编号、下游请求 ID、开始与结束时间、已知状态和响应摘要。它们既帮助在线查询,也让事故后能把“用户说扣了两次”对应到实际尝试。日志不能只记录异常栈,因为异常发生在调用方,并不说明下游最终没有执行。

定期对账是 UNKNOWN 的最后一道保障:订单处于处理中但库存记录不存在,或支付渠道流水存在而订单没有付款状态,都应形成可处理的差异项。对账不会让请求变成 exactly-once,却让少数未能自动收敛的历史有明确出口。

测试也应故意制造这类历史:在服务端提交后断开响应、在外部调用后杀死消费者、重复投递同一事件、让处理租约过期后接管。验收标准不是“没有报错”,而是同一业务键最终只得到一条可解释的订单、付款或补偿记录。

当无法自动确认时,系统应保存证据并升级处理,而不是继续扩大重试范围。

这是接口契约的一部分。

幂等记录也需要容量与清理策略

幂等表会随着请求增长。记录至少包含作用域、key、请求摘要、状态、资源 ID、创建时间、最后更新时间和 lease 版本;若保存完整响应,还要考虑压缩、加密和敏感字段删除。查询索引通常以作用域与 key 为主,过期扫描走独立时间索引,避免清理任务拖慢在线路径。

清理不能只按固定 TTL 一删了之。已退款支付、长期订单和可能被离线客户端重放的请求,生命周期不同。可将短期处理中记录保留到确认完成,再把成功键折叠到业务资源的唯一字段;历史响应过期后,同键重放可以返回资源当前状态或明确的 IDEMPOTENCY_KEY_EXPIRED,不要悄悄创建新资源。

删除与重放之间仍有竞态。清理任务应只删除终态且超过安全窗口的记录,对 PROCESSING、UNKNOWN 和待对账记录禁止回收。归档前还要保留审计所需的业务键与外部流水映射,否则事故发生时只剩一条无法关联的扣款记录。

十二、怎样验证重试与幂等真的成立

最有价值的测试发生在动作已经执行、确认尚未送达的窗口。可以在数据库 commit 后关闭连接,在第三方返回后杀死 worker,在 broker ack 前终止 publisher,再观察重启与重试。只测试“服务端一开始就返回 500”,覆盖不到结果未知。

并发测试应让几十个相同幂等键同时抵达多个实例,确认只有一个业务资源和一个有效 owner;再使用同键不同参数,确认服务端拒绝而非返回旧结果。lease 测试则暂停旧 worker,等待新 worker 接管,再恢复旧 worker,验证 fencing version 阻止过期写入。

重试策略需要压测。让下游逐渐变慢,观察原始请求量、重试量、队列长度与成功率;若下游容量下降 20%,上游流量却因重试翻倍,策略正在放大故障。加入 retry budget、退避和熔断后,应验证负载被限制,恢复时也没有同步洪峰。

对账测试要制造跨系统差异:支付渠道存在流水而订单仍待支付、Outbox 已发布但投影缺失、库存已释放但订单仍取消中。恢复任务应产生唯一、可审计的修复动作,并在重复运行时返回同一结果。

十三、评审接口时检查这些边界

  1. 这个操作是否有副作用,超时后会产生哪几种事实状态?
  2. 重试由谁发起,最大次数、退避和总 deadline 是什么?
  3. 幂等键代表哪个业务意图,同键不同参数怎样处理?
  4. 幂等记录与业务写入是否处于同一原子边界?
  5. 事件会不会重复、乱序或丢失,消费者怎样去重并检查版本?
  6. 外部系统是否接受同一业务键,若不接受如何查询回执和对账?
  7. UNKNOWN 停留多久触发告警,谁有权限补偿或人工确认?

超时、重试和幂等组合起来,让同一次业务意图在重复与失败发生后仍能收敛到一个可解释的结果;它们无法保证每次请求都立即成功。

参考资料