消息队列常见问题解决(偏kafka)—顺序消费、消息积压、消息丢失、消息积压、分布式事务

一、顺序消费

知识回顾

先看另一两篇博客,下面总结知识点:

  • Kafka的一个核心特性:它能严格保证在一个分区内部,消息是绝对有序的;但反过来说,它不提供任何跨分区的顺序保证

  • 全局顺序(Global Order):要求整个Topic内的所有消息,都严格按照先进先出的顺序进行消费。这种场景相对较少,比如需要同步一个全局数据库的Binlog时。

    局部顺序(Partial Order / Scoped Order):不要求全局有序,但要求某一个特定业务范畴内的消息是有序的。这在实际业务中极为常见。例如,对于同一笔电商订单,其"已创建"、"已付款"、"已发货"、"已签收"这几条消息必须按顺序处理;但订单A和订单B之间的消息,则完全可以并行处理,互不影响。

  • 分区与消息路由规则

    ‌带Key的消息‌:根据Key的Hash值对分区数取模确定目标分区,确保相同Key的消息总是进入同一分区。 ‌

    ‌无Key的消息‌:采用粘性分区策略,优先分配到最近一次使用的分区,直到该分区批次满或超时后切换。

  • 在消息队列的语境下,我们所说的"顺序",是指消费者处理消息的顺序,与生产者发送消息的顺序完全一致。

    这里有一个非常关键的细节需要理清:所谓的"发送顺序",并非指在生产者客户端代码执行send()方法的先后,而是指消息抵达Broker(消息队列服务器)的先后顺序。在一个典型的分布式环境下,假设生产者A和生产者B,A在10:00:00.000时执行了发送动作,但由于网络抖动、GC停顿等原因,其消息msg1在10:00:00.500时才到达Broker;而生产者B在10:00:00.100时发送的msg2,却在10:00:00.300时就抵达了Broker。那么从Broker的视角看,msg2就是先于msg1的。

顺序消费的解决方案

一个Toptic一个分区

好了,概念铺垫完毕。现在,面对保证消息顺序的需求,最直观、最简单的方案自然浮出水面:为一个Topic只创建一个分区。

既然一个分区内是绝对有序的,那么让所有消息都进入这唯一的分区,然后消费者消费完前一条消息,再发ack,之后拉下一条消息,不就实现了全局有序,自然也满足了局部有序吗?

没错,这个方案在逻辑上无懈可击,实现起来也极其简单,几乎不需要任何额外的开发工作。但它的"沉重代价"也随之而来------严重的性能瓶颈,这也是面试官紧接着会考察的要点。

  • 对于生产者:所有消息都涌向单一分区,这意味着所有写入流量都将压向该分区所在的单一Broker节点。该节点的网络带宽、CPU使用率、磁盘IO都会成为整个系统的瓶颈,极易被"打满"。
  • 对于消费者:由于只有一个分区,一个消费组(Consumer Group)内最多也只有一个消费者实例能够进行有效工作(其他消费者将处于空闲状态)。这就完全丧失了消息队列引以为傲的水平扩展和并行处理能力。一旦消息生产速率超过单个消费者的处理速率,消息积压将成为必然。

单分区异步消费

在单分区性能受限的情况下,如果瓶颈主要出在消费端业务逻辑处理过慢,导致消息积压,我们能否在消费端做一些优化呢?

一个自然的想法是引入异步消费模式。具体来说,我们可以让唯一的消费者线程不直接执行耗时的业务逻辑,而是扮演一个"二传手"的角色。它的唯一任务就是从消息队列(比如Kafka)分区中高速拉取消息,然后根据某个业务标识(例如orderId),将消息快速分发到不同的任务队列中。后台则启动一个工作线程池,每个线程负责消费一个或多个任务队列,并行地执行真正的业务处理。

这种方式还有个好处,尽量减少IO消耗,前面的消费者可以批量拉取,去处理耗时的Io操作,让后面的去真正处理业务

这种方式,通过一种哈希策略,例如 queueIndex = orderId.hashCode() % 4 (假设有4个工作线程),确保了同一订单的消息会被投递到同一个任务队列,并由同一个工作线程来顺序处理,从而在消费端内部保证了局部顺序性。

然而,这个方案看似巧妙,却带来了两大硬伤:

  • 增加了系统复杂性:你需要自己管理内存队列、线程池、线程安全以及优雅停机等问题,使得消费端的逻辑变得复杂,容易出错。
  • 存在数据丢失风险:消息从Kafka取出,成功提交Offset,然后放入任务队列后,如果此时消费者进程意外崩溃,那么内存中尚未被工作线程处理的消息就会永久丢失。

更重要的是,它丝毫没有解决生产者端和Broker端的单点写入压力问题。既然我们已经有了"按业务ID哈希分发"的思想,为什么不更进一步,直接利用消息队列自身成熟、可靠的分区机制,来实现一个更原生的方案呢?

多分区实现局部有序性

这便引出了我们最终的、也是工业界最主流的解决方案:利用多分区实现局部有序。前面的方案都是抛砖引玉,到这里可能才是面试官真正想和你深入探讨的地方了

这个方案的核心思想也非常清晰:

  • 为Topic创建多个分区(例如4个、8个),以支持高并发读写和水平扩展。
  • 生产者在发送消息时,不再随机发送,而是根据业务键(Business Key,如orderId、userId等)来计算目标分区。通过这种方式,确保具有相同业务键的消息,总是被稳定地发送到同一个分区。

最简单的分区选择策略就是取模哈希:partition = hash(orderId) % partitionCount

这样一来,同一笔订单SN20250823XYZ的所有消息(OrderCreated, PaymentSuccess等)都会落入同一个分区,由Kafka保证其在该分区内的顺序。而不同订单的消息可以被均匀地分散到不同分区,由不同的消费者实例并行处理,系统的整体吞吐能力得到了极大的提升。

问题1、数据倾斜

简单的哈希取模策略,隐含了一个前提:业务键的哈希值是均匀分布的。但在真实世界中,数据往往是不平均的。而且很多时候你无法提前预料。

想象一个营销活动场景,某主播正在直播带货,突然几百万用户同时涌入,产生了大量的订单。如果我们的分区键是activityId,那么这场活动相关的所有消息都会涌入同一个分区,造成这个分区"热点",消息严重积压;而其他分区则可能门可罗雀,资源闲置。

如何应对这种数据倾斜呢?这里有两种比较好的应对手段:

一致性哈希(Consistent Hashing)与虚拟结点

一致性哈希算法是解决分布式系统中负载均衡问题的经典利器。我们可以将所有分区节点想象成分布在一个0到2^32-1的哈希环上。当一条消息到来时,计算其业务键的哈希值,然后在环上顺时针寻找第一个遇到的分区节点,作为其目标分区。

一致性哈希最大的优点在于,当增删分区节点时,只会影响到环上相邻的一小部分数据映射,变动范围小,稳定性好。同时,通过引入"虚拟节点"的机制(即一个物理分区在环上对应多个虚拟节点),当我们发现分区数据不是很均匀的时候,我们可以精细地控制每个物理分区在哈希环上的分布密度和权重,从而更有效地应对数据热点,实现负载的均匀分布。

虚拟槽映射

受启发于一致性hash环,其实是增大了取模分母。同样我们可以引入一个中间层------虚拟槽。不再将业务键直接映射到物理分区,而是先映射到一个固定数量的虚拟槽上(例如Redis的16384个,但是一般情况下我们业务不会用到这么多的槽位,比如在业务中可以简化为2048个,整个数量其实已经足够多了)。

bash 复制代码
slot = hash(businessKey) % 2048

