背景
社区里关于幂等键怎么设计、去重窗口开多大、TTL 怎么对齐的讨论已经很充分了(点赞过百的文章和提问都有)。但我在过去两年里发现,真正让业务状态"打乱"的,往往不是重复消费,而是乱序到达——而这类问题在幂等键体系里是完全放行的,因为乱序消息的幂等键各不相同,去重表看它们都是"新消息"。
一个典型场景:订单履约链路,同一笔订单要依次发生 LOCK_STOCK → DEDUCT_PAY → SHIP 三个事件。正常情况下顺序没问题。但当 DEDUCT_PAY 消费失败进重试 topic、10 分钟后重投成功,此时 SHIP 可能已经先于 DEDUCT_PAY 被消费了(因为 SHIP 是另一条链路异步触发的)。幂等键不同,两条消息都通过了去重,但业务状态机出现了"已发货但扣款还没落"的中间态。
常见分歧
我观察到社区里大致有三派:
第一派:顺序是业务层的事,中间件不该背 KPI。 消费端自己维护 (entity_id, event_seq),收到 seq 小于当前已处理 seq 的消息就丢弃或进延迟队列补序。好处是灵活,坏处是每个消费端都要自己实现一套版本检查逻辑,而且"补序"的窗口和策略没有统一标准。
第二派:用 Kafka 分区亲和 + 单线程消费保证 per-key 顺序。 同一 order_id 的消息永远落同一分区、同一消费者线程串行处理。好处是消费端无脑,坏处是热点 key(大商户)直接打满单分区吞吐,而且跨 topic 的事件(比如 SHIP 来自另一个 topic)根本没法用这个方案。
第三派:放弃顺序保证,用事件溯源 + 最终一致收敛。 所有状态变迁都是幂等且可重放的,消费者不关心到达顺序,只要最终状态机收敛到正确状态就行。好处是架构最干净,坏处是要求业务逻辑本身满足"可交换"或"可补偿",很多业务(比如库存扣减、资金划转)天然不满足。
我的倾向与理由
我偏向第一派 + 有限补偿,具体做法:
- 中间件只保证分区亲和(同一业务实体落同一分区),不承诺全局顺序;
- 消费端按
(entity_id, event_type, event_seq)做单调递增检查:seq ≤ 已处理 seq 的消息进延迟队列(Redis ZSET,score=期望处理时间),不立即丢弃(因为可能是网络抖动导致的真乱序,丢弃会丢事件); - 延迟队列设一个最大补序窗口(我们设的是 30 分钟),超过窗口的乱序消息进死信 + 告警,人工介入判断是"真乱序"还是"事件本身就该被跳过";
- 跨 topic 的事件不追求严格顺序,只保证因果依赖——如果
SHIP依赖DEDUCT_PAY完成,则在SHIP消费时检查前置事件状态,不满足则进延迟队列等。
理由:方案二(单线程串行)在热点 key 场景下吞吐瓶颈太明显;方案三(事件溯源)对业务改造成本太高,而且"可补偿"在资金场景下往往做不到(钱扣了退不回来)。第一派把顺序判断下沉到消费端,灵活但需要团队对"哪些事件有因果依赖、哪些可以乱序"有清晰的领域建模——这本身比写代码更值钱。
想听大家
- 你们在哪些具体场景下愿意用吞吐换顺序(单线程串行)?什么量级的热点 key 开始让你放弃这个方案?
- 跨 topic 因果依赖你们怎么处理的?是消费时查前置状态(我这种方式),还是引入一个轻量的"事件编排"层(类似 Temporal / Cadence 的思路)?
- 延迟补序队列的窗口你们设多大?有没有遇到过"超过窗口但其实是正常延迟"的情况?怎么区分?
- 有没有人在生产里验证过**方案三(事件溯源 + 最终一致)**并跑通了?业务改造成本大概什么量级?
欢迎贴具体配置、代码片段和踩坑数据,比抽象讨论更有价值。