浅谈消息队列

浅谈消息队列

一、为何使用消息队列、优缺点及常用消息队列对比

这里抛出三个问题

  • 为什么使用消息队列?
  • 消息队列有什么优点和缺点?
  • Kafka、ActiveMQ、RabbitMQ、RocketMQ 都有什么区别,以及适合哪些场景?

剖析

1. 为什么使用消息队列

消息队列都有哪些使用场景,然后项目里具体是什么场景,在这个场景里用消息队列是什么?

先说一下消息队列常见的使用场景吧,其实场景有很多,但是比较核心的有 3 个:解耦、异步、削峰。

(1)解耦

看这么个场景。A 系统发送数据到 BCD 三个系统,通过接口调用发送。如果 E 系统也要这个数据呢?那如果 C 系统现在不需要了呢?A 系统负责人几乎崩溃…

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aVhB8TID-1657002585874)(http://ww1.sinaimg.cn/large/a5fa4a8dgy1gdl8sp93ouj20ql0dswgf.jpg)]

在这个场景中,A 系统跟其它各种乱七八糟的系统严重耦合,A 系统产生一条比较关键的数据,很多系统都需要 A 系统将这个数据发送过来。A 系统要时时刻刻考虑 BCDE 四个系统如果挂了该咋办?要不要重发,要不要把消息存起来?头发都白了啊!

如果使用 MQ,A 系统产生一条数据,发送到 MQ 里面去,哪个系统需要数据自己去 MQ 里面消费。如果新系统需要数据,直接从 MQ 里消费即可;如果某个系统不需要这条数据了,就取消对 MQ 消息的消费即可。这样下来,A 系统压根儿不需要去考虑要给谁发送数据,不需要维护这个代码,也不需要考虑人家是否调用成功、失败超时等情况。

img

总结:

通过一个 MQ,Pub/Sub 发布订阅消息这么一个模型,A 系统就跟其它系统彻底解耦了。

  • 传统模式的缺点:系统间耦合性太强,如上图所示,系统A在代码中直接调用系统B和系统C的代码,如果将来D系统接入,系统A还需要修改代码,过于麻烦!
  • 中间件模式:将消息写入消息队列,需要消息的系统自己从消息队列中订阅,从而系统A不需要做任何修改。
(2)异步

再来看一个场景,A 系统接收一个请求,需要在自己本地写库,还需要在 BCD 三个系统写库,自己本地写库要 3ms,BCD 三个系统分别写库要 300ms、450ms、200ms。最终请求总延时是 3 + 300 + 450 + 200 = 953ms,接近 1s,用户感觉搞个什么东西,慢死了慢死了。用户通过浏览器发起请求,等待个 1s,这几乎是不可接受的。

img

一般互联网类的企业,对于用户直接的操作,一般要求是每个请求都必须在 200 ms 以内完成,对用户几乎是无感知的。

如果使用 MQ,那么 A 系统连续发送 3 条消息到 MQ 队列中,假如耗时 5ms,A 系统从接受一个请求到返回响应给用户,总时长是 3 + 5 = 8ms,对于用户而言,其实感觉上就是点个按钮,8ms 以后就直接返回了,爽!网站做得真好,真快!

img

总结:

  • 传统模式缺点:一些非必要的业务逻辑以同步的方式运行,太耗时间。
  • 中间件模式:将消息写入消息队列,非必要的业务逻辑以异步的方式运行,以加快响应速度
(3)削峰

每天 0:00 到 12:00,A 系统风平浪静,每秒并发请求数量就 50 个。结果每次一到 12:00 ~ 13:00 ,每秒并发请求数量突然会暴增到 5k+ 条。但是系统是直接基于 MySQL的,大量的请求涌入 MySQL,每秒钟对 MySQL 执行约 5k 条 SQL。

一般的 MySQL,扛到每秒 2k 个请求就差不多了,如果每秒请求到 5k 的话,可能就直接把 MySQL 给打死了,导致系统崩溃,用户也就没法再使用系统了。

但是高峰期一过,到了下午的时候,就成了低峰期,可能也就 1w 的用户同时在网站上操作,每秒中的请求数量可能也就 50 个请求,对整个系统几乎没有任何的压力。

img

如果使用 MQ,每秒 5k 个请求写入 MQ,A 系统每秒钟最多处理 2k 个请求,因为 MySQL 每秒钟最多处理 2k 个。A 系统从 MQ 中慢慢拉取请求,每秒钟就拉取 2k 个请求,不要超过自己每秒能处理的最大请求数量就 ok,这样下来,哪怕是高峰期的时候,A 系统也绝对不会挂掉。而 MQ 每秒钟 5k 个请求进来,就 2k 个请求出去,结果就导致在中午高峰期(1 个小时),可能有几十万甚至几百万的请求积压在 MQ 中。

img

这个短暂的高峰期积压是 ok 的,因为高峰期过了之后,每秒钟就 50 个请求进 MQ,但是 A 系统依然会按照每秒 2k 个请求的速度在处理。所以说,只要高峰期一过,A 系统就会快速将积压的消息给解决掉。

总结:

  • 传统模式缺点:并发量大的时候,所有的请求直接怼到数据库,造成数据库连接异常
  • 中间件模式:系统A慢慢的按照数据库能处理的并发量,从消息队列中慢慢拉取消息。在生产中,这个短暂的高峰期积压是允许的。
2. 消息队列有什么优点和缺点?

优点上面已经说了,就是在特殊场景下有其对应的好处,解耦、异步、削峰

缺点有以下几个:

  • 系统可用性降低 系统引入的外部依赖越多,越容易挂掉。本来你就是 A 系统调用 BCD 三个系统的接口就好了,人 ABCD 四个系统好好的,没啥问题,你偏加个 MQ 进来,万一 MQ 挂了咋整,MQ 一挂,整套系统崩溃的,你不就完了?如何保证消息队列的高可用,可以点击这里查看。
  • 系统复杂度提高 硬生生加个 MQ 进来,你怎么保证消息没有重复消费?怎么处理消息丢失的情况?怎么保证消息传递的顺序性?头大头大,问题一大堆,痛苦不已。
  • 一致性问题 A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,咋整?你这数据就不一致了。

所以消息队列实际是一种非常复杂的架构,你引入它有很多好处,但是也得针对它带来的坏处做各种额外的技术方案和架构来规避掉,做好之后,你会发现,妈呀,系统复杂度提升了一个数量级,也许是复杂了 10 倍。但是关键时刻,用,还是得用的。

3. Kafka、ActiveMQ、RabbitMQ、RocketMQ 都有什么区别,以及适合哪些场景?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mvy3KWbr-1657002585878)(http://ww1.sinaimg.cn/large/a5fa4a8dgy1gdl8pf8do0j20sn0ogwfp.jpg)]

综上所述,各种对比之后,我个人倾向于是:

一般的业务系统要引入MQ,最早大家都用ActiveMQ,但是现在确实大家用的不多了,没经过大规模吞吐量场景的验证,社区也不是很活跃,所以大家还是算了吧,我个人不推荐用这个了;

后来大家开始用RabbitMQ,但是确实erlang语言阻止了大量的java工程师去深入研究和掌控他,对公司而言,几乎处于不可控的状态,但是确实人是开源的,比较稳定的支持,活跃度也高;

不过现在确实越来越多的公司,会去用RocketMQ,确实很不错,但是我提醒一下自己想好社区万一突然黄掉的风险,对自己公司技术实力有绝对自信的,我推荐用RocketMQ,否则回去老老实实用RabbitMQ吧,人是活跃开源社区,绝对不会黄

所以中小型公司,技术实力较为一般,技术挑战不是特别高,用RabbitMQ是不错的选择;大型公司,基础架构研发实力较强,用RocketMQ是很好的选择

如果是大数据领域的实时计算、日志采集等场景,用Kafka是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范

二、如何保证消息队列的高可用?

剖析

MQ的高可用性怎么保证?

1. RabbitMQ高可用

RabbitMQ是比较有代表性的,因为是基于主从做高可用性的,我们就以他为例子讲解第一种MQ的高可用性怎么实现。

(1)基本架构

  • Broker:消息队列服务进程,此进程包括两个部分:Exchange和Queue
  • Exchange:消息队列交换机,按一定的规则将消息转发到某个队列,对消息进行过滤
  • Queue:消息队列,存储消息的队列,消息到达队列并转发给指定的消费方
  • Producer:消息生产者,即生产方客户端,生产方客户端将消息发送到MQ
  • Consumer:消息消费者,即消费方客户端,接收MQ转发的消息
(2)高可用架构

rabbitmq有三种模式:单机模式,普通集群模式,镜像集群模式

① 单机模式

就是demo级别的,一般就是你本地启动了玩玩儿的,没人生产用单机模式

② 普通集群模式

就是在多个联通的服务器上安装不同的RabbitMQ的服务,这些服务器上的RabbitMQ服务组成一个个节点,通过RabbitMQ内部提供的命令或者配置来构建集群,形成了RabbitMQ的普通集群模式

  • 当用户向服务注册一个队列,该队列会随机保存到某一个服务节点上,然后将对应的元数据同步到各个不同的服务节点上

  • RabbitMQ的普通集群模式中,每个RabbitMQ都保存有相同的元数据

  • 用户只需要链接到任一一个服务节点中,就可以监听消费到对应队列上的消息数据

  • 但是RabbitMQ的实际数据却不是保存在每个RabbitMQ的服务节点中,这就意味着用户可能联系的是RabbitMQ服务节点C,但是C上并没有对应的实际数据,也就是说RabbitMQ服务节点C,并不能提供消息供用户来消费,那么RabbitMQ的普通集群模式如何解决这个问题呢?

  • RabbitMQ服务节点C发现自己本服务节点并没有对应的实际数据后,因为每个服务节点上都会保存相同的元数据,所以服务节点C会根据元数据,向服务节点B(该服务节点上有实际数据可供消费)请求实际数据,然后提供给用户进行消费

  • 这样给用户的感觉就是,在RabbitMQ的普通集群模式中,用户连接任一服务节点都可以消费到消息

优点

提高消费的吞吐量

缺点

  • 为了请求RabbitMQ的实际数据以提供给用户,可能会在RabbitMQ内部服务节点之间进行频繁的进行数据交互,这样的交互比较耗费资源

  • 当其中一个RabbitMQ的服务节点宕机了,那么该节点上的实际数据就会丢失,用户再次请求时,就会请求不到数据,系统的功能就会出现异常

③ 镜像集群服务

镜像集群模式和普通集群模式大体是一样的,不一样的是:

  • 生产者向任一服务节点注册队列,该队列相关信息会同步到其他节点上
  • 任一消费者向任一节点请求消费,可以直接获取到消费的消息,因为每个节点上都有相同的实际数据
  • 任一节点宕机,不影响消息在其他节点上进行消费

镜像集群模式是怎么开启的呢?这里简单说下,在普通集群模式的基础上,我们可以通过web控制端来配置数据的同步策略,可以配置同步所有的节点,也可以配置同步到指定数量的服务节点

虽然镜像集群模式能够解决普通集群模式的缺点,当任一节点宕机了,不能正常提供服务了,也不影响该消息的正常消费,但是其本身也有相应的缺点:

  1. 性能开销非常大,因为要同步消息到对应的节点,这个会造成网络之间的数据量的频繁交互,对于网络带宽的消耗和压力都是比较重的
  2. 没有扩展可言,rabbitMQ是集群,不是分布式的,所以当某个Queue负载过重,我们并不能通过新增节点来缓解压力,因为所以节点上的数据都是相同的,这样就没办法进行扩展了

对于镜像集群而言,当某个queue负载过重,可能会导致集群雪崩,那么如何来减少集群雪崩呢?我们可以通过HA的同步策略来实现

HA的同步策略如下:

HA-modeHA-params说明
all镜像队列将会在整个集群中复制。当一个新的节点加入后,也会在这个节点上复制一份。
exactlycount镜像队列将会在集群上复制count份。如果集群数量少于count时候,队列会复制到所有节点上。 如果大于Count集群,有一个节点crash后,新进入节点也不会做新的镜像。(可以阻止集群雪崩)
nodesnode name镜像队列会在node name中复制。如果这个名称不是集群中的一个,这不会触发错误。 如果在这个node list中没有一个节点在线,那么这个queue会被声明在client连接的节点。
2. Kafka的高可用
(1)基础架构

  • producer
    消息生产者,发布消息到 kafka 集群的终端或服务。
  • broker
    kafka 集群中包含的服务器。
  • topic
    每条发布到 kafka 集群的消息属于的类别,即 kafka 是面向 topic 的。
  • partition
    partition 是物理上的概念,每个 topic 包含一个或多个 partition。kafka 分配的单位是 partition。
  • consumer
    从 kafka 集群中消费消息的终端或服务。
  • Consumer group
    high-level consumer API 中,每个 consumer 都属于一个 consumer group,每条消息只能被 consumer group 中的一个 Consumer 消费,但可以被多个 consumer group 消费。
  • replica
    partition 的副本,保障 partition 的高可用。
  • leader
    replica 中的一个角色, producer 和 consumer 只跟 leader 交互。
  • follower
    replica 中的一个角色,从 leader 中复制数据。
  • controller
    kafka 集群中的其中一个服务器,用来进行 leader election 以及 各种 failover。
  • zookeeper
    kafka 通过 zookeeper 来存储集群的 meta 信息。

Kafka由多个 broker 组成,每个 broker 是一个节点;每创建一个 topic,这个 topic 可以划分为多个 partition,每个 partition 可以存在于不同的 broker 上,每个 partition 就放一部分数据。

这就是天然的分布式消息队列,就是说一个 topic 的数据,是分散放在多个机器上的,每个机器就放一部分数据

实际上 RabbmitMQ 之类的,并不是分布式消息队列,它就是传统的消息队列,只不过提供了一些集群、HA(High Availability, 高可用性) 的机制而已,因为无论怎么玩儿,RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。

(2)高可用架构

Kafka 0.8 以前,是没有 HA 机制的,就是任何一个 broker 宕机了,那个 broker 上的 partition 就废了,没法写也没法读,没有什么高可用性可言。

比如说,我们假设创建了一个 topic,指定其 partition 数量是 3 个,分别在三台机器上。但是,如果第二台机器宕机了,会导致这个 topic 的 1/3 的数据就丢了,因此这个是做不到高可用的。

img

Kafka 0.8 以后,提供了 HA 机制,就是 replica(复制品) 副本机制。每个 partition 的数据都会同步到其它机器上,形成自己的多个 replica 副本。所有 replica 会选举一个 leader 出来,那么生产和消费都跟这个 leader 打交道,然后其他 replica 就是 follower。写的时候,leader 会负责把数据同步到所有 follower 上去,读的时候就直接读 leader 上的数据即可。为什么只能读写 leader?很简单,要是你可以随意读写每个 follower,那么就要 care 数据一致性的问题,系统复杂度太高,很容易出问题。Kafka 会均匀地将一个 partition 的所有 replica 分布在不同的机器上,这样才可以提高容错性。

img

这么搞,就有所谓的高可用性了,因为如果某个 broker 宕机了,没事儿,那个 broker上面的 partition 在其他机器上都有副本的,如果这上面有某个 partition 的 leader,那么此时会从 follower 中重新选举一个新的 leader 出来,大家继续读写那个新的 leader 即可。这就有所谓的高可用性了。

写数据的时候,生产者就写 leader,然后 leader 将数据落地写本地磁盘,接着其他 follower 自己主动从 leader 来 pull 数据。一旦所有 follower 同步好数据了,就会发送 ack 给 leader,leader 收到所有 follower 的 ack 之后,就会返回写成功的消息给生产者。(当然,这只是其中一种模式,还可以适当调整这个行为)

消费的时候,只会从 leader 去读,但是只有当一个消息已经被所有 follower 都同步成功返回 ack 的时候,这个消息才会被消费者读到。

3. RocketMQ高可用
(1)基础架构

主要组成结构:

  • Producer

    ​ 消息发送者

    ​ Producer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer取Topic路由信息,并向提供Topic服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可集群部署。

  • Consumer

    ​ 消息消费者

    ​ Consumer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,订阅规则由Broker配置决定。

  • Broker

    ​ 主要负责消息的存储、投递和查询以及服务高可用保证。

    ​ Broker部署相对复杂,Broker分为Master与Slave,一个Master可以对应多个Slave,但是一个Slave只能对 应一个Master,Master与Slave的对应关系通过指定相同的BrokerName,不同的BrokerId来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。每个Broker与NameServer集群中的所有节点建立长连接,定时注册Topic信息到所有NameServer。

  • NameServer

    ​ 管理Broker和路由信息

    ​ NameServer是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。

补充说明:

  • Topic

    ​ 区分消息的种类;一个发送者可以发送消息给一个或者多个Topic;一个消息的接收者可以订阅一个或者多个Topic消息

  • Tag

    消息标签。RocketMQ支持给在发送的时候给topic打tag,因此消费时可根据不同tag进行不同逻辑处理。

  • Message Queue:相当于是Topic的分区;用于并行发送和接收消息

  • Producer Group
    用来表示一个发送消息应用,一个 Producer Group 下包含多个 Producer 实例,可以是多台机器,也可以 是一台机器的多个迕程,或者一个迕程的多个 Producer 对象。一个 Producer Group 可以发送多个 Topic 消息,Producer Group 作用如下:

    1. 标识一类 Producer
    2. 可以通过运维工具查询某个发送消息应用下的多个 Producer 实例
    3. 发送分布式事务消息时,如果 Producer 中途意外宕机,Broker 会主动回调 Producer Group 内的任意一台机器来确认事务状态
  • Consumer Group
    用来表示一个消费消息应用,一个 Consumer Group 下包含多个 Consumer 实例,可以是多台机器,也可
    以是多个进程,或者是一个进程的多个 Consumer 对象。一个 Consumer Group 下的多个 Consumer 以均摊方式消费消息,如果设置为广播方式,那举返个 Consumer Group 下的每个实例都消费全量数据。

(2)高可用架构
① 单Master模式

这种方式风险较大,一旦Broker重启或者宕机时,会导致整个服务不可用。不建议线上环境使用,可以用于本地测试。

② 多Master模式

一个集群无Slave,全是Master,例如2个Master或者3个Master,这种模式的优缺点如下:

  • 优点:配置简单,单个Master宕机或重启维护对应用无影响,在磁盘配置为RAID10时,即使机器宕机不可恢复情况下,由于RAID10磁盘非常可靠,消息也不会丢(异步刷盘丢失少量消息,同步刷盘一条不丢),性能最高;
  • 缺点:单台机器宕机期间,这台机器上未被消费的消息在机器恢复之前不可订阅,消息实时性会受到影响。
③ 多Master多Slave模式(异步)

每个Master配置一个Slave,有多对Master-Slave,HA采用异步复制方式,主备有短暂消息延迟(毫秒级),这种模式的优缺点如下:

  • 优点:即使磁盘损坏,消息丢失的非常少,且消息实时性不会受影响,同时Master宕机后,消费者仍然可以从Slave消费,而且此过程对应用透明,不需要人工干预,性能同多Master模式几乎一样;
  • 缺点:Master宕机,磁盘损坏情况下会丢失少量消息。
④ 多Master多Slave模式(同步)

每个Master配置一个Slave,有多对Master-Slave,HA采用同步双写方式,即只有主备都写成功,才向应用返回成功,这种模式的优缺点如下:

  • 优点:数据与服务都无单点故障,Master宕机情况下,消息无延迟,服务可用性与数据可用性都非常高;
  • 缺点:性能比异步复制模式略低(大约低10%左右),发送单个消息的RT会略高,且目前版本在主节点宕机后,备机不能自动切换为主机。

三、如何保证消息不被重复消费(如何保证消息消费时的幂等性)

分析

既然是消费消息,那肯定要考虑考虑会不会重复消费?能不能避免重复消费?或者重复消费了也别造成系统异常可以吗?这个是MQ领域的基本问题,其实本质上还是使用消息队列如何保证幂等性,这个是你架构里要考虑的一个问题。

剖析

  • 首先就是比如rabbitmq、rocketmq、kafka,都有可能会出现消费重复消费的问题,正常。因为这问题通常不是mq自己保证的,是你自己保证的。然后我们挑一个kafka来举个例子,说说怎么重复消费吧。

    kafka实际上有个offset的概念,就是每个消息写进去,都有一个offset,代表他的序号,然后consumer消费了数据之后,每隔一段时间,会把自己消费过的消息的offset提交一下,代表我已经消费过了,下次我要是重启啥的,你就让我继续从上次消费到的offset来继续消费吧。

    但是凡事总有意外,比如我们之前生产经常遇到的,就是你有时候重启系统,看你怎么重启了,如果碰到点着急的,直接kill进程了,再重启。这会导致consumer有些消息处理了,但是没来得及提交offset,尴尬了。重启之后,少数消息会再次消费一次。

  • 其实重复消费不可怕,可怕的是你没考虑到重复消费之后,怎么保证幂等性。

    举个例子,假设你有个系统,消费一条往数据库里插入一条,要是你一个消息重复两次,你不就插入了两条,这数据不就错了?但是你要是消费到第二次的时候,自己判断一下已经消费过了,直接扔了,不就保留了一条数据?

    一条数据重复出现两次,数据库里就只有一条数据,这就保证了系统的幂等性

    幂等性,我通俗点说,就一个数据,或者一个请求,给你重复来多次,你得确保对应的数据是不会改变的,不能出错。

  • 那所以第二个问题来了,怎么保证消息队列消费的幂等性?

    其实还是得结合业务来思考,我这里给几个思路:

    (1)比如你拿个数据要写库,你先根据主键查一下,如果这数据都有了,你就别插入了,update一下好吧

    (2)比如你是业务是写入redis,那没问题了,反正每次都是set,天然幂等性。

    (3)比如你不是上面两个场景,那做的稍微复杂一点,你需要让生产者发送每条数据的时候,里面加一个全局唯一的id,类似订单id之类的东西,然后你这里消费到了之后,先根据这个id去比如redis里查一下,之前消费过吗?如果没有消费过,你就处理,然后这个id写redis。如果消费过了,那你就别处理了,保证别重复处理相同的消息即可。

    (3)比如你不是上面两个场景,那做的稍微复杂一点,你需要让生产者发送每条数据的时候,里面加一个全局唯一的id,类似订单id之类的东西,然后你这里消费到了之后,先根据这个id去比如redis里查一下,之前消费过吗?如果没有消费过,你就处理,然后这个id写redis。如果消费过了,那你就别处理了,保证别重复处理相同的消息即可。

    (4)使用消息记录表,每次消费先查询消息记录表中是否有该消息的消费记录(可根据几个唯一字段判断),根据消息记录表来对重复消费做处理,非重复消费就将其记录到消息记录表中。

    (5)还有比如基于数据库的唯一键来保证重复数据不会重复插入多条,我们之前线上系统就有这个问题,就是拿到数据的时候,每次重启可能会有重复,因为kafka消费者还没来得及提交offset,重复数据拿到了以后我们插入的时候,因为有唯一键约束了,所以重复数据只会插入报错,不会导致数据库中出现脏数据

如何保证MQ的消费是幂等性的,需要结合具体的业务来看

四、如何保证消息的可靠性传输(如何处理消息丢失的问题)

分析

用mq有个基本原则,就是数据不能多一条,也不能少一条,不能多,就是刚才说的重复消费和幂等性问题。不能少,就是说这数据别搞丢了。那这个问题你必须得考虑一下。

如果说你这个是用mq来传递非常核心的消息,比如说计费,扣费的一些消息,因为我以前设计和研发过一个公司非常核心的广告平台,计费系统,计费系统是很重的一个业务,操作是很耗时的。所以说广告系统整体的架构里面,实际上是将计费做成异步化的,然后中间就是加了一个MQ。

我们当时为了确保说这个MQ传递过程中绝对不会把计费消息给弄丢,花了很多的精力。广告主投放了一个广告,明明说好了,用户点击一次扣费1块钱。结果要是用户动不动点击了一次,扣费的时候搞的消息丢了,我们公司就会不断的少几块钱,几块钱,积少成多,这个就对公司是一个很大的损失。

剖析

为什么消息会丢失

消息从生产到消费可以经历三个阶段:生产阶段、存储阶段和消费阶段。

  • 生产阶段:在这个阶段,从消息在Producer创建出来,经过网络传输发送到Broker端。
  • 存储阶段: 消息在Broker端存储,如果是集群,消息会在这个阶段被复制到其他的副本上。
  • 消费阶段:Consumer从Broker上拉取消息,经过网络 传输发送在Consumer上。

img

以上任一阶段都可能会丢失消息,我们只要找到这三个阶段丢失消息原因,采用合理的办法避免丢失,就可以彻底解决消息丢失的问题。

保证消息的可靠性传输
  • 生产阶段:消息队列通常使用确认机制,来保证消息可靠传递:当你代码调用发送消息的方法,消息队列的客户端会把消息发送到Broker,Broker接受到消息会返回客户端一个确认。只要Producer收到了Broker的确认响应,就可以保证消息在生产阶段不会丢失。有些消息队列在长时间没收到发送的确认响应后,会自动重试,如果重试再失败,就会一返回值或者异常方式返回给客户端。所以在编写发送消息的代码,需要正确处理消息发送返回值或者异常,保证这个阶段消息不丢失。

  • 存储阶段:如果对消息可靠性要求非常高,可以通过配置Broker参数来避免因为宕机丢消息。对于单个节点Broker,需要配置Broker参数,在收到消息后,将消息写入磁盘再给Producer返回确认响应。如果是Broker集群,需要将Broker集群配置成:至少两个以上节点收到消息,再给客户端发送确认响应。

  • 消费阶段:消费阶段采用和生产阶段类似的确认机制来保证消息的可靠传递。Consumer收到消息后,需在执行消费逻辑后在发送确认消息。

总结:

  • 生产阶段,需要捕获消息发送错误,并重发消息
  • 存储阶段,通过配置刷盘和复制参数,让消息写入多个副本的磁盘上,来确保消息不会因为某个Broker宕机或者磁盘损坏而丢失。
  • 消费阶段:需要在处理完全部消费业务逻辑后,再发送确认消息。
RabbitMQ保证消息可靠性传输
生产阶段

生产者将数据发送到rabbitmq的时候,可能数据就在半路给搞丢了,因为网络啥的问题,都有可能。

解决方法:

  • 可以选择使用rabbitmq提供的事务功能,就是生产者在发送数据之前开启事务,然后发送消息,如果消息没有成功被rabbitmq接收到,那么生产者会捕获异常报错,这时就可以回滚事务,然后尝试重新发送;如果收到了消息,那么就可以提交事务。

    // 创建连接
    ConnectionFactory factory = new ConnectionFactory();
    factory.setUsername(config.UserName);
    factory.setPassword(config.Password);
    factory.setVirtualHost(config.VHost);
    factory.setHost(config.Host);
    factory.setPort(config.Port);	
    Connection conn = factory.newConnection();
    // 创建信道
    Channel channel = conn.createChannel();
    // 声明队列
    channel.queueDeclare(_queueName, true, false, false, null);
    String message = String.format("时间 => %s", new Date().getTime());
    try {
    	channel.txSelect(); // 声明事务
    	// 发送消息
    	channel.basicPublish("", _queueName, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes("UTF-8"));
    	channel.txCommit(); // 提交事务
    } catch (Exception e) {
    	channel.txRollback();
    } finally {
    	channel.close();
    	conn.close();
    }
    

    **缺点:**rabbitmq事务一开启,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。

  • 可以开启Confirm模式。

    Confirm发送方确认模式使用和事务类似,也是通过设置Channel进行发送方确认的。

    Confirm的三种实现方式:

    方式一:channel.waitForConfirms()普通发送方确认模式;

    方式二:channel.waitForConfirmsOrDie()批量确认模式;

    方式三:channel.addConfirmListener()异步监听发送方确认模式;

    方式一:普通Confirm模式

    // 创建连接
    ConnectionFactory factory = new ConnectionFactory();
    factory.setUsername(config.UserName);
    factory.setPassword(config.Password);
    factory.setVirtualHost(config.VHost);
    factory.setHost(config.Host);
    factory.setPort(config.Port);
    Connection conn = factory.newConnection();
    // 创建信道
    Channel channel = conn.createChannel();
    // 声明队列
    channel.queueDeclare(config.QueueName, false, false, false, null);
    // 开启发送方确认模式
    channel.confirmSelect();
    String message = String.format("时间 => %s", new Date().getTime());
    channel.basicPublish("", config.QueueName, null, message.getBytes("UTF-8"));
    if (channel.waitForConfirms()) {
    	System.out.println("消息发送成功" );
    }
    

    看代码可以知道,我们只需要在推送消息之前,channel.confirmSelect()声明开启发送方确认模式,再使用channel.waitForConfirms()等待消息被服务器确认即可。

    方式二:批量Confirm模式

    // 创建连接
    ConnectionFactory factory = new ConnectionFactory();
    factory.setUsername(config.UserName);
    factory.setPassword(config.Password);
    factory.setVirtualHost(config.VHost);
    factory.setHost(config.Host);
    factory.setPort(config.Port);
    Connection conn = factory.newConnection();
    // 创建信道
    Channel channel = conn.createChannel();
    // 声明队列
    channel.queueDeclare(config.QueueName, false, false, false, null);
    // 开启发送方确认模式
    channel.confirmSelect();
    for (int i = 0; i < 10; i++) {
    	String message = String.format("时间 => %s", new Date().getTime());
    	channel.basicPublish("", config.QueueName, null, message.getBytes("UTF-8"));
    }
    channel.waitForConfirmsOrDie(); //直到所有信息都发布,只要有一个未确认就会IOException
    System.out.println("全部执行完成");
    

    以上代码可以看出来channel.waitForConfirmsOrDie(),使用同步方式等所有的消息发送之后才会执行后面代码,只要有一个消息未被确认就会抛出IOException异常。

    方式三:异步Confirm模式

    // 创建连接
    ConnectionFactory factory = new ConnectionFactory();
    factory.setUsername(config.UserName);
    factory.setPassword(config.Password);
    factory.setVirtualHost(config.VHost);
    factory.setHost(config.Host);
    factory.setPort(config.Port);
    Connection conn = factory.newConnection();
    // 创建信道
    Channel channel = conn.createChannel();
    // 声明队列
    channel.queueDeclare(config.QueueName, false, false, false, null);
    // 开启发送方确认模式
    channel.confirmSelect();
    for (int i = 0; i < 10; i++) {
    	String message = String.format("时间 => %s", new Date().getTime());
    	channel.basicPublish("", config.QueueName, null, message.getBytes("UTF-8"));
    }
    //异步监听确认和未确认的消息
    channel.addConfirmListener(new ConfirmListener() {
    	@Override
    	public void handleNack(long deliveryTag, boolean multiple) throws IOException {
    		System.out.println("未确认消息,标识:" + deliveryTag);
    	}
    	@Override
    	public void handleAck(long deliveryTag, boolean multiple) throws IOException {
    		System.out.println(String.format("已确认消息,标识:%d,多个消息:%b", deliveryTag, multiple));
    	}
    });
    

    异步模式的优点,就是执行效率高,不需要等待消息执行完,只需要监听消息即可,以上异步返回的信息如下:

存储阶段

如果没有开启rabbitmq的持久化,那么rabbitmq一旦重启,那么数据就丢了。所以必须开启持久化将消息持久化到磁盘,这样就算rabbitmq挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢失。除非极其罕见的情况,rabbitmq还没来得及持久化自己就挂了,这样可能导致一部分数据丢失。

解决方法:

设置消息持久化到磁盘。设置持久化有两个步骤:

  • 创建queue的时候将其设置为持久化的,这样就可以保证rabbitmq持久化queue的元数据,但是不会持久化queue里面的数据。

    /**
     * 声明队列
     * queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete,Map<String, Object> arguments)
     * queue: 队列名称
     * durable: 是否持久化,true的话,重启服务后该队列依旧存在
     * exclusive: 队列是否独占此连接
     * autoDelete: 队列不再使用时是否自动删除此队列
     * arguments: 队列参数
     */
    channel.queueDeclare(QUEUE_INFORM_EMAIL, true, false, false, null);
    
  • 发送消息的时候将消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时rabbitmq就会将消息持久化到磁盘上。

    // 发送消息
    channel.basicPublish("exchange.persistent", "persistent", MessageProperties.PERSISTENT_TEXT_PLAIN, "persistent_test_message".getBytes());
    
    // 其中MessageProperties.PERSISTENT_TEXT_PLAIN
    public static final BasicProperties PERSISTENT_TEXT_PLAIN =
        new BasicProperties("text/plain",
                            null,
                            null,
                            2,
                            0, null, null, null,
                            null, null, null, null,
                            null, null);
    
    // deliveryMode,//1:nonpersistent 2:persistent
    public BasicProperties(
                String contentType,//消息类型如:text/plain
                String contentEncoding,//编码
                Map<String,Object> headers,
                Integer deliveryMode,//1:nonpersistent 2:persistent
                Integer priority,//优先级
                String correlationId,
                String replyTo,//反馈队列
                String expiration,//expiration到期时间
                String messageId,
                Date timestamp,
                String type,
                String userId,
                String appId,
                String clusterId)
    
    

必须要同时开启这两个才可以。

而且持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前rabbitmq挂了,数据丢了,生产者收不到ack回调也会进行消息重发。

消费阶段

rabbitmq如果丢失了数据,主要是因为你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,那么就尴尬了,rabbitmq认为你都消费了,这数据就丢了。

解决方法:

使用rabbitmq提供的ack机制,首先关闭rabbitmq的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。

// 消费消息设置应答方式
channel.basicConsume(QUEUE_NAME, false, defaultConsumer);//    false手动应答  

// basicConsume方法
String basicConsume(String queue, boolean autoAck, Consumer callback) throws IOException;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0ETEKZTZ-1657002585889)(http://ww1.sinaimg.cn/large/a5fa4a8dgy1gdnakcejwvj20t80j0abt.jpg)]

Kafka保证消息的可靠性传输
生产阶段:

生产者(Producer) 调用send方法发送消息之后,消息可能因为网络问题并没有发送过去。

解决方法:

  • 我们不能默认在调用send方法发送消息之后就认为消息发送成功了。为了确定消息是发送成功,我们要判断消息发送的结果。但是要注意的是 Kafka 生产者(Producer) 使用 send 方法发送消息实际上是异步的操作,我们可以通过 get()方法获取调用结果,但是这样也让它变为了同步操作,示例代码如下:

    SendResult<String, Object> sendResult = kafkaTemplate.send(topic, o).get();
    if (sendResult.getRecordMetadata() != null) {
      logger.info("生产者成功发送消息到" + sendResult.getProducerRecord().topic() + "-> " + sendRe
                  sult.getProducerRecord().value().toString());
    }
    

    但是一般不推荐这么做!可以采用为其添加回调函数的形式,示例代码如下:

    ListenableFuture<SendResult<String, Object>> future = kafkaTemplate.send(topic, o);
    future.addCallback(
      result -> 
         logger.info("生产者成功发送消息到topic:{} partition:{}的消息", result.getRecordMetadata().topic(), result.getRecordMetadata().partition()),
      ex -> logger.error("生产者发送消失败,原因:{}", ex.getMessage())
    );
    

    如果消息发送失败的话,我们检查失败的原因之后重新发送即可!

  • 在生产者端设置retries,设置一个比较合理的值,一般是 3 ,但是为了保证消息不丢失的话一般会设置比较大一点。设置完成之后,当出现网络问题之后能够自动重试消息发送,避免消息丢失。另外,建议还要设置重试间隔,因为间隔太小的话重试的效果就不明显了,网络波动一次你3次一下子就重试完了

存储阶段

比较常见的一个场景,就是kafka的某个broker宕机了,然后重新选举partition的leader时。如果此时follower还没来得及同步数据,leader就挂了,然后某个follower成为了leader,他就少了一部分数据。

解决方法:

一般要求设置4个参数来保证消息不丢失:

  • 在生产者端设置acks=all:表示 要求每条每条数据,必须是写入所有replica副本之后,才能认为是写入成功了

  • 给topic设置 replication.factor参数:这个值必须大于1,我们一般会为 topic 设置 replication.factor >= 3。这样就可以保证每个 分区(partition) 至少有 3 个副本。虽然造成了数据冗余,但是带来了数据的安全性。

  • 在kafka服务端设置min.isync.replicas参数:这个值必须大于1,表示 要求一个leader至少感知到有至少一个follower在跟自己保持联系正常同步数据,这样才能保证leader挂了之后还有一个follower。

  • 在kafka服务端设置 unclean.leader.election.enable = false

    Kafka 0.11.0.0版本开始 unclean.leader.election.enable 参数的默认值由原来的true 改为false

    我们最开始也说了我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。多个 follower 副本之间的消息同步情况不一样,当我们配置了 unclean.leader.election.enable = false 的话,当 leader 副本发生故障时就不会从 follower 副本中和 leader 同步程度达不到要求的副本中选择出 leader ,这样降低了消息丢失的可能性。

消费阶段

唯一可能导致消费者弄丢数据的情况,就是说,你那个消费到了这个消息,然后消费者那边自动提交了offset,让kafka以为你已经消费好了这个消息,其实你刚准备处理这个消息,你还没处理,你自己就挂了,此时这条消息就丢咯。

大家都知道kafka会自动提交offset,那么只要关闭自动提交offset,在处理完之后自己手动提交offset,就可以保证数据不会丢。但是此时确实还是会重复消费,比如你刚处理完,还没提交offset,结果自己挂了,此时肯定会重复消费一次,自己保证幂等性就好了。

生产环境碰到的一个问题,就是说我们的kafka消费者消费到了数据之后是写到一个内存的queue里先缓冲一下,结果有的时候,你刚把消息写入内存queue,然后消费者会自动提交offset。

然后此时我们重启了系统,就会导致内存queue里还没来得及处理的数据就丢失了

解决方法:

关闭自动提交offset,在自己处理完毕之后手动提交offset,这样就不会丢失数据。

普通java程序

// KafkaConsumer的配置类中配置
properties.put("enable.auto.commit", "false");
// KafkaConsumer 使用这种方式提交表示手动提交
consumer.commitSync();

SpringBoot应用

@Bean
public KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<String, String>> kafkaListenerContainerFactory(ConsumerFactory<String, String> consumerFactory) {
   ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
   factory.setConsumerFactory(consumerFactory);
   factory.getContainerProperties().setPollTimeout(1500);
   //配置手动提交offset
   factory.getContainerProperties().setAckMode((ContainerProperties.AckMode.MANUAL));
   return factory;
}

RocketMQ保证消息的可靠性传输
生产阶段

生产者(Producer) 通过网络发送消息给 Broker,当 Broker 收到之后,将会返回确认响应信息给 Producer。所以生产者只要接收到返回的确认响应,就代表消息在生产阶段未丢失。

RocketMQ 发送消息示例代码如下:

DefaultMQProducer mqProducer=new DefaultMQProducer("test");
// 设置 nameSpace 地址
mqProducer.setNamesrvAddr("namesrvAddr");
mqProducer.start();
Message msg = new Message("test_topic" /* Topic */,
        "Hello World".getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
// 发送消息到一个Broker
try {
    SendResult sendResult = mqProducer.send(msg);
} catch (RemotingException e) {
    e.printStackTrace();
} catch (MQBrokerException e) {
    e.printStackTrace();
} catch (InterruptedException e) {
    e.printStackTrace();
}

send 方法是一个同步操作,只要这个方法不抛出任何异常,就代表消息已经发送成功。

消息发送成功仅代表消息已经到了 Broker 端,Broker 在不同配置下,可能会返回不同响应状态:

SendStatus.SEND_OK

SendStatus.FLUSH_DISK_TIMEOUT

SendStatus.FLUSH_SLAVE_TIMEOUT

SendStatus.SLAVE_NOT_AVAILABLE

引用官方状态说明:

image-20200319220927210

上图中不同 broker 端配置将会在下文详细解释

另外 RocketMQ 还提供异步的发送的方式,适合于链路耗时较长,对响应时间较为敏感的业务场景。

DefaultMQProducer mqProducer = new DefaultMQProducer("test");
// 设置 nameSpace 地址
mqProducer.setNamesrvAddr("127.0.0.1:9876");
mqProducer.setRetryTimesWhenSendFailed(5);
mqProducer.start();
Message msg = new Message("test_topic" /* Topic */,
        "Hello World".getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);

try {
    // 异步发送消息到,主线程不会被阻塞,立刻会返回
    mqProducer.send(msg, new SendCallback() {
        @Override
        public void onSuccess(SendResult sendResult) {
            // 消息发送成功,
        }

        @Override
        public void onException(Throwable e) {
            // 消息发送失败,可以持久化这条数据,后续进行补偿处理
        }
    });
} catch (RemotingException e) {
    e.printStackTrace();
} catch (InterruptedException e) {
    e.printStackTrace();
}

异步发送消息一定要注意重写回调方法,在回调方法中检查发送结果。

不管是同步还是异步的方式,都会碰到网络问题导致发送失败的情况。针对这种情况,我们可以设置合理的重试次数,当出现网络问题,可以自动重试。设置方式如下:

// 同步发送消息重试次数,默认为 2
mqProducer.setRetryTimesWhenSendFailed(3);
// 异步发送消息重试次数,默认为 2
mqProducer.setRetryTimesWhenSendAsyncFailed(3);
存储阶段
  • 磁盘持久化

    默认情况下,消息只要到了 Broker 端,将会优先保存到内存中,然后立刻返回确认响应给生产者。随后 Broker 定期批量的将一组消息从内存异步刷入磁盘。

    这种方式减少 I/O 次数,可以取得更好的性能,但是如果发生机器掉电,异常宕机等情况,消息还未及时刷入磁盘,就会出现丢失消息的情况。

    若想保证 Broker 端不丢消息,保证消息的可靠性,我们需要将消息保存机制修改为同步刷盘方式,即消息存储磁盘成功,才会返回响应。

    修改 Broker 端配置如下:

    ## 默认情况为 ASYNC_FLUSH
    flushDiskType = SYNC_FLUSH
    

    若 Broker 未在同步刷盘时间内(默认为 5s)完成刷盘,将会返回 SendStatus.FLUSH_DISK_TIMEOUT 状态给生产者。

  • 主从复制

    为了保证可用性,Broker 通常采用一主(master)多从(slave)部署方式。为了保证消息不丢失,消息还需要复制到 slave 节点。

    默认方式下,消息写入 master 成功,就可以返回确认响应给生产者,接着消息将会异步复制到 slave 节点。

    注:master 配置:flushDiskType = SYNC_FLUSH

    此时若 master 突然宕机且不可恢复,那么还未复制到 slave 的消息将会丢失。

    为了进一步提高消息的可靠性,我们可以采用同步的复制方式,master 节点将会同步等待 slave 节点复制完成,才会返回确认响应。

    异步复制与同步复制区别如下图:

    来源于网络

    注:大家不要被上图误导,broker master 只能配置一种复制方式,上图只为解释同步复制的与异步复制的概念。

    Broker master 节点 同步复制配置如下:

    ## 默认为 ASYNC_MASTER
    brokerRole=SYNC_MASTER
    

    如果 slave 节点未在指定时间内同步返回响应,生产者将会收到 SendStatus.FLUSH_SLAVE_TIMEOUT 返回状态。

  • 总结

    结合生产阶段与存储阶段,若需要严格保证消息不丢失,broker 需要采用如下配置:

    ## master 节点配置
    flushDiskType = SYNC_FLUSH
    brokerRole=SYNC_MASTER
    
    ## slave 节点配置
    brokerRole=slave
    flushDiskType = SYNC_FLUSH
    

    同时这个过程我们还需要生产者配合,判断返回状态是否是 SendStatus.SEND_OK。若是其他状态,就需要考虑补偿重试。

    虽然上述配置提高消息的高可靠性,但是会降低性能,生产实践中需要综合选择。

消费阶段

消费者从 broker 拉取消息,然后执行相应的业务逻辑。一旦执行成功,将会返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 状态给 Broker。

如果 Broker 未收到消费确认响应或收到其他状态,消费者下次还会再次拉取到该条消息,进行重试。这样的方式有效避免了消费者消费过程发生异常,或者消息在网络传输中丢失的情况。

消息消费的代码如下:

// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test_consumer");

// 设置NameServer的地址
consumer.setNamesrvAddr("namesrvAddr");

// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("test_topic", "*");
// 注册回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
        // 执行业务逻辑
        // 标记该消息已经被成功消费
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
});
// 启动消费者实例
consumer.start();