然后,我们再独立维护一个从"槽"到"物理分区"的映射关系。这个映射关系是可配置、可动态调整的,通常存储在配置中心(如Nacos、Apollo)中。例如,初始时我们可以将2048个槽均匀分配给16个分区,每个分区负责128个槽。

当监控系统检测到某个物理分区(比如分区3)成为热点时,我们可以通过动态调整配置中心里的映射关系,将一部分原本映射到物理分区3的虚拟槽(比如槽300-315),迁移到其他负载较低的物理分区上(比如分区5)。这样,后续相关业务键的消息就会被路由到新的分区,从而实现动态的负载均衡。

这两种思路,无论是虚拟槽还是一致性哈希,其本质都是引入一个间接层来解耦业务键和物理分区,从而获得动态调整负载的能力,是解决各类数据分布不均问题的通用思想。

问题2、扩容引发的顺序错乱

解决了数据倾斜,我们再来看另一个在运维中可能遇到的问题。随着业务量的持续增长,我们可能需要为Topic增加分区数量以提升整体吞吐量,比如从5个分区扩容到8个。

这时,灾难可能悄然而至。

我们的分区策略是 partition = hash(orderId) % partitionCount。当partitionCount从5变为8时,对于同一个orderId,计算出的分区索引大概率会发生变化。

考虑以下极限场景:

  • 时间点T1:分区数为5,订单SN20250823XYZ的消息M1(已创建)根据hash("SN20250823XYZ") % 5被发送到了分区2。但此时分区2有些积压,Msg1正在排队等待消费。
  • 时间点T2:我们完成了扩容,分区数变为8。
  • 时间点T3:订单SN20250823XYZ的后续消息M2(已付款)到来,根据hash("SN20250823XYZ") % 8被发送到了分区7。分区7是新增的,非常空闲,消费者立刻就取到了Msg2并完成了处理。

最终的结果是,"已付款"事件先于"已创建"事件被处理,业务逻辑发生严重错乱!

如何化解这个难题?一个简单而有效的工程实践是:为新分区的消费者设置一个"冷静期"。

在为Topic增加分区后,我们让新加入的消费者实例(例如负责消费分区5、6、7的实例)先"暂停"工作一段时间,比如等待5分钟。这个等待时间需要根据经验评估,其核心目标是确保足够让旧分区中积压的、可能与新分区产生业务关联的消息被消费完毕。这里有一个至关重要的前提:这个等待时间必须长于旧分区积压消息的最大消费时间。如果旧分区积压的消息预计需要10分钟才能消费完,那么"冷静期"就至少要设置为10分钟以上。

通过这种短暂的延迟消费,我们就能极大概率上避免因扩容导致的顺序错乱问题。当然,这是一种基于概率的"最终一致"思想,并非100%的强保证。在分区扩容这种低频且高危的操作下,通常会结合完善的业务监控和告警,一旦发现异常,可以进行人工干预和数据修复。

上面这个暂停 应该是 整个集群停止消费新的 先把旧的都处理完吧 然后再处理新的数据

面试实战指南

理论知识固然重要,但如何将这些知识有机地串联起来,以一个实际案例的形式在面试中展现出来,更能体现你的实战能力和思考深度。下面,我将以第一人称,模拟一次面试中的回答。

面试官:你在过往项目,有使用过消息队列吗,有没有遇到过消息顺序消费问题,你是如何解决这个问题的?

还是老规矩,切记一上来就抛出最优方案,像这类围绕项目展开的场景题,最好和面试官多沟通,层层递进,一步一步的从最初的方案开始聊起,然后讲明白各个方案的问题,最后一步步的优化到最终的方案。体现一个解决问题的路径,这样更具真实性和说服力

"面试官您好,关于消息顺序性的问题,我之前在项目中确实有过一次比较深刻的实践。

在我刚加入上一家公司时,就遇到了一个由Kafka引发的线上问题。当时我们有一个核心的订单处理业务,为了保证订单状态流转的正确性,最初的设计者采用了最稳妥的方案:为订单Topic只设置了一个分区。在业务初期,这个方案运行得非常稳定。

但随着公司业务的快速增长,问题开始暴露。我们发现这个Topic的消息积压越来越严重,消费延迟从几秒钟增长到十几分钟,直接影响了下游履约、发货等环节的效率。同时,监控也显示,承载这个唯一分区的那个Broker节点,其CPU和磁盘IO负载远高于集群中的其他节点,时常出现性能抖动,成为了整个系统的性能瓶颈。

这个时候自然就勾起了面试官对于解决方案的兴趣

面试官:"单分区确实会出现这个问题,这个问题你们是怎么解决的呢"
"接到这个优化任务后,我首先深入分析了业务场景。我发现,虽然我们需要保证同一个订单的'创建'、'支付'、'发货'等消息的顺序,但不同订单之间的消息处理其实是完全独立的,并不存在顺序依赖。换句话说,我们的真实需求是业务内的局部有序,而非全局有序。"

这个发现是整个优化的关键。它意味着我们完全不必被'单分区'的枷锁所束缚。于是,接下来就可以顺理成章的引出我们上面总结的档案了:将单分区Topic改造为多分区Topic。

具体的实施步骤是:

我为Topic增加了7个分区,总数达到8个,并相应地将消费者应用的实例数也扩展到8个,实现了真正的并行消费。

在生产者端,我们修改了发送逻辑,引入了基于orderId的分区策略。所有消息在发送时,都会根据其orderId的哈希值对8取模,来决定其目标分区。这样就确保了同一订单的所有消息始终会落入同一个分区。

当然,这个过程中还有一个非常关键的细节需要处理,那就是从单分区切换到多分区(或者说,分区数量发生变化)时,如何避免消息乱序。厉害的面试官会接着追问消息积压导致的消息顺序问题,但是前面我们早有准备,此刻根本丝毫不慌

当时确实遇到了消息乱序的问题。如果在切换过程中,一个旧订单的'创建'消息还在原来的那个分区里积压着,而它后续的'支付'消息,因为新的路由规则被发送到了一个空闲的新分区并被立刻消费,就会造成业务逻辑错误。

为了解决这个问题,我采用了一个简单而有效的策略:在部署完新的生产者和消费者代码后,我让新的消费者应用启动后,先暂停消费5分钟。这个'静默期'的目的,就是为了给旧的、积压在唯一分区里的消息足够的时间被消费完毕。5分钟后,整个系统中的存量消息基本处理完成,此时再放开所有消费者进行消费,就能平滑地过渡到新的多分区模式,从而避免了潜在的乱序风险。

最后还可以顺带讲一下优化后的效果,优化上线后,效果立竿见影。消息积压问题彻底解决,消费延迟恢复到毫秒级。整个Kafka集群的负载也变得非常均衡。

二、消息队列积压百万,除了加机器还有哪些解法?

大多数人的第一反应就是"加机器,扩容消费者"。这个固定搭配虽然暂时缓解了问题,但治标不治本。活动过后,这些为应对峰值而紧急增加的资源又闲置了。那除了简单粗暴地加机器,还有没有更优雅、更体系化的解决方案呢?

为什么我们的消费者不能无限扩展?

在探讨解决方案之前,我们必须先弄清楚一个根本性的制约因素:为什么我们不能像Web服务器那样,简单地通过无限增加消费者实例来解决问题?只要预算足够,加就对了。

答案在于消息队列的核心设计------分区(Partition)模型。以Kafka为例,它引入了分区的概念来提升并行处理能力。一个Topic可以被划分为多个分区,消息被分散存储在这些分区中。而在消费端,一个消费组(Consumer Group)内的消费者会与这些分区进行绑定。当有新的消费者加入或离开消费 "消费组" 时,会触发一次"再均衡(Rebalance)",由消费组协调器(Group Coordinator)根据预设的分配策略(如Range或RoundRobin),重新分配分区与消费者的对应关系。

