RocketMQ全量

RocketMQ

一.概述

1.MQ简介

MQ(Message Queue)提供消息队列服务的中间件,称为消息中间件,提供消息生产,储存,消费全称api的软件系统,消息即数据,消息的体量一般不会很大

2.MQ用途

削流限峰

MQ将系统的超量请求暂存其中,以便系统后期自行处理,避免请求的丢失或者系统被压跨

异步解耦

上游系统对下游系统的调用若为同步调用,则会大大降低系统的吞吐量与并发量,系统的耦合度太高,异步调用解决此问题,常见的作法是添加一个MQ层

数据收集

分布式系统会产生海量级数据流,如:业务日志,监控数据,用户行为,针对此数据实时或批量采集汇总,对数据进行大数据分析

3.RocketMQ概念

消息(Message)

A message is the smallest unit of data transimission in RocketMQ

主题(Topic)

消息集合,每个消息只属于一个主题,消息订阅基本单位

生产者同时发送多种Topic的消息,消费者对某种特定的Topic可订阅和消费

队列(Queue)

存储Message的物理实体,一个Topic中可以包含多个Queue,每个Queue中存放的就是该Topic的Message,一个Topic的Queue也被称为一个Topic消息的分区Partition

一个消费者可以消费同一Topic下的多个Queue的Message,一条Queue消息不能被多个消费者同时消费

分片Shard ing是指同一个Topic在不同的Broker的队列

消息标识(MessageId/Key)

每个消息拥有唯一i的MessageId,可以携带具有业务标识的Key,便于对业务的查询,Producer send() Message 时,自动生成一个MessageId(msgId),消息到达Broker后,自动生成MessageId(offsetId),msgId,offsetMsgId和Key都称为消息标识

  • msgId:由producer生成,规则为: produce IP + 进程id+MessageClientIDSetter类的class Loader的hashcode+ 当前时间 +AutomicInteger 自增计数器

  • offsetMsgId :broker生成 ,规则为:broker IP + 物理分区的offset

  • key:拥护指定的业务相关唯一标识

4.RocketMQ系统架构

Producer

消息生产者,Producer通过MQ的负载均衡模块选择相应的Broker集群队列进行消息投递,投递过程支持快速失败并且低延迟

消息生产者都是以生产者组(producer group) 的形式出现,是发送相同topic类型的生产者的集合

Consumer

消费者以消费者组(Consumer Group)形式出现,消费同一个Topic类型的Message

  • 一个Consumer group 中的consumer 必需订阅完全相同的topic

  • 一个Consumer Group只能消费一个Topic的消息,不能同时消费多个Topic

Name Server
功能介绍

Name Server 是一个Broker 与 Topic 路由的注册中心,支持Broker的动态注册中心与发现

包括两个功能:

  • Broker management:接受Broker集群的注册信息并且保存作为Routing信息的基本数据,提供心跳检测机制,检查broker是否存活

  • 路由信息管理:每个NameServer都保存者Broker集群的整个Routing 信息和 用于客户端查询的队列信息,Producer 和 Consumer 通过NameServer 获取整个Broker集群的路由信息

路由注册

Name Server通常也是以集群的方式部署,Name server无状态,各节点之间无差异,相互之间不进行信息通讯,Broker节点启动时,轮询NameServer列表,与每个NameServer节点建立长连接,发起注册请求,在内部维护着Broker列表,用于动态存储Broker的信息

Broker为证明自身存活,为维护与NameServer的长连接,会将最新的信息以心跳包的方式上报NameServer,每30s一次 发送心跳.心跳包含BrokerID,Broker Address(IP+Port),Broker name,Broker Cluster name .etc ,接受到心跳包后,更新心跳时间戳,记录此Broker 的最新存活时间,NameServer不能随意扩容,Broker不重新配置的话,新增的Name Server是不可见的

路由剔除

由于Broker 关机宕机或者网络抖动.etc原因,NameServer未收到心跳,NameServer可能会将此Broker从列表中剔除

NameServer 中存在一个定时任务,每隔10s就会扫描一次Broker表,查看每一个Broker的最新心跳时间戳距离当前时间是否超过120s,超过就会判定Broker失效,从列表剔除

路由发现

采用pull模型,Topic路由信息出现变换时,NameServer不会主动推送给客户端,而是客户端定时拉取主题最新的路由,默认30s一次

  • Pull模型:实时性差

  • Push模型:维护长链接

  • long Polling 模型:对Push和Pull模型整合

客户端NameServer选择策略

客户端值 Producer和Consumer ,配置时填入NameServer集群地址,首先随机策略,失败后轮询

4.Broker
功能介绍

Broker 充当消息中转角色,负责存储消息,转发消息.Broker在RocketMQ系统中负责接受并存储从Produce发来的消息,同时为Consumer的拉取请求做准备.Broker 同时也存储着消息相关的元数据,包括Consumer Group 消费进度偏移offset ,主题,队列.etc

模块构成
Broker Server
Remoting module
Client Management
Store Service
HA Service
Index Service
.etc
  • Remoting module : 整个Broker的实体,负责接受来自Clients端的请求

  • Client management:客户端管理器,负责接收,解析客户端(Producer/Consumer)请求,管理客户端

  • Store Service: 存储服务,提供方便简单的API接口,处理消息储存到物理硬盘和消息查询功能

  • HA Service:高可用服务,提供Master Broker 和 Slave Broker 之间的数据同步功能

  • Index Servcie: 索引服务.根据特定的Message Key,对投递到Broker的消息进行索引服务,也提供根据Message Key 的快速查询功能

集群部署

Broker以集群方式出现,各集群节点可能存放相同Topic的不同Queue

为主备集群,具有Master和Slave,Master负责读写操作请求,Slave负责对Master中的数据进行备份,一个Slave只能隶属于一个Master,二者对应关系是通过相通的BrokerName,不同的BrokerId来确定,BrokerId为0表示Master,反之表示Slave,每个Broker于NameServer集群中的所有结点建立长连接,定时注册Topic信息到所有的NameServer

二.部署

1.单机部署

主要组件NameServer,Broker,console使用docker-compose部署(最好采用服务器)

docker-compose
  • sudo curl -L "https://get.daocloud.io/docker/compose/releases/download/v2.15.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
    
  • chmod +x /usr/local/bin/docker-compose
    

如果docker-compose 未正确安装,自身下载文件linux .x86-64,load至服务器

文件结构
rocketmq
broker
conf
bin
logs
data
runbroker.sh
nameserver
conf
bin
runserver.sh

其中.sh启动脚本内中有一个自动计算最大堆内存和新生代内存的函数会导致在不同硬件环境下设置最大堆内存和新生代内存环境变量不被应用,注释掉即可

docker-compose

注:阿里云服务器需要开放端口