以上消费消息过程的,我们需要注意返回消息状态。只有当业务逻辑真正执行成功,我们才能返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS。否则我们需要返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,稍后再重试。

五、如何保证消息的顺序性?

分析

生产系统中常见的问题,如何保证消息顺序。

剖析

为什么要保证消息的顺序性

消息队列中的若干消息如果是对同一个数据进行操作,这些操作具有前后的关系,必须要按前后的顺序执行,否则就会造成数据异常。举例:
比如通过mysql binlog进行两个数据库的数据同步,由于对数据库的数据操作是具有顺序性的,如果操作顺序搞反,就会造成不可估量的错误。比如数据库对一条数据依次进行了 插入->更新->删除操作,这个顺序必须是这样,如果在同步过程中,消息的顺序变成了 删除->插入->更新,那么原本应该被删除的数据,就没有被删除,造成数据的不一致问题。

保证消息的顺序性
RabbitMQ保证消息的顺序性

生产者发送消息时,可以指定key发送到同一个队列,保证相关key的消息发送的顺序性

  • 场景一

    • 错乱场景

      一个queue,有多个consumer去消费,这样就会造成顺序的错误,consumer从MQ里面读取数据是有序的,但是每个consumer的执行时间是不固定的,无法保证先读到消息的consumer一定先完成操作,这样就会出现消息并没有按照顺序执行,造成数据顺序错误。

      img

    • 解决方案

      拆分多个queue,每个queue一个consumer,就是多一些queue而已,确实是麻烦点;这样也会造成吞吐量下降,可以在消费者内部采用多线程的方式取消费。

      img

  • 场景二

    • 错乱场景

      一个queue对应一个consumer,但是consumer里面进行了多线程消费,这样也会造成消息消费顺序错误。

      img

    • 解决方案

      或者就一个queue但是对应一个consumer,然后这个consumer内部用内存队列做排队,然后分发给底层不同的worker来处理

      img