这里的关键规则是:一个分区在同一时刻,只能被消费组内的一个消费者实例所消费。假设这里有N个分区,但是如果不足N 个消费者,那么就会有一些消费者同时从多个分区里面拉取数据,如下图所示:

那如果消费者数量多于分区呢?多出的分区将处于空闲状态,无任务消费。这就意味着,消费者的并行度上限,被分区的数量牢牢锁定了。如果你有5个分区,那么部署超过5个消费者实例是毫无意义的,多出来的消费者将处于空闲(Idle)状态,永远拿不到任何消息。

在面试中,清晰地解释分区与消费者的这种绑定关系,是展现你基础扎实的第一个关键点。当你解释完这个核心规则后,一个很好的加分项是主动阐述其背后的设计权衡。你可以这样补充:

"这种设计的背后,其实是一种权衡:它保证了在单个分区内,消息是被顺序消费的,这对于很多需要保证顺序性的业务场景(如订单状态变更、用户行为轨迹)至关重要。同时,它也简化了消费端的协调逻辑,避免了多个消费者同时处理一个分区数据时可能出现的复杂并发问题。"

这样的补充,能立刻让面试官感知到你思考的深度。

"这种方案虽然简单,易实现,但他有很大的约束性,当消息积压发生时,如果我们已经将消费者数量扩展到了与分区数相等,那么"加机器"这条路就已经走到头了。我们必须寻找其他维度的突破口。

如何为Topic规划合理的分区数?

既然分区数是消费能力的天花板,那么在Topic创建之初,科学地规划分区数量就显得至关重要。这是一种主动防御,能有效避免未来的很多麻烦。

那么,这个"合理"的分区数,到底该如何确定呢?

业界并没有一个放之四海而皆准的公式。最严谨的方式,当然是利用MQ自带的压测脚本(如Kafka的kafka-producer-perf-test.sh),在测试环境中模拟生产环境的消息大小、吞吐量,通过不断调整分区数和消费者线程数来找到最佳值。但现实是,很多团队没有这样的测试条件,或者不敢轻易在生产环境进行压测。

在这种情况下,我在这里分享一个在实践中总结的、简单有效的估算方法:

  • 评估生产者峰值吞吐:首先预估业务高峰期,所有生产者写入消息的总速率。这需要和业务方充分沟通,了解未来的增长预期和活动规划。数据来源可以是历史监控数据(如Prometheus/Grafana中的指标)、业务数据分析报表等。假设峰值为5000条/秒。
  • 评估单分区写入上限:通过压测或咨询运维团队,了解当前MQ集群下单分区的写入性能极限。这个值受限于Broker的磁盘I/O、网络带宽、副本同步策略(acks参数)、消息压缩方式等多种因素。假设是250条/秒。
  • 评估单消费者处理能力:在不考虑任何优化的情况下,评估单个消费者实例处理消息的平均速率。这个速率的瓶颈通常不在于消费者本身,而在于其处理逻辑中涉及的外部依赖,比如数据库写入、RPC调用等。可以通过对消费逻辑进行性能剖析(Profiling)来精确测量。假设是100条/秒。

基于以上数据,我们可以计算出两个所需的分区数:

  • 满足生产需求的分区数 = 生产者峰值吞吐 / 单分区写入上限 = 5000 / 250 = 20个分区。
  • 满足消费需求的分区数 = 生产者峰值吞吐 / 单消费者处理能力 = 5000 / 100 = 50个分区。

为了确保生产者不被阻塞,且消费者能及时处理,我们应该取两者中的最大值,即50个分区。在此基础上,再增加一些冗余(比如10%~20%),最终确定为55或60个分区,以应对未来的业务增长和流量波动。

当你在面试中给出这个计算方法后,如果想让回答更上一层楼,就不能止步于此。面试官其实更想听到你对"权衡"的理解。你可以接着补充

"不过,分区数也并非越多越好。过多的分区会增加Broker元数据管理的开销和客户端的内存消耗。更重要的是,过多的分区会显著延长消费者组发生再均衡(Rebalance)的时间,在此期间整个消费组是停止消费的,反而可能加剧消息积压。所以,这是一个需要在吞吐能力和系统开销之间寻找平衡的决策。"

应对积压的快速解决方案

尽管我们做了事前规划,但突发状况仍在所难免。当告警响起,消息积压已成事实,我们该如何快速应对?

首先,要冷静分析积压的类型或者是消息积压的原因

  • 突发流量所致:由于某个活动或突发事件导致的短暂流量高峰,消费者的处理能力本身是足够的。这种情况在电商领域是非常常见的,比如进行某个促销活动,往往都会伴随着短暂的流量高分,活动已过,流量又恢复正常。此时,我们可以通过监控指标(如消费滞后量Consumer Lag、生产速率、消费速率)来估算恢复时间。例如,积压了100万条,消费速率比生产速率快1000条/秒,那么大约需要 1,000,000 / 1000 = 1000秒 ≈ 17分钟 就能恢复。如果你的业务对17分钟的延迟完全可以接受,那么我们甚至可以不进行干预。如果持续时间相对较长,就可以考虑我们后续介绍的接种办法

  • 消费者能力不足:消费者的整体处理能力已经跟不上生产者的速度,积压量会持续增长,这时就必须采取行动了。这种情况往往可能是因为我们的业务代码有一些隐藏的故障,导致消费能力很弱,或者是随着时间的拉长,原本数据库里的表数据变得越来越大,此时在数据存储层的处理时间越来越久,拖慢了整个消费速度,这个时候就需要我们去进行一些慢SQL优化之类的工作了

接下来,我们就来看看,应对消费能力不足,并且在消费者数量已经等于分区数的前提下,要快速解决问题有哪些可行的方案?

方案一:扩容分区(最直接)

最简单直接的方法就是增加Topic的分区数量。比如从50个分区扩容到80个,这样我们就能相应地将消费者实例也增加到80个,系统的总消费能力自然就提升了。

在面试中提出这个方案时,一个体现你经验丰富的小技巧是,主动说明其局限性。

你可以说:"当然,这个方案虽然直接,但在实际操作中可能会受限。比如,有些公司的中间件运维团队对线上Topic的变更管控非常严格,随意扩容分区可能会被禁止。而且,增加分区后,消息在分区间的分布可能会发生变化,如果业务逻辑依赖于特定的分区策略(比如基于某个ID的哈希),需要谨慎评估其影响。"

这表明你具备线上运维的风险意识,而不仅仅是纸上谈兵。

方案二:创建新Topic(曲线救国)

很多时候线上消息队列的分区,是不允许随便添加的,因为这会牺牲掉Key的全局顺序性(仅保证增加之后的新消息按新规则路由)。并且这个操作需要和下游消费方充分沟通,确保他们的业务逻辑能够容忍这种变化,整体而言对整个系统的改动还是比较大的。所以一般我们可以采取"曲线救国"的策略,创建新的Topic,这种策略有两种具体的落地方案:

并行消费
  • 创建一个全新的Topic,例如order_topic_v2,并为其设置远超当前需求的分区数(比如100个)。
  • 让生产者将新的消息写入这个order_topic_v2
  • 同时,部署两套消费组:一套继续消费旧Topic(order_topic)中的积压消息;另一套新的消费组,以足够多的消费者实例,开始消费新Topic中的消息。
  • 当旧Topic中的消息全部被消费完毕后,下线旧的消费组,整个系统平滑过渡到新的Topic。
消息转发