version: '3.8'
services:
  rmqnamesrv:
    image: apache/rocketmq:5.1.0
    container_name: rmqnamesrv
    ports:
      - 9876:9876
    restart: always
    privileged: true
    volumes:
      - /storage/rocketmq/nameserver/logs:/home/rocketmq/logs
      - /storage/rocketmq/nameserver/bin/runserver.sh:/home/rocketmq/rocketmq-5.1.0/bin/runserver.sh
    environment:
      - MAX_HEAP_SIZE=256M
      - HEAP_NEWSIZE=128M
    command: ["sh","mqnamesrv"]
  broker:
    image: apache/rocketmq:5.1.0
    container_name: rmqbroker
    ports:
      - 10909:10909
      - 10911:10911
    restart: always
    privileged: true
    volumes:
      - /storage/rocketmq/broker/logs:/home/rocketmq/logs
      - /storage/rocketmq/broker/store:/home/rocketmq/logs
      - /storage/rocketmq/broker/conf/broker.conf:/home/rocketmq/broker.conf
      - /storage/rocketmq/broker/bin/runbroker.sh:/home/rocketmq/rocketmq-5.1.0/bin/runbroker.sh
    depends_on:
      - 'rmqnamesrv'
    environment:
      - NAMESRV_ADDR=rmqnamesrv:9876
      - MAX_HEAP_SIZE=512M
      - HEAP_NEWSIZE=256M
    command: ["sh","mqbroker","-c","/home/rocketmq/broker.conf"]
  rmqdashboard:
    image: apacherocketmq/rocketmq-dashboard:latest
    container_name: rocketmq-dashboard
    ports:
      - 8080:8080
    restart: always
    privileged: true
    depends_on:
      - 'rmqnamesrv'
    environment:
      - JAVA_OPTS= -Xmx256M -Xms256M -Xmn128M -Drocketmq.namesrv.addr=rmqnamesrv:9876 -Dcom.rocketmq.sendMessageWithVIPChannel=false

三.RocketMQ工作原理

1.消息产生

Producer可以将消息写入至某Broker的Queue中,过程如下:

  • Producer发送消息之前,会先向NameServer发出获取消息Topic的Routing info的request

  • NameServer返回该Topic路由表Broker列表

    • 路由表: 实际为一个Map, k 为 Topic name,v 为 QueueData实例列表.一个Broker中该Topic的所有Queue对应一个Queue Data,一个Broker对应一个QueueData,包含brokerName

    • Broker列表:实际为一个Map, k 为 BrokerName ,v 为BrokerData,一套broker Name相通的Master-slave集群对应一个Brokerdata,其中包含brokerName和一个map,该map的 k 为brokerId,v 为该broker 对应地址,brokerID = 0 表示master,非0表示Slave

  • Producer根据代码中指定的Queue选择策略,从Queue列表中选择一个,用于后续存储信息

  • Producer对消息中做一些特殊处理,ex: 消息本身超过4m,对其压缩

  • Producer向选择的Queue所在的Broker发出PRC request,将消息发送到选择出的queue

2.Queue选择算法

对于无序消息,消息投递算法,存在如下:

轮询算法

default algorithm,保证每个Queue中均匀获取msg

算法的问题是:如果某些Broker的RT较高,会导致Producer的缓存队列出现较大的消息挤压,影响消息投递的性能

最小投递延迟算法

统计每次消息投递的时间延迟,根据统计出的结构将消息投递到时间延迟最小的Queue,延迟相同,采用轮询算法

msg在Queue的分配不均匀,投递延迟小的Queue可能存在大量的msg,该queue的消费压力骤增,导致mq消息堆积

3.消息储存

用户主目录store下文件

filedescription
abortbroker启动后自动创建,正常关闭此文件自动消失,若未启动Broker的情况下,文件存在,表明Broker关闭不正常 // todo 什么情况不正常关闭
checkpoint存储commitlog,consumeque,index文件的最后刷盘时间戳
commitlog存放mappedFile文件,消息写在mappedlFile文件
config存放Broker运行时的配置数据
consumequeue存放consumequeue文件,队列存放在这个目录
index存放消息索引文件indexFile
lock运行期间使用到的全局资源锁
commitlog

此目录下文件简称commitlog文件,源码中称为mappedFile

当前Broker中的所有消息落盘到mappedFile中,文件最大size为1G,文件名由20位十进制数构成,表示当前文件的第一条消息的起始偏移量

第一个mappedFile的文件名是00000000000000000000,第一个文件的commitlog offset = 0

当第一个mappedFile写满时,自动生成第2个mappedFile存放消息,假设第一个文件szie为1073741824byte(1G=1073741824byte),则第二个文件名为00000000001073741824

一个Broker下的mappedFile的commitlog offset 是连续的

一个Broker中仅含一个commitlog目录,消息存放时并未按照Topic进行分类存放,仅按照顺寻写入mappedFile

消息单元

RocketMQ之消息存储和查询原理_rocketmq的borntime-CSDN博客

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

mappedFile由消息单元构成,其包括消息总长MsgLen,消息物理位置physicalOffset,消息体内容Body,消息体Body,消息体长度BodyLength,消息源(Producer)BornHost,消息发送时间戳BornTimestamp,消息主题Topic,消息所在队列QueueId,队列存储的偏移量QueueOffset.etc

一个mappedFile中第M+1个消息单元的commitLog offset 为

L(M+1) = L(M) +MsgLen(M) (M>=0)

consumequeue

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
每个Topic会在consumequeue中创建{$Topic}目录,并且为该Topic每个Queue创建一个目录,目录名为queueId,每个目录存放若干的consumequeue文件,其为commitLog的索引文件,可根据consumequeue中的索引条目定位到具体消息

consumequeue文件名由20位数字构成,表示当前文件的第一个索引条目的起始位移偏移量,其后续文件名固定,consumequeue文件大小不变

索引条目

设计(Design) · Apache RocketMQ开发者指南

每个consumequeue文件包含30w个索引条目,包含:消息在mappedFile文件的偏移量commitLog offset ,消息长度,消息Tag的hashCode,共占20Byte,每个文件的大小是固定的5.7220458984375mb

一个consumequeue中的Msg,Topic一定相同,但是tag可能不同

文件读写
消息写入

持久化之前过程:

  • Broker根据queueId(Producer),获取到该Msg的QueueOffset(Broker计算得出),即此消息对应的索引条目要在consumequeue目录中的写入偏移量

  • Msg与queueId,queueOffset.etc数据,一起封装为消息单元

  • 将消息单元写入commitLog

  • 形成消息索引条目

  • 将消息索引条目分发到相应的consumequeue

消息拉取