Kafka保证消息的顺序性

生产者在写的时候,可以指定一个key。比如指定某个订单的id作为key,这个订单相关的数据,一定会被分发到一个partition中去,写入一个partition中的数据一定是有顺序的。

  • 场景一

    • 错乱场景

      kafka一个topic,多个partition,多个consumer,消息顺序存储在同一个partition中,但是不同的消费者消费同一个partition,可能造成消息乱序消费

    • 解决方案

      让每个消费者消费不同的partition,这样每个消费者所消费的消息都是顺序的。

  • 场景二

    • 错乱场景

      kafka一个topic,多个partition,多个consumer,消息顺序存储在同一个partition中,同一个消费者使用多线程消费同一个partition,有可能造成消息乱序

      img

    • 解决方案

      消费者按key分别写N个内存queue,然后N个线程分别消费一个内存queue,就可保证统一个key的消息被同个线程顺序消费

      img

RocketMQ保证消息的顺序性
场景分析

顺序消费是指消息的产生顺序和消费顺序相同

假设有个下单场景,每个阶段需要发邮件通知用户订单状态变化。用户付款完成时系统给用户发送订单已付款邮件,订单已发货时给用户发送订单已发货邮件,订单完成时给用户发送订单已完成邮件。

发送邮件的操作为了不阻塞订单主流程,可以通过mq消息来解耦,下游邮件服务器收到mq消息后发送具体邮件,已付款邮件、已发货邮件、订单已完成邮件这三个消息,下游的邮件服务器需要顺序消费这3个消息并且顺序发送邮件才有意义。否则就会出现已发货邮件先发出,已付款邮件后发出的情况。

