这几年我一直在和消息队列、数据库、分布式事务这些东西打交道。如果你也在维护一套核心交易链路,大概率体会过那种“所有环节看起来都正常,数据却对不上”的窒息感——消费者组重新分配了,消息被重复消费了,明明开了事务却还是出现了脏数据。今天这篇,就把业务修罗场里最要命的三个坑放在一起说透:消费者组、Exactly-Once 语义,以及事务。这三样东西单独拎出来都有固定答案,但一旦组合到一个真实的业务系统里,问题就会像多米诺骨牌一样连环倒下。
这篇内容适合正在做订单、支付、库存、积分这类强一致业务的开发者,也适合准备大厂后端面试、想搞清楚“分布式事务一致性到底怎么落地”的同学。我会从底层原理讲起,再给出一套可以直接套用的设计思路和踩坑清单,保证你看完不只是“知道概念”,而是能在下一次方案评审时有底气地说出“这里该用哪种一致性模型、在哪里兜底、在哪里允许最终一致”。
1. 消费者组:业务分区的调度中枢
1.1 一张分区表背后的调度逻辑
先聊消费者组。很多人对它的理解停留在“一组消费者一起消费消息”,但这个定义太模糊,实际工程里它解决的是两个非常具体的问题:负载均衡和故障转移。
以 Kafka 为例,一个主题下有若干个分区(Partition),同一个消费者组内的消费者会分摊这些分区。这里有一条硬性规则:同一个分区,在同一个消费者组内,同一时刻只能被一个消费者实例消费。这条规则意味着什么?假设一个主题有 6 个分区,你的消费者组里有 3 个实例,那么理想状态下每个实例消费 2 个分区;如果你把消费者实例扩到 8 个,反而会有 2 个实例空转。
我见过很多新人在设计阶段纠结“消费者数是不是越多越好”,这里可以给一个最简单的心法:消费者组内的消费者数量,最多只能等于分区数,超过一个就有一个闲着。但注意,这是针对“同组同主题”而言的。如果你有多个主题,或者多个消费者组,情况就会复杂起来,因为组与组之间是互不影响的——这正好是“发布订阅”和“点对点”两种模式的分水岭。
从业务视角看,消费者组最大的价值是让“水平扩展”有了着力点。你的消费能力不够了,加实例就行,前提是分区数要够;你的某个消费者挂了,组内其他成员会接手它的分区,业务不会中断。但这一切的代价,就是接下来要说的那个隐形杀手:再均衡。
1.2 再均衡:一次业务的“全剧终”
再均衡(Rebalance)是 Kafka 消费者组里面最容易被低估、也最容易出事故的机制。它发生的条件很常见:消费者加入、消费者退出、消费者崩溃、分区数变化、订阅主题变化。听起来都是正常操作,但机制内部做的事情很粗暴——所有消费者一起停止消费,等待重新分配分区。
我把这个过程类比成银行柜台重排:原本 4 个柜台各办各的队,突然有一个柜台员工离开,系统喊了一声“所有人停下手里的活”,然后重新分配客户队列。停下来重排的这几秒里,业务是断的。更麻烦的是,在重排前的过程中,部分消息可能已经被消费但还没来得及提交 offset,重排后这些消息就会被重新消费一遍。
这就是消费者组里“重复消费”的主要来源之一,它跟消息队列的语义无关,是组管理机制本身带来的。举个例子,一个消费者处理一条消息要 3 秒,但下游数据库偶尔抖动,处理时间涨到 50 秒,超过了max.poll.interval.ms(默认 5 分钟),Kafka 会判定这个消费者“失联”,踢出组并触发重平衡。你回头看日志,会发现消费没有报错,只是慢了,但消费者已经被踢了,然后所有消费者开始一轮新的重平衡,过程中的消息被重复处理。
应对再均衡,业界有几种成熟手段:
- 调大
max.poll.interval.ms和session.timeout.ms,给慢处理留出缓冲空间。但这只能缓解,不能根除。 - 开启静态消费成员(Static Membership),通过
group.instance.id让消费者在重启时保留原有分区分配,减少触发重平衡的范围。这个对“发布重启”场景非常有用。 - 使用合作式(Cooperative)重平衡策略,只调整需要变更的分区,而不是全组停摆。配合
partition.assignment.strategy=cooperative-sticky使用。
提示:如果你在线上看到“rebalance 频繁发生”,不要急着加机器,先看是不是消费者的处理耗时波动太大、或
heartbeat.interval.ms设置不合理。机器越加越多、分区分摊越碎,重平衡的风暴反而可能更频繁。
1.3 修罗场里的第一个真问题:怎么设计分区数
说完机制,再看设计。很多人创建主题时分区数拍脑袋定,等到流量涨上去才发现问题。这里给一个实用的估算思路。
分区数的下限是max(消费者组需要的并发数, 单分区能够支撑的吞吐上限的分母)。更直白一点:假设你预期的峰值消费吞吐是 2 万条/秒,而单分区实测稳定的消费能力是 4000 条/秒,那至少需要 5 个分区才能扛住 2 万,再留 30% 的冗余,取 8 个分区比较稳妥。但分区数也不能无限大——分区越多,broker 上的文件句柄、replication 开销、消费者组管理开销都会上升,选举和重平衡的成本也跟着涨。通常一个 Kafka 集群单分区数总量控制在万级以内是安全的,单个主题给到 64 或者 128 个分区已经能覆盖绝大部分业务。
还要考虑顺序性。Kafka 只保证“同一分区内有序”,如果你的业务要求某个用户的操作严格有序,就要保证同一个用户的消息进同一个分区。做法很简单:指定消息 key,分区器按 key 哈希路由。但要注意,如果你为了提升并行度把分区数调大,但业务上把大量 key 哈希到少数热门分区,那“并行度”只是纸面上的,热点分区照样会成为瓶颈。
消费者组这一层我最后再补一个经验:消费端的幂等设计必须跟消费者组解耦。也就是说,不要试图靠“消费者组保证只消费一次”来规避重复,组机制做不到这一点,它只能做到分区分配和故障转移。真正的防线,要在下一章讲的 Exactly-Once 和事务里去搭。
2. Exactly-Once:从语义承诺到落地实践
2.1 三种投递语义,别再搞混了
消息中间件里有三种经典的投递语义:At-Most-Once(至多一次)、At-Least-Once(至少一次)、Exactly-Once(精确一次)。很多文章会用“丢消息”“重复消息”来区分,但我想换个角度:它们实质上是“投递失败时的默认姿态”。
At-Most-Once 是“丢了就丢了,不管了”。典型实现是消费者先提交 offset 再处理消息,处理过程中一旦崩溃,消息就永久丢失。适合日志采集、监控上报这种丢了不心疼、但绝对不能重复的场景。
At-Least-Once 是“没成功就重来,宁多勿缺”。典型实现是先处理消息、再提交 offset,崩溃后消息会被重新投递,于是会出现重复。现实中绝大多数系统默认就是这个语义,因为实现简单,配合幂等消费就能达到业务层面的“最终不重复”。
Exactly-Once 是“不多不少,刚刚好”。难点在于,分布式环境下没有全局时钟和共享状态,要做到“刚刚好”远比听起来复杂。它不是一个单独的技术点,而是一套组合机制。很多人以为“不开 at-least-once 就是 exactly-once”,这是最常见的误解。
用快递打个比方:At-Most-Once 是“包裹丢了就补发一张道歉信”,At-Least-Once 是“没收到就一直重发,给你发三五个”,Exactly-Once 是“保证签收记录里只有你签的那一次”。业务里扣款、发券、流水记录这类场景,都是“少一次不行,多一次更不行”的典型。
2.2 Kafka 的 Exactly-Once 是怎么实现的
Kafka 在 0.11 版本引入了真正的 Exactly-Once 支持,它由两部分组成:幂等生产者和事务。
幂等生产者的核心是给每条消息打上“序列号”。生产者启动时会向 broker 申请一个 PID(Producer ID),之后每发送一条消息到同一个分区,序列号加一。broker 端会缓存最近几条消息的序列号,如果收到重复序列号,直接拒绝写入。这个机制解决了“生产者重试导致的消息重复”,但只覆盖单个生产者会话内、单分区的场景,无法覆盖“生产者重启后 PID 变化”以及“跨分区原子性”的需求。
于是 Kafka 引入了事务 API。它的做法类似两阶段提交:生产者向一个叫Transaction Coordinator的组件发起事务,协调器把事务状态记录在自己的日志里,等所有分区的数据都写好了,再给协调器发 COMMIT。消费者端可以通过isolation.level=read_committed只读取已提交事务的消息。这样,Kafka 就保证了“一批消息要么全部可见、要么全部不可见”。
但这里有一个关键边界:Kafka 的 Exactly-Once 是 Kafka 内部的精确一次。它保证的是“消息在 Kafka 主题之间不丢不重”,不保证“你消费消息后写入 MySQL、调用第三方 API 也 absolutely once”。如果你的消费端要做外部写操作,那 Kafka 事务管不到那个外部系统,只能靠你那边的幂等设计来兜底。这个认知很重要,很多线上事故就是从这里开始埋雷的。
2.3 现实中 Exactly-Once 到底能做到什么程度
我在真实项目里见过一种最常见的 EOS 误解:有人用 Kafka 事务生产者发消息,消费端也开了read_committed,就以为全链路是 exactly-once 了。直到某次下游数据库超时,消息重放,数据库里多了一条重复订单,才意识到 Kafka 的承诺只到“主题与主题之间”。
举个例子。一个订单系统从 Kafka 消费“支付成功”事件,然后往 MySQL 插入一条支付流水,再更新订单状态。假设消费者在“插入流水成功、提交 offset 之前”崩溃了,Kafka 会重新消费这条消息,流水表就会多出一条重复记录。这时就算你用了幂等生产者、事务生产者,也拦不住这个重复,因为问题出在 Kafka 到 MySQL 这一段。
要解决这个场景,标准的思路是两个:
- 下游幂等:在 MySQL 里建唯一索引(比如订单号+事件类型),重复插入时捕获冲突并忽略。
- 事务消息/本地消息表:把“消息写库”和“业务写库”放到同一个本地事务里,再通过消息表异步投递,投递成功后删除消息。
这两条路会在第四章细讲。先记住一个结论:Exactly-Once 是一种端到端的复合能力,而不是 Kafka 开启一个开关就能交付的承诺。只有当你理解了消费者组、消息语义、下游存储三者各自能做到什么程度,你才能把“精确一次”真正落到自己的业务里。
注意:如果你只想在 Kafka 内部的流处理(比如 Kafka Streams)里追求 EOS,那么“幂等生产者 + 事务 + read_committed 消费端”这套组合是够用的。但一旦跨出 Kafka 的边界,就要立刻切换思路,不要再指望 EOS 替你兜底。
3. 事务与分布式一致性:打破局部原子性
3.1 单机事务的边界:ACID 在分布式面前失效了
聊完消息,回到事务。单机数据库事务,也就是我们最常用的 MySQL 事务,靠的是 ACID:原子性、一致性、隔离性、持久性。这四样在单库单表时代非常稳固,但到了微服务架构里,就碰到一个根本性矛盾——ACID 假设的是“一个连接、一个数据库、一套锁”。而分布式系统里,业务数据散落在多个服务、多个数据库里,没有一个全局的连接和锁来协调它们。
先看隔离级别。MySQL 里事务级别常见四种:读未提交、读已提交、可重复读、串行化。MySQL 默认是可重复读,Oracle 默认是读已提交,这个差异在跨库对比时经常被拿出来聊。可重复读解决的是“同一事务里两次读的结果一致”,但它允许幻读(在 InnoDB 下通过间隙锁可以部分规避)。如果你在一个分布式事务里同时操作两个库,两边各自的隔离级别只能约束本地,无法约束对方,这就导致了“看起来各自都对、合起来错”的窘境。
再看事务注解。Java 后端最常用的是 Spring 的@Transactional,很多人以为加上这个注解就万事大吉。但实际上它有几个经典盲区:方法必须由 Spring 代理调用(同类内部自调用会绕过代理,导致事务不生效)、异常必须抛出到代理层(方法内自己 catch 掉了事务就会正常提交)、默认只回滚 RuntimeException(检查异常需要显式配置 rollbackFor)。这些细节在面试里是“送分题”,在线上就是“送命题”。
更重要的是边界:@Transactional只能管到当前线程、当前数据源连接。当你调用了另一个服务、操作了另一张库表,这层事务就完全罩不住了。MySQL 事务级别设计得再精细、锁等待再严格,也无法跨服务保证一致性,这就是分布式事务要出场的原因。
3.2 分布式事务的主流方案横向对比
分布式事务的方案没有“一招鲜”,每个方案都在一致性和可用性之间做取舍。我把主流方案拉在一起做个对比:
| 方案 | 一致性模型 | 吞吐与延迟 | 实现复杂度 | 典型适用场景 |
|---|---|---|---|---|
| 2PC / XA | 强一致 | 低吞吐、高延迟 | 高,需数据库支持 XA | 银行核心、跨库同构且量小 |
| TCC | 最终一致(业务补偿) | 中,需写大量补偿逻辑 | 很高 | 资金类、预算类、资源类 |
| Saga | 最终一致 | 中,长流程友好 | 高,涉及事件溯源 | 订单、旅游、长流程业务 |
| 本地消息表 | 最终一致 | 高,成本低 | 低 | 订单->消息->库存、积分 |
| 事务消息 | 最终一致 | 高,依赖 MQ 支持 | 中 | RocketMQ 事务消息、Kafka 事务 |
2PC 是最经典的强一致方案。它的核心是增加一个协调者,分两阶段让所有参与者先准备后提交。问题在于“准备完成之后的提交阶段”如果协调者挂了,所有参与者会陷入不可终止的阻塞,而且两轮 RPC 的开销在业务高峰期很难看。所以现在很少有人在互联网高并发核心链路里裸用 XA。
TCC 的思路是把业务拆成 Try、Confirm、Cancel 三步。Try 阶段冻结资源,Confirm 阶段确认使用,Cancel 阶段释放资源。它跟 2PC 最大的区别是:每个操作都是业务级的,不是数据库锁,所以可以跨服务。但代价是业务侵入极强,你得为每个资源操作写三套逻辑。我知道不少人被 TCC 的复杂度劝退,实际上也确实只适合资金、库存这类“必须严格控制资源”的场景,普通业务为了一个订单去上 TCC 属于大炮打蚊子。
Saga 则是把长事务拆成一系列本地事务,每个本地事务都有对应的补偿事务。如果某个环节失败,就按逆序执行补偿。它不需要锁资源,适合订单、出行这类“先干着,不行再回滚”的流程。缺点是中间状态对外可见,一致性窗口期较长,而且补偿逻辑要写得非常完善,否则补偿本身也会失败。
本地消息表和事务消息,则是我个人在业务中最常用、也最推荐优先考虑的两个方案。它们本质上是“最终一致 + 异步重试”,上手门槛低,对业务侵入小,并且能处理大多数“订单与库存分布式事务”这类需求。完整的落地做法我会在第四章展开,这里先不细说。
3.3 事务注解与事务级别的典型坑
先说事务级别在 MySQL 里的坑。默认的可重复读(Repeatable Read)配上 InnoDB 的 MVCC 和间隙锁,在大多数订单场景下能避免不少问题,但它有一个容易被忽略的代价:间隙锁会扩大锁范围,高并发插入时更容易产生死锁。如果业务里对某个指标做“先查再插或先查再更新”,并发一高,死锁日志会刷屏。
我的建议是:写多读少的报表类库,可以用读已提交;OLTP 核心链路如果确实需要防止不可重复读,可以继续用可重复读,但尽量保证事务短小精悍,不要在事务里做远程调用。远程调用是长事务的头号杀手,你在事务里 sleep 一秒、调一个外部接口,等于把一个数据库连接锁在原地,其他的写请求全在排队。线上数据库连接池被打满,十有八九是这种长事务造成的。
@Transactional的坑我整理一个清单,都是我亲眼见过的:
- 自调用失效:同类里
this.method()调用,代理不生效,事务形同虚设。 - 非 public 方法失效:Spring 默认基于 CGLIB/JDK 代理,protected、private 方法不会被事务切面管理。
- 异常被吞:catch 住异常后没有抛出,事务自动正常提交。
- 传播行为设错:
REQUIRES_NEW和REQUIRED混用导致部分提交、部分回滚。 - 多数据源跨库:一个事务注解管不了两个数据源,除非接入分布式事务方案。
- 事务内调用 MQ 发送:消息先发出去,事务后回滚,消费者已经处理了。
- 长事务:事务里做批量查询、远程调用、循环写库,导致锁等待和连接耗尽。
这七条在面试里基本是“分布式事务一致性”话题前的开胃菜,但很多故障的根因就是其中某一条。先把单机事务的边界摸透,你才有资格评估分布式事务方案。
4. 组合拳:消费者组 + Exactly-Once + 事务的正确打开方式
4.1 三种一致性架构形态
把消费者组、Exactly-Once、事务组合起来,才是真正的“业务修罗场”。我归纳了三种常见的架构形态,从简单到复杂,你可以根据业务对一致性的要求来选。
形态一:消费者组 + At-Least-Once + 下游幂等
这是成本最低的落地方式。消息队列走 at-least-once,消费者组负责水平扩展和故障转移,真正的防重复完全交给下游的幂等机制。典型做法是给业务表加唯一键或去重表。比如订单号唯一,重复消息插入时数据库直接报唯一键冲突,业务捕获后当作“已处理”忽略。
这个形态最怕的是“没有幂等键”的业务。比如扣减库存,库存表里没有天然唯一键,每次扣减都是一次 update,重复消费就会重复扣钱。这种情况光靠去重表还不够,需要把“消息 ID + 业务主键”组合成唯一记录,先查后写,利用数据库唯一索引挡住第二次。形态一适合内部系统、非核心链路、对实时性要求不高的场景。
形态二:本地消息表 + 定时任务补偿
如果要同时保证“业务库写操作”和“消息不丢”,形态一是做不到的,因为它没法保证“消息一定发送成功”。形态二的思路是:把业务操作和消息写入放到同一个本地事务里,事务提交后消息已经躺在消息表里,然后由一个异步任务把消息表里的记录投递到 MQ,投递成功后标记为已发送。
这个方法的关键点在于“本地事务”和“消息投递”之间的衔接。一个典型的实现流程是:
- 在本地事务里,更新订单表 + 插入库存扣减消息记录。
- 事务提交后,异步任务查询消息表里未发送的记录。
- 把消息投递到 Kafka / RocketMQ,消费者消费后执行库存扣减。
- 如果消费者处理成功,回调或定时任务删除消息记录。
这套方案的优点是:业务代码侵入小,不需要改消息中间件的配置,MySQL 本身就是消息的持久化存储,可靠性和一致性都落在数据库身上。缺点是:消息表的数据量会增长,需要定期清理;投递的实时性取决于轮询频率;消费者失败后需要重试策略。
形态三:事务消息 + 半消息机制
RocketMQ 的事务消息,或者 Kafka 的事务 API + 幂等生产者,把“发送消息”和“本地事务”合并成一次原子操作。生产者先发一条“半消息”,消息对消费者不可见;然后执行本地事务,成功则提交半消息,失败则回滚半消息;如果生产者进程在本地事务执行中崩溃了,消息中间件通过回查机制询问业务方“这个事务到底成没成”,由业务方给出明确答复。
这个形态的复杂度取决于你对消息中间件的熟悉程度。Kafka 的事务 API 配合 read_committed 消费端,可以在 Kafka 内部实现“一批写入要么全有、要么全无”,非常适合 Kafka Streams 或流式ETL。而 RocketMQ 的事务消息,则是业务侧与 MQ 侧的原子性绑定,常用于订单、支付这类业务系统。
三种形态的界限不是黑白的,很多系统会混用:核心链路用形态三,次要链路用形态一。关键是你要清楚每一条链路允许的最终一致窗口和故障恢复方式,不能一套模板打天下。
4.2 实战中的常见问题与排查技巧实录
这部分先讲我在消费者组上踩过的一个真实案例。当时一个订单服务消费支付回调消息,消费者组 5 个实例、主题 10 个分区,平时很稳。结果一次发布后,重启的实例触发重平衡,由于组里有消费者处理消息耗时从 200ms 涨到 4 秒,max.poll.interval.ms设置的是 30 秒,看起来够用,但有两个实例因为 Full GC 停顿超过 30 秒,被判定失联踢出组,然后全组重平衡。重平衡期间消息消费中断,下游订单状态迟迟不更新,用户开始投诉“支付成功但订单没变化”。
排查思路:先看消费者组的状态和 member 列表,发现频繁 rebalance;再看消费者日志,发现有Member ... has failed的字样;最后用kafka-consumer-groups.sh --describe看每个分区的 Lag,发现重平衡期间 Lag 暴涨。解决办法是:临时把max.poll.interval.ms调到 5 分钟,把每个批次的max.poll.records从 500 降到 100,同时优化了 GC 停顿。后面又开启了静态消费成员,发布重启对组内其他成员的影响大幅降低。
再讲一个 EOS 事务相关的坑:某团队开启 Kafka 事务生产者后,偶发PRODUCER_FENCED异常。这个问题的根源是同一个 PID 的旧生产者实例没有关闭,新实例启动后认为旧实例还活着,直接把旧实例“隔离”掉。处理方式很简单:确保同一事务性生产者的生命周期唯一,先关旧实例再开新实例,不要并发出现两个持有相同 transactional.id 的生产者。同时可以调大transaction.timeout.ms,避免事务执行时间过长导致超时回滚。
最后是事务消息的“半消息卡住”问题。RocketMQ 事务消息里,如果回查接口没实现或者回调出错,半消息一直处于未知状态,消费者永远看不到这条消息。排查时要去消息中间件的后台查看事务消息状态,确认回查逻辑是否正常执行、返回结果是否与本地事务状态一致。这个环节最容易出的低级错误是:回查时查了缓存,但缓存数据与数据库的最终事务结果不一致,导致半消息被错误地提交或回滚。
我整理了一个快速问题排查表,方便你遇到同类问题时直接对照:
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| 消费组频繁重平衡且 Lag 抖动 | 消费耗时波动、心跳超时、实例崩溃 | max.poll.interval.ms、heartbeat.interval.ms、GC 日志 |
| 消费组有成员但从不消费 | 分区数小于消费者数、订阅的 topic 写错了 | 分区分配列表、Subscribe 的 topic 名称 |
| 消息重复消费 | offset 未提交成功、重平衡触发、手动提交时机不对 | 消费端幂等设计、enable.auto.commit=false 后的提交逻辑 |
| Kafka 事务抛 ProducerFenced | 多个实例共用 transactional.id | 检查实例生命周期、是否重复创建生产者 |
| RocketMQ 事务消息不投递 | 半消息未确认、回查失败、本地事务结果未上报 | Broker 事务状态、回查接口返回值 |
| 事务注解不回滚 | 自调用、异常被吞、rollbackFor 未配置 | Spring 代理链、异常是否抛出到代理层 |
| 事务消息消费后写库重复 | Kafka EOS 不覆盖外部系统写入 | 检查下游唯一索引、幂等表 |
4.3 一套可复制的订单与库存分布式事务设计参考
订单与库存是分布式事务的经典战场。我以这个场景做一套组合设计,核心思路是“本地消息表 + 事务消息 + 消费者组 + 下游幂等”混合使用,既保证一致性,又不会过度设计。
先看数据库层。订单库里有业务表order和order_event(本地消息表)。创建订单的接口在一个本地事务里做两件事:插入订单记录、插入一条order_event,事件内容就是“扣减库存”。事务提交后,一个后台任务扫order_event表,把未发送的事件发到 Kafka 的order_stock_deduct主题。尽量保证业务动作和消息落库同生共死,这是整套方案的基石。
主题order_stock_deduct设置 12 个分区,库存服务以一个消费者组接入,分区数按峰值吞吐估算是 8,留了 4 个的冗余。库存服务消费消息后执行真正的扣减操作。为了防重复,库存表设计上保留一个“扣减流水号”,也就是order_id + sku_id联合唯一键。重复消费时插入流水失败,数据库会报唯一键冲突,这段逻辑捕获后直接返回成功,因为流水已经有了,说明扣减已经发生了。
如果 Kafka 投递失败或消费者处理失败,order_event表的记录不会被删除,定时任务会重投。重投达到一定次数后进入死信队列,人工介入。从创建订单到库存扣减完成,链路是异步的,通常几百毫秒内就能看到最终一致的结果,这个窗口对绝大多数电商业务是完全可以接受的。
如果你想让一部分核心订单走更严格的事务保证,可以引入事务消息:订单服务用 Kafka 事务生产者,把“订单已创建”事件和“扣减库存请求”放在同一个事务批次里发送,下游配合 read_committed 消费。这样至少保证了 Kafka 内部的原子可见性,但注意它依然不保证下游 MySQL 的写入不重复,所以唯一键的幂等设计无论如何都不能省。
这套设计里有几个要点:
- 消息表字段至少包含:id、biz_id、topic、payload、status、retry_count、next_retry_time。
- 定时任务扫描时,用
next_retry_time <= now过滤,避免失败消息无限重压 MQ。 - 重试要有退避,初始间隔 10 秒,每次翻倍,最高 5 分钟。
- 消费端统一封装幂等注解,基于 Redis SETNX 或数据库唯一键实现。
4.4 面试官想听的回答路径
最后顺手点一下面试相关的内容。现在大厂后端面试几乎必问分布式事务和消息一致性,很多人答不好是因为把“概念背诵”和“工程取舍”混在一起说。比如面试官问“你怎么保证消息不丢”,你先别急着背 Kafka 的 ack 参数,而是先反问一句“是生产者不丢、broker 不丢,还是消费者不丢?”这三个层面的答案完全不同。
事务这块,面试官问“MySQL 事务级别有哪些”,你直接说完四种级别后,最好补一句“InnoDB 默认是可重复读,配合 MVCC 和间隙锁,在绝大多数订单场景下能防幻读,但高并发插入时要注意间隙锁带来的死锁隐患”——这句话能明显拉开你和背资料的人的差距。
问到“分布式事务方案怎么选”,最有说服力的回答不是把 2PC、TCC、Saga 都背一遍,而是结合自己的项目说清楚:为什么某个场景没用强一致方案,而是选了本地消息表,最终一致窗口有多长,失败了靠什么补偿,有没有死信兜底。这符合“分布式事务一致性”考察的本质——它考的不是你会不会用理论框架,而是你在真实业务里有没有判断力和兜底能力。同样,问到“消息队列的 Exactly-Once 怎么实现”,你能说出“幂等生产者解决生产者重试、事务解决跨分区原子性、read_committed 解决消费端可见性,但跨系统边界仍要下游幂等兜底”,这句话会让面试官刮目相看。
我自己在实际项目里最深的一个体会是:一致性从来不是一个中间件能包圆的,而是一条完整链路里每一层防守的叠加。消费者组负责高可用,Exactly-Once 负责队列语义,事务负责本地原子性,幂等负责最终去重,补偿任务负责兜底。所以下次再听到“业务修罗场”这个说法,不要把它当成调侃——每一层设计,都是你在混乱的分布式环境里为自己争得的确定性。哪怕没有银弹,至少踩坑时手里有一张地图,知道该往哪儿查、该往哪儿补,这比背诵任何“最佳实践”都管用。