Consumer拉取步骤;

  • Consumer获取到其要消费Msg所在的Queue的Message offset,计算Consumer offset

    1. 消息偏移量(Message Offset):

      • 消息偏移量是每条消息在队列中的唯一标识,它代表了消息在队列中的位置。
      • 消息偏移量通常用于在分布式系统中确保消息的顺序性。例如,如果一个系统有多个消费者,那么每条消息只会被一个消费者消费,而消息偏移量可以确保每个消费者按照消息在队列中的顺序进行消费。
      • 消息偏移量通常由消息队列系统管理,不需要消费者自行维护。
    2. 消费偏移量(Consumer Offset):

      • 消费偏移量是消费者当前已消费的消息的位置。它用于跟踪消费者在队列中的进度。
      • 当消费者从队列中消费一条消息后,该消费者的消费偏移量就会更新为该消息的偏移量。
      • 消费者可以定期将消费偏移量提交(commit)到消息队列系统,以便在系统崩溃或重启后能够从上次的消费位置继续消费。
      • 消费偏移量通常由消费者自行维护,并在需要时提交给消息队列系统。
    3. Consumer offset = Message offset +1

  • Consumer从Broker发送拉取消息,其中包含拉取消息的Queue,Msg OffsetMsg Tag

  • Broker 计算该consumequeue中的queueOffset(queueOffset = Message offset * 20 Byte)

  • 从该queueOffset处向后查找第一条指定Tag的索引条目

  • 解释该索引条目的前8个Byte(commitLog offset),即定位

  • 读取后发送给Consumer

Performance
mmap (memory map)
pageCache

4.indexFile

rocketMq提供根据key进行进行消息查询的功能.此查询通过store目录中的index子目录indexFile进行索引快查.indexFile中的索引数据是 包含key的Msg发送到Broker时写入的,若Msg不含key,不写入

索引条目结构
indexFile

每个Broker会包含一组indexFile,每个indexFile以时间戳命名,其由三部分构成 : indexHeader,slots槽位,indexes索引数据.每个indexFile文件包含500w个slot槽,每个slot槽可能挂载很多index索引单元

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

indexHeader

固定40字节

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

constructuredescription
beginTimeStamp此indexFile第1条消息的存储时间
endTimeStamp此indexFile最后1条消息的存储时间
beginPhyoffset此index File中第1条Msg在commitLog中的偏移量commitLog offset
endPhyoffset此index File中最后1条Msg在commitLog中的偏移量commitLog offset
hashSlotCount已经填充有index的slot数量
indexCount此indexFile包含的索引个数
slot&&index unit

此结构图展示,slot与index索引单元的关系

此处非实际存储结构,意在展示slot与index索引的关系,非实际数据排布方式,实际slot数目500W,index索引单元存储在slot之后

1634476565600

key的hashCode%500w对应一个slot槽位(存储index索引的indexNo),根据此indexNo可以计算该index单元在indexFile(index索引单元存储时按顺序,有唯一且递增的indexNo对应)

此种处理会有hash碰撞,解决方案是对index索引单元加入preIndexNo属性,存储碰撞的前一个index索引单元

indexNo为indexFile的递增且对于每个index索引单元唯一的number,从0开始

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

attributedescription
keyHash消息中指定的业务key的hash值
phyoffset此Msg对应在commitLog中的偏移量commitLog offset
timeDiff此key对应消息的存储时间与当前indexFile创建时间的时间差
preIndexNo此slot下当前index索引单元的前一个index索引单元的indexNo
indexFile的创建

创建时机:

  • 当第一条携带key的Msg进入后,系统发现无indexFile,此时创建第一个indexFile

  • 当一个indexFile中挂载的index索引单元数量超过2000W时,会创建新的indexFile(原理是:接受Msg后,读取IndexHeader最后四个Byte,即indexCount,若其>=2000w,创建新的indexFile)

根据业务key查询时,查询条件除key之外,还需指定查询时间戳,表示要查询不大于该时间戳的最新消息,时间戳文件名可简化查询

文件size为(40+500w * 4+2000w * 20)Byte ,约为400mb

5.消息消费

Consumer从Broker中获取Msg的方式:pull 拉取 / push 推动 ,Consumer group 对于Msg消费的模式分为两种:集群消费Clustering ,广播消费Broadcasting

推拉消费类型
拉取式消费

Consumer主动从Broker中拉取消息,主动权在Consumer,一旦获取批量消息,即启动消费过程

推送式消费

Broker收到数据后主动推送给Consumer,典型发布-订阅模型,即Consumer向其关联的Queue注册监听器,一旦发现有新的Msg就触发回调的执行,回调方法是Consumer去Queue中拉取消息,基于Consumer与Broker的长连接

对比
  • pull:需要应用实现关联的Queue的遍历(do it yourself)

    拉取时间间隔自行指定,注意适当选取

  • push:封装对关联的Queue的遍历,实时性强,但会占用较多系统资源

消费模式
广播消费

集群消费和广播消费

相同Consumer Group 中的每个Consumer 实例都接受同一个Topic的全量Msg,即每个Msg都会发送到Consumer Group的每个Consumer

集群消费

集群消费和广播消费

相同Consumer Group的每个Consumer实例平均分配同一个Topic的消息,即每条消息只会被发送到Consumer Group的某个Consumer

消息进度保存
  • 广播模式:Consumer Group中的每一个Consumer都会消费所有Msg,但是不同的Consumer消费进度不同,因此消费进度保存在Consumer

  • 集群模式:Consumer Group中的consumer共同消费同一个Topic中的Msg,同一Msg只会消费一次,消费进度参与到消费的负载均衡,因此消费进度保存于Broker

Rebalance

集群

概念

再均衡:将一个Topic下的多个Queue在同一个Consumer Group 中的多个Consumer间再分配的过程,提升Msg的并行消费能力

限制

一个Queue可分配给一个Consumer,当消费者实例数量大于Queue数量,多余的Consumer不会分配任何队列

危害

消费暂停:指在只有一个Consumer时,它负责消费所有队列,在新增了一个Consumer后,会触发Rebalance的发生。此时原Consumer就需要暂停部分队列的消费,等到这些队列分配给新的Consumer后,这些暂停消费的队列才能继续被消费。

消费暂停:Consumer在消费新分配给自己的队列时,必须接着之前Consumer提交的消费进度Offset继续消费,默认情况,offset异步提交,导致提交到Broker中的offset与consumer实际消费的msg不一致,不一致的差值为重复消费的msg

同步提交:consumer提交其消费完毕的一批消息的offset给brokerg后,需等待broker的成功ack,收到ack后,consumer才会继续获取下一批msg,等待ack期间,consumer阻塞

异步提交:consumer提交其消费完毕的一批消息的offset给brokerg后,无需等待broker的成功ack,consumer可直接获取并消费下一批msg

消费突刺:由于reblance可能导致重复消费,如果需要重复消费的msg过多,或者由于reblance暂停时间过长积压msg