但是mq消费者往往是集群部署,一个消费组内存在多个消费者,同一个消费者内部,也可能存在多个消费线程并行消费,如何在消费者集群环境中,如何保证邮件mq消息发送与消费的顺序性呢?

顺序消费又分两种,全局顺序消费和局部顺序消费

  • 全局顺序消费

    所有发到mq的消息都被顺序消费,类似数据库中的binlog,需要严格保证全局操作的顺序性

    那么RocketMQ中如何做才能保证全局顺序消费呢?

    这就需要设置topic下读写队列数量为1

    为什么要设置读写队列数量为1呢?
    假设读写队列有多个,消息就会存储在多个队列中,消费者负载时可能会分配到多个消费队列同时进行消费,多队列并发消费时,无法保证消息消费顺序性

    那么全局顺序消费有必要么?
    A、B都下了单,B用户订单的邮件先发送,A的后发送,不行么?其实,大多数场景下,mq下只需要保证局部消息顺序即可,即A的付款消息先于A的发货消息即可,A的消息和B的消息可以打乱,这样系统的吞吐量会更好,将队列数量置为1,极大的降低了系统的吞吐量,不符合mq的设计初衷

    举个例子来说明局部顺序消费。假设订单A的消息为A1,A2,A3,发送顺序也如此。订单B的消息为B1,B2,B3,A订单消息先发送,B订单消息后发送

    消费顺序如下
    A1,A2,A3,B1,B2,B3是全局顺序消息,严重降低了系统的并发度
    A1,B1,A2,A3,B2,B3是局部顺序消息,可以被接受
    A2,B1,A1,B2,A3,B3不可接收,因为A2出现在了A1的前面

  • 局部顺序消费

    要保证消息的顺序消费,有三个关键点

    • 消息顺序发送

      消息顺序发送,多线程发送的消息无法保证有序性,因此,需要业务方在发送时,针对同一个业务编号(如同一笔订单)的消息需要保证在一个线程内顺序发送,在上一个消息发送成功后,在进行下一个消息的发送。对应到mq中,消息发送方法就得使用同步发送,异步发送无法保证顺序性

    • 消息顺序存储

      消息顺序存储,mq的topic下会存在多个queue,要保证消息的顺序存储,同一个业务编号的消息需要被发送到一个queue中。对应到mq中,需要使用MessageQueueSelector来选择要发送的queue,即对业务编号进行hash,然后根据队列数量对hash值取余,将消息发送到一个queue中

    • 消息顺序消费

      要保证消息顺序消费,同一个queue就只能被一个消费者所消费,因此对broker中消费队列加锁是无法避免的。同一时刻,一个消费队列只能被一个消费者消费,消费者内部,也只能有一个消费线程来消费该队列。即,同一时刻,一个消费队列只能被一个消费者中的一个线程消费

    上面第一、第二点中提到,要保证消息顺序发送和消息顺序存储需要使用mq的同步发送和MessageQueueSelector来保证,具体Demo会有体现

    至于第三点中的加锁操作会结合源码来具体分析

