乱序是怎么发生的
老王遇到过一次诡异的事:订单 10086 明明是「已发货」,积分系统却按「已下单」算了一笔新手积分。追日志发现,两条消息一前一后发出,却被分到了两个队列,两个消费者并行处理,快的跑赢了慢的。并行是吞吐的来源,也是乱序的来源——鱼与熊掌的关系。
乱序的三个惯犯:
- 多队列并行:同一业务的消息落在不同队列,消费完成时间不可控;
- 消费重试:第一条处理失败进重试,第二条处理成功先落了地;
- Rebalance 位点回退:队列被分给新消费者后从旧位点重放,后面的消息可能先于前面的消息被再次处理。
全局有序:听起来完美,用起来劝退
让所有消息严格按发送顺序被消费,方法只有一个:整个 Topic 一个队列,队列里单线程消费。顺序是有了,吞吐也归一了——每秒几千条的口子,大促直接堵死。全局有序在业务系统里基本是空中楼阁,除非万不得已,别碰。
分区内有序:99% 场景的正解
回看业务需求:订单 10086 的「下单→支付→发货」必须有序,但订单 10086 和订单 10087 之间谁先谁后,用户根本不关心。业务要的不是全局有序,而是「同一业务 key 内有序」——这就是分区内有序。
实现只要两步。第一步,生产端按 key 路由,让同一个订单的消息永远进同一条队列:
sendResult = producer.send(msg, (mqs, message, arg) -> {
Long orderId = (Long) arg; // 业务 key
int index = Math.abs(orderId.hashCode()) % mqs.size();
return mqs.get(index); // 同单永远同队
}, order.getId());Kafka 天生如此:按 key 哈希进同一 partition,partition 内天然有序,不用写选择器。第二步,消费端对这条队列串行消费:RocketMQ 用 MessageListenerOrderly,队列加锁加消费锁,保证单线程处理;Kafka 单个 partition 只分配给组内一个消费者,天然串行。
顺序的代价:队头阻塞
串行是拿吞吐换的。更要命的是队头阻塞:队列里第一条消息处理失败,顺序消费不能跳过它(跳过就乱序了),只能原地重试——RocketMQ 的顺序消费会把这条队列挂起本地重试,重试期间整个队列停摆,后面的消息全堵着。一条毒消息卡一条队列,这就是顺序消费最大的雷。所以顺序消费的重试策略、告警和人工介入通道,要提前设计。
兜底方案:让乱序失去杀伤力
工程上还有一个思路:与其消灭乱序,不如让业务对乱序免疫。还记得第 7 篇的状态机吗?带前置状态校验的更新天然免疫乱序:
-- 「已下单」消息迟到时,状态已是 38,UPDATE 影响 0 行,直接丢弃
UPDATE orders SET status = 30 WHERE id = 10086 AND status = 20;再进一步,消息里带上版本号或时间戳,消费端只接受比自己新的——这和数据库乐观锁是同一个思想。能改业务逻辑免疫乱序的,优先改逻辑;改不了的,再上分区内有序。
要不要顺序的判断表
| 场景 | 需要顺序吗 | 方案 |
|---|---|---|
| 同一订单的状态流转通知 | 需要 | 订单号 key 路由 + 分区内串行,或状态机免疫 |
| 不同订单之间 | 不需要 | 并行消费,吞吐优先 |
| 数据库 binlog 同步 | 同一行需要 | 表名+主键做 key 路由(Canal 的标准做法) |
| 埋点日志上报 | 基本不需要 | 乱序无害,全力吞吐 |
小结
乱序是并行的影子,治法分三层:能免疫的用状态机和版本号,必须有序的按 key 路由加分区内串行,全局有序能不用就不用。用顺序消费前先问一句:我承受得起队头阻塞吗?
可靠性的四大金刚——不丢、不重、有序——到这一篇集齐了。下一站开始进阶特性:延迟消息。订单 30 分钟不支付自动取消,除了定时扫表还有没有优雅解法?
评论 (0)