【背景】
去年我们做订单履约链路,一条 Kafka 消息进来,消费者要做三件事:
- 写订单状态到 MySQL(
status: PENDING → CONFIRMED) - 调物流 API 创建运单
- 写 Redis 缓存(订单详情)
崩溃可能发生在任意一步之后、下一步之前。举两个真实踩过的坑:
- 步骤 1 写成功,步骤 2 调 API 超时(实际对方已收到),消费者 crash → 重投后步骤 1 重复写,步骤 2 重复调 API,物流那边多了一条运单,人工对账对了一下午。
- 步骤 2 成功,步骤 3 写 Redis 时连接池耗尽,消费者 crash → 重投后步骤 2 又调了一次 API,这次对方直接 500(因为运单已存在),但我们的状态机没兜住,整条消息被判为失败进了死信队列。
这就是「崩溃窗口」问题:ack 之前 crash,重投后哪些步骤会重复执行?
【常见分歧——三种做法】
方案 A:全幂等(每步独立幂等,不记状态)
- 写 DB:
INSERT ... ON CONFLICT (order_id, step) DO NOTHING - 调 API:带
idempotency-key(通常用orderId + step拼),服务端去重 - 写 Redis:
SET key value NX EX 3600 - ✅ 简单,不依赖额外状态存储,消费者无状态
- ❌ 写放大(每步都要判重)、幂等键跨服务传递链路长、第三方 API 的幂等支持参差不齐(有的只支持 24h 窗口,有的根本不支持)
方案 B:状态机 + Outbox(记状态,按状态续做)
- 加一张
fulfillment_progress(order_id, step, status, updated_at, retry_count) - 每步推进:
PENDING → STEP1_DONE → STEP2_DONE → DONE - 崩溃后重启,读当前状态,从断点续做
- ✅ 状态可追溯(出问题时一眼看到卡在哪步)、不依赖每步幂等、排障友好
- ❌ 多一张表(但通常就几个字段)、状态机设计有维护成本、并发消费同一 order_id 时需要锁(行锁 or 乐观锁)
方案 C:事务消息(RocketMQ 事务消息 / Kafka 事务)
- 把「写业务库 + 发消息」放进本地事务,消息在事务提交后才对消费者可见
- 消费者侧用
@Transactional+ 手动 ack - ✅ 语义最干净,「消息可见 = 业务已落库」
- ❌ 强依赖中间件(Kafka 事务主要管 producer 侧,consumer 侧的 exactly-once 限制多)、调试复杂(事务超时、悬挂、回滚的边界 case 多)、RocketMQ 事务消息的 Half Message 在 broker 侧有额外存储开销
【我的倾向与理由】
大多数内部系统(非金融核心链路),我倾向 方案 B(状态机 + Outbox),三个理由:
-
可追溯性 > 优雅性:出 P1 的时候,我宁可查一张状态表看到「卡在 STEP2,retry_count=3,最后一次 updated_at 是 14:23:17」,也不想在幂等表里 grep「这条 order_id 到底有没有被处理过」。状态机把「做到哪一步」变成了显式数据,而不是靠推断。
-
不依赖第三方幂等:调物流、调支付、调短信,每个下游的幂等支持都不一样。状态机把「我做到了哪步」的记录留在自己手里,不依赖对方「请帮我记住这个 idempotency-key 24 小时」。
-
并发控制更可控:同一 order_id 只有一条状态记录,用 MySQL 行锁(
SELECT ... FOR UPDATE)或乐观锁(WHERE step = 'STEP1_DONE')就能防并发。比「每个下游都自己判重」的分布式协调成本低一个量级。
但如果是轻量场景——只有一两步、没有外部调用、QPS 不高——方案 A 完全够用,别为了「架构优雅」加一张状态表。过度设计比欠设计更贵,因为维护成本是长期的。
方案 C 我不太推荐,主要因为 Kafka 事务的 consumer 侧支持有限(isolation.level=read_committed 只是读端过滤 aborted 事务,真正的消费端 exactly-once 还是要靠幂等),而 RocketMQ 事务消息的 Half Message 机制在 broker 侧有额外复杂度,除非你团队对 RocketMQ 事务消息的边界 case 非常熟,否则坑比好处多。
【想听大家在什么场景下怎么选】
-
你们的消费端「崩溃窗口」兜底用的是哪种方案? 有没有从 A 迁到 B(或反过来)的经历?迁移时踩了什么坑?
-
状态机方案的并发控制:同一 order_id 被两个 consumer 实例同时消费(比如 rebalance 窗口内),你们怎么防?行锁、乐观锁、还是分布式锁?有没有踩过「锁粒度太粗导致吞吐上不去」或「锁粒度太细导致状态被覆盖」的坑?
-
第三方 API 的幂等键:你们是统一网关层加
idempotency-key,还是每个消费者自己拼?跨服务传递时有没有丢失、冲突、或者对方幂等窗口过期导致「幂等失效」的情况? -
一个更尖锐的问题:如果你们的状态表本身成了瓶颈(比如高 QPS 下
fulfillment_progress表的行锁竞争),你们怎么拆?按 order_id 分表?还是换 Redis 做状态存储(但 Redis 持久化又是新的崩溃窗口)?
欢迎拍砖,也欢迎贴你们线上的真实案例——尤其是「翻车之后怎么修的」那种,比「我们用了 XX 方案」更有价值。