消息队列详述

274 阅读12分钟

最近在做面试辅导和简历优化的时候,经常看到毕业不久的小同学们的简历上,各个项目中把MQ用得飞起。

每每看到,我就开始夺命三连问:“项目中的这个地方为什么用MQ?除了使用MQ,还有没有其他的解决方案?引入MQ带来的弊端是什么?”

基本上,这些小同学没有几个回答得上来的。

我先来说下,MQ的三大使用场景,即:异步、消峰、解耦。

使用场景

异步场景

同步,指一个进程(线程)在执行某个请求的时候,如果该请求需要一段时间才能返回信息,那么这个进程(线程)会一直等待下去,直到收到返回信息才继续执行下去。

同步的最大弊端就是,整体执行时间较长。

异步,是指该进程不需要一直等待下去,而是继续执行下面的操作,不管其他进程的状态,当有信息返回的时候会通知进程进行处理。

这样做的意义是,把一些可异步化、非必须直接返回结果的步骤去掉,从而降低整体执行时间。

说一个真实的电商场景,当我们购买某件商品,下单支付成功后,可以通过消息队列异步通知账户中心和短信平台,给该用户增加100京豆作为奖励,并给用户发送一个购买成功的短信。

消峰场景

削峰的应用场景,主要是上游系统在某个时间点,忽然产生高出平时N倍量级的请求,比如:春运时期的火车票抢票场景,这个时候下游系统可能扛不住,或者下游没有足够多的机器来保证冗余。

这个时候,消息队列就可以在中间起到一个缓冲的作用,它可以把生产者的请求暂存在消息队列的Broker中,下游消费者服务就可以按照自己可控的节奏进行慢慢处理。

解耦场景

解耦,是一个常被提到的技术术语,它直译过来就是“把一模一样的东西分离开来”或“使其相互不受影响”。

但实际上更深刻的含义是:把系统中不同的部分分离开来,使它们之间可以互相独立地运行,互不干扰,不被任何不必要的部分影响到,使系统开发和维护更容易,更具可扩展性。

如上图左侧所示,系统A在代码中直接调用系统B、C、D的代码,如果将来系统E接入,系统A还需要修改代码,系统间耦合性太强。

如果接入消息队列的话,那么当系统E接入的时候,系统A就无需修改代码了,只需要系统E自行接入,进行消息订阅即可。

消息队列与性能

经常看一些同学的简历写着,“项目中XX模块的XX功能,通过引入Kafka来提升性能”,看得我有些啼笑皆非。

MQ可以提升性能吗?这个问题我们需要从消息队列的本质实现说起。

MQ的实现方式是:生产者通过RPC调用,将消息发送给Broker,然后Broker进行持久化操作,最后消费者再进行一次RPC操作,将消息从Broker上拉取过来。

即:MQ的本质是两次RPC + 一次持久化。

那么,处理相同的业务场景,跟一次RPC操作的同步调用相比,消息队列是不可能提升性能的,反而是降低性能了。

之所以有人会认为消息队列提升性能了,那是因为,本来某业务操作有五个步骤,RPC同步调用一个步骤不少地都做完了。

而消息队列的生产者只做了两个步骤,然后发消息给Broker,让消费者去做剩下的三个步骤去了。然后在统计接口响应时间的时候,只统计了生产者处理两个步骤 + 往消息队列发送数据的响应时间。

那这么算的话,是不是多少有点儿耍牛芒了。。。(手动狗头)

当然,任何技术架构都不是银弹,当我们去使用它的时候,在享受它所带来的优点的同时,同样需要去容忍随之而来的缺点。

下面我来介绍一下MQ的两个缺点。

消息队列的缺点

系统可用性降低

系统可用性:高可用的系统,故障时间少,止损快,在任何给定的时刻都可以及时地工作。一般公司在系统可用性的要求在99.9%——99.99%之间,即:宕机时长在50分钟——500分钟之间。

