做后端开发的人应该都有过这种体验:系统越做越大,各个服务之间的调用关系像蜘蛛网一样缠在一起,一个核心接口慢了几百毫秒,整个链路跟着遭殃。后来我把消息队列引入到了订单、库存、通知这几个核心模块里,很多原本要花大力气去"硬扛"的问题,一下就变得顺了。这也是我决定写这篇东西的原因——消息队列在分布式系统中的应用,不是简单地把一个中间件搭起来就能完事的,它牵扯到模型选择、可靠性保障、顺序性处理、延迟消息实现等一系列的问题。这篇文章不打算做教科书式的知识罗列,而是把我实际做过的方案、踩过的坑、调优过的参数,以及面试里最常被追问的那些点,一次性讲清楚。
我默认阅读对象是已经对Spring Boot、Redis、主流MQ有一定了解的后端开发,如果你刚接触分布式系统也没关系,涉及基础概念的地方我会用生活化的类比解释,保证能跟上。
1. 消息队列在分布式系统里到底解决了什么——从同步调用链路的痛点开始聊
消息队列往往是被"逼"出来的。拿我最熟悉的一个电商下单流程来说:用户点下单,后端同步去创建订单、扣库存、发优惠券、发短信通知。整个流程看起来没什么问题,但仔细拆开看,每一步都是隐患。
1.1 同步调用的三个痛点:耦合、脆断、阻塞
同步调用的第一个问题就是耦合。订单服务和库存服务、优惠券服务、短信服务直接绑死在一起,订单接口的代码里硬编码了三个下游的地址和调用逻辑。某一天短信服务说要升级接口,订单服务必须跟着发版;库存服务临时挂了,订单接口直接报错。这就是典型的"牵一发而动全身"。
第二个问题是脆断。一条调用链路上的任何一个环节出问题,整条链路就断了。库存服务响应超时3秒,用户眼睁睁看着下单按钮转圈,然后收到"系统繁忙"的提示。分布式系统里,链路越长,某个环节出故障的概率就越高,同步调用把所有服务的可用性叠乘在一起,整体可用性惨不忍睹。
第三个问题是阻塞。同步调用意味着上游必须等下游处理完才能继续往下走。发短信这个动作如果耗时500毫秒,用户就要多等500毫秒。一次下单可能要调用5个服务,每个服务平均200毫秒,一个请求就是1秒起步。这还不是最糟的——如果下游服务突然变慢,线程池会被占满,整个服务的吞吐量直接腰斩。
1.2 异步解耦:订单流程拆掉的那两根"硬线"
引入消息队列之后,我做的第一件事就是把订单服务和其他服务的同步调用改成异步消息通知。订单服务创建订单成功后,往"订单创建成功"这个Topic里丢一条消息,然后就立刻返回"下单成功"给用户。库存服务、优惠券服务、短信服务各自订阅这个Topic,各取所需。
从代码层面看,订单服务里不再依赖任何下游的SDK或HTTP客户端,只依赖消息队列的客户端。下游服务不需要的时候,订单服务甚至不知道它们的存在。这就是解耦——每个服务只关心自己的事情,消息队列成了它们之间唯一的"信使"。
需要说明的是,异步解耦不是银弹,它换来的是最终一致性。原本同步调用能保证强一致,下单成功的那一刻,库存一定扣减了。改成异步之后,下单成功的那一刻,库存可能还没扣,需要几毫秒甚至几十毫秒之后才扣。对大部分业务来说,这种程度的延迟完全可接受,但对资金类、库存超卖极度敏感的业务,就需要额外设计对账和补偿机制。这个后面细说。
1.3 流量削峰:把洪峰变成平峰
消息队列削峰的本质是"蓄水"。电商大促的时候,每秒可能有几万个下单请求打进来,订单服务的数据库连接池最多抗住每秒两三千的写入。如果不加任何缓冲,数据库直接被击穿。
我在订单服务和数据库之间加了一层消息队列,下单请求先写到MQ里,订单消费端按自己最大的处理能力——比如说每秒2000条——去拉取消息、落库。多出来的那些请求,就老老实实待在MQ里排队。用户看到的仍然是"下单成功",只是数据库没有在同一瞬间被冲垮。
画个简单的对比图:没有MQ时,请求量是"陡峭的尖峰";有MQ后,尖峰被削平了,处理速率变成了"平稳的直线",只是后面拖了一条尾巴。削峰的本质是允许请求排队,用时间换空间。
1.4 数据最终一致性的落地场景
刚才提到消息队列会把强一致变成最终一致,这里展开说下我实际落地的一个场景:订单支付成功之后,需要给用户加积分,同时通知仓储系统发货。
这个场景里,支付成功是一个关键事件。支付服务只需要把"支付成功"消息发到MQ里,积分服务和仓储服务各自消费。如果积分服务挂了,消息还在MQ里,等它恢复后继续消费,不会丢。如果仓储服务处理失败,消息会触发重试,直到成功。这就保证了整个系统在某个时间窗口内处于"不一致"状态,但最终所有服务都会收敛到一致的状态。
最终一致性的落地有一个前提:每个消费方都必须做到幂等。因为消息重试必然带来重复投递,消费方如果不对重复消息做处理,就会出现积分加了两次、库存扣了两次这种事故。这块我会在第3章详细展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MQ选型对比——不同业务场景下的不同选择
市面上主流的消息队列有RocketMQ、Kafka、RabbitMQ、Redis Stream(严格说Redis Stream不是传统MQ,但实际用的人很多),选型不做对错之分,只有适配之分。
2.1 四种主流MQ的核心差异
我先用一张表把关键差异列出来,再逐个说我的体会。
| 维度 | RocketMQ | Kafka | RabbitMQ | Redis Stream |
|---|---|---|---|---|
| 消息模型 | Topic + 消费组 | Topic + 消费组 | Exchange + Queue | Consumer Group |
| 顺序消息 | 支持 | 分区内有序 | 单队列有序 | 单Stream内有序 |
| 延迟消息 | 开源版本支持18个固定级别 | 不支持,需二次开发 | 通过TTL + 死信队列实现 | 需自己实现 |
| 消息回溯 | 支持 | 支持 | 有限支持 | 有限支持 |
| 吞吐量 | 中高 | 极高 | 中 | 中 |
| 运维复杂度 | 中 | 中高 | 低 | 极低(复用Redis) |
| 可靠性 | 很高 | 高 | 高 | 高(取决于Redis持久化配置) |
Kafka的强项是超高吞吐量和天然支持消息回溯,常用于日志收集、大数据管道、流处理。但你用它做业务消息,会有点别扭:延迟消息不支持,消息粒度比较粗,重试机制也没那么灵活。
RocketMQ是中间件的优等生,顺序消息、延迟消息、事务消息都是开箱即用,阿里内部大量核心交易链路就是用它扛过来的。如果你做的是电商、交易相关的业务,RocketMQ是最省心的选择。
RabbitMQ的路由模型非常灵活,延迟消息也能通过TTL加死信队列实现,但吞吐量相比Kafka和RocketMQ要低一些,适合中小规模系统。
Redis Stream严格说不是一个完整的MQ,它没有完善的消息回溯、死信队列、延迟消息机制,但它胜在简单。很多中小团队已经有Redis了,不想再引入一套Kafka或RocketMQ集群,就会选择Redis Stream来顶一阵。这个选型我用过,后面第5章会专门讲。
2.2 选型时容易忽略的几个维度
很多人在选型时只看吞吐量和功能,忽略了三个更实际的问题:
**团队的技术储备。**团队没人玩过Kafka,只有一个人用过RabbitMQ,那即便Kafka功能更合适,也未必是最好的选择。一个没人能运维的中间件,上线后就是灾难。我见过一个团队上了Kafka集群,结果没有专职运维,GC参数调不好,线上频繁抖动,最后又退回RocketMQ。
**消息堆积的能力。**大促期间消息洪峰来了,消费端也可能跟不上,消息会在Broker端堆积。这个堆积量可能达到几千万条甚至上亿条。RocketMQ和Kafka对海量堆积的支持都很好,RabbitMQ堆积到一定程度性能下降明显,这条路一定要提前想清楚。
**运维成本。**一个三节点的Kafka集群加上监控告警、Topic管理、消费组管理,至少需要一个人花不少精力去维护。如果你所在的团队只有两三个人,业务流量又不高,直接用Redis Stream或者云上的托管MQ反而更划算。
2.3 我们团队选型的一次真实决策过程
有一年我们做一个物流中台项目,日订单量在30万左右,峰值QPS大概2000,需要延迟消息来推动超时未支付订单的关单。团队一共5个人,没人用过RocketMQ,但都用过Kafka。
最终我的结论是选RocketMQ。原因有两条:第一,延迟消息是硬需求,Kafka做延迟消息得自己搭一套时间轮或者分级扫描方案,投入成本太高;第二,RocketMQ和Kafka在运维上都算"重组件",既然都要花精力,不如选功能更匹配业务的。至于团队没有经验的问题,花了一周时间做了个技术预研,把RocketMQ的常用API和核心原理过了一遍,后来上线也一直很稳定。
这个决策过程想说明的是:选型不要只看网上那些对比文章,要回到自己的业务诉求和团队能力上做取舍。
3. 三大经典难题:不丢消息、不重复消费、不乱顺序
消息队列用起来简单,但想在核心链路上稳定运行,绕不开三个经典难题。每个问题我都在生产环境里踩过坑。
3.1 消息不丢失:三个环节的ACK机制
消息从生产到消费,完整地经过三个环节:生产者发送到Broker、Broker存储、消费者拉取消费。任何一个环节都可能丢消息。
**生产者端的发送确认。**生产者调用send()之后,默认是异步的,消息是否真的到了Broker,需要Broker返回确认。Kafka里有个acks参数,我通常推荐设置成acks=all,含义是消息要等所有ISR副本都写入成功后才算发送成功。RocketMQ里对应的是同步发送加SendResult回调,一定要判断发送结果是OK再落业务库。
**Broker端的持久化。**Kafka和RocketMQ默认都依赖磁盘刷盘。这个过程有一个参数——Kafka的min.insync.replicas和RocketMQ的刷盘策略。生产环境建议把Kafka的min.insync.replicas设置为2,意思是至少两个副本都写入成功才算成功,这样即使一个节点宕机,数据也不会丢。RocketMQ如果需要降低丢失风险,可以把刷盘策略从异步刷盘改成同步刷盘,代价是吞吐量下降。我在非核心链路用异步刷盘,核心交易链路用同步刷盘。
**消费者端的ACK。**消费者拉取到消息之后,如果还没有处理完就提交了offset,进程崩溃后消息就丢了。我通常用的是手动ACK:先处理业务,再提交offset。如果业务处理失败,不提交offset,消息会在后续被再次拉取到。这里有个细节要注意:如果处理的是"取消息"和"提交offset"之间的时间窗口,进程挂了,消费者重启后可能会重复消费一些消息——这是不可避免的,所以幂等是底线。
3.2 重复消费的根源与幂等设计
重复消费几乎无法从机制层面彻底杜绝。生产端为了可靠性会做重试,消费端为了不丢消息也会做重试,重试就意味着同一个消息可能被投递多次。所以处理重复消费的正解,不是让MQ保证只投递一次,而是让消费端具备幂等性。
我在业务里常用的幂等方案有三种:
**数据库唯一索引。**适合落库场景。比如订单消息表,在order_id上建唯一索引,消费端插入时如果冲突了,说明已经处理过,直接忽略。这是最可靠的方案。
**Redis分布式锁。**适合非落库场景。消费消息时,先用消息的唯一ID在Redis里加锁,锁存在就说明已经有消费请求在处理,直接跳过。注意锁要设置过期时间,防止消费端崩溃后锁一直不释放。
**状态机校验。**适合有状态流转的场景。比如订单状态从"待支付"改为"已支付",消费端在处理时先查一下当前订单状态,如果已经是"已支付",就不再处理。这个方案要配合乐观锁使用,更新时带上状态条件。
幂等这块我个人的经验是:能上数据库唯一索引的,就不要依赖分布式锁,更不要依赖状态机。唯一索引是数据库层面强保证,分布式锁还有超时导致锁失效的风险,状态机遇到并发修改会有脏数据问题。
3.3 顺序性:单分区+局部串行消费
消息顺序问题很大部分来自并发消费。消费者为了提升吞吐量,通常会开多个线程甚至多台机器同时消费,这会让消息处理的顺序完全乱掉。
解决思路是"局部有序,全局宽松"。一个Topic会有多个分区,同一个订单的数据要保证顺序,就把这类消息发到同一个分区,然后这个分区只被一个消费线程消费。Kafka里的实现方式是给生产者指定key(比如订单ID),相同key的消息会被路由到同一个分区。RocketMQ也有类似的消息队列选择器机制。
我踩过的坑是:只设置了消息key,没注意消费者的并发度。Kafka一个分区可以被一个消费组里的多个消费者共享,但如果消费者组里只有一个消费者,它能同时拉取多个分区,并且内部默认是多线程并发处理的。要让单个分区的消息串行消费,需要在消费者配置里把max.poll.records调小,同时用一个单线程的线程池来兜底。在Spring Kafka里可以设置Concurrency=1,表示每个Listener容器只起一个线程消费一个分区。
3.4 一个典型的可靠性配置组合
上面说的内容,我在实际项目里通常这样组合:
- 生产端:Kafka设置acks=all + retries=3 + enable.idempotence=true。开启幂等生产者后,同一个会话内Broker不会接收重复消息。
- Broker端:min.insync.replicas=2 + 异步刷盘(非核心链路)。
- 消费端:enable.auto.commit=false + 手动ACK + 每个消费业务都做幂等。
这套组合下来,消息丢失的概率几乎可以忽略不计,重复消费被幂等挡住,顺序性通过分区内串行保证。这三个问题解决了,消息队列落地在业务系统里才算是有了底气。
4. 延迟消息的实现方案与踩坑记录
延迟消息在业务里的需求特别常见:下单后30分钟未支付自动关单、定时提醒、优惠券到期提醒。RocketMQ原生支持延迟消息,Kafka和RabbitMQ、Redis都需要自己实现,我重点说下自己在没有原生支持时的四种实现路线。
4.1 延迟消息的四条实现路线
- **RocketMQ自带的定时消息。**发送消息时指定delayTimeLevel,有几个固定的延迟级别(1s、5s、10s、30s、1m、2m、10m、30m、1h、2h、6h、12h等)。这个方案最省事,缺点是延迟级别是固定的,不能随意指定任意时间。
- **RabbitMQ的TTL + 死信队列。**给消息设置过期时间,消息过期后自动转入死信队列,消费者从死信队列取消息即可。这个方案的问题是同一个队列里堆积了大量不同延迟时间的消息,前面的消息不过期,后面的消息即使到了时间也出不去(队头阻塞),实际使用时通常要为不同延迟级别建不同队列。
- **Redis过期键监听。**这个方法很流行,网上教程一大堆,但坑也很多,我在4.2单独说。
- **定时任务轮询 + Redis ZSet。**把延迟消息放进ZSet,score是执行时间戳,定时任务每秒扫描一次,把到期消息取出来处理。这个方案实现简单、可控性强,适合中小项目。
- **时间轮。**Kafka内部用的就是时间轮,基于嵌套的时间轮实现秒/分/小时的调度,适合高并发的延迟任务调度系统。自己实现时间轮比较复杂,一般业务系统没有必要。
4.2 Redis过期键监听方案的坑
很多项目遇到延迟消息,第一反应就是用Redis的key过期监听来做:订单创建时在Redis写一个key,过期时间设为30分钟,订单过期后Redis触发一个事件,消费者监听到事件后去关单。
这个方案的实现成本极低,但坑非常深:
第一,**Redis的过期键监听依赖pub/sub机制,消息不落盘。**如果消费者的网络闪断,或者Redis服务重启,过期事件就丢了,订单永远不会被关单。生产环境丢过一次,凌晨三点被报警叫起来,后来彻底弃用。
第二,**过期事件不是精确触发的。**Redis的过期键清理是惰性删除+定期删除的结合,默认10秒扫一次,极端情况下过期事件的延迟可能达到几十秒。如果是秒级精确的延迟任务,这个方案完全不可用。
第三,**一个key过期事件里包含的是key的名字,payload为空。**所以你必须在key里编码业务信息(比如orderID_xxx),消费端再解析,用起来很别扭。
结论:这个方案只适合"丢了也无所谓"的场景,比如非关键提醒。核心业务千万别用。
4.3 基于Redis Stream实现延迟队列的完整步骤
最终我给团队落在生产上的方案是:Redis Stream + 延迟消息扫描器。
思路是这样的:
- 发送延迟消息时,把消息体写到Redis Stream的临时"待发Stream"里,同时把消息的时间戳和消息ID写进Redis ZSet,score是"到期时间戳"。
- 一个后台扫描器模块每秒执行一次,用
ZRANGEBYSCORE READY_QUEUE -inf NOW LIMIT 0 100取出所有到期消息ID,对于每条到期消息,从待发Stream里把完整消息取出来,写入正式的StreamPENDING_BIZ。 - 业务消费者只需要从
PENDING_BIZ消费即可,对业务层完全透明。
这个方案的优点在于:Redis ZSet天然支持按时间排序,扫描器只需要查询到期部分,条数不多时性能压力很小;消息正式内容在Stream里有持久化,不会像过期key事件那么没有保障;整个方案依赖的只是Redis原生的数据结构和Stream特性,不用引入额外组件。
4.4 定时扫描+重投机制
这里必须讲一个容易忽略的细节:扫描器把到期消息写入目标Stream后,如果业务消费者处理失败,需要重试。重试可以沿用Stream本身的消费者组机制(手动ACK + Pending读取),不需要额外设计重试队列。
但扫描器本身也是单点,它崩溃了怎么办?我为扫描器加了一个"多实例抢占"机制:多个扫描器实例通过Redis分布式锁抢占执行权,同一时刻只有一个实例在扫描。扫描任务执行完成后,释放锁,下一个周期再抢。这样即使某台机器宕机,其他实例也能接管扫描工作。
5. Spring Boot集成Redis Stream拉取消息的实战
Spring Boot集成Redis Stream在很多团队里作为轻量级MQ在用,这里把完整的一个实战过程和踩坑经验分享出来。
5.1 为什么是Redis Stream而不是List或Pub/Sub
很多人一开始会拿Redis的List当消息队列用:LPUSH生产、BRPOP消费。List的优势是简单,但问题也明显:没有消费组的概念,多台机器消费同一个List会出现同一个消息被多个消费者抢到的问题;没有消息确认机制,客户端崩溃消息就丢了。
Pub/Sub是发布订阅模型,消息是即发即弃的,如果不订阅就收不到,没有持久化,也没有堆积能力。拿它做任务队列基本不合格。
Redis Stream是Redis 5.0引入的专门为消息队列设计的数据结构,有消息ID、消费组、Pending列表、ACK机制,这些特性让它比List更接近一个"正经MQ"。虽然比不上Kafka和RocketMQ,但中小系统够用。
5.2 消费者组模式的监听配置
在Spring Boot里,我推荐用 @EnableScheduling 加一个定时拉取任务,而不是用 @RedisListener 注解。原因在后文说。
先看消费者组的完整Java实现:
java复制@Component
@Slf4j
public class StreamConsumer {
@Value("${redis.stream.key:order-stream}")
private String streamKey;
@Value("${redis.stream.group:order-group}")
private String groupName;
@Value("${redis.stream.consumer:consumer-1}")
private String consumerName;
@Resource
private StringRedisTemplate stringRedisTemplate;
@PostConstruct
public void init() {
// 创建消费组,如果已存在则忽略
try {
stringRedisTemplate.opsForStream().createGroup(streamKey, groupName);
} catch (Exception e) {
log.info("consumer group already exists: {}", groupName);
}
}
@Scheduled(fixedDelay = 1000)
public void pollMessages() {
// 读取未ACK的消息
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream()
.read(Consumer.from(groupName, consumerName),
StreamReadOptions.empty().count(10).block(Duration.ofMillis(1000)),
StreamOffset.create(streamKey, ReadOffset.lastConsumed()));
if (records == null || records.isEmpty()) {
return;
}
for (MapRecord<String, Object, Object> record : records) {
try {
// 业务处理
processMessage(record.getValue());
// 处理成功后手动ACK
stringRedisTemplate.opsForStream().acknowledge(streamKey, groupName, record.getId());
} catch (Exception e) {
log.error("consume message failed, id={}", record.getId(), e);
// 处理失败不ACK,消息会留在Pending里
}
}
}
}
核心逻辑是:定时从Stream的 lastConsumed 位置拉取消息,拉取到后先处理业务,处理成功才调用 acknowledge 确认。如果业务处理异常,不ACK,消息仍然留在消费组的Pending列表里,下次拉取会再次拿到。
5.3 手动ACK与Pending列表的处理
手动ACK这个问题值得展开讲。如果你用自动ACK(Spring默认的 autoAcknowledge 为 AUTO_ACKNOWLEDGE),消费者读到消息的那一刻就会被标记为已消费,但此时业务还没处理完,线程如果崩溃,消息就丢了。手动ACK是必须的。
Pending列表的处理也要注意。Stream的 XPENDING 命令可以查看所有已经投递但未确认的消息。如果消费者处理某条消息时进程挂了,从 lastConsumed 再次拉取是拿不到这条消息的——因为它已经投递过,但没有进入新一轮读取范围。正确的处理方式是定期执行 XAUTOCLAIM,将Pending列表中超过一定时间还没有ACK的消息重新分配给当前消费者。这就是"死信补偿"。
我实际写过一个回查任务,每30秒执行一次:
java复制@Scheduled(fixedDelay = 30000)
public void claimPendingMessages() {
// 取出pending中超过60秒仍未ACK的消息
List<Object> pendingIds = stringRedisTemplate.execute(
(RedisCallback<List<Object>>) connection -> {
// 使用 XPENDING 获取 pending 消息列表
return connection.xPending(streamKey.getBytes(), groupName.getBytes());
}
);
// 对超时消息执行 XAUTOCLAIM,把它重新分配给当前消费者
}
这个机制业务上不常用,但真的遇到消费者崩溃恢复的时候,它就是救命的东西。
5.4 实际运行中的性能参数调优
我用Redis Stream当MQ跑了日均50万的业务消息。以下几个参数是实测下来最有效的:
count(10):每次拉取10条,太小了浪费网络IO,太大了处理失败时重试范围过大。10到30之间比较合适。fixedDelay = 1000:每秒轮询一次。如果对延迟敏感,可以改成500甚至100。延迟越低,CPU消耗越高,这个要看业务容忍度。block(Duration.ofMillis(1000)):阻塞等待1秒,如果Stream里没有新消息,就阻塞在Redis上,减少空轮询。cache size:消费线程数和Redis连接池大小要匹配。默认的连接池8个线程,如果消费并发调大了,连接池也必须跟着调,否则线程会阻塞在获取连接上。我一般调成20到30。
6. 消息队列面试高频问题梳理
如果你在准备面试或者要去带团队,下面这几个问题基本是必考点。我一个个说下我的答案和思考角度。
6.1 消息基于什么数据结构存储
Kafka基于分区日志存储,核心数据结构是稀疏索引。Kafka的每个分区是一个有序的、不可变的日志文件,消息按顺序追加写入,日志文件根据大小分隔成多个段。每个段有一个索引文件和一个日志文件,索引文件存的是稀疏的偏移量索引,查询时通过二分查找找到最近的位置再顺序扫描。这种设计让Kafka能高效支持顺序读写,无论是生产端还是消费端,都是顺序IO为主。
RocketMQ的存储模型也类似,CommitLog统一存储所有消息,一组ConsumerQueue相当于逻辑上的Topic索引。RocketMQ的写入是先顺序写CommitLog,然后异步构建ConsumerQueue。这个设计把随机写变成了顺序写,大幅提升了吞吐量。
RabbitMQ的存储机制则不同,它默认将消息先写入内存,再异步落盘。如果RabbitMQ节点正常关闭,内存里的消息会全部持久化;但如果宕机,没有落盘的消息会丢失。
Redis Stream的存储则是基于Rax基数树,消息ID用时间戳+序列号构成,天然按时间有序,每个Stream条目都保存了消息内容和元数据。
6.2 为什么Kafka这么快
Kafka快的原因有四个层面:
第一,顺序写磁盘。Kafka的消息追加到日志文件的末尾,是顺序IO,顺序写磁盘的速度可以接近内存随机读。生产端批量发送消息,也能提升磁盘写入效率。
第二,页缓存。Kafka使用操作系统的Page Cache,写入的消息先进入页缓存,不一定立即落盘。读取时优先从页缓存读。大量场景下热数据都在内存里,不需要走磁盘。
第三,零拷贝。消费者读取消息时,Kafka通过sendfile系统调用,让数据从磁盘到网卡直接传输,不需要经过用户态内存拷贝。这个过程省掉了两次内存复制和多次上下文切换。
第四,批量处理和压缩。生产端批量发送,消费端批量拉取,网络IO次数大幅减少;同时消息在批量传输时会做压缩,序列化和解序列化的开销也被摊薄。
6.3 堆积了大量消息怎么办
首先要确认堆积的原因。两种情况:生产速度正常,消费速度跟不上,或者消费者下线/处理异常。前者是容量问题,后者是故障问题。
如果是消费速度跟不上,核心手段是扩容消费者。但扩容消费者有个前提:Topic的分区数要足够多。Kafka一个分区同一时间只能被一个消费者实例消费,如果你只有4个分区,最多只能开4台消费者的机器,再开也是闲置。所以一开始创建Topic时,就要根据业务峰值预留分区数,一般建议是现有消费者数量的5到10倍。
如果是消费者卡在了某条异常消息上,常见处理方法是跳过或降级。先把有问题的消息单独捞出来到死信队列,让正常消息继续往下消费。所有主流MQ都有死信队列机制,RocketMQ的叫DLQ,Kafka可以设置 dead-letter-queue 的Topic。
另外,堆积消息会带来另一个问题:消息过期。Kafka的消息默认保留7天,如果堆积太久,旧消息可能被清理掉。所以堆积问题要在监测到消费积压的早期就去处理。
6.4 如何保证消息不被重复消费
这个问题在3.2里已经详细讲了,这里做一句话总结:消费方要做到幂等。
用三种方式实现:数据库唯一索引、Redis分布式锁、状态机校验。面试时回答这个问题的关键是不要把重点放在"让MQ不重复投递",而是放在"消费端如何识别和处理重复消息",这是面试官最想听的答案。
回到最开始的动机,消息队列在分布式系统里的价值,本质上是通过异步、解耦、削峰三个手段,让系统的可扩展性和稳定性上了一个台阶。但它的引入绝不是零成本的,可靠性问题、顺序问题、维护成本都是需要为之付的"账单"。我在项目中总结出的经验是:先明确业务痛点,再选合适的MQ,然后把可靠性和幂等设计放在功能实现之前。这样才不会被消息队列本身的问题反噬。希望这篇内容能帮你在自己团队的架构里少踩一些我踩过的坑。