这种方式主的核心思路是将旧的Topic的消息转发到我们创建的分区更多的新Toptic下,然后部署全新的消费者组来袭来消费新Toptic,由于新的Toptic分区多,所以可以部署更多的消费者,提升消费能力,具体步骤如下:

  • 创建一个分区数更多的新Topic order_topic_v2。
  • 生产者切换到新Topic。
  • 专门部署一个"搬运工"服务,它作为消费者从旧Topic中拉取积压数据,然后作为生产者,将这些数据转发到新Topic中。这个"搬运工"服务自身也需要保证高可用和高性能。
  • 主力消费组只需要专注于消费新Topic即可。

能清晰地分析出不同方案的trade-off,是架构师能力的重要体现。在介绍完这两种方式后,你可以主动进行对比:

"方式A处理积压数据的速度更快,但需要在短期内维护两套消费逻辑,增加了部署和运维的复杂性。而方式B的消费逻辑统一,代码更易于维护,但增加了一个转发环节,可能会稍微降低处理积压数据的整体速度。选择哪种方案,取决于当时对恢复速度的要求和团队的运维能力。"

消费者性能优化

前面的增加分区或者是增加Topic只是一种比较粗力度的快速解决方案,应急过后,我们还需要向内求索,尤其是当外部扩容受限时,无法扩容的时候,更要通过通过优化消费者自身的处理逻辑来"提速"。这里主要介绍三种常用的方案

引入降级

在某些业务场景下,消费逻辑并非"全有或全无"。我们可以借鉴微服务治理中的"降级"思想,在消息积压时,有策略地放弃一些非核心操作,以换取整体处理速度的提升。例如,一个用户动态(Feed)更新的消费者,其主要逻辑是:调用用户服务、调用内容服务、计算权重分、写入缓存。

在消息积压时,我们可以引入降级策略:在处理消息前,先检查该动态的Feed缓存是否存在。如果缓存已存在,则跳过后续所有复杂的计算和调用,直接认为处理成功。这个逻辑的依据是:既然数据有10分钟的缓存,那么在积压的紧急情况下,用户暂时看到几分钟前的旧数据是可以接受的。

将微服务治理的思想灵活运用到消息消费场景,能向面试官展现你知识体系的广度和解决问题的灵活性。

批处理

观察生产者的行为,有时也能发现优化的契机。设想一个批量更新商品库存的场景。上游系统每当一个商品库存变更,就发送一条消息。当有成千上万的商品需要同时更新时,就会产生海量的单条消息。

我们可以对生产者进行改造,让它将短时间内的多个库存变更聚合成一条消息再发送。相应地,消费者也改造为支持批量处理。一次数据库操作处理上百个商品的库存更新,其效率远高于执行上百次单独的更新操作。

在面试时,一个更能体现你主动性的说法是:"即使生产者无法改造,我们也可以只优化消费者。让消费者一次拉取一批消息(如100条),然后在内存中将这些消息构造成一个批量请求,再一次性提交给下游服务或数据库。这种'消费侧聚合'同样能取得不错的效果,更能体现我们作为消费端负责人的担当和优化能力。"

亮点方案:异步消费+批量提交

如果说前面的方法是"术",那么接下来要介绍的异步消费模型,则更接近于"道",它是一种架构层面的重构,能最大程度地压榨消费端的处理能力。

标准消费模型是"拉取-处理-提交"的同步循环。消费者线程拉取一条消息,执行业务逻辑,完成后提交位点,再拉取下一条。这个过程的瓶颈在于,拉取消息的网络I/O和处理消息的业务逻辑是串行的,互相等待。

而异步消费模型则将其解耦:

  • 一个专门的消费者线程:它的唯一职责就是高效地从消息队列中拉取消息,然后迅速将消息放入一个内存队列(如Java中的ArrayBlockingQueue)中。

  • 一个独立的线程池:这个线程池中的工作线程,从内存队列中获取消息,并执行真正的业务逻辑。

    这样一来,拉取消息的I/O操作和处理消息的CPU/I/O密集型操作就完全分离开来,互不干扰,整体吞吐能力大大增强。

在介绍这个终极方案时,面试官一定会追问其复杂性。你需要主动、深入地探讨该模型带来的三大挑战及其解决方案,这才是体现你架构设计能力的关键。

挑战1:消息丢失风险

这个方案确实可以极大提升消费能力,但是也有可能带来很多的问题,首先一点就是可能造成消息丢失。正常情况下,消息队列的消费是要消费完一条消息,提交成功之后,才会接着消费下一条。改成这种消费架构之后,这个转发消费者根本不关心消息是否被正确消费,只管把消息放入消息队列就完事了。这就可能出现消费者线程将消息放入任务队列后,worker线程还未处理完消息,应用就宕机了,worker重启之后会接着消费后续消息,刚才这条消息就永久丢失了。

应对这种情况我们就可以考虑批量提交。消费者线程一次性拉取一批消息(比如100条),分发给工作线程池。然后,它会等待这100条消息全部被工作线程处理完毕后,才一次性向MQ提交这批消息的最高位点。

挑战2:重复消费问题

批量提交虽然解决了消息丢失,但又引入了重复消费的可能。如果在100条消息处理完、但在批量提交前发生宕机,那么应用重启后,这100条消息会被再次拉取和处理。

这种情况最好的办法当然就是保证消费逻辑的幂等性(Idempotence)。这是解决重复消费问题的唯一正确途径。无论同一条消息被处理多少次,其最终产生的结果都应该是一致的。实现幂等性的常见方法包括:使用数据库唯一键约束、乐观锁(版本号机制)、或是在处理前查询状态等。

挑战3:批次内部分失败

此时厉害的面试官,还可能会接着问一个更棘手的问题,假设一批100条消息中,有99条成功了,但有1条因为某种原因(如数据库连接超时)处理失败了。我们该怎么办?如果因为这1条失败就整个批次不提交,那么会造成所有99条成功的消息被不断重复处理,消费进度被阻塞。

应对这种情况这里有多种处理策略,每一种都体现了不同的健壮性设计思路。

  • 同步重试:让失败的工作线程立即重试几次(例如,间隔100ms重试3次)。这种方法简单直接,但缺点是会拖慢整个批次的处理时间。因为当这个重复worker在重试的时候,其他worker必须要等它,所以要注意控制住重试的次数和重试的整体时间。

  • 异步重试:将失败的消息放入一个专门的重试线程中异步重试,让主流程继续。这种方式不会阻塞主流程,但会增加实现的复杂性。

  • 失败消息重入队列:这是一种更优雅的做法。当工作线程处理某条消息失败后,它不抛出异常,而是将这条消息(可以附带上重试次数等信息)重新发送回同一个Topic。这样,这条失败的消息将会在稍后被再次消费,而当前批次可以顺利完成并提交位点。需要特别注意的是,必须在消息体中加入一个重试计数字段,当重试次数达到阈值(如3次)后,就不再重新投递,而是将消息记录到死信队列(Dead Letter Queue)或日志中,进行人工干预,以防止"毒丸消息"导致无限循环。

通过"批量提交 + 幂等保障 + 失败消息重入队列/死信队列"这一套组合拳,我们就可以构建一个既高效又健壮的异步消费体系。在面试的时候,这也是一套可以让面试官眼前一亮的消息积压优化方案

小结

消息积压几乎是每个后端工程师在系统发展到一定阶段后都会遇到的经典难题,它不仅考验着我们对中间件的理解深度,更考验着我们面对线上复杂问题的综合处理能力。当面试官问你如何解决消息积压问题时,一个结构清晰、层层递进的回答会大大加分。你可以按照以下思路来组织你的答案:

  1. 定性问题:首先,搞清楚消息积压的原因,区分是临时性积压还是永久性积压。表明你具备线上问题分类处理的思路。
  2. 分析根源:从消息队列的分区模型入手,解释为何不能无限增加消费者,点出问题的本质。
  3. 分层解答:
  • 架构层(事前):讨论如何进行容量规划,科学地预估和设置分区数。
  • 应急层(事中):提出快速见效的方案,如扩容分区和创建新Topic,并分析其利弊。
  • 优化层(事后):深入到消费者代码层面,通过案例(去锁、降级、批处理)展示你的代码优化和性能调优能力。
  • 进阶层(架构重构):最后,抛出异步消费这个"大招",并深入探讨其背后的复杂性(消息丢失、重复消费、部分失败),展现你对复杂系统设计的驾驭能力。