代码实现
  • 消息生产者

    producer中模拟了两个线程,并发顺序发送100个消息的情况,发送的消息中,key为消息发送编号i,消息body为orderId,大家注意下MessageQueueSelector的使用

    consumer的demo有两个,第一个为正常集群消费的consumer,另外一个是顺序消费的consumer,从结果中观察消息消费顺序

    理想情况下消息顺序消费的结果应该是,同一个orderId下的消息的编号i值应该顺序递增,但是不同orderId之间的消费可以并行,即局部有序即可

    // 消息发送者
    public class Producer {
        public static void main(String[] args)  {
            try {
                MQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
                ((DefaultMQProducer) producer).setNamesrvAddr("111.231.110.149:9876");
                producer.start();
    			
    			//顺序发送100条编号为0到99的,orderId为1 的消息
                new Thread(() -> {
                    Integer orderId = 1;
                    sendMessage(producer, orderId);
                }).start();
    			//顺序发送100条编号为0到99的,orderId为2 的消息
                new Thread(() -> {
                    Integer orderId = 2;
                    sendMessage(producer, orderId);
                }).start();
    			//sleep 30秒让消息都发送成功再关闭
                Thread.sleep(1000*30);
    
                producer.shutdown();
            } catch (MQClientException e) {
                e.printStackTrace();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
        // 具体发送逻辑
        private static void sendMessage(MQProducer producer, Integer orderId) {
            for (int i = 0; i < 100; i++) {
                try {
                    Message msg =
                            new Message("TopicTestjjj", "TagA", i + "",
                                    (orderId + "").getBytes(RemotingHelper.DEFAULT_CHARSET));
                    // 使用MessageQueueSelector来让相同orderId的消息发送到同一个队列
                    SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
                        @Override
                        public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                            Integer id = (Integer) arg;
                            int index = id % mqs.size();
                            return mqs.get(index);
                        }
                    }, orderId);
                    System.out.println("message send,orderId:"+orderId);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    
  • 消息消费者

    • 普通消费者消费

      模拟了一个消费者中多线程并行消费消息的情况,使用的消费监听器为MessageListenerConcurrently

      public class Consumer {
      
          public static void main(String[] args) throws InterruptedException, MQClientException {
      
              DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4");
      
              consumer.setNamesrvAddr("111.231.110.149:9876");
      
              consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
      
              consumer.subscribe("TopicTestjjj", "*");
              //单个消费者中多线程并行消费
              consumer.setConsumeThreadMin(3);
              consumer.setConsumeThreadMax(6);
      
              consumer.registerMessageListener(new MessageListenerConcurrently() {
      
                  @Override
                  public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
                      ConsumeConcurrentlyContext context) {
                      for (MessageExt msg : msgs) {
      //                    System.out.println("收到消息," + new String(msg.getBody()));
                          System.out.println("queueId:"+msg.getQueueId()+",orderId:"+new String(msg.getBody())+",i:"+msg.getKeys());
                      }
                      return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                  }
              });
      
              consumer.start();
      
              System.out.printf("Consumer Started.%n");
          }
      }
      
      

      看下结果输出,如图,同一个orderId下,编号为10的消息先于编号为9的消息被消费,不是正确的顺序消费,即普通的并行消息消费,无法保证消息消费的顺序性

    • 顺序消费者消费
      顺序消费的消费者例子如下,使用的监听器是MessageListenerOrderly

      public class Consumer {
      
          public static void main(String[] args) throws MQClientException {
              DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
              consumer.setNamesrvAddr("111.231.110.149:9876");
      
              consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
      
              consumer.subscribe("TopicTestjjj", "TagA");
      
              //消费者并行消费
              consumer.setConsumeThreadMin(3);
              consumer.setConsumeThreadMax(6);
      				// 使用MessageListenerOrderly来顺序消费消息
              consumer.registerMessageListener(new MessageListenerOrderly() {
                  @Override
                  public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
      //                context.setAutoCommit(false);
                      for (MessageExt msg : msgs) {
                          System.out.println("queueId:"+msg.getQueueId()+",orderId:"+new String(msg.getBody())+",i:"+msg.getKeys());
                      }
                      return ConsumeOrderlyStatus.SUCCESS;
                  }
              });
      
              consumer.start();
              System.out.printf("Consumer Started.%n");
          }
      
      }
      

      结果如下,同一个orderId下,消息顺序消费,不同orderId并行消费,符合预期

源码分析

在源码分析之前,先来思考下几个问题

前面已经提到实现消息顺序消费的关键点有三个,其中前两点已经明确了解决思路

第一点,消息顺序发送,可以由业务方在单线程使用同步发送消息的方式来保证
第二点,消息顺序存储,可以由业务方将同一个业务编号的消息发送到一个队列中来实现

还剩下第三点,消息顺序消费,实现消息顺序消费的关键点又是什么呢?

举个例子,假设业务方针对某个订单发送了N个顺序消息,这N个消息都发送到了mq服务端的一个队列中,假设消费者集群中有3个消费者,每个消费者中又是开了N个线程多线程消费

第一种情形,假设3个消费者同时拉取一个队列的消息进行消费,结果会怎么样?N个消息可能会分配在3个消费者中进行消费,多机并行的情况下,消费能力的不同,无法保证这N个消息被顺序消费,所以得保证一个消费队列同一个时刻只能被一个消费者消费

假设又已经保证了一个队列同一个时刻只能被一个消费者消费,那就能保证顺序消费了?同一个消费者多线程进行消费,同样会使得的N个消费被分配到N个线程中,一样无法保证消息顺序消费,所以还得保证一个队列同一个时刻只能被一个消费者中一个线程消费

下面顺序消息的源码分析中就针对这两点来进行分析,即

  • 如何保证一个队列只被一个消费者消费

    消费队列存在于broker端,如果想保证一个队列被一个消费者消费,那么消费者在进行消息拉取消费时就必须向mq服务器申请队列锁,消费者申请队列锁的代码存在于RebalanceService消息队列负载的实现代码中

    先明确一点,同一个消费组中的消费者共同承担topic下所有消费者队列的消费,因此每个消费者需要定时重新负载并分配其对应的消费队列,具体为消费者分配消费队列的代码实现在RebalanceImpl#rebalanceByTopic中,本文不多讲

    • 客户端实现

      消费者重新负载,并且分配完消费队列后,需要向mq服务器发起消息拉取请求,代码实现在RebalanceImpl#updateProcessQueueTableInRebalance中,针对顺序消息的消息拉取,mq做了如下判断

      核心思想就是,消费客户端先向broker端发起对messageQueue的加锁请求,只有加锁成功时才创建pullRequest进行消息拉取,下面看下lock加锁请求方法

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JUiCdXSy-1657002585894)(http://ww1.sinaimg.cn/large/a5fa4a8dgy1gdpls60q9dj20zm0h30xp.jpg)]

      代码实现逻辑比较清晰,就是调用lockBatchMQ方法发送了一个加锁请求,那么broker端收到加锁请求后的处理逻辑又是怎么样?

    • Broker端

      broker端收到加锁请求的处理逻辑在RebalanceLockManager#tryLockBatch方法中,RebalanceLockManager中关键属性如下

      //默认锁过期时间 60秒
          private final static long REBALANCE_LOCK_MAX_LIVE_TIME = Long.parseLong(System.getProperty(
              "rocketmq.broker.rebalance.lockMaxLiveTime", "60000"));
       //重入锁
          private final Lock lock = new ReentrantLock();
       //key为消费者组名称,value是一个key为MessageQueue,value为LockEntry的map
          private final ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable =
              new ConcurrentHashMap<String, ConcurrentHashMap<MessageQueue, LockEntry>>(1024);
      
      

      LockEntry对象中关键属性如下

      //消费者id
      private String clientId;
      //最后加锁时间
      private volatile long lastUpdateTimestamp = System.currentTimeMillis();
      

      isLocked方法如下

      public boolean isLocked(final String clientId) {
                  boolean eq = this.clientId.equals(clientId);
                  return eq && !this.isExpired();
              }
      
              public boolean isExpired() {
                  boolean expired =
                      (System.currentTimeMillis() - this.lastUpdateTimestamp) > REBALANCE_LOCK_MAX_LIVE_TIME;
      
                  return expired;
              }
      
      

      对messageQueue进行加锁的关键逻辑如下:

      如果messageQueue对应的lockEntry为空,标志队列未加锁,返回加锁成功

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vX52sMlr-1657002585895)(C:\Users\86137\AppData\Roaming\Typora\typora-user-images\image-20220704152634935.png)]

      如果lockEntry对应clientId为自己并且没过期,标志同一个客户端重复加锁,返回加锁成功(可重入)

      如果锁已经过期,返回加锁成功

      总而言之,broker端通过对ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable的维护来达到messageQueue加锁的目的,使得同一时刻,一个messageQueue只能被一个消费者消费

  • 如何保证一个消费者中只有一个线程能进行消费

    synchronized申请线程独占锁
    假设消费者对messageQueue的加锁已经成功,那么就进入到了第二个步骤,创建pullRequest进行消息拉取,消息拉取部分的代码实现在PullMessageService中,消息拉取完后,需要提交到ConsumeMessageService中进行消费,顺序消费的实现为ConsumeMessageOrderlyService,提交消息进行消费的方法为ConsumeMessageOrderlyService#submitConsumeRequest,具体实现如下

    可以看到,构建了一个ConsumeRequest对象,并提交给了ThreadPoolExecutor来并行消费,看下顺序消费的ConsumeRequest的run方法实现

    里面先从messageQueueLock中获取了messageQueue对应的一个锁对象,看下messageQueueLock的实现

    其中维护了一个ConcurrentMap<MessageQueue, Object> mqLockTable,使得一个messageQueue对应一个锁对象object

    获取到锁对象后,使用synchronized尝试申请线程级独占锁

    • 如果加锁成功,同一时刻只有一个线程进行消息消费
    • 如果加锁失败,会延迟100ms重新尝试向broker端申请锁定messageQueue,锁定成功后重新提交消费请求

