消息语义、幂等与顺序
Category
networking / IPC。节点定位:投递语义(至多/至少/恰好一次)与业务语义(幂等、顺序、唯一身份)的收敛。
Definition
消息系统真正难的地方,通常不在“能不能发出去”,而在“发出去以后到底算什么语义”。一次投递失败,是不是可以重试;一次消息重复,是不是会产生副作用;两个事件先后到达顺序错了,系统还能不能解释。
这几个问题常常被分开讨论,但在线上它们几乎总是一起出现。因为只要系统存在重试,就会有重复;只要存在并发和缓冲,就会有乱序;只要存在异步处理,就必须讨论业务语义到底如何收敛。
先分清“投递语义”不是“业务语义”
很多团队一上来讨论“至少一次”“至多一次”“恰好一次”,但真正影响业务的,是消息到达以后会产生什么副作用。
例如:
- 至少一次投递,业务上可能依然是安全的,只要消费幂等
- 至多一次投递,看起来不重复,但可能直接丢掉关键事实
- 所谓恰好一次,即便底层尽力支持,业务侧通常仍然要处理重复感知和重放问题
所以投递语义只是基础设施语义,业务最终是否正确,仍然取决于幂等设计和状态收敛方式。
Problem
为什么幂等几乎是必备能力
只要系统里存在下面任何一种情况,就几乎一定需要幂等:
- 消息重试
- 消费者超时后重新拉取
- 上游因为没拿到确认而重复发送
- 客户端或任务调度器主动重放
这意味着,很多重要操作都必须回答一个问题:如果同一件事被处理两次,会发生什么。
典型例子包括:
- 战斗结算事件重复消费,是否会重复发奖
- 支付回调重复到达,是否会重复发货
- 成就进度更新被重复触发,是否会错误累加
没有幂等设计,异步系统只要一进入恢复流程,就很容易制造重复副作用。
顺序为什么总是比想象中更贵
很多团队会自然假设“消息发出的顺序就是处理顺序”。这在单线程、低并发、无重试时也许还勉强成立,一旦引入分区、并行消费、失败重试和积压恢复,顺序成本就会迅速上升。
真正要问的不是“能不能保序”,而是:
- 需要全局保序,还是只需要局部保序
- 是按玩家保序、按房间保序,还是按订单保序
- 乱序后能否靠版本号、时间戳或状态机自我修正
顺序要求越宽,系统吞吐和扩展性通常越差;顺序要求越窄,系统越容易并行扩展。
更现实的做法是缩小顺序作用域
真实工程里,更稳妥的方式通常不是追求全局严格顺序,而是缩小顺序作用域。例如:
- 同一玩家的资产操作保序
- 同一房间的结算事件保序
- 同一订单状态流转保序
这样既能保住必要语义,又不会把整个系统都绑死在单线程处理上。
消息语义设计时,先把“唯一身份”定出来
幂等和顺序要想真正落地,首先需要业务上有清楚的唯一身份。例如:
- 一场战斗的结算 ID
- 一笔订单的业务单号
- 一次奖励发放的唯一请求号
- 一次迁移操作的事务编号
没有这些唯一标识,系统就无法判断“这是新消息”还是“老消息重放”,后面幂等和补偿都无从谈起。
一个更贴近工程实际的消费策略
比较稳妥的消费设计通常会同时包含几件事:
- 基于业务 ID 做去重
- 基于状态机判断当前消息是否还能生效
- 在必要范围内维持局部顺序
- 对无法立即处理的消息给出重试、延后或死信去向
这样设计的重点不是追求某种理论上的完美语义,而是让系统在重复、乱序和失败同时出现时仍然能收敛。
“恰好一次”为什么常被误解
工程上最容易被神化的就是“恰好一次”。它不是没价值,而是经常被错误理解成“只要底层支持,业务就不用再考虑重复和幂等”。现实里,跨服务、跨存储、跨消费者的完整业务链路很难真正把副作用全部做到绝对唯一。很多时候更可靠的做法反而是:
- 底层接受至少一次
- 业务层明确幂等
- 审计层保留可追踪记录
这套组合通常比盲目追求“绝不重复”更可落地。
常见误区
最常见的错误,是把底层投递语义直接当成业务正确性的保证。第二类错误,是对所有消息要求全局保序,结果系统并发能力被完全锁死。第三类错误,是没有唯一业务标识和状态机约束,导致重复、乱序和重试一来,业务副作用立刻失控。
实战案例:一次「奖励发了两次,成就进度还倒退」的唯一身份缺失排查
案例为教学示例(非摘自真实项目),数字经过取整简化。它把本页「为什么幂等几乎是必备能力」「更现实的做法是缩小顺序作用域」「消息语义设计时,先把“唯一身份”定出来」用到深水区:重试必然存在,重复是不设防的后果——这是奖励发放的重复与乱序排查,也是「只要系统存在重试,就会有重复;只要存在并发和缓冲,就会有乱序」的完整展开。
背景与现象:某项目把奖励发放与成就进度消费改成多分区并行消费追求吞吐,重试策略照常开着:重复发奖日均 217 起,成就进度乱序工单日均 83 起(跨分区并行乱序 54 起 + 积压恢复重放旧事件 29 起),两账合计 300 起;大促当天重复发放点券 18 万,一批玩家的成就进度出现倒退。
第一步:现象与口径——重复、乱序、基数三笔账
重复口径:同一次发放被处理两次即算重复,日均 217 起,全部来自超时重拉与失败重试——没有唯一业务标识,重试即重复。
乱序口径:进度事件先到、解锁事件后到被状态机拒绝,日均 83 起,其中跨分区并行乱序 54 起、积压恢复重放旧事件 29 起——玩家看到的是进度倒退。
基数口径:217 + 54 + 29 = 300 起构成归因基数,按 72% / 18% / 10% 三分账。
第二步:分层归因——按本页幂等与顺序清单排查
重复与乱序失控归因 占比
无唯一业务标识,重试即重复发奖 72%
跨分区并行,进度与解锁事件乱序 18%
积压恢复重放,旧事件覆盖新事件 10%逐项比对:本页列的四种幂等触发(消息重试、超时重拉、上游重复发送、主动重放)命中前两种;顺序作用域没有缩小,多分区并行消费把玩家级顺序打散;积压恢复重放没有状态机兜底,旧进度事件把新事件顶掉。三类都在,无唯一标识那条占比最大:217 + 54 + 29 = 300,72% + 18% + 10% = 100%,账能对上。
第三步:根因——唯一身份缺席,幂等与顺序都无从谈起
本页原话:只要系统存在重试,就会有重复;只要存在并发和缓冲,就会有乱序。没有这些唯一标识(结算 ID、订单号、奖励请求号、迁移事务号),系统就无法判断“这是新消息”还是“老消息重放”——投递语义只是基础设施语义,业务正确性还要靠幂等设计和状态收敛。
第四步:处置与回填——把唯一身份和作用域一起收回来
- 唯一身份先行:结算 ID、订单号、奖励请求号、迁移事务号四类必填,消费端基于业务 ID 去重、基于状态机判断当前消息是否还能生效。
- 顺序作用域缩小:只按玩家、房间、订单局部保序,清理「全局保序」默认值,恢复并行扩展能力。
- 语义组合落地:底层接受至少一次,业务层明确幂等,审计层保留可追踪记录,重试、延后与死信去向明确。
复核账:重复发奖 217 起/日 → 3 起/日;进度乱序工单 83 起/日 → 2 起/日;关键链路幂等键覆盖 0% → 100%。
回填清单:
| 回填项 | 动作 | 回填处 |
|---|---|---|
| 幂等四情形 | 重试、超时重拉、重复发送、主动重放四情形进立项评审 | 为什么幂等几乎是必备能力 |
| 顺序作用域 | 按玩家、房间、订单局部保序,禁止全局保序默认值 | 更现实的做法是缩小顺序作用域 |
| 唯一身份登记 | 结算 ID、订单号、奖励请求号、事务号四类必填 | 消息语义设计时,先把“唯一身份”定出来 |
| 消费策略四件 | 业务 ID 去重、状态机判断、局部顺序、死信去向 | 一个更贴近工程实际的消费策略 |
案例的三个教训
- 重试是默认存在的,重复是不设防的后果——没有唯一身份,恢复流程本身就是发奖事故。
- 全局保序是对吞吐征税——顺序要求越宽,系统吞吐和扩展性通常越差,先问到底谁需要保序。
- 投递语义不等于业务正确——至少一次加幂等加审计,比神化「恰好一次」更落地。
Used By
支付回调、奖励发放、跨服事件等会重复、会乱序的链路设计。
Related
上游:通信模式(02)与主/外围判别(03)。下游:结算权威(server 树与教程第 18 讲口径)、经济防刷(economy 树,规划中)。关系链:投递语义 → 幂等键 → 顺序作用域 → 业务收敛。
Reference
本页为工程经验归纳;页内实战案例为教学示例(数字取整简化)。