这样一套组合拳打下来,不仅全面地回答了问题,更向面试官展示了你从原理到实践、从宏观到微观、从简单方案到复杂架构的完整知识体系和系统化思考能力

三、在使用 MQ 的时候,怎么确保消息 100% 不丢失?

还是老规矩,应对场景题,一个优秀的工程师,在面试的时候不应该直接抛出解决方案,而应该先展现自己的分析思路层层递进。拿到这个问题我们脑海中首先应该想到的就是以下三个问题:

  • 哪些地方可能导致消息丢失?
  • 怎样检测有没有消息丢失
  • 怎样确保消息不丢失?

哪些地方可能消息丢失

一个消息从生产者产生,到被消费者消费,主要经过这3个过程:

因此如何保证MQ不丢失消息,可以从这三个阶段阐述:

  • 生产者保证不丢消息
  • 存储端不丢消息
  • 消费者不丢消息

生产者保证不丢消息------一致性同步模型

生产端如何保证不丢消息呢?确保生产的消息能到达存储端?

实际上就是一致性的同步模型,是同步发送还是异步发送?发送几个算成功?

如果是RocketMQ消息中间件,Producer生产者提供了三种发送消息的方式,分别是:

  • 同步发送
  • 异步发送
  • 单向发送

生产者要想发消息时保证消息不丢失,可以:

  • 采用同步方式发送,send消息方法返回成功状态,就表示消息正常到达了存储端Broker。
  • 如果send消息异常或者返回非成功状态,可以重试。
  • 可以使用事务消息,RocketMQ的事务消息机制就是为了保证零丢失来设计的

kafka的消息写入机制是由acks参数控制的,这个参数有三种不同的级别,对应了三种不同的可靠性承诺。

  • acks = 0:"发送不管模式" 这种模式下,生产者把消息发出去就不管了,就可以接着发送下一条消息。这种配置性能最高,但可靠性最差。在这种模式下是最容易发生消息丢失的,因为没有确认响应嘛,只管发送,收没收到都不知道。所以一旦出现网络都懂,或者是Broker宕机,或者重启都会直接丢失消息
  • acks = 1(默认值):"写入 Leader 即成功模式" ,这是 Kafka 的默认配置。只要消息成功写入 Leader 分区,生产者就会收到成功的响应。这种模式在性能和可靠性之间取得了平衡。这种模式牺牲了一定的性能,但是在性能和可靠性之间取得了一定的平衡,增加了消息可靠性,这也是kafka的默认消息发送机制。但是还是会出现消息丢失,比如 Leader 刚写完消息,还没来得及同步给任何一个 Follower 就宕机了,那么这条消息就会永久丢失。
  • acks = all (或 -1):"写入所有 partion 副本才成功模式", 确保消息写入到leader分区、还确保消息写入到对应副本都成功后,接着发送下一条,性能是最差的,但最安全.这种模式就很安全了,它要等 partition集合中所有的 Follower 都同步完成,才会发送下一条数据。所以在消息生产阶段一般不会丢失消息。问题肯定没有这么简单,到这里面试官可能就会追问了
    这样的话,那我们把消息发送模式的acks设置为-1,是否就能保证消息不再丢失了呢

存储端不丢消息------刷盘机制

如何保证存储端的消息不丢失呢?确保消息持久化到磁盘。大家很容易想到就是刷盘机制

刷盘机制分同步刷盘和异步刷盘:

生产者消息发过来时,只有持久化到磁盘,RocketMQ的存储端Broker才返回一个成功的ACK响应,这就是同步刷盘。它保证消息不丢失,但是影响了性能。

异步刷盘的话,只要消息写入PageCache缓存,就返回一个成功的ACK响应。这样提高了MQ的性能,但是如果这时候机器断电了,就会丢失消息。

Broker一般是集群部署的,有master主节点和slave从节点。消息到Broker存储端,只有主节点和从节点都写入成功,才反馈成功的ack给生产者。这就是同步复制,它保证了消息不丢失,但是降低了系统的吞吐量。与之对应的就是异步复制,只要消息写入主节点成功,就返回成功的ack,它速度快,但是会有性能问题。

和数据库一样,Kafka 在写入数据时,为了性能考虑,也是先写入操作系统的 Page Cache(页缓存),然后由操作系统在合适的时机异步地刷写到磁盘。

这意味着,即使 acks = all,所有 partition 副本都确认收到了消息,但这些消息可能都还静静地躺在各个 Broker 的内存里。如果此时整个机房突然断电,所有 Broker 同时宕机且无法恢复,那么这部分在内存中的数据就会全部丢失。

当然,这种情况极其罕见,但理论上确实存在。Kafka 提供了几个参数来控制刷盘策略:

bash 复制代码
log.flush.interval.messages 消息达到多少条时刷盘
log.flush.interval.ms 距离上次刷盘超过多少毫秒就强制刷盘。
log.flush.scheduler.interval.ms  周期性检查,是否需要将信息刷盘

Broker要通过调用fsync函数完成刷盘动作,理论上,要完全让kafka保证单个broker不丢失消息是做不到的,只能通过调整刷盘机制的参数缓解该情况。比如,减少刷盘间隔,减少刷盘数据量大小。时间越短,性能越差,可靠性越好

不过,在实践中,我们很少会去主动调整这些参数。因为强制同步刷盘会极大地牺牲性能,我们更愿意依赖 Kafka 自身强大的副本机制来保证可靠性。

消费阶段不丢消息------执行完再反馈

等消费者执行完业务逻辑,再反馈回Broker说消费成功,而不是收到消息就反馈成功,这样才可以保证消费阶段不丢消息。

如何检测消息丢失

在明确了消息丢失场景之后,我们下面就需要思考,在业务中如何能检测到消息丢失呢?

如果公司有成熟的分布式链路追踪系统(比如SkyWalking、Jager),那自然是首选,每一条消息的生命周期都能被完整追踪。但如果没有,我们也可以自己动手,实现一个轻量级的检测方案。

核心思路是利用消息队列在单个分区内的有序性。我们可以在生产者(Producer)发送消息时,为每一条消息注入一个唯一且连续递增的序列号。消费者(Consumer)在接收到消息后,只需检查这个序列号是否连续,就能判断出是否有消息丢失。

举个例子,假设我们正在处理一个电商订单系统,生产者A负责发送订单创建消息到分区0。

  • 第一条消息,我们给它一个ID:ProducerA-Partition0-1
  • 第二条消息,ID就是:ProducerA-Partition0-2
  • 以此类推...

消费者在处理时,只需要维护一个对ProducerA-Partition0的期望序列号。比如当前收到的是...-2,那么下一条期望的就是...-3。如果下一条收到的是...-4,那么我们就知道,第3条消息"失踪"了,需要立即告警,并根据ID进行追查。

在分布式环境下,这个方案需要注意几个细节:

  • 分区维度的有序性:像Kafka、RocketMQ这类MQ,全局有序很难保证,但分区内是有序的。因此,序列号的生成和检测都必须在分区这个维度上进行。
  • 多生产者问题:如果多个生产者实例同时向一个分区发送消息,协调全局序列号会非常复杂。更实际的做法是,每个生产者维护自己的序列号,并在消息中附带上自己的唯一标识(如IP地址或实例ID),消费者则需要为每个生产者分别维护一套序列号检测逻辑。