至此,第三个关键点的解决思路也清晰了,基本上就两个步骤

  • 创建消息拉取任务时,消息客户端向broker端申请锁定MessageQueue,使得一个MessageQueue同一个时刻只能被一个消费客户端消费
  • 消息消费时,多线程针对同一个消息队列的消费先尝试使用synchronized申请独占锁,加锁成功才能进行消费,使得一个MessageQueue同一个时刻只能被一个消费客户端中一个线程消费
顺序消息重试机制

在使用顺序消息时,一定要注意其异常情况的出现,**对于顺序消息,当消费者消费消息失败后,消息队列 RocketMQ 版会自动不断地进行消息重试(每次间隔时间为 1 秒),重试最大值是Integer.MAX_VALUE。**这时,应用会出现消息消费被阻塞的情况。因此,建议您使用顺序消息时,务必保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生

重要的事再强调一次:在使用顺序消息时,一定要注意其异常情况的出现!

六、消息积压在消息队列怎么办

分析

本质针对的场景,都是说,可能你的消费端出了问题,不消费了,或者消费的极其极其慢。接着就坑爹了,可能你的消息队列集群的磁盘都快写满了,都没人消费,这个时候怎么办?或者是整个这就积压了几个小时,你这个时候怎么办?或者是你积压的时间太长了,导致比如rabbitmq设置了消息过期时间后就没了怎么办?