原因
  • 消费者订阅的queue数量变化

    • Broker扩容或缩容

    • Broker升级运维

    • Broker与NameServer网络异常(伪变化)

  • 消费者组中的消费者数量发生变化

    • Consumer Group 扩容或缩容

    • Consumer升级运维

    • Consuemr与NameServer网络通信异常(伪变化)

过程

在broker中维护多个map集合,动态存放着当前Topic中的queue的信息和Consumer Group中Consumer实例的信息,一旦发现消费者所订阅的Queue数量发生变化,或者消费者组中的数量变化,立即向Consumer Group中的每个实例发送reblance通知

TopicManager😦 k,v) => (topic name,topicConfig),TopicConfig中维护着该Topic中所有Queue的数据

ConsuemrManager: (k,v) => (Consumer Group Id,ConsumerGroupInfo),ConsumerGroupInfo中维护着该Group中所有Consumer的实例数据

ConsumerOffsetManager:(k,v) => (topic与订阅该Topic的group组合,内层map(QueueId,消费进度offset))

实例接受通知后采用Queue分配算法获取自己对应的Queue,自身reblance

Queue分配算法

前提:一个Topic中的Queue只能由Consumer Group中的一个Consumer进行,而一个Consumer可以同时消费多个Queue中的Msg

平均分配

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

a v g = q u e u e C o u n t c o n s u e m r C o u n t avg= \frac{queueCount}{consuemrCount} avg=consuemrCountqueueCount

不能整除时,按顺序分配

环形平均

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

一致性hash
概念

一致性哈希(Consistent Hash)算法是1997年提出,是一种特殊的哈希算法,目的是解决分布式系统的数据分区问题:当分布式集群移除或者添加一个服务器时,必须尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系。

解决问题

传统的按服务器节点数量取模在集群扩容和收缩时存在一定的局限性。而一致性哈希算法正好解决简单哈希算法在分布式集群中存在的动态伸缩的问题。降低节点上下线的过程中带来的数据迁移成本,同时节点数量的变化与分片原则对于应用系统来说是无感的,使上层应用更专注于领域内逻辑的编写,使得整个系统架构能够动态伸缩,更加灵活方便。

使用场景

一致性哈希算法是分布式系统中的重要算法,使用场景也非常广泛。主要是是负载均衡、缓存数据分区等场景。

一致性哈希应该是实现负载均衡的首选算法,它的实现比较灵活,既可以在客户端实现,也可以在中间件上实现,比如日常使用较多的缓存中间件memcached 使用的路由算法用的就是一致性哈希算法。

此外,其它的应用场景还有很多:

  • RPC框架Dubbo用来选择服务提供者
  • 分布式关系数据库分库分表:数据与节点的映射关系
  • LVS负载均衡调度器
原理

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

一致性哈希算法将整个哈希值空间映射成一个虚拟的圆环,整个哈希空间的取值范围为0~2^32-1;

  • 计算各服务器节点的哈希值,并映射到哈希环上;
  • 将服务发来的数据请求使用哈希算法算出对应的哈希值;
  • 将计算的哈希值映射到哈希环上,同时沿圆环顺时针方向查找,遇到的第一台服务器就是所对应的处理请求服务器。
  • 当增加或者删除一台服务器时,受影响的数据仅仅是新添加或删除的服务器到其环空间中前一台的服务器(也就是顺着逆时针方向遇到的第一台服务器)之间的数据,其他都不会受到影响。
缩容

服务器缩容就是减少集群中服务器节点的数量或是集群中某个节点故障。假设,集群中的某个节点故障,原本映射到该节点的请求,会找到哈希环中的下一个节点,数据也同样被重新分配至下一个节点,其它节点的数据和请求不受任何影响

扩容

服务器扩容就是集群中需要增加一个新的数据节点,假设,由于需要缓存的数据量太大,必须对集群进行扩容增加一个新的数据节点。此时,只需要计算新节点的哈希值并将新的节点加入到哈希环中,然后将哈希环中从上一个节点到新节点的数据映射到新的数据节点即可。其他节点数据不受影响

数据倾斜

问题:由于哈希计算的随机性,导致:大多数访问请求都会集中少量几个节点的情况。特别是节点太少情况下,容易因为节点分布不均匀造成数据访问的冷热不均,失去集群和负载均衡的意义

解决方案:引入虚拟节点机制,即对每一个物理服务节点映射多个虚拟节点,将这些虚拟节点计算哈希值并映射到哈希环上,当请求找到某个虚拟节点后,将被重新映射到具体的物理节点。虚拟节点越多,哈希环上的节点就越多,数据分布就越均匀,从而避免了数据倾斜的问题。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

实践

结点类:

public class Node {
    private static final int VIRTUAL_MACHINE_NO_PRE_NODE = 200;
    private final String ip;
    private final List<Integer> virtualNodeHashes = new ArrayList<>(VIRTUAL_MACHINE_NO_PRE_NODE);
    private final Map<Object,Object> cacheMap = new HashMap<>();

    public Node(String ip){
        //编写代码更加流畅,但是不便于定制化处理nullPointException
        Objects.requireNonNull(ip);
        this.ip = ip;
        initialVirtualMachine();
    }

    /**
     * 构造虚拟结点,内部采用StringBuilder优化
     */
    private void initialVirtualMachine(){
        StringBuilder sb = new StringBuilder().append(ip).append("@");
        int s = sb.length();
        sb.append(1);
        for (int i = 1; i <= VIRTUAL_MACHINE_NO_PRE_NODE; i++) {
            sb.replace(s,sb.length(),String.valueOf(i));
            virtualNodeHashes.add(HashUtils.hashcode(sb.toString()));
//            System.out.println(String.valueOf(sb));
        }
        //伪抽象
//        String virtualNodeKey;
//        for (int i = 1; i <= VIRTUAL_MACHINE_NO_PRE_NODE; i++) {
//            virtualNodeKey = ip + "#" + i;
//            virtualNodeHashes.add(HashUtils.hashcode(virtualNodeKey));
//            System.out.println(virtualNodeKey);
//        }
    }
    public String getIp(){
        return this.ip;
    }
    public List<Integer> getVirtualNodeHashes(){
        return this.virtualNodeHashes;
    }
    public void addCacheItem(Object k,Object v){
        cacheMap.put(k,v);
    }
    public Object getCacheItem(Object k){
        return cacheMap.get(k);
    }
    public void removeCacheItem(Object k){
        cacheMap.remove(k);
    }
}

hash工具类

public class HashUtils {

    /**
     * FNV1_32_HASH
     *
     * @param obj
     *         object
     * @return hashcode
     */
    public static int hashcode(Object obj) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        String str = obj.toString();
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        if (hash < 0)
            hash = Math.abs(hash);
        //System.out.println("hash computer:" + hash);
        return hash;
    }
}

核心类

import lombok.Synchronized;
import lombok.extern.slf4j.Slf4j;

import java.util.*;