当你把这套监控方案清晰地阐述给面试官后,你已经成功了一半。这表明你不仅懂技术,更有系统化、产品化的设计思维。

所以,总结下 怎样确保消息不丢失?

终于进入到最核心的地方,也是在面试的时候最能展现我们亮点的地方了,这里我们模拟一个面试场景来展开

面试官:"OK。上面消息丢失的一些场景分析的不错,那么在你的项目中,是如何设计一套方案来确保消息从生产到消费的整个链路都绝对可靠呢?"

基于前面的分析,我们知道了消息的丢失可能出现在消息生产,消息存储,和消息消费三个阶段,那么在设计保障方案的时候,我们也要构建一套从生产端到消费端全链路的消息保障体系。

参数配置

其实在前面分析消息丢失场景的时候,我们已经知道了大部分的消息生产端的消息丢失都是与acks参数设置相关。那么这里如果要保证消息100%不丢,这里我们自然要设置acks参数为all/-1。不过这也要依据业务场景来。一般只有在特别关键,并且性能要求不高的业务上才会这样去设置,而对于性能要求较高的业务是不合适的。具体可以做如下"极限"的配置:

acks = all**:确保消息写入所有 partition 副本。

min.insync.replicas = 2(或更高):这个参数设定了 ISR 中最少需要有几个副本。比如,如果 Topic 的副本因子是 3,这里设置为 2,就意味着至少要有一个 Leader 和一个 Follower 存活,acks=all 的写入请求才能成功。这可以防止在 ISR 副本数不足时,数据写入的可靠性降级。

unclean.leader.election.enable = false:坚决杜绝"不干净"的 Leader 选举,防止数据丢失。设置为false之后,Kafka 不会从非ISR副本中选举新的Leader。由于非ISR副本可能含有不完整或滞后的数据,从它们中选择Leader会带来数据丢失或不一致的风险。

代码健壮性保证

假设你的业务代码调用send()方法时,来发送消息。在编码时,我们必须对发送操作的结果进行处理:

同步发送:这种方式下,send()方法会阻塞,直到收到Broker的响应或者超时。java代码的话我们可以用try-catch块来捕获可能出现的异常(如网络抖动、Broker无响应等)。一旦捕获到异常,就必须进行重试,或者将失败的消息记录下来,后续进行补偿。

java 复制代码
// 以Kafka同步发送为例
try {
    // send方法返回一个Future,调用get()会阻塞等待结果
    RecordMetadata metadata = producer.send(record).get();
    // 收到metadata,说明发送成功,可以记录日志或继续业务
    System.out.println("消息发送成功,分区:" + metadata.partition() + ", 偏移量:" + metadata.offset());
} catch (Throwable e) {
    // 捕获到异常,说明发送失败
    System.out.println("消息发送失败,准备重试或记录日志!");
    // 在这里实现重试逻辑或将消息持久化到本地磁盘/数据库
    System.out.println(e);
}

异步发送:为了追求更高的吞吐量,如果是用异步发送。这时,send()方法会立即返回,不会等待Broker的响应。因此,我们必须在提供的回调函数(Callback)中检查发送结果。很多新手在这里"踩坑",只管发不管结果,这是导致消息丢失的常见原因之一。

java 复制代码
// 以Kafka异步发送为例
producer.send(record, (metadata, exception) -> {
    // exception不为null,说明发送过程中出现了错误
    if (exception != null) {
        System.out.println("消息发送失败,进行处理!");
        // 打印异常信息,用于排查问题
        System.out.println(exception);
        // 在这里同样需要实现重试或补偿逻辑
    } else {
        // 发送成功,可以打印日志,方便追踪
        System.out.println("消息异步发送成功,分区:" + metadata.partition() + ", 偏移量:" + metadata.offset());
    }
});

消息消费确认

消息在消费端的丢失,基本上都是消息消费后没来得及发送确认给生产者导,而导致这种情况一般都是异步消费引起的,所以这里可以参考消息积压这篇文章的异步消费优化情况来进行处理。

核心思想就是采用批量提交。异步消费的时候消费者线程一次性拉取一批消息(比如100条),分发给工作线程池。然后,它会等待这100条消息全部被工作线程处理完毕后,才一次性向MQ提交这批消息。提交完成之后才会发送下一批消息,这样来保证每一条消息都被消费了

消息生产阶段失败 如何重试?

到这里消息队列层面的消息保障基本上就做到位了。但是厉害的面试官可能还会问一个消息发送的可靠性问题

面试官:消息队列层面分析的挺全面的,这里有个问题,我们在业务侧发送消息的时候一般是有业务场景需要,比如注册完用户之后要给用户加积分,那这里这个加积分的操作一般都是通过消息队列异步化来实现的,这里注册和发消息到消息队列加积分可以看作是两个操作,怎么保证这个注册完成之后,消息一定会发送成功呢

消息事务------创建数据库消息表

这其实可以看作是个分布式的事务问题,业务操作和发送消息是两个独立的步骤,这里就是要保证这两个操作要么都成功,要么都失败,而如果这里业务操作成功,发消息失败,即发送消息的时候出现了消息丢失。这就会造成一致性问题了。所以这里就可以用消息事务来解决了

你可以这样回复,这里其实是个分布式的事务问题,我们要保证注册和发消息要么都成功,要么都失败,可以用消息事务来实现,具体方案我们可以选择业界用的比较多的本地消息表来实现

这个方案的核心思想是,将消息的发送封装进本地数据库事务中。具体流程如下:

  1. 开启本地事务:启动一个数据库事务。

  2. 执行业务操作:比如,在用户库里创建一条用户记录。

  3. 记录消息:在同一个事务中,向一张"本地消息表"里插入一条记录,状态为"待发送"。这条记录包含了完整的消息体、目标 Topic 等信息。

  4. 提交本地事务:提交数据库事务。

到这一步,即使应用立刻宕机,由于用户操作和"待发送"的消息记录在同一个事务里,数据的一致性得到了保证。用户创建成功了,那么要发送的消息也一定被记录下来了。

接下来,我们再处理真正的消息发送:

  1. 尝试发送消息:事务提交后,立即尝试将消息发送到 Kafka。

  2. 更新状态:如果发送成功,就更新本地消息表里对应记录的状态为"已发送",或者直接删除。

  3. 失败与补偿:如果发送失败,也不用担心。我设计了一个异步补偿任务(比如一个定时任务),它会定期扫描本地消息表中那些"待发送"且超过一定时间(比如 5 分钟)的记录,然后进行重试发送。为了避免无限重试,表中还会记录重试次数,达到阈值后就告警,转由人工处理。

介绍完具体方案之后,面试官还可能接着追问:如果数据库事务提交了,但是服务器宕机了,消息还没发送出去怎么办?

这里其实不用担心,我们有补充机制,异步补偿任务会轮训扫描消息表中待发送的消息,找出这条消息进行补发

在讲完本地消息表方案之后,还可以适当引申一下你对这种方案的优缺点分析,突出你考察问题的全面性和一个架构师方案选型方面的能力

当然这种方案也有它的优缺。优点是实现逻辑简单,开发成本比较低。

缺点也比较突出:

与业务场景绑定,高耦合,不通用。

本地消息表与业务数据表在同一个库,占用业务系统资源,量大可能会影响数据库性能。

本地消息表的方案也并非最优选择,现在有很多的消息队列也支持事务了,比如RocketMQ这类消息在中间件,本身就支持事务消息,在选用上就更方便。如果在业务中已经选用了本身就不支持事务的消息队列,并且业务量也不是太大的话,可以考虑本地消息表方案。

四、高并发场景下,如何处理消费过程中的重复消息?

