分布式系统如何处理重复请求和乱序响应?
简化版
重复请求用稳定的业务幂等键识别,并原子地保存“请求键—处理结果”;乱序响应则用单调版本、请求序号或状态机拒绝旧结果覆盖新状态。超时后不能假设请求未执行,重试、结果查询、幂等和顺序校验必须一起设计。
详细版
重复常来自客户端重试、网关重放、MQ 至少一次投递和响应丢失;乱序来自并发请求、不同网络路径、重试跨节点和异步处理速度差异。两者是不同问题:幂等防止同一业务动作重复生效,版本控制防止较旧动作在较新动作之后落地。
写操作应由调用方生成与业务动作绑定的幂等键,服务端用数据库唯一约束原子抢占,并保存成功结果供重试返回。对账户、订单等状态更新,还要携带 expectedVersion 或序列号,只有版本匹配且状态迁移合法才更新。前端搜索一类只关心最新响应的读请求,可记录递增 requestId,忽略小于当前最新序号的返回。
去重记录必须定义作用域、TTL 和参数摘要:同一幂等键但参数不同应报冲突,不能误返回旧结果。分布式锁不是最终兜底,因为锁可能过期且无法保存历史处理结果;数据库唯一约束、状态机和结果表更可靠。
完整版教学
一、重复和乱序是怎么产生的
调用方 300ms 未收到响应时无法知道服务端是否执行成功,重试会生成第二次请求。消息系统为了避免丢失通常采用至少一次投递,消费者处理完成但确认丢失时也会再次收到同一消息。重复是可靠传输机制的常见副产品,而不是罕见异常。
乱序则不要求发生故障。请求 A 先发但执行 500ms,请求 B 后发只执行 50ms,B 就会先返回。若 A 的旧结果最后覆盖 B 的新结果,用户会看到状态倒退。
记忆钩子:重复问“是不是同一件事”,乱序问“这件事还够不够新”;分别用幂等键和版本号回答。
二、幂等键怎样设计才稳定
幂等键应标识一次业务意图,而不是一次网络尝试。支付创建可用商户订单号,消息处理可用事件 ID;每次重试若重新生成 UUID,服务端仍会把它们当作不同操作。键还要带业务作用域,避免不同租户或接口碰撞。
服务端通常同时保存参数摘要、状态和结果。同一键再次到达且参数摘要相同,就返回已保存结果;参数不同则返回冲突,防止调用方误复用键。记录处于处理中时可以返回处理中或等待,不能再次执行业务。
idempotency_key = tenant_id + operation + business_order_no
record = { key, request_hash, status, response, expire_at }
三、并发请求必须原子抢占
先查“键不存在”再插入不是原子操作:两个节点可能同时查不到并都执行。应依赖唯一索引插入,只有抢占成功者进入处理;其他请求读取已有状态。业务写入和幂等结果最好处在同一本地事务中,避免业务成功但结果记录丢失。
INSERT INTO request_dedup(idem_key, request_hash, status)
VALUES ('pay:order-42', 'a91...', 'PROCESSING');
-- idem_key UNIQUE;重复插入失败后查询原记录
若外部副作用不能放在数据库事务内,要用状态机、outbox 或可查询的第三方业务号恢复未知结果,而不是简单删除 PROCESSING 记录重做。
四、版本号如何阻止旧写覆盖新写
假设用户先把地址改为 B,随后改为 C。请求 B 因网络慢最后到达,如果服务端仅按到达顺序覆盖,状态会退回 B。客户端携带读取到的版本,服务端使用 CAS,只有当前版本等于预期值才提交,可显式暴露冲突。
| 场景 | 顺序标识 | 处理旧结果 |
|---|---|---|
| 数据库对象更新 | version/ETag | 条件更新失败,重新读取或合并 |
| 前端搜索建议 | requestId | 小于最新请求号的响应不渲染 |
| MQ 同一聚合根 | aggregateVersion | 暂存、重试或进入异常队列 |
| leader 写入 | term + log index | 拒绝低任期写 |
| 设备遥测 | device sequence | 去重并检测缺口 |
时间戳不如版本可靠,因为不同节点时钟可能偏移;版本应由拥有该状态的权威方生成。
五、状态机比简单序号更懂业务
版本只能说明新旧,不能说明迁移是否合法。订单 PAID -> CREATED 即使携带更大版本也不应允许;退款事件晚到时则可能需要进入专门的补偿路径。服务端必须同时检查当前状态和允许的目标状态。
CREATED -> PAID -> SHIPPED -> COMPLETED
\-> CANCELLED
禁止:COMPLETED -> CREATED
条件:SHIPPED 后的取消需走 RETURNING,而非直接 CANCELLED
事件消费者可按聚合根维护最后版本。收到 v12 而当前只有 v10,说明 v11 缺失,不能盲目应用;可等待一段时间、回源拉取快照,或将其转入异常处理。
六、去重窗口和存储成本怎么权衡
幂等记录不能无期限保存,也不能过早删除。TTL 至少覆盖客户端最大重试窗口、消息最长保留期和人工重放周期。若消息可能在 7 天内重放,去重记录只留 1 小时,重放仍会产生副作用。
假设每天 1 亿次请求,每条去重记录含索引约 200B,保存 7 天原始量约 100000000 × 200 × 7 ≈ 140GB,还未计副本。可以按日期分区、冷热分层,或使用业务结果表本身的唯一键,不能因为成本直接取消正确性保护。
前端读请求的乱序保护只需保留当前会话最新序号,成本很低;不要把所有问题都套成持久化去重表。
七、怎样测试异常路径
测试要主动制造“执行成功但响应丢失”:服务端提交事务后断开连接,客户端重试,验证业务只生效一次且拿到相同结果。还要并发发送相同幂等键、相同键不同参数,并交换 v10/v11 事件到达顺序。
指标包括重复请求数、幂等命中率、键冲突数、长时间 PROCESSING 记录、旧版本拒绝数、事件版本缺口和乱序等待时间。幂等命中突然升高常意味着上游超时或网络问题,不能只当作服务端正常去重。
dedup_hits: 1842
idempotency_parameter_conflicts: 3
stuck_processing_over_5m: 0
stale_version_rejections: 27
event_sequence_gaps: 2
八、常见误区与追问
- 误区:接口用了 UUID 就具备幂等性。 如果每次重试生成新 UUID,服务端无法识别同一业务动作。
- 误区:先查询再插入去重记录足够安全。 并发节点可能同时查不到,必须靠唯一约束或原子操作抢占。
- 误区:按时间戳较大者覆盖就不会乱序。 时钟偏差和业务状态机会让这种覆盖丢失正确更新。
- 误区:GET 天然幂等,所以无需考虑乱序。 读操作可能不改服务端,但旧响应仍会覆盖界面上的新结果。
- 追问:幂等记录一直处于处理中怎么办? 查询业务事实或外部系统结果,按状态机接管;不能未经确认就删除重做。
- 追问:分布式锁能否代替幂等表? 不能,锁只约束一段并发时间,不保存已完成历史,租约过期后重复仍可能执行。
- 追问:事件严格有序是否必要? 通常只需同一聚合根或分区内有序,全局有序成本高且多数业务不需要。
九、加强记忆
按“键、原子、结果、版本、状态”处理重复与乱序:用稳定业务键识别同一意图,唯一约束原子抢占,保存结果供重试复用;再用单调版本拒绝旧更新,并由业务状态机判断迁移是否合法。超时产生的是未知状态,不是失败证明,因此查询、重试和幂等必须形成闭环。