原文链接:https://mp.weixin.qq.com/s/VBOTIr1GhKdv712POBNImA
此文都是干货,有点长,建议先收藏。
消息队列,即常说的 MQ 是经常用到的一个东西,本文并不是要个告诉你如何使用消息中间件,而是站更高的一个层次,思考当我们使用任何消息队列解决业务问题时,都需要面对的一些通用的问题,这些问题理解透彻了,MQ 才能被你用的出神入化。
目录
- 1、消息队列使用场景
- 1.1、场景 1:异步处理
- 1.2、场景 2:应用解耦
- 1.3、场景 3:流量削锋
- 1.4、场景 4:日志处理
- 1.5、场景 5:分布式事务
- 1.6、场景 6:消息通讯
- 2、事务消息如何实现?
- 2.1、电商中有这样的一个场景
- 2.2、方式一:业务事务中投递消息
- 2.3、方式二:先业务事务、后投递消息
- 2.4、方式三:通过事务消息记录实现
- 2.5、方式四:2 阶段投递消息
- 2.6、方式五:2 阶段投递消息优化
- 2.7、投递消息选择哪种方式呢?
- 3、消息消费的 2 种方式:pull 方式、push 方式,如何选择?
- 3.1、消息消费通常有 2 种方式
- 3.2、push 方式
- 3.3、pull 方式
- 3.4、pull 方式和 push 方式选择建议
- 4、如何确保消息消息至少被成功消费一次?
- 4.1、消息消费的过程
- 4.2、消费失败出现死循环
- 4.3、采用衰减式消费+人工干预解决消息消费失败的问题
- 4.4、消息消费需确保幂等性
- 5、如何确保消息消费的幂等性?
- 5.1、什么是幂等性?
- 5.2、幂等性设计
- 5.3、方式 1(普通方式)
- 5.4、方式 2(jvm 加锁方式)
- 5.5、方式 3(悲观锁方式)
- 5.6、方式 4(乐观锁方式)
- 5.7、方式 4(唯一约束方式)
- 5.8、幂等性总结
- 6、顺序消息如何实现?
- 7、消息中间件视频教程
- 8、往期资源需要请自取
1、消息队列使用场景
异步处理,应用解耦,流量削锋、日志处理、分布式事务、消息通讯六个场景。
1.1、场景 1:异步处理
场景说明:用户注册后,需要发注册邮件和注册短信。传统的做法有两种 1.串行的方式;2.并行方式
(1)串行方式:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。以上三个任务全部完成后,返回给客户端
(2)并行方式:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的时间
假设三个业务节点每个使用 50 毫秒钟,不考虑网络等其他开销,则串行方式的时间是 150 毫秒,并行的时间可能是 100 毫秒。
因为 CPU 在单位时间内处理的请求数是一定的,假设 CPU1 秒内吞吐量是 100 次。则串行方式 1 秒内 CPU 可处理的请求量是 7 次(1000/150)。并行方式处理的请求量是 10 次(1000/100)
小结:如以上案例描述,传统的方式系统的性能(并发量,吞吐量,响应时间)会有瓶颈。如何解决这个问题呢?
引入消息队列,将不是必须的业务逻辑,异步处理。改造后的架构如下:
按照以上约定,用户的响应时间相当于是注册信息写入数据库的时间,也就是 50 毫秒。注册邮件,发送短信写入消息队列后,直接返回,因此写入消息队列的速度很快,基本可以忽略,因此用户的响应时间可能是 50 毫秒。因此架构改变后,系统的吞吐量提高到每秒 20 QPS。比串行提高了 3 倍,比并行提高了两倍
1.2、场景 2:应用解耦
场景说明:用户下单后,订单系统需要通知库存系统。传统的做法是,订单系统调用库存系统的接口。如下图
传统模式的缺点:
- 假如库存系统无法访问,则订单减库存将失败,从而导致订单失败
- 订单系统与库存系统耦合
如何解决以上问题呢?引入应用消息队列后的方案,如下图:
- 订单系统:用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功
- 库存系统:订阅下单的消息,采用拉/推的方式,获取下单信息,库存系统根据下单信息,进行库存操作
- 假如:在下单时库存系统不能正常使用。也不影响正常下单,因为下单后,订单系统写入消息队列就不再关心其他的后续操作了。实现订单系统与库存系统的应用解耦
1.3、场景 3:流量削锋
流量削锋也是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛
应用场景:秒杀活动,一般会因为流量过大,导致流量暴增,应用挂掉。为解决这个问题,一般需要在应用前端加入消息队列。
- 可以控制活动的人数
- 可以缓解短时间内高流量压垮应用
- 用户的请求,服务器接收后,首先写入消息队列。假如消息队列长度超过最大数量,则直接抛弃用户请求或跳转到错误页面
- 秒杀业务根据消息队列中的请求信息,再做后续处理
1.4、场景 4:日志处理
日志处理是指将消息队列用在日志处理中,比如 Kafka 的应用,解决大量日志传输的问题。架构简化如下
- 日志采集客户端,负责日志数据采集,定时写入 Kafka 队列
- Kafka 消息队列,负责日志数据的接收,存储和转发
- 日志处理应用:订阅并消费 kafka 队列中的日志数据
以下是新浪 kafka 日志处理应用案例
(1)、Kafka:接收用户日志的消息队列
(2)、Logstash:做日志解析,统一成 JSON 输出给 Elasticsearch
(3)、Elasticsearch:实时日志分析服务的核心技术,一个 schemaless,实时的数据存储服务,通过 index 组织数据,兼具强大的搜索和统计功能
(4)、Kibana:基于 Elasticsearch 的数据可视化组件,超强的数据可视化能力是众多公司选择 ELK stack 的重要原因
1.5、场景 5:分布式事务
使用消息队列可以实现分布式事务中最终一致性的场景。
1.6、场景 6:消息通讯
消息通讯是指,消息队列一般都内置了高效的通信机制,因此也可以用在纯的消息通讯。比如实现点对点消息队列,或者聊天室等
点对点通讯:
客户端 A 和客户端 B 使用同一队列,进行消息通讯。
聊天室通讯:
客户端 A,客户端 B,客户端 N 订阅同一主题,进行消息发布和接收。实现类似聊天室效果。
以上实际是消息队列的两种消息模式,点对点或发布订阅模式。模型为示意图,供参考。
2、事务消息如何实现?
如何确保本地事务执行成功的情况下,消息一定会投递成功;或者本地事务执行失败的情况下,消息取消投递,这也就是常说的事务消息。
2.1、电商中有这样的一个场景
- 下单成功之后送积分的操作,我们使用 mq 来实现
- 下单成功之后,投递一条消息到 mq,积分系统消费消息,给用户增加积分
我们主要讨论一下,下单及投递消息到 mq 的操作,如何实现?每种方式优缺点?
2.2、方式一:业务事务中投递消息
过程
- step1:开启本地事务
- step2:生成购物订单
- step3:投递消息到 mq
- step4:提交本地事务
这种方式是将发送消息放在了事务提交之前。
可能存在的问题
- step3 发生异常:导致 step4 失败,商品下单失败,直接影响到商品下单业务
- step4 发生异常,其他 step 成功:商品下单失败,消息投递成功,给用户增加了积分
2.3、方式二:先业务事务、后投递消息
下面我们换种方式,我们将发送消息放到事务之后进行。
过程
- step1:开启本地事务
- step2:生成购物订单
- step3:提交本地事务
- step4:投递消息到 mq
可能会出现的问题
step4 发生异常,其他 step 成功:导致商品下单成功,投递消息失败,用户未增加积分
上面两种是比较常见的做法,也是最容易出错的。
2.4、方式三:通过事务消息记录实现
- step1:开启本地事务
- step2:生成购物订单
- step3:本地库中插入一条需要发送消息的记录 t_msg_record
- step3:提交本地事务
- step5:新增一个定时器,轮询 t_msg_record,将待发送的记录投递到 mq 中
这种方式借助了数据库的事务,业务和消息记录作为了一个原子操作,业务成功之后,消息日志必定是存在的。解决了前两种方式遇到的问题。如果我们的业务系统比较单一,可以采用这种方式。
对于微服务化的情况,上面这种方式不是太好,每个服务都需要上面的操作;也不利于扩展。
2.5、方式四:2 阶段投递消息
增加一个消息服务及消息库,负责消息的落库、将消息发送投递到 mq。
- step1:开启本地事务
- step2:生成购物订单
- step3:当前事务库插入一条日志:生成一个唯一的业务 id(bus_id),将 bus_id 和订单关联起来保存到当前事务所在的库中
- step4:调用消息服务:携带 bus_id,将消息先落地入库,此时消息的状态为待发送状态,返回消息 id(msg_id)
- step5:提交本地事务
- step6:如果上面都成功,调用消息服务,将消息投递到 mq 中;如果上面有失败的情况,则调用消息服务取消消息的发送
能想到上面这种方式,已经算是有很大进步了,我们继续分析一下可能存在的问题:
- 系统中增加了一个消息服务,商品下单操作依赖于该服务,业务对该服务依赖性比较高,当消息服务不可用时,整个业务将不可用。
- 若 step6 失败,消息将处于待发送状态,此时业务方需要提供一个回查接口(通过 bus_id 查询),验证业务是否执行成功;消息服务需新增一个定时任务,对于状态为待发送状态的消息做补偿处理,检查一下业务是否处理成功;从而确定消息是投递还是取消发送
- step4 依赖于消息服务,如果消息服务性能不佳,会导致当前业务的事务提交时间延长,容易产生死锁,并导致并发性能降低。我们通常是比较忌讳在事务中做远程调用处理的,远程调用的性能和时间往往不可控,会导致当前事务变为一个大事务,从而引发其他故障。
2.6、方式五:2 阶段投递消息优化
在以上方式中,我们继续改进,进而出现了更好的一种方式:
- step1:生成一个全局唯一业务消息 id(bus_msg_id),调用消息服务,携带 bus_msg_id,将消息先落地入库,此时消息的状态为待发送状态,返回消息 id(msg_id)
- step2:开启本地事务
- step3:生成购物订单
- step4:当前事务库插入一条日志(将 step3 中的业务和 bus_msg_id 关联起来)
- step5:提交本地事务
- step6:分 2 种情况:如果上面都成功,调用消息服务,将消息投递到 mq 中;如果上面有失败的情况,则调用消息服务取消消息的发送
若 step6 失败,消息将处于待发送状态,此时业务方需要提供一个回查接口(通过 bus_msg_id 查询),验证业务是否执行成功;
消息服务需新增一个定时任务,对于状态为待发送状态的消息做补偿处理,检查一下业务是否处理成功;从而确定消息是投递还是取消发送。
方式五和方式四对比,比较好的一个地方:将调用消息服务,消息落地操作,放在了事务之外进行,这点小的改进其实算是一个非常好的优化,减少了本地事务的执行时间,从而可以提升并发量,阿里有个消息中间件RocketMQ就支持方式 5 这种,大家可以去用用。
2.7、投递消息选择哪种方式呢?
- 若我们的系统系统比较小比较单一简单,建议采用方式三
- 若我们的系统采用微服务的方式,建议使用方式五
3、消息消费的 2 种方式:pull 方式、push 方式,如何选择?
3.1、消息消费通常有 2 种方式
- push 方式
- pull 方式
3.2、push 方式
push 方式的过程
- mq 接收到消息
- mq 主动将消息推送给消费者(消费者需提供一个消费接口)
mq 属于主动方,消费者属于一种被动消费,一旦有消息到达 mq,会触发 mq 推送机制,将消息推送给消费者,不管消费者处于何种状态。
push 方式优点
- 消费者代码较少:对于消费者来说,只需提供一个消费接口给 mq 即可;mq 将接收到的消息,随即推送到指定的消费接口
- 消息实时性比较高:对于消费者来说,消息一旦到达 mq,mq 会立即推送给消费者
push 方式缺点
- 消费者属于被动方,消息量比较大时,对消费者性能要求比较高;若消费者机器资源有限,可能会导致压力过载,引发宕机的情况。
- 对消费者可用性要求比较高:当消费者不可用时,会导致很 push 失败,在 mq 方需要考虑至少推送成功一次,这块的设计下章节会做说明。
3.3、pull 方式
push 方式过程
1.消费端采用轮询的方式,从 mq 服务中拉取消息进行消费
2.消费完成通知 mq 删除已消费成功的消息
3.继续拉取消息消费
对于消费者来说,是主动方,可以采用线程池的方式,根据机器的性能来增加或缩小线程池的大小,控制拉取消息的速度,可以很好的控制自身的压力。
push 方式优点
1.消费者可以根据自己的性能主动控制消息拉取的速度,控制自己的压力,不至于把自己弄跨
2.实时性相对于 push 方式会低一些
3.消费者属于主动方,控制权更大一些
push 方式缺点
1.消费方需要实现消息拉取的代码
2.消费速度较慢时,可能导致 mq 中消息积压,消息消费延迟等
3.4、pull 方式和 push 方式选择建议
- 消费者性能较好,对实时性要求比较高的,可以采用 push 的方式
- 消费者性能有限,建议采用 pull 的方式
- 整体上来说,主要在于消费者的性能,机器的性能如果没有问题,push 和 pull 都是可以的
- 通常我们在 Spring 中使用的 RabbitMQ、RocketMQ 都是采用 pull 的方式,消费端会启动多个消费者,不断的从 MQ 中拉取消息进行消费
4、如何确保消息消息至少被成功消费一次?
4.1、消息消费的过程
- step1、从 mq 中拉取消息
- step2、执行本地业务
- step3、将消息从队列中删除
- step4、继续重复 step1
4.2、消费失败出现死循环
若 step2 执行失败,队列会被 step2 阻塞,step2 消费会产生死循环。
4.3、采用衰减式消费+人工干预解决消息消费失败的问题
当消息消费失败之后,可以将消息丢到延迟队列,比如第一次失败之后,延迟 2 秒再次重试,第二次失败了,延迟 4 秒再次重试。
第1次失败:延迟2秒再次消费
第2次失败:延迟4秒
第3次失败:延迟8秒
第4次失败:延迟16秒
.......
第n次失败:延迟2的n次方秒
n 可以设置一个阈值,比如 100 次,尝试 100 次,且都是失败的情况,此时就需要有监控系统触发报警,有人工介入解决了。
4.4、消息消费需确保幂等性
消息消费成功了,但是未将其从队列中剔除,会导致消息再次消费,此时需要通过幂等性来确保消息只被成功消费一次。
5、如何确保消息消费的幂等性?
5.1、什么是幂等性?
对于同一笔业务操作,不管调用多少次,得到的结果都是一样的。
5.2、幂等性设计
我们以对接支付宝充值为例,来分析支付回调接口如何设计?
如果我们系统中对接过支付宝充值功能的,我们需要给支付宝提供一个回调接口,支付宝回调信息中会携带(out_trade_no【商户订单号】,trade_no【支付宝交易号】),trade_no 在支付宝中是唯一的,out_trade_no 在商户系统中是唯一的。
回调接口实现有以下实现方式。
5.3、方式 1(普通方式)
过程如下:
1.接收到支付宝支付成功请求
2.根据trade_no查询当前订单是否处理过
3.如果订单已处理直接返回,若未处理,继续向下执行
4.开启本地事务
5.本地系统给用户加钱
6.将订单状态置为成功
7.提交本地事务
这个过程存在一个问题:
对于同一笔订单,如果支付宝同时通知多次,会出现什么问题?当多次通知同时到达第 2 步时候,查询订单都是未处理的,会继续向下执行,最终本地会给用户加两次钱。
此方式适用于单机,通知按顺序执行的情况,只能用于自己写着玩玩。
5.4、方式 2(jvm 加锁方式)
方式 1 中由于并发出现了问题,此时我们使用 java 中的 Lock 加锁,来防止并发操作。
过程如下:
1.接收到支付宝支付成功请求
2.调用java中的Lock加锁
3.根据trade_no查询当前订单是否处理过
4.如果订单已处理直接返回,若未处理,继续向下执行
5.开启本地事务
6.本地系统给用户加钱
7.将订单状态置为成功
8.提交本地事务
9.释放Lock锁
这个过程存在一个问题:
Lock 只能在一个 jvm 中起效,如果多个请求都被同一套系统处理,上面这种使用 Lock 的方式是没有问题的,不过互联网系统中,多数是采用集群方式部署系统,同一套代码后面会部署多套,如果支付宝同时发来多个通知经过负载均衡转发到不同的机器,上面的锁就不起效了。此时对于多个请求相当于无锁处理了,又会出现方式 1 中的结果。此时我们需要分布式锁来做处理。
5.5、方式 3(悲观锁方式)
使用数据库中悲观锁实现。悲观锁类似于方式二中的 Lock,只不过是依靠数据库来实现的,数据中悲观锁使用 for update 来实现。
过程如下:
1.接收到支付宝支付成功请求
2.打开本地事物
3.查询订单信息并加悲观锁:select * from t_order where order_id = trade_no for update;
4.判断订单是已处理
5.如果订单已处理直接返回,若未处理,继续向下执行
6.给本地系统给用户加钱
7.将订单状态置为成功
8.提交本地事物
重点在于for update,对for update,做一下说明:
- 当线程 A 执行 for update,数据会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程 A 释放锁之后,才可以获取锁,继续后续操作。
- 事物提交时,for update 获取的锁会自动释放。
方式 3 可以正常实现我们需要的效果,能保证接口的幂等性,不过存在一些缺点:
- 如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,我们的 web 服务中的线程数量一般都是有限的,如果大量线程由于获取 for update 锁处于等待状态,不利于系统并发操作。
5.6、方式 4(乐观锁方式)
依靠数据库中的乐观锁来实现。
过程如下:
- 接收到支付宝支付成功请求
- 查询订单信息select * from t_order where order_id = trade_no;
- 判断订单是已处理
- 如果订单已处理直接返回,若未处理,继续向下执行
- 打开本地事物
- 给本地系统给用户加钱
- 将订单状态置为成功,注意这块是重点,伪代码:update t_order setstatus = 1 where order_id = trade_no where status = 0;
//上面的update操作会返回影响的行数num
if(num==1){
//表示更新成功提交事务;}else{//表示更新失败回滚事务;}
关键代码解释:
update t_order set status = 1 where order_id = trade_no where status = 0;
这个 sql 是依靠乐观锁来实现的,status=0 作为条件去更新,类似于 java 中的 cas 操作。
执行这条 sql 的时候,如果有多个线程同时到达这条代码,数据内部会保证 update 同一条记录会排队执行,最终最有一条 update 会执行成功,此时成功的 num 为 1;其他未成功的,num 为 0,然后根据 num 是否为 1 来判断是否成功 。
5.7、方式 4(唯一约束方式)
依赖数据库中唯一约束来实现。
我们可以创建一个表:
CREATE TABLE `t_uq_dipose` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联对象类型',
`ref_id` varchar(64) NOT NULL DEFAULT '' COMMENT '关联对象id',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_1` (`ref_type`,`ref_id`) COMMENT '保证业务唯一性'
) ENGINE=InnoDB;
对于任何一个业务,有一个业务类型(ref_type),业务有一个全局唯一的订单号,业务来的时候,先查询 t_uq_dipose 表中是否存在相关记录,若不存在,继续放行。
过程如下:
- 接收到支付宝支付成功请求
- 查询 t_uq_dipose(条件 ref_id,ref_type),可以判断订单是否已处理select * from t_uq_dipose where ref_type = '充值订单' and ref_id = trade_no;
- 判断订单是已处理
- 如果订单已处理直接返回,若未处理,继续向下执行
- 打开本地事物
- 给本地系统给用户加钱
- 将订单状态置为成功
- 向 t_uq_dipose 插入数据,插入成功,提交本地事务,插入失败,回滚本地事务,伪代码:try{
insert into t_uq_dipose (ref_type,ref_id) values ('充值订单',trade_no);
提交本地事务:}catch(Exception e){
回滚本地事务;}
关键代码解释:
对于同一个业务,ref_type 是一样的,当并发时,插入数据只会有一条成功,其他的会违反唯一约束,进入 catch 逻辑,当前事务会被回滚,最终最有一个操作会成功,从而保证了幂等性操作。
关于这种方式可以写成通用的方式,不过业务量大的情况下,t_uq_dipose 插入数据会成为系统的瓶颈,需要考虑分表操作,解决性能问题。
上面的过程中向 t_uq_dipose 插入记录,最好放在最后执行,原因:插入操作会锁表,放在最后能让锁表的时间降到最低,提升系统的并发性。
关于消息服务中,消费者如何保证消息处理的幂等性?
每条消息都有一个唯一的消息 id,类似于上面业务中的 trade_no,使用上面的方式即可实现消息消费的幂等性。
5.8、幂等性总结
- 实现幂等性常见的方式有:悲观锁(for update)、乐观锁、唯一约束
- 几种方式,按照最优排序:乐观锁 > 唯一约束 > 悲观锁
6、顺序消息如何实现?
一个队列配合一个消费者即可实现,就像火车站买票一样,只开一个窗口,然后让大家排队,即可按顺序购票,先确保顺序消费的消息被投递到同一个队列,消费端需要确保只能有一个消费者,拉取一个消费一个,消费完毕,再拉取另外一条消息。
但是,咱们的系统可能采用集群的方式部署,如果是集群的方式,代码相同,此时就相当于一个队列有多个消费者了,集群中通常有 2 种方式解决这个问题:
- 分布式锁的方式:上锁成功的机器负责消费,分布式锁建议使用 zookeeper 或者 redis 来实现
- 选主的方式:多个机器自己通过选主的方式,只有一台机器会成为 master,成为 master 的机器负责消费消息,选主可以通过 zookeeper 来实现