「消息顺序不对」的工单,查到最后往往是三个不同的问题:真的乱序、同一条被处理了两次、以及某一条根本没到。三者都表现为时间线不对,但成因、代价和解法完全不同,用一套方案硬扛的结果通常是乱序没解决、吞吐先没了。这篇把三个问题分开算账,重点讲保序范围该压到多小、重试为什么是最大的乱序来源、以及出向那一半顺序为什么得自己保证。示意按 wecomapi 的事件回调与消息接口组织。
三种症状分开算账,优先级不该一样
先排优先级,再谈方案。丢失是不可逆的,一条没到就是永远没到,只能靠补偿;重复是可逆的,用幂等键能消掉,代价只是一次查重;乱序介于两者之间,多数场景下只影响观感,却最容易被过度设计成全局有序。
- 丢失:优先级最高,兜底手段唯一 —— 可重放的存档加定时对账,没有第二条路。
- 重复:优先级次之,解法成熟 —— 幂等键加去重窗口,站内另有一篇专门讲键怎么拼。
- 乱序:优先级最低,但保序范围必须在设计期就定下来,事后再改要动分区、锁和数据模型。
动手之前还有一个前置判断:确认用户说的「顺序不对」到底发生在哪一侧。客户端展示用的是它自己的时间线,你系统里的处理顺序是另一回事,两者不必然一致。如果只是内部报表或回放页面看着别扭,那是个展示问题,别去改消费链路 —— 这个误判每年都要浪费掉几个人周。
真正值钱的判断在保序范围上。它一旦定错,后面所有工程努力都白费:定得太大,吞吐直接见底;定得太小,因果关系会被打散,而这类 bug 只在高峰期偶现,本地永远复现不出来。
先问一句:这条链路真的需要全局有序吗
几乎没有企业微信集成需要全局有序。全局有序等价于单一消费线程,吞吐上限被钉死在一个 worker 上,任何一条慢消息都会阻塞所有账号的所有会话。你真正需要的是「同一个会话内的事件按发生顺序处理」,这个范围小得多,也便宜得多。
把范围压到会话粒度之后,可并行度就等于活跃会话数,这个数在任何一家公司都远大于消费者实例数。剩下的问题只是把同一个会话的事件稳定路由到同一个消费单元 —— 队列本身怎么切分站内另有一篇,这里只谈顺序键怎么选。
- 1优先取会话维度(单聊的对端、群的群标识)。业务上要求「先看到问题再看到回答」的因果关系,几乎全部发生在会话内部。
- 2需要跨会话保序的,通常是客户维度的状态变更,比如归属、标签、阶段。那就单独用客户键,不要和消息共用一个键。
- 3账号维度只适合做限速,不适合做保序。一个账号下所有会话串行,等于变相回到单线程,还会在活跃账号上形成热点。
顺序键一旦选定,会渗进分区规则、锁粒度和监控维度,属于最难改的一类决定。把它写进设计文档,比写在代码注释里管用得多。
入向乱序的四个来源,三个是你自己造的
平台侧的投递不承诺严格顺序,这条是外部约束,只能适应。剩下三个来源都长在你自己的系统里,而且都比外部因素严重。
- 1并发消费:同一会话的两条事件被两个 worker 抢到,谁先落库取决于调度。最常见,也最容易在压测里被掩盖 —— 压测数据往往没有真实的会话集中度。
- 2重试:最隐蔽的一种。一条失败的消息被丢回队尾,等它再次被处理时,后面几条早就完成了,时间线被自己打乱。
- 3补偿回灌:对账补回来的历史事件和实时事件走同一个通道,那批历史数据会整体插进当前时间线的中间。补偿流要么单独开通道,要么在写入层按事件自带的时间做合并。
- 4扩缩容的交接窗口:消费者实例增减那一刻,同一个键可能短暂被两个实例持有。分区式消费要等旧实例交出所有权,锁式消费要给锁留一个大于最长处理时间的 TTL。
前两项的解法是同一件事:同一顺序键在同一时刻只允许一条在飞。实现上可以按会话分区消费,也可以用一把带 TTL 的会话锁。用 wecomapi 的事件回调时,事件里带有会话标识,那就是天然的顺序键 —— 在入队前算好分区,比在消费端抢锁少一次往返,热点也更容易观测。
这里唯一需要拍板的取舍是:重试要不要阻塞同一个键的后续消息。默认答案是不阻塞 —— 允许跳过,失败那条进死信人工处理。只有当后一条的处理确实依赖前一条的结果时才阻塞,而一旦选择阻塞,就必须给等待设上限,否则一条毒消息会把整个会话卡死,且因为「没有报错」而长时间没人发现。
// 示意逻辑,事件来自 wecomapi 的回调,字段名以线上文档为准
async function onEvent(evt) {
const key = orderKey(evt); // 会话维度,不要用账号维度
await withKeyLock(key, async () => { // 同一键同时只允许一条在飞
if (await seen(evt.id)) return; // 幂等做在产生副作用这一层
try {
await handle(evt);
} catch (e) {
if (retryable(e) && evt.tries < 3) return requeueInPlace(evt);
await deadLetter(evt); // 不阻塞同一会话的后续事件
}
});
}乱序还需要一个能被观测到的量:同一顺序键上「本次事件的产生时间早于上一条已处理事件」的次数。这个计数平时应该是零,一涨就说明并发或重试策略出了问题。没有它,乱序只能靠客户投诉发现,而客户描述的现象和真正的成因几乎从不对应。
出向的顺序,平台不欠你,得自己保证
入向讲完只是一半。你自己连着发两条消息,如果是两个并发请求,到达顺序不保证 —— 这件事在本地永远看不出来,因为本地网络太稳,两次请求的时间差远大于抖动。该怎么控,站内讲消息接口那篇已经给过结论:同一会话的在途消息数限制成 1,会话之间才并发,靠加几十毫秒延时糊过去只是把概率调小到你测不出来。时序视角上要补的是另外两件事。
- 把「一段话拆成三条发」当成一个原子任务:整段串行发送,中途失败要么整体重来(配合出向幂等键),要么明确标记为部分成功,不要让第二条的重试排到第三条后面。
- 出向的并发单元要和入向的顺序键取成同一个:两边都按会话切。取不同的键时,同一个会话在两条链路上被切成不同的并行单元,你在入向辛苦保住的因果关系会在发送这一步被重新打散。
// 同一会话内必须等上一条返回;跨会话的并发交给上层调度
async function sendInOrder(guid, toId, lines) {
for (const [i, content] of lines.entries()) {
const r = await post("/message/sendText", { guid, toId, content });
if (!r.ok) throw new Error(`stopped at line ${i}`); // 别跳过失败继续发
}
}还有一个容易忽略的先后关系:你发出的消息可能以事件形式再回到自己的回调里。如果处理逻辑认不出这是自己发的,就会出现机器人回复自己的循环,而且它在低流量时不一定复现。判断要用发送时留下的标记,别靠内容匹配 —— 内容匹配会在客户原样复述一句话的时候失效。
排序依据取什么,比怎么排更重要
很多排序逻辑写得很认真,输入却是错的。排序算法不会骗人,时间戳会。
- 不要用你自己的接收时间戳。它测的是你的调度延迟,并发、GC 和跨机器时钟都会让它失真。
- 优先用事件自带的产生时间或序号,并且只在同一来源内比较;跨账号、跨节点的时间戳之间本来就不可比。
- 没有可靠序号时,把「顺序」降级成「因果」:只保证有依赖关系的动作先后正确,比如回复必须发生在收到之后,其余按到达处理。
还有一类顺序问题和排序无关:状态类事件和消息事件混在一起时,谁先谁后直接决定业务对不对。「客户被转接」和「客户发来一句话」几乎同时到达,两者走不同的顺序键,回复就可能落到旧的接待人身上。遇到这种跨类型的因果,正确做法不是把两类事件塞进同一个队列去排队,而是在处理消息时重新读一次当前归属 —— 让状态以读取为准,而不是以到达顺序为准。
如果确实要还原时间线(会话回放、时间线展示、导出对账),可以在交付前加一个短窗口的排序缓冲:窗口内的事件先攒住,按时间排好再交付。代价很实在 —— 所有消息都要付出这个固定延迟。判断很清楚:展示型链路值得,实时应答链路不值得,两条链路应该分开走,别为了一个回放页面让机器人回复慢一秒。
重复靠幂等,丢失只能靠对账
换一个「更可靠」的中间件解决不了丢失,因为丢失的位置通常不在中间件里。它多半发生在 ACK 之后、持久化之前的那几毫秒,或者藏在某个 catch 住异常却没有重新入队的分支里 —— 两处都在你自己的代码里。
- 1ACK 之前只做一件事:把原始报文落到可重放的存档里。这是全站一致的口径,也是后续所有补偿手段的地基。
- 2消费失败的终点必须是死信队列,不能是日志。日志里的失败没有人会去捞,而死信有存量、有告警、能重放。
- 3定时对账:按会话维度拉一个时间窗内的记录,和本地存档比对,缺的补进来。频率按业务容忍度定,小时级通常够用。
对账的前提是两侧能用同一个会话键读出来。接入前先核一遍读取接口和事件回调用的是不是同一套会话标识 —— 在 wecomapi 这类统一网关上一般是一致的,自己拼多套接口形态时就要先做一层身份映射,否则对账脚本第一行就写不下去。
对账还有一个常被忽略的用途:它是唯一能把「到底丢没丢」量化出来的手段。很多团队凭印象说系统偶尔会丢消息,真跑一次对账才发现缺口全部集中在某个时间段,对应的是一次发布或一次网络抖动。没有这个数字,这类讨论只能停在互相怀疑上。
三件事分开治:丢失靠存档与对账,重复靠幂等键,乱序靠顺序键。任何一套想同时解决三个问题的设计,最后都会退化成全局串行。精确的事件结构与字段以 wecomapi 线上文档为准。
常见问题
- 必须保证全局顺序吗?
- 几乎不需要。全局有序意味着单线程消费,吞吐被钉死,且一条慢消息会阻塞全部账号。把保序范围压到会话粒度,可并行度等于活跃会话数,这是唯一可持续的做法。
- 乱序和重复能用同一套机制解决吗?
- 不能。顺序键解决「谁先谁后」,幂等键解决「同一条只生效一次」,两者取的维度往往不同 —— 顺序键取会话,幂等键取事件本身。分开设计,互不替代。
- 怎么确认真的丢了一条?
- 只有和权威侧比对才算数:按会话维度拉一个时间窗的记录,和本地存档逐条比。做之前先看清 wecomapi 文档里读取接口的时间窗与分页语义,边界条件搞错会把「没丢」查成「丢了」。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