@Slf4j
public class ConsistentSearch {
    private static volatile ConsistentSearch instance = null;
    private ConsistentSearch(){}
    public static ConsistentSearch getInstance(){
        if (instance == null){
            synchronized (ConsistentSearch.class){
                if(instance == null){
                    instance = new ConsistentSearch();
                }
            }
        }
        return instance;
    }
    //natural order
    private final TreeMap<Integer,Node> hashRing = new TreeMap<>();
    //存储实际的node结点
    public List<Node> nodeList = new ArrayList<>();
    @Synchronized
    public void addNode(String ip){
        Objects.requireNonNull(ip);
        Node node = new Node(ip);
        nodeList.add(node);
        List<Integer> nodeVirtualNodeHashes = node.getVirtualNodeHashes();
        for (Integer virtualNodeHash : nodeVirtualNodeHashes) {
            hashRing.put(virtualNodeHash,node);
//            log.info("==>virtualNode: "+virtualNodeHash+ " has been add");
        }
    }
    @Synchronized
    public void removeNode(String ip){
        Objects.requireNonNull(ip);
        Optional<Node> first = nodeList.stream().filter(t -> t.getIp().equals(ip)).findFirst();
        Node node = first.get();
        log.info("==>remove Node:{}"+ip);
        Objects.requireNonNull(node);
        nodeList.remove(node);
        //删除结点时的数据转移怎么做??
//        Map<Object, Object> cacheMap = node.getCacheMap();
//        if (cacheMap.isEmpty()) {
//            return;
//        }
//        //传参为Integer的findNode()
//        Optional<Integer> maxVirtualHash = node.getVirtualNodeHashes().stream().max(Integer::compare);
//        if (maxVirtualHash.isPresent()) {
//            Node nextNode = findMatchNode(maxVirtualHash.get());
//            //逻辑上的数据转移
//            nextNode.getCacheMap().putAll(cacheMap);
//            //
//        }
    }
    /**
     * 返回对应  k 的 v
     * @param k 查询的键值
     * @return v
     */
    public Object get(Object k){
        Node matchNode = findMatchNode(k);
//        log.info("==>find match node :"+matchNode.getIp());
        return matchNode.getCacheItem(k);
    }

    /**
     * add entry
     * @param key 添加的k
     * @param value 添加的v
     */
    public void put(Object key, Object value) {
        Node node = findMatchNode(key);
        node.addCacheItem(key, value);
    }
    private Node findMatchNode(Object k){
        Objects.requireNonNull(k);
        //返回一个表示大于或等于 key 的最小键值的映射关系的 Map.Entry 对象;如果不存在这样的键,则返回 null
        Map.Entry<Integer, Node> integerNodeEntry = hashRing.ceilingEntry(HashUtils.hashcode(k));
        if (integerNodeEntry == null) {
            integerNodeEntry = hashRing.firstEntry();
        }
        return integerNodeEntry.getValue();
    }
    private Node findMatchNode(Integer hash){
        Objects.requireNonNull(hash);
        //返回一个表示大于或等于 key 的最小键值的映射关系的 Map.Entry 对象;如果不存在这样的键,则返回 null
        Map.Entry<Integer, Node> integerNodeEntry = hashRing.ceilingEntry(hash);
        if (integerNodeEntry == null) {
            integerNodeEntry = hashRing.firstEntry();
        }
        return integerNodeEntry.getValue();
    }
    public Node getNode(String ip){
        Objects.requireNonNull(ip);
        return (Node)nodeList.stream().filter(t -> t.getIp().equals(ip));
    }
}


测试类


import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.RandomUtils;

import java.util.ArrayList;
import java.util.List;
@Slf4j
public class Test {
    public static final int NODE_SIZE = 10;
    public static final int STRING_COUNT = 100 * 100;
    private static ConsistentSearch consistentHash = ConsistentSearch.getInstance();
    private static List<String> sList = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < NODE_SIZE; i++) {
            String ip = new StringBuilder("10.2.1.").append(i)
                    .toString();
            consistentHash.addNode(ip);
        }

        // 生成需要缓存的数据;
        for (int i = 0; i < STRING_COUNT; i++) {
            sList.add(RandomStringUtils.randomAlphanumeric(10));
        }

        // 将数据放入到缓存中。
        for (String s : sList) {
            consistentHash.put(s, s);
        }

        for (int i = 0; i < 10; i++) {
            int index = RandomUtils.nextInt(0, STRING_COUNT);
            String key = sList.get(index);
            String cache = (String) consistentHash.get(key);
            System.out.println("Random:" + index + ",key:" + key + ",consistentHash get value:" + cache + ",value is:" + key.equals(cache));
        }
        System.out.println("输出节点及数据分布情况");
        // 输出节点及数据分布情况
        for (Node node : consistentHash.nodeList) {
            log.info("ip:{},num:{}",node.getIp(),node.getCacheMap().size());
        }
        consistentHash.removeNode("10.2.1.1");

        System.out.println("输出节点及数据分布情况2");
        for (Node node : consistentHash.nodeList) {
            log.info("ip:{},num:{}",node.getIp(),node.getCacheMap().size());
        }

    }
}


至少一次原则

每条msg必须要被成功消费一次:Consumer消费完msg后向消费进度记录器提供消费消息的offset,offset被成功记录

广播消费:Consumer本身为消费进度记录器

集群消费:Broker为消费进度记录器

订阅关系一致性

同一个Consumer Group的所有Consumer实例所订阅的TopicTag及对msg的处理逻辑必须完全一致

常见错误:

  • 同一Consumer Group中的两个Consumer 实例订阅不同的Topic

  • 同一Consumer Group中的两个Consumer 实例订阅不同数量的Topic

  • 同一Consumer Group中的两个Consumer 实例订阅相同的Topic的不同Tag

6.Offset管理

Consumer的消费进度offset,根据消费进度记录器的不同分为本地模式和远程模式

LocalOffsetStore

广播消费时,offset用于本地模式存储,每条msg会被所有的Consumer消费,每个Consumer管理自己的消费进度,各个Consumer无消费进度的交集

以json形式持久化,目录.rocketmq_offsets/${clientId}/group/offsets.json,clientId默认为ip@DEFAULT,group为Consumer Group名称

RemoteBrokerOffsetStore

目的是保证reblance机制

Broker
offset存储和加载

rocketMQ的broker端中,offset的是以json的形式持久化到磁盘文件中,文件路径为${user.home}/store/config/consumerOffset.json

文件内容:

{
    "dataVersion":{
        "counter":0,
        "stateVersion":0,
        "timestamp":1701204020539
        },
    "groupTopicMap":{},
    "offsetTable":{}
}

