消息队列笔记

消息队列的核心作用有哪些?

引入MQ核心就是为了解决三个问题:解耦、异步、削峰。

拿电商场景举例,用户付完款之后要扣库存、发优惠券、加积分、发短信,如果订单系统直接RPC调用这些下游服务,那耦合度就非常高,任何一个下游挂了都可能拖垮整个支付流程。引入MQ之后,订单系统只需要往MQ里发一条”支付成功”的消息就结束了,下游谁关心谁自己去订阅,彼此完全解耦。

异步其实是解耦带来的附加收益。原来串行调这么多接口,每个50毫秒加起来可能好几秒,用户体验很差。有了MQ之后主流程只需要改状态然后发消息,几毫秒就响应了,非核心操作在后台异步消费就行。

削峰就是在秒杀、大促这种场景下,瞬时流量可能是数据库承受能力的几十倍,直接打过去肯定宕机。MQ充当一个蓄水池的角色,先把请求暂存,后端按自己的处理能力匀速消费,把流量洪峰拉平。

消息队列有什么缺点?

MQ虽然好处很明显,但引入之后会带来三个比较大的挑战。

第一是系统可用性降低了。原来A直接调B,现在中间多了个MQ,MQ要是挂了整条链路就断了,所以必须花精力去做MQ的高可用集群部署。

第二是系统复杂度大幅上升。原来一个方法调用的事情,换成消息之后就要处理各种异常场景,比如消息发重了怎么办、消息丢了怎么办、消费失败怎么重试,都需要额外的机制来保障。

第三是数据一致性问题。A系统本地事务成功了,消息也发了,但下游B消费的时候失败了,A以为成功B实际失败,两边数据就脱节了。要解决这个就得引入事务消息或者分布式事务方案,开发成本不低。

所以MQ不是万能药,只有在确实需要解耦、异步、削峰的场景下,收益大于引入的复杂度时才值得用。

1.4 消息队列常见的使用场景有哪些?

消息队列的使用场景可以从”解决什么问题”来分类理解。解耦场景:比如电商系统,下单成功后需要通知库存、积分、物流、短信等多个系统。如果用RPC直接调用,每新增一个下游系统就要改订单代码,下游挂一个全链路崩。用MQ的话,订单系统只发一条”下单成功”消息,下游各自订阅各自处理,互不影响。异步场景:用户注册后要发验证邮件、初始化用户画像、赠送新人优惠券,如果同步串行要等3秒,用MQ异步化后主流程50ms就返回了,用户体验质的飞跃。削峰场景:秒杀是最典型的——10万人同时抢100个商品,请求全打到数据库必崩。在前面放个MQ,10万请求先进队列排队,后端按每秒2000的速度匀速消费,50秒就处理完了,系统稳如老狗。

1.7 消息队列是参考哪种设计模式实现的?

观察者模式:观察者模式本质上体现的是一种一对多的依赖关系。在该模式中包含一个主题(Subject)和多个观察者(Observer),其中主题同时也被称为被观察者。当主题产生新的消息或状态发生变化时,会向所有已经注册的观察者发送通知,使它们能够获取到最新的信息。其结构如图所示:首先,各个观察者需要向主题进行订阅;订阅完成后,主题内部会维护一个观察者列表。当主题发布消息时,会遍历这个观察者列表,并依次向每个观察者发送通知,从而完成消息的传递。

发布订阅模式:发布订阅模式与观察者模式的主要区别在于:发布者与订阅者之间实现了完全解耦。在这种模式中,消息的传递需要通过一个中间的发布订阅中心来完成。发布者只负责发布消息,而不需要知道消息最终会被哪些订阅者接收,因此两者之间不存在直接依赖关系。正因为如此,发布订阅模式通常包含三个核心角色:发布者、发布订阅中心以及订阅者。消息的流转过程为:发布者将消息发送到发布订阅中心,再由发布订阅中心将消息分发给相应的订阅者。

消息重复消费怎么解决?

以Kafka为例,消费者的工作流程是:拉取消息 → 处理消息 → 提交offset(告诉Broker”我已经消费到第N条了”)。关键问题在于”处理消息”和”提交offset”是两个独立的步骤,不是原子操作。假设消费者拉了第100条消息,处理完了,正准备提交offset=100,这一瞬间消费者宕机了。重启后Broker发现上次提交的offset还是99,于是又把第100条消息推给消费者——重复消费就这么发生了。