接下来再举个例子说说,为什么MQ会降低系统可用性。

据说,某在线教育巨头的技术团队曾经出过这样一个大故障。

用户购课下单成功后,会走消息队列将信息推送给班课系统,再由班课系统进行排课,完成在线学习的流程。

结果这个至关重要的MQ莫名其妙地挂了,基础架构团队的能力也是差点儿事,捣鼓来捣鼓去也恢复不了,业务团队也没做任何的Plan B,就这样当天的上课高峰期整个停摆了。

据说,哈哈哈,据说,之前用户下单成功后,是通过Feign来同步调用班课系统的。

后来,部门来了一个特别牛逼的资深架构师,觉得这种方式的话,订单系统和班课系统耦合得太严重了,决定解耦。(手动狗头)

结果,恰好MQ这个链路中多出来的环节,影响了系统的整体可用性。

系统复杂度提高

百度技术团队有一句话,叫做:“简单,可依赖”。

即:非必要的情况下,不会考虑在关键链路上多引入一个服务或中间件,因为这样会增加系统的复杂度。

MQ这些常见的问题:重复消费、漏消费、顺序消费,都需要考虑并处理,系统的复杂度一下子就提升了。

我们以Kafka为例,说下重复消费和漏消费。

生产者的重复消费、漏消费

Kafka的生产者是通过幂等性和事务来这两个特性,以此来解决实现重复消费和漏消费的问题,实现exactly once(精确一次)

幂等性

enable.idempotence设置为true即可,默认为false(生产者参数)

该幂等为生产者幂等,Kafka为此引入了producerId和序列号(sequence number),每个新的生产者实例在初始化的时候都会被分配一个producerId,消息发送到的每一个分区都有对应的序列号。

broker端会在内存中为每一对<producerId,分区>维护一个序列号。对于收到的每一条消息,只有当它的序列号的值比broker端中维护的对应的序列号的值大1时,broker才会接收它。

Kafka的幂等只能保证单个生产者中单分区的幂等。

如果 SN new> SN old + 1,那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,对应的生产者会抛出 OutOfOrderSequenceException ,这个异常是个严重异常,后续的诸如 send()、 beginTransaction()、 commitTransaction() 方法的调用都会抛出 IllegalStateException 的异常。

事务

引入事务目的:幂等性并不能跨多个分区运作,而事务可以弥补这个缺陷。

作用为:生产者多次发送消息可以封装成一个原子操作,要么都成功,要么失败。

consumer-transform-producer模式下,因为消费者提交偏移量出现问题,导致在重复消费消息时,生产者重复生产消息。需要将这个模式下消费者提交偏移量操作和生成者一系列生成消息的操作封装成一个原子操作。

为了实现事务,应用程序必须提供唯一的transactionalld,这个transactionalld通过客户端参数transactional.id来显式设置,事务要求生产者开启幂等特性。

transactionalld与PID一一对应,不同的是transactionalld由用户显式设置,而PID是由Kafka内部分配的。

为了保证新的生产者启动后具有相同transactionalld的旧生产者能够立即失效,每个生产者通过transactionalld获取PID的同时,还会获取一个单调递增的producer epoch。如果使用同一个transactionalld 开启两个生产者,那么前一个开启的生产者会报错。

  • initTransactions()方法用来初始化事务

  • beginTransaction()方法用来开启事务

  • sendOffsetsToTransaction()方法为消费者提供在事务内的位移提交的操作

  • commitTransaction()方法用来提交事务

  • abortTransaction()方法用来回滚事务

在消费端有一个参数isolation.level,与事务有着莫大的关联,这个参数的默认值为“read uncommitted”, 意思是说消费端应用可以看到(消费到)未提交的事务, 当然对于己提交的事务也是可见的。这个参数还可以设置为“read committed”(如果想正常使用事务的话)

