MQ的相关概念
什么是MQ
MQ本质上是一个先进先出的队列,队列里存放的内容是message,是一种跨进程的通信机制,用于上下游传递消息
在互联网架构中,MQ是一种常见的上下游 “逻辑解耦 + 物理解耦” 的消息通信服务。使用了MQ之后,消息发送上游只需要依赖MQ,不用依赖其他服务
为什么使用MQ
流量削峰
- 如果订单系统在正常情况下最多能够处理一万个订单。但是在高峰期,如果有两个万下个请求,只能限制超过一万后不允许用户下单
- 使用消息队列做缓冲,可以取消这个限制,将一秒内下的订单分散成一段时间来处理,用户可能在下单后一段时间才能收到下单成功的通知,但是比不能下单的体验要好
应用解耦
- 以电商系统为例,有订单系统、库存系统、物流系统、支付系统等。用户创建订单后,如果耦合调用库存系统、物流系统、支付系统,任何一个子系统出现了故障,都会造成下单操作异常
- 使用消息队列,系统间的调用问题会减少很多,比如物流系统发生故障,需要几分钟来修复。在这几分钟的时间里,物流系统要处理的消息被缓存到消息队列中,用户的下单可以继续完成。当物流系统恢复以后,继续处理订单信息即可,提升系统的可用性
异步处理
- 有的服务间调用是异步的,例如A调用B,B需要花费很长时间执行,但是A需要知道B什么时候可以执行完,一般有两种方式,A过一段时间去调用B的查询方法;或者A提供一个callback(),B执行完之后调用方法通知A服务
- 使用消息队列,A调用B服务后,只需要监听B处理完成的消息,当B处理完成后,会发送一个消息给MQ,MQ将消息转发给A服务,A服务能够及时得到异步处理成功的消息
MQ的分类
RabbitMQ
RabbitMQ 是采用 Erlang 语言实现的 AMQP 协议的消息中间件,基于Erlang语言的 高并发特性,性能较好,吞吐量到万级,MQ功能比较完备,健壮、稳定、易用、跨平台、支持多种语言
数据量没有那么大,中小型公司优先选择功能比较完备的 RabbitMQ
Kafka
性能卓越,最大的优点就是 吞吐量高,kafka是分布式的,一个数据多个副本,少数机器宕机不会丢失数据。消费者采用Pull方式获取消息,消息有序,通过控制能够保证所有消息被消费且仅被消费一次
适合产生 大量数据 的互联网服务的数据收集业务
RabbitMQ
原理
Broker
接收和分发消息的应用
Virtual host
出于多租户和安全因素设计的,把AMQP的基本组件划分到一个虚拟的分组中
当多个不用的用户使用同一个 RabbitMQ server提供服务时,可以划分出多个 vhost,每个用户都在自己的 vhost 创建 exchange / queue 等
Connection
publisher / consumer 和 broker 之间的 TCP连接
Channel
如果每一次访问 RabbitMQ 都建立一个 Connection,在消息量大的时候建立 TCP Connection的开销是巨大的,效率也较低,Channel作为轻量级的connection极大减少了操作系统建立 TCP connection的开销
Channel 是 connection 内部建立的逻辑连接,如果应用程序支持多线程,通常每个 thread 创建单独的 channel 进行通讯,AMQP method 包含了 channel id 帮助客户端和 message broker 识别 channel,所以 channe之间是完全隔离的
Exchange
生产者生成的消息不会直接发送到队列,而是将消息发送到交换机,具体路由到那个队列由交换机根据路由键(routing key)完成
- direct: 如果路由键匹配,消息就投递到对应的队列
- fanout:投递消息给所有绑定在当前交换机上面的队列
- topic:topic队列名称有两个特殊的关键字。 ***** 可以替换一个单词,**# **可以替换所有的单词
Queue
消息被送到这等待消费者消费
Binding
exchange 和 queue 之间的虚拟连接,binding中可以包含 routing key,Binding 信息被保存到 exchange 中的查询表中,用于 message的分发依据
消息丢失
持久化
保障当RabbitMQ服务停掉以后消息生产者发送过来的消息不丢失,需要将队列和消息都标记为持久化
队列实现持久化
- 创建的队列如果是非持久化的,如果RabbitMQ重启,队列就会被删除掉
- 队列实现持久化,需要在声明队列的时候吧 durable 参数设置为持久化
消息实现持久化
- 在消息生产者修改代码,添加属性 MessageProperties.PERSISTENT_TEXT_PLAIN
- 将消息标记为持久化并不能完全保证不会丢失消息,尽管它告诉 RabbitMQ 将消息保存到磁盘,存在当消息刚准备存储在磁盘的时候,但是还没有存储完,消息还在缓存的一个间隔点,此时并没有真正写入磁盘
- 持久性保证并不强,发布确认 为更强有力的持久化策略
发布确认
发布确认方案
- 给数据库保存每一个消息的详细信息,定期扫描数据库将失败的消息再发送一遍
- 确认回调报错误了,修改数据库当前消息的状态
发布确认原理
- 生产者将信道设置成 confirm模式,所有在该信道上面发布的消息都将会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,broker就会发送一个确认给生产者(包含消息的唯一ID),即生产者知道消息已经正确到达目的队列了,如果消息和队列是可持久化的,那么确认消息会在将消息写入磁盘之后发出
- broker 回传给生产者的确认消息中 delivery-tag域 包含了 确认消息的序列号
- broker也可以设置basic. ack的multiple域,表示到这个序列号之前的所有消息都已经得到了处理
confirm模式的好处
-
异步的,一旦发送一条消息,生产者可以在等待信道返回确认的同时继续发送下一条消息
- 当消息最终得到确认之后,生产者以通过回调方法来处理该确认消息
-
如果 RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条 nack 消息,生产者同样可以在回调方法中处理该nack消息
配置文件
spring.rabbitmq.publisher-confirm-type=correlated
- NONE:禁用发布确认模式,是默认值
- CORRELATED:发布消息成功后交换器会触发回调方法
- SIMPLE:经测试有两种效果
- 其一效果和 CORRELATED 一样会触发回调方法
- 其二在发布消息成功后使用 rabbitTemplate 调用waitForConfirms方法 或 waitForConfirmsOrDie方法,等待 broker 节点返回发送结果,根据返回结果来判定下一步的逻辑。要注意的点是
waitForConfirmsOrDie 方法如果返回 false 则会关闭 channel,则接下来无法发送消息到broker
回退消息
mandatory参数
- 在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给生产者发送确认消息。如果发现该消息不可路由,那么消息会被直接丢弃,并且生产者是不知道消息被丢弃这个时间的
- 设置 mandatory参数 可以在当消息传递过程中不可达目的地时将消息返回给生产者
确认应答
为了保证消息在发送过程中不丢失,rabbitMQ引入消息应答机制
消费者在接受到消息并且处理该消息之后,告诉 rabbitMQ 它已经处理了,可以把该消息删除了
自动应答
这种模式仅适用在消费者可以高效并以某种速率处理消息的情况下使用
- 消息发送后立即被认为已经传送成功,这种模式需要在 高吞吐量和数据传输安全性方面做权衡,因为这种模式如果消息在接收到之前,消费者那边出现 连接 或者 channel 关闭,那么消息就丢失了
- 另一方面这种模式消费者那边可以 传递过载的消息,没有对传递的消息数量进行限制,可能会导致消费者接受太多还来不及处理的消息,导致这些消息的挤压,最终导致内存耗尽,这些消费者线程被操作系统杀死
消息应答的方法
- Channel.basicAck(用于肯定确认) :RabbitMQ知道该消息成功处理,可以丢弃了
- Channel.basicNack(用于否定确认)
- Channel.basicReject(用于否定确认) :不处理该消息直接拒绝,可以将其丢弃
Multiple的解释
channel.basicAck(deliveryTag, true);
手动应答的好处是 可以批量应答并且减少网络拥堵
消息自动重新入队
如果消费者由于某些原因失去连接(其通道以关闭,连接以关闭或TCP连接丢失),导致消息未发送ACK确认,RabbitMQ将了解到消息未完全处理,并将其重新排队
如果此时其他消费者可以处理,它将很快将其重新分发给另一个消费者。这样即使某个消费者偶尔死亡,也可以确认不会丢失任何消息
手动应答
- 消费端确认(保证每条消息都被正常消费,此时broker才可以删除此消息)
- 一定要开启手动签收模式 spring.rabbitmq.listener.simple.acknowledge-mode: manual
- 默认是自动确认的,只要消息被接受到,客户端会自动确认,服务端就会移除这个消息
- 消费者手动确认模式。只要没有明确告诉MQ,货物被签收。没有ack,消息就一直是unacked状态,即使consumer宕机,消息不会丢失,会重新变为Ready,下一次有新的Consumer连接进来就会发给他
消息重复
消息重复的场景
- 消息消费成功,事物已经提交,ack时,机器宕机。导致ack没有成功,broker的消息重新有unack变为ready,并发送给其他消费者
- 消息消费失败,由于重试机制,自动又将消息发送出去
- 成功消费,ack时宕机,消息由unack变为ready,broker又重新发送
解决方案
- 业务消费接口应该设计为 幂等性的,例如 扣库存有工作单的状态标志
- 使用 防重表 (mysql/redis),发送消息每一个都有业务的唯一标识,处理过就不用处理
- rabbitMQ的每一个消息都有 redelivered 字段,可以获取 是否被重新投递过来的,而不是第一次投递过来的
消息积压
消息积压的场景
- 消费者宕机积压
- 消费者消费能力不足积压
- 发送者流量太大
解决方案
- 上线更多的消费者,进行正常消费
- 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理
不公平分发和预期值
轮训分发
有两个消费者在处理任务,其中 消费者A 处理任务的速度非常快,而 消费者B 处理速度却很慢,如果采用轮训分发会导致处理速度快 消费者A 很大一部分时间处于空闲状态,而处理慢的那个消费者B 一直在消费消息
不公平分发
int prefetchCount = 1;
channel.basicQos(prefetchCount);
- 为了避免这种情况,设置参数 channel.basicQos(1)
- 当前消费者没有处理完任务或者无应答,RabbitMQ就会把任务分配给空闲消费者,如果所有的消费者都在繁忙状态,队列还在不停的添加新任务可能会爆满,这个时候只能添加新的 worker 或者 改变存储任务的策略
预取值
- 消息的发送是异步的,在任何时候 channel 上肯定不只有一个消息,另外消费者的手动确认本质上也是异步的
- 因此这里就存在一个未确认的消息缓冲区,因此希望开发人员 能限制此缓冲区的大小,以避免缓冲区里面无限制的未确认消息问题
- 可以通过使用 basic.qos方法 设置 “预取计数” 值来完成的。该值定义通道上允许的未确认消息的最大数量。一旦数量达到配置的数量,RabbitMQ将停止在通道上传递更多消息,除非至少有一个未处理的消息被确认
例如,假设在通道上有未确认的消息5、6、7、 8,并且通道的预取计数设置为4
- 此时RabbitMQ将不会在该通道上再传递任何消息,除非至少有一个未应答的消息被ack
- 假设 tag = 6 这个消息刚刚被确认ACK,RabbitMQ 将会感知这个情况到并再发送一条消息,消息应答和QoS预取值对用户吞吐量有重大影响
- 通常,增加预取将提高向消费者传递消息的速度。虽然自动应答传输消息速率是最佳的,但是在这种情况下已传递但尚未处理的消息的数量也会增加,从而增加了消费者的RAM消耗(随机存取存储器)
- 应该小心使用具有无限预处理的自动确认模式或手动确认模式,消费者消费了大量的消息如果没有确认的话,会导致消费者连接节点的内存消耗变大,所以找到合适的预取值是一个反复试验的过程,不同的负载该值取值也不同100到300范围内的值通常可提供最佳的吞吐量,并且不会给消费者带来太大的风险
- 预取值为1是最保守的,当然这将使吞吐量变得很低,特别是消费者连接延迟很严重的情况下和消费者在连接等待时间较长的环境。对于大多数应用来说,稍微高一点的值将是最佳的
死信队列、TTL、延迟队列
死信队列
死信的概念
- queue里的某些消息无法被消费,这样的消息如果没有后续的处理就变成了死信
死信的来源
- 消息 TTL 过期
- 队列达到最大长度,无法再添加数据到mq中
- 消息被拒绝(basic.reject 或 basic.nack) 并且 requeue = false
TTL
RabbitMQ中的TTL
- TTL 是 RabitMQ中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,单位是毫秒
- 如果一条消息设置了 TTL属性 或者 进入了设置TTL属性的队列,这条消息如果在 TTL 设置的时间内没有被消费,则会成为 死信
- 队列和消息同时设置了 TTL属性,那么较小的那个值将会被使用
设置TTL
两者的区别
- 如果设置了 队列的TTL属性,一旦消息过期,就会被队列丢弃 (如果配置了死信队列会被丢到死信队列中)
- 如果设置了 消息的TTL属性,消息即使过期,也不一定会马上丢弃,消息是否过期是在即将投递到消费者之前判定的,如果当前队列有严重的消息积压情况,则过期的消息也许还能存活较长的时间
- 如果不设置 TTL,表示消息永远不会过期
- 如果将 TTL 设置为0,表示除非此时可以直接投递该消息到消费者,否则该消息将会被丢弃
延时队列
延时队列的概念
- 用来存放需要在指定时间被处理元素的队列
延时队列使用场景
-
订单在半小时之内没有支付则自动取消
-
用户一个月没有登录则进行短信提醒
-
用户发起退款,如果在一天内没有得到处理则通知相关运营人员
Kafka
Kafka的原理
Kafka的组成
Kafka的核心概念有Producer、Consumer、Broker和Topic
- Producer 为消息生产者
- Consumer为消息消费者
- Broker 为Kafka的消息服务端,负责消息的存储和转发
- Topic 为消息类别,Kafka按照Topic来对消息分类
Partition
- 为了提高集群的并发度,Kafka设计了 Partition 用于 Topic上数据的分区,一个Topic数据可以分为多个Partition,每个Partition都负责保存和处理其中一部分消息数据
- Partition的个数对应了消费者和生产者的并发度
Kafka的数据存储设计
Partition数据文件
Partition中的每条 消息 都包含3个属性:Offset、MessageSize、Data
- Offset表示 消息 在这个Partition中的偏移量,它在逻辑上是一个值,唯一确定了Partition中的一条消息
- MessageSize 表示 消息内容 Data 的大小
- Data 为 Message的具体内容
Segment数据文件
- Partition在 物理上 由多个Segment数据文件组成,每个Segment数据文件都大小相等、按顺序读写,每个Segment数据文件都以该段中最小的Offset命名,文件扩展名为.log。这样在查找指定Offset的Message的时候,用二分查找就可以定位到该Message在哪个Segment数据文件中
- Segment数据文件首先会被存储在内存中,当Segment上的消息条数达到配置值或消息发送时间超过阈值时,其上的消息会被Flush到磁盘,只有被Flush到磁盘的消息才能被消费者消费到
- Segment达到一定的大小(可以通过配置文件设定,默认为1GB)后将不会再往该Segment中写数据,Broker会创建新的Segment
数据文件索引
- Kafka为每个Segment数据文件都建立了索引文件以方便数据寻址,索引文件的文件名与数据文件的文件名一致,不同的是索引文件的扩展名为.index
- Kafka的索引文件并不会为数据文件中的每条Message都建立索引,而是采用稀疏索引的方式,每隔一定字节建立一条索引。这样可以有效地降低索引文件的大小,方便将索引文件加载到内存中以提高集群的吞吐量。
- 索引文件中的第一位表示索引对应的Message的编号,第二位表示索引对应的Message的数据位置
- Kafka Index的存储结构如图5-6所示,00000368769.index索引文件采用稀疏索引的方式记录了第1个Message、第3个Message、第6个Message的索引分别为(1,0)、(3,479)、(6,1407)
为什么使用消息队列
优点
解耦
A 系统跟其它各种的系统严重耦合,A 系统产生一条比较关键的数据,很多系统都需要 A 系统将这个数据发送过来。A 系统要时时刻刻考虑 BCDE 四个系统如果挂了该咋办?要不要重发,要不要把消息存起来?
如果使用 MQ,A 系统产生一条数据,发送到 MQ 里面去,哪个系统需要数据自己去 MQ 里面消费。如果新系统需要数据,直接从 MQ 里消费即可;如果某个系统不需要这条数据了,就取消对 MQ 消息的消费即可。即A 系统不需要去考虑要给谁发送数据,不需要维护这个代码,也不需要考虑人家是否调用成功、失败超时等情况
面试技巧
通过一个 MQ,Pub/Sub 发布订阅消息这么一个模型,A 系统就跟其它系统彻底解耦了
一个系统或者一个模块,调用了多个系统或者模块,互相之间的调用很复杂,维护起来很麻烦。但是其实这个调用是不需要直接同步调用接口的,如果用 MQ 给它异步化解耦
异步
A 系统接收一个请求,需要在自己本地写库,还需要在 BCD 三个系统写库,自己本地写库要 3ms,BCD 三个系统分别写库要 300ms、450ms、200ms。最终请求总延时是 3 + 300 + 450 + 200 = 953ms
使用 MQ,那么 A 系统连续发送 3 条消息到 MQ 队列中,假如耗时 5ms,A 系统从接受一个请求到返回响应给用户,总时长是 3 + 5 = 8ms
削峰
系统高峰期的时候,每秒并发请求数量突然会暴增到 5k+ 条。系统是直接基于 MySQL 的,大量的请求涌入 MySQL,每秒钟对 MySQL 执行约 5k 条 SQL
一般的 MySQL,扛到每秒 2k 个请求就差不多了,如果每秒请求到 5k 的话,可能就直接把 MySQL 给打死了,导致系统崩溃,用户也就没法再使用系统了
如果使用 MQ,每秒 5k 个请求写入 MQ,A 系统每秒钟最多处理 2k 个请求,因为 MySQL 每秒钟最多处理 2k 个。A 系统从 MQ 中慢慢拉取请求,每秒钟就拉取 2k 个请求,不要超过自己每秒能处理的最大请求数量就 ok,这样下来,哪怕是高峰期的时候,A 系统也绝对不会挂掉。而 MQ 每秒钟 5k 个请求进来,就 2k 个请求出去,结果就导致在中午高峰期(1 个小时),可能有几十万甚至几百万的请求积压在 MQ 中
这个短暂的高峰期积压是 ok 的,因为高峰期过了之后,每秒钟就 50 个请求进 MQ,但是 A 系统依然会按照每秒 2k 个请求的速度在处理。所以说,只要高峰期一过,A 系统就会快速将积压的消息给解决掉
缺点
系统可用性降低
系统引入的外部依赖越多,越容易挂掉。本来你就是 A 系统调用 BCD 三个系统的接口就好了,ABCD 四个系统还好好的,没啥问题,你偏加个 MQ 进来,万一 MQ 挂了咋整?MQ 一挂,整套系统崩溃
系统复杂度提高
- 怎么保证消息没有重复消费
- 怎么处理消息丢失的情况
- 怎么保证消息传递的顺序性
一致性问题
A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了