RabbitMQ也一样:消费者收到消息处理完了但还没来得及发ack就宕机了,RabbitMQ认为这条消息没被确认,会重新投递给其他消费者。

还有生产者端也会造成重复:消息发到Broker了,Broker也存下来了,但返回ack的时候网络断了,生产者以为没发成功又发了一次——同一条消息就被存了两次。

既然重复消费无法100%避免(除非做分布式事务,代价太高),业界的共识是:MQ保证at-least-once(至少投递一次),业务层自己做幂等保证重复消费不影响结果。

Push vs Pull

Push vs Pull是MQ设计中的经典取舍,理解它们的核心是搞清楚一个根本问题:消费速率应该由谁来控制?

Push模式——Broker说了算。 Broker收到消息后主动推给消费者,就像老师发试卷一样,发到你手里你就得开始做。好处是实时性极高,消息到了立刻推送,延迟可以做到毫秒级。坏处是Broker不了解每个消费者的处理能力——有的消费者处理快,有的处理慢,Broker按同一个速度推送,处理慢的消费者就会被压垮。虽然可以通过流控(比如RabbitMQ的prefetch参数限制一次推几条)来缓解,但本质上控制权在Broker手里,消费者是被动接收的。

Pull模式——消费者说了算。 消费者主动去Broker拉取消息,就像自助餐一样,吃完一盘再去拿下一盘,按自己的胃口来。好处很明显:消费者永远不会被撑死,处理能力强就拉得快,处理能力弱就拉得慢。而且消费者可以灵活控制offset——想重新消费历史消息就把offset往回拨,想跳过积压就把offset往前调。坏处是如果Broker里暂时没有新消息,消费者每次去拉都拉到空数据,就会空轮询浪费CPU和网络资源。Kafka的解决方案是让消费者在拉取时传一个timeout参数(长轮询),如果没有新数据就阻塞等待一段时间再返回,避免了忙等。

Kafka采用的是pull拉取模型

消费者主动从Broker拉取数据,由消费者自己记录和控制消费进度(offset)。这样的好处是消费者可以按自己的处理能力来控制消费速率,不会被Broker推送的速度压垮。而且消费者可以灵活控制偏移量,比如重置到旧的offset重新消费,或者跳到最新位置。push模式的问题在于发送速率由Broker决定,很容易造成消费者来不及处理导致拒绝服务或网络拥塞。pull模式的一个缺点是如果Broker没有数据,消费者可能会空轮询,Kafka通过设置timeout参数来解决这个问题,没有数据时消费者会等待一段时间再返回。

Kafka 为什么这么快?

Kafka快不是靠某一个黑科技,而是四个层面的优化叠加,用一条消息的生命周期来串联理解:

生产者端:批量+压缩。生产者不是来一条发一条,而是把多条消息攒成一个batch,压缩后一次性发出去。比如100条小消息单独发要100次网络往返,攒成一批只要1次。压缩后数据量从100KB变成20KB,网络传输更快。

Broker写入:顺序写磁盘。收到消息后追加写入日志文件末尾,永远不修改已有数据。为什么这么快?磁盘最慢的是”寻道”(磁头移动到目标位置),顺序写不需要寻道,速度可达600MB/s,接近内存写入。这就是Kafka敢用磁盘存消息还能这么快的秘密。而且操作系统会把频繁读写的磁盘数据缓存在Page Cache里,很多时候读的是内存而非磁盘。

Broker发送给消费者:零拷贝(sendfile)。传统方式要把数据从磁盘读到内核缓冲区→拷贝到用户空间→再拷贝到Socket缓冲区→发到网卡,4次拷贝。sendfile让数据在内核空间内直接从磁盘到网卡,2次拷贝就搞定,CPU几乎不参与数据搬运。

分区并行:一个Topic切成多个Partition,多个消费者并行消费,吞吐量随分区数线性增长。

四者叠加:少发(批量)→发得少(压缩)→写得快(顺序写)→读得快(零拷贝)→并行读(分区),每一层都在减少瓶颈。