broker端启动后,会调用BrokerController.initialize()方法,方法中会对offset进行加载,consumerOffsetManager.load()。获取文件内容后,序列化为ConsumerOffsetManager对象,实质是其属性ConcurrentMap<String,ConcurrentMap<Integer, Long>> offsetTableoffsetTable的数据结构为ConcurrentMap,是一个线程安全的容器,key的形式为topic@group(每个topic下不同消费组的消费进度),value是一个ConcurrentMap,key为queueId,value为消费位移(这里不是offset而是位移),通过全局ConsumerOffsetManager对象就可以对各个topic下不同消费组的消费位移进行获取与管理。

/**ConsumerOffsetManager.offsetTable*/
private ConcurrentMap<String/* topic@group */, ConcurrentMap<Integer, Long>> offsetTable =
        new ConcurrentHashMap<String, ConcurrentMap<Integer, Long>>(512);

/**ConsumerOffsetManager.decode*/
public void decode(String jsonString) {
        if (jsonString != null) {
            // 序列化成功后复制给全局ConsumerOffsetManager对象
            ConsumerOffsetManager obj = RemotingSerializable.fromJson(jsonString, ConsumerOffsetManager.class);
            if (obj != null) {
                this.offsetTable = obj.offsetTable;
            }
        }
    }
nextBeginOffset

对于consumer的消费请求处理(PullMessageProcessor.processRequest()),除了待消费的消息内容,broker在responseHeader(PullMessageResponseHeader)附带上当前消费队列的最小offset(minOffset)、最大offset(maxOffset)、及下次拉取的起始offset(nextBeginOffset)。

  • minOffset、maxOffset是当前消费队列consumeQueue记录的最小及最大的offset信息。
  • nextBeginOffset是consumer下次拉取消息的offset信息,即consumer对该consumeQueue的消费进度。

其中nextBeginOffset是consumer在下一轮消息拉取时offset的重要依据,无论当次拉取的消息消费是否正常,nextBeginOffset都不会回滚,这是因为rocketMQ对消费异常的消息的处理是将消息重新发回broker端的重试队列(会为每个topic创建一个重试队列,以%RERTY%开头),达到重试时间后将消息投递到重试队列中进行消费重试。对消费异常的处理不是通过offset回滚,使得客户端简化了offset的管理

Client
offset初始化

consumer启动过程中(Consumer主函数默认调用DefaultMQPushConsumer.start()方法)根据MessageModel选择对应的offsetStore,然后调用offsetStore.load()对offset进行加载,LocalFileOffsetStore是对本地文件的加载,而RemotebrokerOffsetStore是没有本地文件的,因此load()方法没有实现。在rebalance完成对messageQueue的分配之后会对messageQueue对应的消费位置offset进行更新。

/** RebalanceImpl */
/**
doRebalance() -> rebalanceByTopic() -> updateProcessQueueTableInRebalance() 
-> computePullFromWhere()
*/
private boolean updateProcessQueueTableInRebalance(final String topic, final Set<MessageQueue> mqSet,
        final boolean isOrder) {
    // (省略部分代码)负载均衡获取当前consumer负责的消息队列后对processQueue进行筛选,删除processQueue不必要的messageQueue

    // 获取topic下consumer消息拉取列表,List<PullRequest>
    List<PullRequest> pullRequestList = new ArrayList<PullRequest>();
    for (MessageQueue mq : mqSet) {
        if (!this.processQueueTable.containsKey(mq)) {
                if (isOrder && !this.lock(mq)) {
                    log.warn("doRebalance, {}, add a new mq failed, {}, because lock failed", consumerGroup, mq);
                    continue;
                }

                // 删除messageQueue旧的offset信息
                this.removeDirtyOffset(mq);
                ProcessQueue pq = new ProcessQueue();
                // 获取nextOffset,即更新当前messageQueue对应请求的offset
                long nextOffset = this.computePullFromWhere(mq);
                if (nextOffset >= 0) {
                    ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq);
                    if (pre != null) {
                        log.info("doRebalance, {}, mq already exists, {}", consumerGroup, mq);
                    } else {
                        log.info("doRebalance, {}, add a new mq, {}", consumerGroup, mq);
                        PullRequest pullRequest = new PullRequest();
                        pullRequest.setConsumerGroup(consumerGroup);
                        pullRequest.setNextOffset(nextOffset);
                        pullRequest.setMessageQueue(mq);
                        pullRequest.setProcessQueue(pq);
                        pullRequestList.add(pullRequest);
                        changed = true;
                    }
                } else {
                    log.warn("doRebalance, {}, add new mq failed, {}", consumerGroup, mq);
                }
            }
    }

}

Push模式下,computePullFromWhere()方法的实现类为RebalancePushImpl.class。根据配置信息consumeFromWhere进行不同的操作。ConsumeFromWhere的类型枚举如下

//consumer启动时第一条msg消费的offset
public enum ConsumeFromWhere {
    CONSUME_FROM_LAST_OFFSET,
    //从queue的当前最后一条msg开始消费
    @Deprecated
    CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST,
    @Deprecated
    CONSUME_FROM_MIN_OFFSET,
    @Deprecated
    CONSUME_FROM_MAX_OFFSET,
    CONSUME_FROM_FIRST_OFFSET,
    //从queue的当前第一条msg开始消费
    CONSUME_FROM_TIMESTAMP,
    //从指定时间戳开始消费 consumer.setconsumerTimestamp("yyyyMMddHHHmmSS")
}

对于lastOffset、maxOffset、时间戳查找offset都是通过MQClientAPIImpl提供的接口进行查询的,MQClientAPIImplclient对broker请求的封装类,使用Netty进行异步请求,对应的RequestCode分别为RequestCode.QUERY_CONSUMER_OFFSET,RequestCode.GET_MAX_OFFSET,RequestCode.SEARCH_OFFSET_BY_TIMESTAMP