所以就这事儿,其实线上挺常见的,一般不出,一出就是大case,一般常见于,举个例子,消费端每次消费之后要写mysql,结果mysql挂了,消费端卡那儿了,不动了。或者是消费端出了个什么叉子,导致消费速度极其慢。

剖析

1. 大量消息在mq里积压了几个小时了还没处理
场景

几千万条数据在MQ里积压了七八个小时,从下午4点多,积压到了晚上很晚,10点多,11点多

这个是我们真实遇到过的一个场景,确实是线上故障了,这个时候要不然就是修复consumer的问题,让他恢复消费速度,然后傻傻的等待几个小时消费完毕。

一个消费者一秒是1000条,一秒3个消费者是3000条,一分钟是18万条,1000多万条

所以如果你积压了几百万到上千万的数据,即使消费者恢复了,也需要大概1小时的时间才能恢复过来

解决方案

这种时候只能操作临时扩容,以更快的速度去消费数据了。具体操作步骤和思路如下:

  • 先修复consumer的问题,确保其恢复消费速度,然后将现有consumer都停掉。
  • 临时建立好原先10倍或者20倍的queue数量(新建一个topic,partition是原来的10倍)。
  • 然后写一个临时分发消息的consumer程序,这个程序部署上去消费积压的消息,消费之后不做耗时处理,直接均匀轮询写入临时建好分10数量的queue里面。
  • 紧接着征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的消息。
  • 这种做法相当于临时将queue资源和consumer资源扩大10倍,以正常速度的10倍来消费消息。
  • 等快速消费完了之后,恢复原来的部署架构,重新用原来的consumer机器来消费消息。