日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息(Control Batch)。控制消息一共有两种类型:COMMIT 和 ABORT,分别用来表征事务己经成功提交或己经被成功中止。

Kafka Consumer可以通过这个控制消息来判断对应的事务是被提交了还是被中止了,然后结合参数isolation.level 配置的隔离级别来决定是否将相应的消息返回给消费端应用,Control Batch对消费端应用不可见。

consumer-transform-producer模式下,consumer要将enable.auto.commit参数设置为false,代码里也不能手动提交消费位移,用sendOffsetsToTransaction()方法。

Kafka并不能保证己提交的事务中的所有消息都能够被消费,这是由于log保留策略,消费者在消费时没有分配到事务内的所有分区等原因。

消费者的重复消费、漏消费

先说下消费者的几个重要参数。

auto.offset.reset

该属性指定了在没有偏移量可提交时(比如消费者第1次启动时),或者请求的偏移量在broker上不存在(因消费者长时间失效,包含偏移量的记录已经过时并被删除)时,消费者该作何处理。

它的默认值是latest,意思是说,在偏移量无效的情况下,消费者将从最新的记录开始读取数据(在消费者启动之后生成的记录),latest可能丢消息。

另一个值是earliest,意思是说,在偏移量无效的情况下,消费者将从起始位置读取分区的记录,earliest大量重复消息。

enable.auto.commit

该属性指定了消费者是否自动提交偏移量,默认值是true。为了尽量避免出现重复数据和数据丢失,可以把它设为false,由自己控制何时提交偏移量。

auto.commit.interval.ms

当enable.auto.commit = true时,可以通过该配置属性来控制提交的频率,默认值是 5s

那么,就可以得出来结论了,MQ的消费者,如果不从业务逻辑上规避的话,是无法做到exactly once(精确一次)的。

因为,如果先处理业务逻辑,还没提交偏移量的情况下,消费者崩溃了,再重新启动执行,就会造成重复消费。

如果先提交偏移量,还没进行业务逻辑处理的情况下,消费者崩溃了,再重新启动执行,就会造成漏消费。

顺序消费

同样,先说几个生产者的参数。

retries

默认值为0,不重试

retries参数的值决定了生产者可以重发消息的次数,如果达到这个次数,生产者会放弃重试并返回错误。

默认情况下,生产者会在每次重试之间等待100ms,不过可以通过retry.backoff.ms参数来改变这个时间间隔。

一般情况下,因为生产者会自动进行重试,所以就没必要在代码逻辑里处理那些可重试的错误。你只需要处理那些不可重试的错误或重试次数超出上限的情况。

max.in.flight.requests.per.connection

默认值为5。

该参数指定了生产者在收到服务器响应之前可以发送多少批次消息。它的值越高,就会占用越多的内存,不过也会提升吞吐量。把它设为1可以保证消息是按照发送的顺序写入服务器的,即使发生了重试。

如果把retries设为非零整数,同时把max.in.flight.requests.per.connection设为比1大的数,那么,如果第一个批次消息写入失败,而第二个批次写入成功,broker会重试写入第一个批次。如果此时第一个批次也写入成功,那么两个批次的顺序就反过来了。

一般来说,如果某些场景要求消息是有序的,那么消息是否写入成功也是很关键的,所以不建议把retries设为0。可以把max.in.flight.requests.per.connection设为1,这样在生产者尝试发送第一批消息时,就不会有其他的消息发送给broker。不过这样会严重影响生产者的吞吐量,所以只有在对消息的顺序有严格要求的情况下才能这么做。

关于数据一致性问题

有的朋友认为,引入MQ会带来数据一致性问题。

但我觉得,这种说法并不准确,因为微服务下的同步RPC的情况下,照样会有数据一致性的问题。

哈哈,这个锅MQ真的不背。

结语

这次大概就写这么多吧,消息队列的知识点太多了,随便找一个点扩展,就能写大几千字,我们慢慢来吧。