/** RebalancePushImpl */
public long computePullFromWhere(MessageQueue mq) {
        long result = -1;
        final ConsumeFromWhere consumeFromWhere = this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeFromWhere();
        final OffsetStore offsetStore = this.defaultMQPushConsumerImpl.getOffsetStore();
        switch (consumeFromWhere) {
            case CONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST:
            case CONSUME_FROM_MIN_OFFSET:
            case CONSUME_FROM_MAX_OFFSET:
            case CONSUME_FROM_LAST_OFFSET: {
                // 从broker获取当前消费队列offset
                long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
                if (lastOffset >= 0) {
                    result = lastOffset;
                }
                // First start,no offset
                else if (-1 == lastOffset) {
                    if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                        result = 0L;
                    } else {
                        try {
                            // 获取消费队列最大offset
                            result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
                        } catch (MQClientException e) {
                            result = -1;
                        }
                    }
                } else {
                    result = -1;
                }
                break;
            }
            case CONSUME_FROM_FIRST_OFFSET: {
                // 先查询当前消费队列消费进度
                long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
                if (lastOffset >= 0) {
                    result = lastOffset;
                }
                // 当前消费队列消费进度小于0,则从0开始
                else if (-1 == lastOffset) {
                    result = 0L;
                } else {
                    result = -1;
                }
                break;
            }
            case CONSUME_FROM_TIMESTAMP: {
                // 同样也是先查询当前消费队列消费进度
                long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);
                if (lastOffset >= 0) {
                    result = lastOffset;
                } else if (-1 == lastOffset) {
                    if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                        try {
                            result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);
                        } catch (MQClientException e) {
                            result = -1;
                        }
                    } else {
                        try {
                            // 获取consumer启动时间
                            long timestamp = UtilAll.parseDate(this.defaultMQPushConsumerImpl.getDefaultMQPushConsumer().getConsumeTimestamp(),
                                UtilAll.YYYYMMDDHHMMSS).getTime();
                            // 根据时间戳获取offset信息
                            result = this.mQClientFactory.getMQAdminImpl().searchOffset(mq, timestamp);
                        } catch (MQClientException e) {
                            result = -1;
                        }
                    }
                } else {
                    result = -1;
                }
                break;
            }

            default:
                break;
        }

        return result;
    }
offset提交更新

consumer从broker拉取消息后,会将消息的扩展信息MessageExt存放到ProcessQueue的属性TreeMap<Long, MessageExt> msgTreeMap中,key值为消息对应的queueOffset,value为扩展信息(包括queueID等)。并发消费模式下(Concurrently),获取的待消费消息会分批提交给消费线程进行消费,默认批次为1,即每个消费线程消费一条消息。消费完成后调用ConsumerMessageConcurrentlyService.processConsumeResult()方法对结果进行处理:消费成功确认ack,消费失败发回broker进行重试。之后便是对offset的更新操作。
首先是调用ProcessQueue.removeMessage()方法,将已经消费完成的消息从msgTreeMap中根据queueOffset移除,然后判断当前msgTreeMap是否为空,不为空则返回当前msgTreeMap第一个元素,即offset最小的元素,否则返回-1。
如果removeMessage()返回的offset大于0,则更新到offsetTable中。offsetTable的结构为ConcurrentMap<MessageQueue, AtomicLong> offsetTable,是一个线程安全的Map,key为MessageQueue,value为AtomicLong对象,值为offset,记录当前messageQueue的消费位移。

/** ConsumeMessageConcurrentlyService.class */
public void processConsumeResult(
        final ConsumeConcurrentlyStatus status,final ConsumeConcurrentlyContext context,final ConsumeRequest consumeRequest) {
    // .... (省略部分代码)根据消费结果判断是否需要发回broker重试

    // 在msgTreeMap中删除msg,标记当前消息已被消费,msgTreeMap不为空返回当前msgTreeMap中最小的offset
    long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());

    // 更新offsetTable中的消费位移,offsetTable记录每个messageQueue的消费进度
    // updateOffset()的最后一个参数increaseOnly为true,表示单调增加,新值要大于旧值
    if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
        this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true);
     }
}

/** ProcessQueue.class */
public long removeMessage(final List<MessageExt> msgs) {
        long result = -1;
        final long now = System.currentTimeMillis();
        try {
            this.lockTreeMap.writeLock().lockInterruptibly();
            this.lastConsumeTimestamp = now;
            try {
                if (!msgTreeMap.isEmpty()) {
                    result = this.queueOffsetMax + 1;
                    int removedCnt = 0;
                    // // 从msgTreeMap中删除该批次的msg
                    for (MessageExt msg : msgs) {
                        MessageExt prev = msgTreeMap.remove(msg.getQueueOffset());
                        if (prev != null) {
                            removedCnt--;
                            msgSize.addAndGet(0 - msg.getBody().length);
                        }
                    }
                    msgCount.addAndGet(removedCnt);

                    // 删除后当前msgTreeMap不为空,返回第一个元素,即最小的offset
                    if (!msgTreeMap.isEmpty()) {
                        result = msgTreeMap.firstKey();
                    }
                }
            } finally {
                this.lockTreeMap.writeLock().unlock();
            }
        } catch (Throwable t) {
            log.error("removeMessage exception", t);
        }

        return result;
    }

/** RemoteBrokerOffsetStore */
public void updateOffset(MessageQueue mq, long offset, boolean increaseOnly) {
        if (mq != null) {
            AtomicLong offsetOld = this.offsetTable.get(mq);
            if (null == offsetOld) {
                // offsetTable中不存在mq对应的记录
                // putIfAbsent 如果传入key对应的value已存在,则返回存在的value,不替换;如果不存在,则新增,返回null
                offsetOld = this.offsetTable.putIfAbsent(mq, new AtomicLong(offset));
            }

            // offsetTable存在记录,替换,这里increaseOnly为true,offsetOld<offset才替换
            if (null != offsetOld) {
                if (increaseOnly) {
                    MixAll.compareAndIncreaseOnly(offsetOld, offset);
                } else {
                    offsetOld.set(offset);
                }
            }
        }
    }

到这里一条消息的消费流程已经结束,offset更新到了本地缓存offsetTable,而将offset上传到broker是由定时任务执行的。MQClientInstance.start()会启动客户端相关的定时任务,包括NameService通信、offset提交等。

/** MQClientInstance.startScheduledTask() */
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

            @Override
            public void run() {
                try {
                    // 提交offset至broker
                    MQClientInstance.this.persistAllConsumerOffset();
                } catch (Exception e) {
                    log.error("ScheduledTask persistAllConsumerOffset exception", e);
                }
            }
        }, 1000 * 10, this.clientConfig.getPersistConsumerOffsetInterval(), TimeUnit.MILLISECONDS);

LocalFileOffsetStore模式下,将offset信息转化成json保存到本地文件中;RemoteBrokerOffsetStore则offsetTable将需要提交的MessageQueue的offset信息通过MQClientAPIImpl提供的接口updateConsumerOffsetOneway()提交到broker进行持久化存储。
另一种情况,当应用正常关闭时,consumer的shutdown()方法会主动触发一次持久化offset到broker的操作。

client对offset的更新是在消息消费完成后将offset更新到offsetTable,再由定时任务进行持久化。这个过程有需要注意的地方。

  • 由于是先消费再更新offset,因此存在消费完成后更新offset失败,但这种情况出现的概率比较低,更新offset只是写到缓存中,是一个简单的内存操作,出错的可能性较低。
  • 由于offset先存到内存中,再由定时任务每隔10s提交一次,存在丢失的风险,比如当前client宕机等,从而导致更新后的offset没有提交到broker,再次负载时会重复消费。因此consumer的消费业务逻辑需要保证幂等性

7.消费幂等

Consumer对于某条msg重复消费时,重复消费结果相同,多次消费未对业务产生负面影响

幂等:若某操作执行多次与执行一次对系统产生的影响相同