2. 消息设置了过期时间,过期就丢了怎么办
场景

假设你用的是rabbitmq,rabbitmq是可以设置过期时间的,就是TTL,如果消息在queue中积压超过一定的时间就会被rabbitmq给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在mq里,而是大量的数据会直接搞丢。

解决方案

假设你用的是rabbitmq,rabbitmq是可以设置过期时间的,就是TTL,如果消息在queue中积压超过一定的时间就会被rabbitmq给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在mq里,而是大量的数据会直接搞丢。

这个情况下,就不是说要增加consumer消费积压的消息,因为实际上没啥积压,而是丢了大量的消息。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上12点以后,用户都睡觉了。

这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入mq里面去,把白天丢的数据给他补回来。也只能是这样了。

假设1万个订单积压在mq里面,没有处理,其中1000个订单都丢了,你只能手动写程序把那1000个订单给查出来,手动发到mq里去再补一次

3. 积压消息长时间没有处理,mq放不下了怎么办
场景

如果走的方式是消息积压在mq里,那么如果你很长时间都没处理掉,此时导致mq都快写满了,咋办?这个还有别的办法吗?

解决方案

这个就没有办法了,肯定是第一方案执行太慢,这种时候只好采用 “丢弃+批量重导” 的方式来解决了。

首先,临时写个程序,连接到mq里面消费数据,收到消息之后直接将其丢弃,快速消费掉积压的消息,降低MQ的压力,然后走第二种方案,在晚上夜深人静时去手动查询重导丢失的这部分数据。

七、如何设计一个消息队列

分析

  • 需要从架构思想去设计一个消息队列

剖析

设计消息队列系统,我们来从以下几个角度来考虑一下

(1)首先这个mq得支持可伸缩性吧,就是需要的时候快速扩容,就可以增加吞吐量和容量,那怎么搞?设计个分布式的系统呗,参照一下kafka的设计理念,broker -> topic -> partition,每个partition放一个机器,就存一部分数据。如果现在资源不够了,简单啊,给topic增加partition,然后做数据迁移,增加机器,不就可以存放更多数据,提供更高的吞吐量了?

(2)其次你得考虑一下这个mq的数据要不要落地磁盘吧?那肯定要了,落磁盘,才能保证别进程挂了数据就丢了。那落磁盘的时候怎么落啊?顺序写,这样就没有磁盘随机读写的寻址开销,磁盘顺序读写的性能是很高的,这就是kafka的思路。

(3)其次你考虑一下你的mq的可用性啊?这个事儿,具体参考我们之前可用性那个环节讲解的kafka的高可用保障机制。多副本 -> leader & follower -> broker挂了重新选举leader即可对外服务。

(4)能不能支持数据0丢失啊?可以的,参考我们之前说的那个kafka数据零丢失方案

八、个人总结

实际使用感受
  • 非数据型场景个人推荐RocketMQ,数据采集等建议用kafka
    • 优点:吞吐量大、时效性高、分布式、mq功能完善、对接简单、源码是java
    • 缺点:要钱,阿里提供平台维护和可视化界面操作
  • 实际生产使用情况:
    • topic一个、mq集群机器两台、项目服务器两台、业务高峰消息数三十万、业务场景(设备注册、设备定时任务执行、定时广告播放、广告定时拉取、订单支付回调)
  • 实际生产遇到过的问题:
    • 消息堵塞,消费不过来
      • 解决:增加队列、增加服务去消费,高峰期存在消费延时是允许的(需要临时备用服务和新加队列)
    • 消息重复消费:
      • 解决:通过业务代码去解决重复消费的问题,生产上很有可能会产生重复消息,这种时候往往都是同一时间一起消费,基本都是通过分布式锁解决(类似并发操作),只需要保证其幂等性即可。
  • 感受:
    • 搭好完整的mq体系后,消息带来便利性高,很多请求高的业务都可以用mq做处理
    • 对于业务高峰对库压力大的业务场景起到很大的解压作用
    • 平摊了服务器的压力(曾因为请求量大,处理业务复杂导致服务器cpu飙升至宕机)
    • 维护成本高,出了点问题排查起来不是很方便(体系一定要搭好,包括排查体系)
    • 按量付费的模式(也挺贵)
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值