假设有这样一个场景:"在我们的电商系统中,订单创建后会发送一条消息,下游的优惠券兑换系统会订阅这个消息,然后发放优惠券。我们的系统需要确保每一张优惠券,无论网络如何波动、系统如何异常,都只能被成功兑换一次。你会如何设计呢?"

这个问题看似简单,但它背后考验的是工程师对分布式系统复杂性的理解,尤其是对"幂等性"这一核心概念的掌握程度。很多同学的第一反应可能是"消息队列不是有'exactly-once'(精确一次)的保证吗?"。但事实上,绝对的"精确一次"在分布式系统中是一个难以达到的理想状态,都需要业务方配合于重试和幂等来达成。

为什么消息会重复?

在分布式系统中,组件间的网络通信本质上是不可靠的,生产端和消费端都有导致重复消费的场景。

  • 生产者重复发送:生产者发送消息后,因为网络超时等原因没收到 Broker 的确认,它无法判断消息是否发送成功,为了保证消息不丢失,通常会选择重试。这就可能导致同一条消息被发送了多次。
  • 消费者侧的重复消费:消费者拉取消息,业务逻辑处理完了,正准备提交消费位点(ACK)时,服务突然宕机或重启。当服务恢复后,它会从未提交的位点重新拉取消息,导致同一条消息被再次消费

如何保证幂等

首先我们要搞清楚,我们在设计方案的时候不是去追求一个完美的、永不重复的环境,而是要让我们的消费端服务具备幂等处理消息的能力。所谓幂等,就是无论一个请求被重复执行多少次,其对系统状态产生的影响都和第一次执行时完全相同

那么如何实现幂等操作呢?最好的方式就是,从业务逻辑设计上入手,将消费的业务逻辑设计成具备幂等性的操作。但是,不是所有的业务都能设计成天然幂等的,这里就需要一些方法和技巧来实现幂等

数据库唯一约束

这是最简单、最直接,也是最常用的一种方案。其核心思想是,利用数据库中"唯一索引"或"主键"的特性,来阻挡重复数据的插入。

假设我们有一个电商系统,用户下单后会发送一条消息,触发给用户增加积分的操作。消息内容可能包含{ "order_id": "202508310001", "user_id": 58, "points_to_add": 100 }

这个"增加积分"的操作,天然是非幂等的。我们可以这样改造:

建立一张积分流水表(points_log)。

表中包含字段:id (自增主键), order_id (订单ID), user_id (用户ID), points (变更积分), create_time。

关键一步: 对 order_id 这个字段建立一个唯一索引。

bash 复制代码
-- 尝试插入积分流水记录
-- 假设 order_id 字段上有唯一索引
INSERT INTO points_log (order_id, user_id, points) VALUES ('202508310001', 58, 100);

第一次消费:该订单ID首次出现,INSERT操作成功。然后我们可以安全地去更新用户的总积分。

重复消费:MQ再次投递相同的消息,消费者尝试INSERT时,数据库会因为order_id的唯一索引冲突而直接报错。我们的代码捕获这个异常后,就可以知道这是重复操作,直接忽略并返回ACK即可。

这是一种最简单的实现情况,面试的时候,为了展现你的思考能力,还可以做一个适当延伸,说明下这种方案的优缺点,以及扩展性

这种方案的优点是: 实现简单,成本低,效果可靠。 缺点也很明显: 强依赖数据库特性,对于非数据库操作的场景无能为力。

redis setnx

基于这个思路,如果不用关系型数据库,Redis的SETNX命令(SET if Not eXists)也能达到异曲同工的效果,可以用order_id作为key,实现分布式锁或状态记录。

版本号机制

这个时候面试官可能会问:"上面的方案都是基于数据插入场景的,假设我们的业务操作不是数据插入,而是数据更新呢"

确实,如果我们的业务不是INSERT,而是UPDATE呢?比如,更新订单状态。这时,唯一约束就派不上用场了。我们可以引入"前置条件"或"版本号"机制,也就是常说的乐观锁。

假设有这样一个场景,订单支付成功后,需要将订单状态从"待支付"(status=1)更新为"待发货"(status=2)。消息内容为{ "order_id": "202508310002", "target_status": 2 }

直接执行UPDATE orders SET status = 2 WHERE order_id = '202508310002'是非幂等的。如果因为某种原因,后续还有一个"取消订单"的操作把状态改回了1,这条重复的消息可能会错误地再次把订单改为"待发货"。

我们可以这样改造:

在orders表中增加一个version字段,默认为0或1。

消费消息时,我们从消息中(或者先查询一次数据库)拿到当前的版本号。

执行UPDATE时,带上版本号作为条件。

bash 复制代码
-- 更新订单状态,同时检查版本号
-- 假设当前数据库中该订单的 version 是 5
UPDATE orders 
SET status = 2, version = version + 1 
WHERE order_id = '202508310002' AND version = 5;

第一次消费:version为5,条件满足,UPDATE成功。数据库中的version变为6。

重复消费:MQ再次投递消息,消费者执行同样的SQL,但此时数据库中的version已经是6了,不满足AND version = 5的条件。UPDATE语句会执行失败,影响行数为0。我们就知道这是重复操作了。

同样在分析完这个方案之后,你可以做一个方案优缺点的补充。优点: 适用范围比唯一约束更广,能处理大部分更新操作。 缺点: 需要在业务表中增加额外字段(如version),有一定侵入性。

亮点方案

到这里,面试官还不满意,接着追问,如果我们的业务逻辑非常复杂,可能涉及多个表的更新,甚至是一些外部RPC调用,这个时候版本号已经不起作用了,此时应该怎么办呢?

这个时候就到了我们祭出我们第一个亮点方案的时候了:全局唯一ID + 单独的防重表(或缓存)

防重表

防重表也叫幂等记录表,这个方案的核心思想是,为每一次消息处理操作生成一个全局唯一的标识。在执行核心业务逻辑前,先将这个唯一标识插入一张"幂等记录表"或直接利用业务表中的唯一约束字段。如果插入成功,说明是首次处理,继续执行业务;如果插入失败(因为唯一键冲突),则说明这条消息已经被处理过了,直接丢弃即可。

在面试的时候你可以先介绍下这个方案的基本流程

  • 如果业务复杂,我们可以采用防重表的方案,将业务逻辑和幂等逻辑解耦。单独建立一张防重表,具体的步骤如下:
  • 为每条消息生成一个全局唯一ID(GUID)。这个ID可以在生产者发送时就放入消息体或Header中。
  • 建立一张"消费记录表"(consumed_log),表结构很简单,核心就是一个字段message_id,并将其设为主键或唯一索引。

消费者处理逻辑变为一个"三段式":

a. 开启事务。

b. INSERT消息的GUID到consumed_log表中。

c. 执行真正的业务逻辑(更新数据库、调用RPC等)。

d. 提交事务。

这样如果是重复消息的话,就会插入消费记录表失败,就不会执行后面的业务逻辑了

这里其实隐藏了一个问题,厉害的面试官可能会继续深挖

"这里你的方案里提到了用事务,在一个数据库里确实没有问题,可以用本地事务来保证防重逻辑和业务逻辑的原子性,但是如果是分布式环境下,跨库要怎么处理呢?"

异步校对

这里如果你能把分布式环境下的跨库幂等性实现也讲清楚的话,其实就已经可以跟一般候选人拉开差距了。确实,在微服务架构下,业务操作往往是跨服务的,比如"扣减库存"和"创建物流单"可能分别由两个不同的服务实现。这时,本地事务就失效了。