场景
发送msg重复

一条msg已被成功发送到Broker并完成持久化,此时出现网络闪断,从而导致Broker对Producer应答失败,若此时Producer意识到消息发送失败并再次尝试发送消息,此时broker会出现两条内容相同且Message Id 也相同的msg,后续Consumer消费2次该msg

消费msg重复

msg已投递至Consumer并完成业务处理,当Consumer给Broker反馈应答时网络闪断,Broker未收到msg消费成功响应,为保证至少被消费一次原则,Broker将在网络恢复后再次尝试投递之前被处理的msg

Rebalance

见上

通用解决
因素
  • 幂等令牌:Producer和Consumer的既定协议,指具备唯一业务标识的字符串(一般由producer随msg一同发送)

  • 唯一性处理:服务端通过采用一定的算法策略,保证同一业务逻辑不会被重复执行成功多次

步骤
  • 通过缓存去重,在缓存中如果已存在某幂等令牌,则此次操作为重复性,缓存未命中(存在有效期,可能出现缓存和数据库不一致现象),下一步

  • 唯一性处理之前,先在数据库中查询幂等令牌为索引的数据是否存在,若存在,则此次操作重复,否则下一步

  • 同一事务完成三项操作:唯一性处理后,将幂等令牌写入缓存,并将其作为唯一索引写入DBMS

8.消费堆积和消费延迟

概念

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

msg处理过程,如果Consumer的消费速度小于producer的发送速度,MQ中未处理的msg数量增多,产生堆积消息,进而造成消费延迟,以下场景:

  • 业务系统的上下游能力不匹配造成的持续堆积

  • 业务系统对msg的消费实时性要求较高,即使是短期的消费延迟无法接受

产生原因

Consumer采用长轮询Pull模式消费msg,分为两个阶段:

拉取msg

Consumer拉取到msg后,缓存到本地缓冲队列.拉取式消费,内网环境吞吐量很高

TPS(Transaction processing systems),它是一个事务处理系统,又称为电子数据处理系统(electronic data processing system,EDPS)。TPS是面向企业最底层的管理系统,对企业日常运作所产生的事务信息进行处理

msg消费

Consumer将本地缓存的msg提交到消费线程,使用业务消费逻辑对消息进行处理,完成后获取到结果,此时Consumer的消费能力依赖于msg的消费耗时消费并发度,若业务处理逻辑复杂,导致处理单条msg耗时较长,则整体的msg吞吐量肯定不高,就会导致Consumer本地缓冲队列达到上限,停止从服务端拉取msg

消费耗时

主要原因集中于外部IO:

  • 读写外部DB,ex:访问mysql

  • 读写外部缓存,ex:访问redis

  • 下游系统调用,ex:Dubbo的RPC远程调用,SpringCloud 对下游系统的http接口调用

关于下游系统调用逻辑需要进行提前梳理,掌握每个调用操作预期的耗时,目的判断消费逻辑中IO操作耗时是否合理.通常msg堆积由于下游系统出现服务异常(可能是网络带宽问题)或达到DBMS容量限制

单机线程数

理想环境 : C ∗ T 1 + T 2 T 2 理想环境: C*\frac{T1+T2}{T2} 理想环境:CT2T1+T2

C:cpu内核数 T1:CPU内部逻辑计算耗时 T2:外部IO耗时

生产环境先设置较小数值后观察效果,逐步上升

9.消息清理

msg顺序存储于commitLog文件,以commitLog文件为单位清理,其存在过期时间

RocketMQ的commitLog文件清理规则基于时间、磁盘空间占用和消息文件状态等因素综合考虑,具体如下:

  • 文件过期(默认72小时),且到达清理时点(默认是凌晨4点),会删除过期文件。

  • 文件过期(默认72小时),且磁盘空间达到了水位线(默认75%),也会删除过期文件。

  • 当磁盘达到必须释放的上限(85%水位线)时,系统会开始批量清理文件(无论是否过期),直到空间充足。

  • 若磁盘空间达到危险水位线(默认90%),出于保护自身的目的,broker会拒绝写入服务。

  • 清理完commitLog后,获取到最小的偏移量offset,然后将ConsumeQueue和IndexFile中最小的offset删除掉(同样也是删除文件)。

TPS(Transaction processing systems),它是一个事务处理系统,又称为电子数据处理系统(electronic data processing system,EDPS)。TPS是面向企业最底层的管理系统,对企业日常运作所产生的事务信息进行处理

msg消费

Consumer将本地缓存的msg提交到消费线程,使用业务消费逻辑对消息进行处理,完成后获取到结果,此时Consumer的消费能力依赖于msg的消费耗时消费并发度,若业务处理逻辑复杂,导致处理单条msg耗时较长,则整体的msg吞吐量肯定不高,就会导致Consumer本地缓冲队列达到上限,停止从服务端拉取msg

消费耗时

主要原因集中于外部IO:

  • 读写外部DB,ex:访问mysql

  • 读写外部缓存,ex:访问redis

  • 下游系统调用,ex:Dubbo的RPC远程调用,SpringCloud 对下游系统的http接口调用

关于下游系统调用逻辑需要进行提前梳理,掌握每个调用操作预期的耗时,目的判断消费逻辑中IO操作耗时是否合理.通常msg堆积由于下游系统出现服务异常(可能是网络带宽问题)或达到DBMS容量限制

单机线程数

理想环境 : C ∗ T 1 + T 2 T 2 理想环境: C*\frac{T1+T2}{T2} 理想环境:CT2T1+T2

C:cpu内核数 T1:CPU内部逻辑计算耗时 T2:外部IO耗时

生产环境先设置较小数值后观察效果,逐步上升

9.消息清理

msg顺序存储于commitLog文件,以commitLog文件为单位清理,其存在过期时间

RocketMQ的commitLog文件清理规则基于时间、磁盘空间占用和消息文件状态等因素综合考虑,具体如下:

  • 文件过期(默认72小时),且到达清理时点(默认是凌晨4点),会删除过期文件。

  • 文件过期(默认72小时),且磁盘空间达到了水位线(默认75%),也会删除过期文件。

  • 当磁盘达到必须释放的上限(85%水位线)时,系统会开始批量清理文件(无论是否过期),直到空间充足。

  • 若磁盘空间达到危险水位线(默认90%),出于保护自身的目的,broker会拒绝写入服务。

  • 清理完commitLog后,获取到最小的偏移量offset,然后将ConsumeQueue和IndexFile中最小的offset删除掉(同样也是删除文件)。

参考资料:

图解一致性哈希算法,看这一篇就够了! -阿里云开发者社区 (aliyun.com)
rocketMQ – offset管理 - 简书 (jianshu.com)
https://www.bilibili.com/video/BV1cf4y157sz/?spm_id_from=333.337.search-card.all.click

  • 15
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lamooo19

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值