此时,我们需要引入最终一致性的设计思想,并辅以一个异步校对机制。整个流程会演变成三步:

  • 预操作:收到消息后,第一步是在幂等记录表中插入一条记录,但状态标记为"处理中(PROCESSING)"。例如,插入一条记录 (order_id, 'PROCESSING')。这一步是幂等性的关键防线。
  • 执行业务:调用库存、物流等下游服务,执行核心业务逻辑。
  • 确认操作:所有业务逻辑成功执行后,回来将幂等记录表中的状态更新为"已完成(COMPLETED)"。

注意 和上面的区别在于 没有开启事务

这里由于没有事务保证,所以很可能出现第二步执行业务成功了,但第三步更新幂等表对应数据为已完成的时候失败了(比如网络问题或服务宕机)。这时,幂等记录表里会留下一条"处理中"的"记录。

这个时候就是异步校对机制发挥作用的时候了。它会定期扫描幂等记录表中那些长时间处于"处理中"状态的记录,然后反向查询各个业务系统(比如查询物流系统是否存在该订单的物流单),来判断业务是否真的执行成功。如果查询下来业务确实已经成功,校对任务就负责将幂等记录的状态更新为"已完成"。

这里面试官可能问一个问题:"如果查询下来业务也没有成功,会怎么样呢?"

这里其实就回到了我们的经典重试问题的处理方案了

你可以这样回答:"其实这个时候还是一致性的状态,就说明业务确实是没有执行成功,所以不会修改状态为已完成,这个时候可以直接重新再出发一次业务操作就可以了,可以设置一个重试次数,如果超过重试阈值,一致不成功,最后只能由人工介入"

到这里,你已经展示了在复杂分布式场景下的问题解决能力,但面试官可能还想继续了解你的技术深度,会继续施压:

"这个方案很好,但所有请求,无论是首次还是重复的,最终都要访问数据库。在每秒几万甚至几十万请求的场景下,数据库很快就会成为瓶颈。你有什么优化思路?"

这正是引出我们第二板斧的绝佳时机

性能提升
缓存判重

很容易想到,数据库有读瓶颈的话,最好的优化方式就是加缓存,同样这里我们可以在防重表的上层加一个redis来缓存近期处理过的 key。你可以详细解释下这个方案

当一个新的消息进来的时候,我们先通过redis做一次判重校验,如果这个key存在,那么我们就认为这是重复的key,如果redis不存在,再通过数据库做一次兜底校验,如果key存在就认为是重复的消息,如果key不存在,就认为不是重复消息,没处理过

引入redis后的整个判重校验逻辑如下图

一旦引入缓存,就涉及到缓存和数据库的一致性问题了,这也是面试的时候,面试官最喜欢问的点,那这里我们每次处理一个新的消息之后,是先更新数据库(防重表)呢,还是先更新缓存redis呢?

这里一定是先更新数据库,因为它是最可靠的,也是我们的兜底方案,你可以这样回答

处理完业务逻辑之后,先更新数据库,把这个新的消息写入到防重表,在更新redis。这里即使redis更新失败,也没有关系,下一次这个重复的消息过来的时候,做重复性校验的时候,无非就是redis这里的拦截不起作用了,但是还会透穿到数据库层面去做校验, 还是能把重复消息拦截掉。保证消息幂等消费

上面这个说的好像不够好 旁路缓存策略其实更好吧

"那这里如何确定redis里key的过期时间呢?"

但凡涉及到redis的缓存问题,过期时间的确定也是一个高频考点。

关于redis的key过期时间其实没有一个严格的标准,一般是根据业务场景来定的,可以先观察具体消息队列接入的业务key的出现频率来设定,通过测试观察比如key的过期时间大概是5分钟出现一次的话,那我们比过期时间稍微设置长一点时间即可,比如六七分钟都是可以的。但是这里的key过期时间不宜设置过长,比如半小时,一小时。如果设置的太长,如果业务量大,并发量高的话,会造成redis的存储量暴涨,引起redis瓶颈

那假设这里重复的key出现间隔就是很久,比如一批一批的重复消息大量出现。redis用来做这一层隔离不合适,应该怎么办呢?这里就要祭出我们的终极亮点方案了

布隆过滤器

布隆过滤器不会出现漏判(False Negative),即如果它说不存在,那一定不存在。但是会出现误判(False Positive),即可能说一个元素存在,但实际上却不存在。

为了增加性能,不让每一次判重逻辑都走数据库,我们可以在数据库前面加个布隆过滤器,每一个新的消息过来,先用布隆过滤器判重,如果不存在,我们就正常处理业务逻辑,然后再更新布隆过滤器和防重表。如果布隆过滤器存在这个key的话,由于布隆过滤器存在假阳性的问题,所以这里就可以透穿到数据库再做一次校验。就如果是重复消息,直接拒绝就可以了,如果不是重复消息,就正常处理消息

扩展方案 连续整数型id bitmap直接标记

有没有比布隆过滤器更好的选择?

在某些特定场景下,确实有。如果你的业务唯一标识本身就是连续或接近连续的整数(例如,订单系统分库分表后的数据库自增ID),那么使用位图(Bit Array / Bitmap)会是比布隆过滤器更优的选择。

位图用每一个比特位来标记一个ID是否存在,1代表存在,0代表不存在。它不仅完全没有假阳性的问题,判断绝对精确,而且在数据密集的情况下,内存效率极高。我们甚至可以用一个很小的偏移量来映射业务ID,比如,业务ID从8,800,000开始,那么我们可以让位图的第0位对应ID 8,800,000,第1位对应8,800,001,以此类推,极大地节省了存储空间。

但是这种方案有一个局限性,那就是我们的业务唯一ID必须是数值型的,且最好是自增的,这样用起来才比较方便。如果满足这种条件的话,用位图来替换布隆过滤器是个绝佳选择

在介绍完上面的布隆过滤器方案的时候,你可以主动引出这个位图的方案,分析下它的优劣势,说明其局限性和使用场景。到这里,你基本上已经可以打败90%的面试候选人了。

小结

到这里,我们的消息幂等方案就介绍的差不多了。我们从一个简单的消息重复消费问题出发,从最基础的数据库唯一键约束,一路升级打怪,共同构建了一套由 Redis、布隆过滤器乃至位图组成的完整技术方案。

这些方案的设计思路其实都遵循着同一个核心原则:让系统具备幂等处理能力。无论是通过数据库约束阻挡重复数据,还是通过缓存和过滤器提升性能,本质上都是在解决"如何让重复的操作产生相同的结果"这个根本问题。

可以看到,在应对面试官关于消息重复消费的层层追问时,关键并不仅仅在于给出一个单一的"最优解",而在于展现我们作为工程师解决问题的完整思考框架:从识别问题、到设计基础方案、再到分析瓶颈并层层优化。

最后,技术方案永远没有标准答案,关键是要能够根据具体的业务场景、并发量级、团队技术栈等因素,选择最合适的解决方案。这才是一个成熟工程师应该具备的核心素养。

五、分布式事务

相关推荐
Liquad Li2 小时前
RabbitMQ 和 Kafka 对比
分布式·kafka·rabbitmq
nlog3n2 小时前
分布式任务事务框架设计与实现方案
java·分布式
熙客2 小时前
分布式调度问题:定时任务
java·分布式·spring cloud
一條狗3 小时前
学习日报 20250929|数据库与缓存一致性策略的选择
redis·mysql·kafka
nlog3n4 小时前
分布式计数器系统完整解决方案
java·分布式
哈哈很哈哈6 小时前
Spark核心Shuffle详解(二)ShuffleHandler
大数据·分布式·spark
xrkhy10 小时前
分布式之抢购
分布式
王嘉俊92511 小时前
Kafka 和 RabbitMQ 使用:消息队列的强大工具
java·分布式·中间件·kafka·消息队列·rabbitmq·springboot
cominglately11 小时前
kafka和rocketmq的副本机制区别: isr 主从模式,Dledger模式
分布式·kafka·rocketmq