rocketmq学习总结

rocketmq

消息丢失,积压,幂等

  • 丢失

    • 手动ack,消息日志记录
  • 积压

    • (消费能力不够,出现异常)

    • 上线更多的消费者

    • 控制上线流量

    • 转移积压的消息到数据库或者其它地方进行离线处理,恢复正常消费

rabbitmq

  • jms,mqtt ----> AMQP
  • 生产者 --> routekey ---> broker(exange->queue) -- 消费者
  • channel (通道,负责通信的 nio 多路复用)
  • exange分发类型 (Direct,fanout,topic )

消息队列消息总线

  • 高危的managementAPI(*消息队列clientAPI权限太大

    • 你看看redis的client:jedis——它甚至具备了flushAll,flushDB的功能(清空所有redis数据),除了能关闭server还有什么事它不能做?

    • 就RabbitMQ而言,它的officialnative java client,可以创建/删除其通信的核心组件:exchange,queue。你能直接将这些client散布到各个业务系统里去而不加阻拦?你当然有必要做二次封装以移除这些高危的managementAPI。

    • 而我们期待的消息总线却是企业里各个系统中消息的通信,侧重点在于通信上。消息队列只是提供了一种非常适合于消息通信的实现机制(消息有序,消息缓存等),因此消息总线是在消息队列提供的技术支撑上封装出适合消息交互的业务场景。

  • 总线的优势:统一入口,简化拦截成本
    无论是消息总线还是服务总线,其实所谓的总线就是进行先收拢再发散的过程。先收拢,从统一的入口进去,完成必要的统一处理逻辑;再发散,按照路由规则,路由到各个组件去处理。事实上这就是代理的作用:屏蔽内部细节,对外统一入口。在基于代理的基础上,我们可以对消息总线上所有的消息做日志记录(因为所有消息的通信都必须经过代理),并且还是在不切断RabbitMQ自身Channel的基础上,而如果想在路由上实现一个Proxy,那基本上离不开一个树形拓扑结构。
    ————————————————

横向比较

系统解耦,流量削峰,数据分发

复杂度提高 ,一致性问题

特性 rabbitmq rocketmq kafka
语言 erlang java Scala和Java编写
吞吐量 w级 10w级 10w级
时效性 us ms ms
可用性 主从架构(高) 分布式架构(非常高) 分布式架架构(非常高)
功能特性 基于erlang开发,性能高,延时低 功能完备,扩展性好 大数据领域应用广

部署角色

  • nameserver 集群
  • broker 集群 [ master-slave (s) ]
  • producer (生产者)
  • consumer (消费者)

producer&consumer 向nameserver获取broker服务地址列表

producer 发送信息 到 broker

consumer 接收信息 从 broker

nameserver <--routing info--> broker 路由注册发现

broker-master <--data sync--> broker-slave

集群方式

broker-master <--data sync--> broker-slave

  • broker_name确定分组
  • 再用broker_id区分主从 ,为0代表主节点,非0代表从节点

多master模式

  • 某master宕机期间,因为没有备份(slave),未被消费的消息在master恢复前不可用

多master-slave(异步)

  • 性能和多master几乎一样,但是master宕机还可以自动从slave消费

  • 可能丢失少量数据 (master宕机,硬盘损坏)

多master-slave(同步)

  • 只有主备都写成功才返回,消息不会丢失,无延迟,数据可用性高
  • 性能比异步复制模式略低(10%),单个消息的(延时RT)略高

eg.双主双从异步

  • 流程

    1. start nameserver 监听等待(broker,consumer) 连接

    2. broker启动,长连接nameserver,定时发送心跳包信息(ip:port,topic )

      此时nameserver中也就有了topic和broker的对应关系

    3. 收发信息前,先 create topic,指定broker

    4. producer 长连接一台nameserver ,根据topic获取brokers ,轮询选择broker上的队列并选择一个队列,选定队列所在broker建立长连接并进行发送消息

    5. consumer和producer类似(略)

消息发送

同步消息

  • 会阻塞 等待 sendStatus
  • 可靠性高
  • 适合 重要的消息通知
  • DefaultMQProducer send

异步消息

DefaultMQProducer  producer = ::new;
producer.setRetryTimesWhenSendAsyncFailed(time);
    
producer.send(msg,new SendCallBack(){
    @overrite 
    onSuccess(sendResult)
})

Oneway

  • void sendOneway(msg)

消息消费

推拉模式

DefaultMQPushConsumer;

consumer.subscribe(topic,subExpression);

consumer.registerMessageListener(new MessageListenerConcurrently());

// 推拉模式

多个消费者

consumer.setMessageModel(MessageModel.BROADCASTING);

MessageModel.CLUSTERING ; //默认为集群负载均衡模式

广播模式

​ 每个消费者都消费了相同的消息

负载均衡(集群并发模式)

​ 共同消费一批消息

负载均衡

producer重试机制

  • producer寻址

    • 请求到路由表,多个broker负载 (轮询broker+queue)

    • 如果故障的broker没有被nameserver及时剔除 ,producer需要故障规避转移

      • 重试3次
      • 屏蔽故障broker 30s

Producer负载均衡

  • Producer端,每个实例在发消息的时候,默认会轮询所有的message queue发送,以达到让消息平均落在不同的queue上。而由于queue可以散落在不同的broker,所以消息就发送到不同的broker下,发布方会把第一条消息发送至 Queue 0,然后第二条消息发送至 Queue 1,以此类推。

Consumer负载均衡

在集群消费模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的一个实例即可。RocketMQ采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条message queue。

而每当实例的数量有变更,都会触发一次所有实例的负载均衡,这时候会按照queue的数量和实例的数量平均分配queue给每个实例。

默认的分配算法是AllocateMessageQueueAveragely,还有另外一种平均的算法是AllocateMessageQueueAveragelyByCircle,也是平均分摊每一条queue,只是以环状轮流分queue的形式

需要注意的是,集群模式下,queue都是只允许分配只一个实例,这是由于如果多个实例同时消费一个queue的消息,由于拉取哪些消息是consumer主动控制的,那样会导致同一个消息在不同的实例下被消费多次,所以算法上都是一个queue只分给一个consumer实例,一个consumer实例可以允许同时分到不同的queue。

通过增加consumer实例去分摊queue的消费,可以起到水平扩展的消费能力的作用。而有实例下线的时候,会重新触发负载均衡,这时候原来分配到的queue将分配到其他实例上继续消费。

但是如果consumer实例的数量比message queue的总数量还多的话,多出来的consumer实例将无法分到queue,也就无法消费到消息,也就无法起到分摊负载的作用了。所以需要控制让queue的总数量大于等于consumer的数量。

由于广播模式下要求一条消息需要投递到一个消费组下面所有的消费者实例,所以也就没有消息被分摊消费的说法。在实现上,其中一个不同就是在consumer分配queue的时候,所有consumer都分到所有的queue。

消息重试

一条消息无论重试多少次,这些重试消息的 Message ID 不会改变

  • 顺序重试

    • 顺序消息消费失败会造成阻塞,所以要保证能及时监控并处理
    • 消息队列 RocketMQ 会自动不断进行消息重试(每次间隔时间为 1 秒)
  • 无序重试

    • 无序消息的重试对集群模式有效,对广播模式不会重试
    • 默认最多16次重试 (10s,30s,1分钟~10m,20m,30m,1h,2h)
  • 死信队列

    • 消息重试n次无效后,进入死信队列 dead-letter Queue
    • 不会被正常消费,有效期3天
    • 包含一个group产生的所有死信消息,不论消息属于哪个topic

消息幂等

消息发送

  • producer -> broker

    broker反馈应答时网络闪断,producer 会再重试

消息消费

  • broker->consumer
    • consumer反馈应答时网络闪断,broker会再push
  • 负载均衡消息重复
    • 网络抖动,broker和消费方 重启或扩缩容时会触发rebalance

解决方法

  • messageid (rocketmq不保证唯一)

  • 业务唯一标识

高可用机制

nameserver高可用

  • nameserver集群

发送高可用

  • 双主双重(一个topic对应多个master)

消费高可用

  • consumer本身有自动切换机制

消息数据主从复制

  • 虽然consumer会自动切换到slave,但是slave本身没有直接写数据,所以需要配置复制模式
  • 同步,异步复制
    • brokerRole = ASYNC_MASTER | SYNC_MASTER | SLAVE
    • 一般和同步/异步刷盘配合使用 ( 异步刷盘+主从同步复制)

消息存储

顺序写(性能高)

高性能磁盘顺序写可达 600M/s , 随机写速度只有100k/s

  • 对于从磁盘中读取数据的操作,叫做IO操作,这里有两种情况:

    • 随机IO

      假设我们所需要的数据是随机分散在磁盘的不同页的不同扇区中的,那么找到相应的数据需要等到磁臂(寻址作用)旋转到指定的页,然后盘片寻找到对应的扇区,才能找到我们所需要的一块数据,一次进行此过程直到找完所有数据,这个就是随机IO,读取数据速度较慢。

    • 顺序IO

      假设我们已经找到了第一块数据,并且其他所需的数据就在这一块数据后边,那么就不需要重新寻址,可以依次拿到我们所需的数据,这个就叫顺序IO。

  • 顺序IO是指读取和写入操作基于逻辑块逐个连续访问来自相邻地址的数据。在顺序IO访问中,HDD所需的磁道搜索时间显着减少,因为读/写磁头可以以最小的移动访问下一个块。数据备份和日志记录等业务是顺序IO业务。随机IO是指读写操作时间连续,但访问地址不连续,随机分布在磁盘LUN的地址空间中。产生随机IO的业务有OLTP服务,SQL,即时消息服务等。(其实就是说在数据库查询时读取的不是连续区域,是要在整个磁盘上进行查找,多数时间可能耗费在了磁头寻道上)

     ① 顺序I/O一般只需扫描一次数据、所以、缓存对它用处不大
    
     ② 顺序I/O比随机I/O快
    
     ③ 随机I/O通常只要查找特定的行、但I/O的粒度是页级的、其中大部分是寻址,耗费时间,顺序I/O所读取的数据、通常发生在想要的数据块上的所有行更加符合成本效益。 所以、缓存随机I/O可以节省更多的workload
    

零拷贝消息发送

  • 零拷贝

    • 少了一次内核态的数据拷贝

    • java中用mappedByteBuffer实现 文件大小不能太大 (1.5-2G), rocketmq默认文件大小1g

消息存储结构

store/commitlog/

  • 00001010100111100100

  • 真正的物理存储文件 (topic,queueid,message)

  • producer ---(产生消息)--> 堆外内存 (NIO MappedByteBuffer) -> page cache(4k) -> (同步、异步刷盘) -> DISK commitlog (1GB)

  • commitlog文件名

    左边补0 + 文件大小 (20位)

consumerQueue/

  • 消息的逻辑队列,类似数据库的索引文件 , commitlog的索引文件

  • 每个topic下的每个messageQueue对应一个consumerQueue 文件

  • 文件组成 : commitlog offset - size - message tag hashcode ,默认有30万个item = 20(字节)*30w = 5.72M ,最终根据consumerQueue 文件找到消息本身

index/

  • indexFile :提供了一种key索引,时间区间查询文件

  • orderid,payid检索 key hash (id) + commitlog offset + timestamp + nextIndexOffset

其它文件

  • config/ /* 运行期间一些配置信息*/

    ​ topics.json

    ​ consumerOffset.json

  • checkpoint

    ​ 文件检查点,记录刷盘时间戳 (commitlog index consumerqueue)

  • abort 都是0KB,如果上次异常退出会出现这个文件,正常退出会删除这个文件

    • 实现机制是Broker在启动时创建abort文件,在退出时通过JVM钩子函数删除abort文件

刷盘机制

  • 同步刷盘和异步刷盘
  • producer ---(产生消息)--> 堆外内存 (NIO MappedByteBuffer) -> page cache(4k) -> (同步、异步刷盘) -> DISK commitlog (1GB)

特征消息发送

顺序消息

  • 局部消息顺序

生产者:某个订单或者某个用户放在同一队列(broker下有多个队列)

消费者:一个队列对应一个消费者线程

// 1.producer
//msg 消息对象
// 队列选择器
// 选择队列的业务标识
send(msg,new MessageQueueSelector(){
    // mqs 队列集合
    // msg 消息
    // arg:业务标识 orderId
    select(mqs,msg,arg)
},orderId);
----------------------------------------------------------------
// 2. consumer
// 并发消费
consumer.registerMessageListener(new MessageListenerConcurrently());
//队列单线程顺序消费
consumer.registerMessageListener(new MessageListenerOrderly());

延时消息

msg.setDelayTimeLevel();

批量消息

list

如果总长度大于4M,要进行分隔

过滤消息

  • tag过滤

  • sql语法过滤

    • 生产时设置约定属性
    • 消费时订阅时用选择器过滤bysql

    ​ consumer.subscribe(topic,MessageSelecor.bySql("a>1 and b<5"));

rocketmq事务消息

事务消息状态

  • 提交事务 (TransactionStatus.CommitTransaction)

    • 可以被消费
  • 回滚事务 (TransactionStatus.RollbackTransaction)

    • 消息将被删除,不允许被消费
  • 中间状态 (TransactionStatus.Unknow)

    • 需要检查消息队列来确定状态

与普通消息区别

  1. 事务消息不支持延时,批量
  2. 事务消息确认之后才对用户可见
  3. 事务消息允许反向查询,mq可以通过生产id查询到消费者

流程

​ 生产者要进行事务提交,短暂对消费者不可见(half msg)

  • 流程

    // 1. producer send half msg -> MQ server(broker)
    // 2. Half Msg send OK
    // 3. 执行本地事务
    // 4. producer->MQ server commit or rollback
    // 5. check back 当第4步的确认信息没有收到时触发回查(MQ server -> producer)
    // 6. producer查询本地事务状态
    // 7. 再次返回事务消息状态 (commit or rollback) producer->MQ Server 
    // 8. MQ Server 
    // 		8.1 commit send msg-> consumer
    //	  	8.2 rollback delete msg  
    	
    TransactionMQProducer producer;
    //添加事务监听器
    producer.setTransactionListener();   
    myTransactionListener implements TransactionListener {
        //3.执行本地事务
        executeLocalTransaction(Message msg,arg){
            // COMMIT_MESSAGE , ROLLBACK_MESSAGE,NULL;
            // 返回本地事务执行状态 ,如果是返回null就相当于UNKNOW状态,会触发回查方法
            return LocalTransactionState.COMMIT_MESSAGE;
            
        }
        //5.MQ回查生产者 ,校验事务消息状态
        checkLocalTransation(MessageExt msg){
            // 6.查询本地事务状态
              queryState..
            // 7.返回事务状态
        }
    }
    // 1.producer send half msg 
    producer.sendMessageInTransaction(msg,null);
    

本质是什么

Producer

自动修改了主题 my_topic - > RMQ_SYS_TRANS_HALF_TOPIC 提交到broker

RMQ_SYS_TRANS_HALF_TOPIC -> OP -> (my_topic)

注意事项

  1. 可能不只一次检查消费所以消费方要做幂等处理

  2. 事务消息的生产者id不能和其它类型消息的生产者id共享

  3. 如果希望确保事务消息不丢失,建议使用同步的双重写入机制

    • 双Master双Slave (主从同步复制) brokerRole为SYNC_MASTER

    • 多 Master 多 Slave 模式,同步双写

      每个 Master 配置一个 Slave,有多对Master-Slave,HA 采用同步双写方式,主备都写成功,向应用返回成功。

      优点:数据与服务都无单点,Master宕机情况下,消息无延迟,服务可用性与数据可用性都非常高

      缺点:性能比异步复制模式略低,大约低 10%左右,发送单个消息的 RT 会略高。目前主宕机后,备机不能自动切换为主机,后续会支持自动切换功能。

  4. 回查机制 (默认15次)

  5. 回查超时时间可以配置

如果业务流程涉及三个及以上节点需要协调完成分布式事务流程,我这里以三个应用节点举例

  1. 第一个应用是事务的发起方,理论上只有事务消息生产
  2. 第二个应用是事务的中转者,它在事务消息消费逻辑中还要发起事务消息的发送流程,也就是说,事务消息的中转者是先消费它的上游的事务消息,处理完本地的逻辑之后,再该事务消息继续传递下去。(中转者发送的消息也是事务消息)
  3. 第三个应用是事务的终结者,理论上它只有事务消息的消费逻辑

下单与扣款通过事务消息保证一致性,保证成功率

通知过程通过MQ进行异步解耦,使用普通消息即可,因为通知过程本身是为了最大努力送达,属于最终一致性的范畴,不要求数据的强一致性。

如果通知达到上限阈值,则停止通知,等待商户侧发起主动查询即可。通过通知回调+主动查询,能够在跨网络的交易场景下,实现端与端之间的订单状态的最终一致。

在平台内部,跨服务之间的分布式事务,通过RMQ的事务消息得到保证,事务消息原理可简单介绍。

案例地址

https://github.com/TaXueWWL/order-charge-notify

核心功能分析

NameServer

特点

  • 任何Producer、Consumer、Broker与所有NameServer通信,向NameServer请求或者发送数据。

  • 而且都是单向的,Producer和Consumer请求数据,Broker发送数据。正是因为这种单向的通信,RocketMQ水平扩容变得很容易。

启动步骤

1.创建NameSrvController

NameSrvStartup -> createNameSrvController

​ namesrvConfig nettyServerConfig 返回controller

NameSrvController controller = new NameSrvController (namesrvConfig ,nettyServerConfig);

2.Controller初始化

2.1 NameSrvController initialize 加载kv配置管理器 NettyRemotingServer

2.2 NettyRemotingServer 创建Server , 创建线程池 , 注册请求处理器

​ nettyServerConfig.setListenerPort(9876);

​ 创建线程池 : public线程池 + 不同业务类型不同线程池 (消息发送,消息消费,心跳信息等)

​ nameserver-broker 心跳 per 10s扫描broker

​ scheduledExecutorService NameSrvController每隔10s扫描未活跃的broker信息

3.注册关闭资源Hook
Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log,new Callable<void>(){
  public  void call() throws Exception{
        controller.shutdown();
      	return null;
    }
}));

路由管理

(元数据)RouteInfoManager
//topic消息队列的路由信息,消息发送时根据路由表进行负载均衡 。包含brokername QueueData : brokerName
HashMap<String/* topic */,List<QueueData>> topicQueueTable; 
//broker的基础信息 包含所属集群BrokerData : cluster
HashMap<String/* brokerName */,List<BrokerData>> brokerAddrTable;
//broker的集群信息 存储集群中brokername
HashMap<String/* clusterName */,set<String /* brokerName */>> clusterAddrTable;
//BrokerLiveInfo存储broker的状态心跳信息
//lastUpdateTimeStamp上次收到的心跳包时间
HashMap<String/* brokerAddr */,BrokerLiveInfo> brokerLiveTable;
//用于信息过滤
HashMap<String/* brokerAddr */,List<String/* Filter Server */>> filterServerTable;
brokerAddrTable   ip port 
路由注册

通讯协议:netty (remoting)

brokerStartup brokerConfig nettyClientConfig nettyServerConfig brokerController

​ brokerController init

brokerController > registerBrokerAll [broker nettyClient] -> nameserver

broker per 30s 注册一次broker信息到nameservers

  • RocketMQ路由注册是通过Broker与NameServer的心跳功能实现的。Broker启动时向集群中所有的NameServer发送心跳信息,每隔30s向集群中所有NameServer发送心跳包,NameServer收到心跳包时会更新brokerLiveTable缓存中BrokerLiveInfo的lastUpdataTimeStamp信息

  • 然后NameServer每隔10s扫描brokerLiveTable,如果连续120S没有收到心跳包,NameServer将移除Broker的路由信息同时关闭Socket连接。

final CountDownLatch countDownLatch = new CountDownLatch(nameServerAddressList.size());
for (final String namesrvAddr : nameServerAddressList) {
    brokerOuterExecutor.execute(new Runnable() {
        @Override
        public void run() {
            try {
                //分别向NameServer注册

代码:BrokerController#start

//注册Broker信息
this.registerBrokerAll(true, false, true);
//每隔30s上报Broker信息到NameServer
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

    @Override
    public void run() {
        try {
            BrokerController.this.registerBrokerAll(true, false, brokerConfig.isForceRegister());
        } catch (Throwable e) {
            log.error("registerBrokerAll Exception", e);
        }
    }
}, 1000 * 10, Math.max(10000, Math.min(brokerConfig.getRegisterNameServerPeriod(), 60000)), 
                                                  TimeUnit.MILLISECONDS);

代码:BrokerOuterAPI#registerBrokerAll

//获得nameServer地址信息
List<String> nameServerAddressList = this.remotingClient.getNameServerAddressList();
//遍历所有nameserver列表
if (nameServerAddressList != null && nameServerAddressList.size() > 0) {

    //封装请求头
    final RegisterBrokerRequestHeader requestHeader = new RegisterBrokerRequestHeader();
    requestHeader.setBrokerAddr(brokerAddr);
    requestHeader.setBrokerId(brokerId);
    requestHeader.setBrokerName(brokerName);
    requestHeader.setClusterName(clusterName);
    requestHeader.setHaServerAddr(haServerAddr);
    requestHeader.setCompressed(compressed);
	//封装请求体
    RegisterBrokerBody requestBody = new RegisterBrokerBody();
    requestBody.setTopicConfigSerializeWrapper(topicConfigWrapper);
    requestBody.setFilterServerList(filterServerList);
    final byte[] body = requestBody.encode(compressed);
    final int bodyCrc32 = UtilAll.crc32(body);
    requestHeader.setBodyCrc32(bodyCrc32);
    final CountDownLatch countDownLatch = new CountDownLatch(nameServerAddressList.size());
    for (final String namesrvAddr : nameServerAddressList) {
        brokerOuterExecutor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    //分别向NameServer注册
                    RegisterBrokerResult result = registerBroker(namesrvAddr,oneway, timeoutMills,requestHeader,body);
                    if (result != null) {
                        registerBrokerResultList.add(result);
                    }

                    log.info("register broker[{}]to name server {} OK", brokerId, namesrvAddr);
                } catch (Exception e) {
                    log.warn("registerBroker Exception, {}", namesrvAddr, e);
                } finally {
                    countDownLatch.countDown();
                }
            }
        });
    }

    try {
        countDownLatch.await(timeoutMills, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
    }
}

代码:BrokerOutAPI#registerBroker

if (oneway) {
    try {
        this.remotingClient.invokeOneway(namesrvAddr, request, timeoutMills);
    } catch (RemotingTooMuchRequestException e) {
        // Ignore
    }
    return null;
}
RemotingCommand response = this.remotingClient.invokeSync(namesrvAddr, request, timeoutMills);
处理心跳包

DefaultRequestProcessor 判断请求类型

  • broker->nameserver

    如果是broker的心跳注册

    ​ RouteInfoManager , 路由注册时加锁 writelock ,维护了那几个MAP元数据信息

    ​ registerBroker

  • producer->nameserver

  • consumer->nameserver

路由删除
  • Broker每隔30s向NameServer发送一个心跳包,心跳包包含BrokerIdBroker地址,Broker名称,Broker所属集群名称、Broker关联的FilterServer列表。
  • 但是如果Broker宕机,NameServer无法收到心跳包,此时NameServer如何来剔除这些失效的Broker呢?NameServer会每隔10s扫描brokerLiveTable状态表,如果BrokerLivelastUpdateTimestamp的时间戳距当前时间超过120s,则认为Broker失效,移除该Broker,关闭与Broker连接,同时更新topicQueueTablebrokerAddrTablebrokerLiveTablefilterServerTable

RocketMQ有两个触发点来删除路由信息

  • NameServer定期扫描brokerLiveTable检测上次心跳包与当前系统的时间差,如果时间超过120s,则需要移除broker。
  • Broker在正常关闭的情况下,会执行unregisterBroker指令

这两种方式路由删除的方法都是一样的,就是从相关路由表中删除与该broker相关的信息。

路由发现

RocketMQ路由发现是非实时的,当Topic路由出现变化后,NameServer不会主动推送给客户端,而是由客户端定时拉取主题最新的路由。

问题集锦

数据不一致 ?
  • 集群相互之间不通信,会不会造成nameserver上数据不一致 ?

  • 最终一致

  • 调用 RouterlnfoManager 的方法,从路由 表 topicQueueTable、 brokerAddrTable、 filterServerTable中分别填充TopicRouteData中的List、List和 filterServer 地址表 。如果找到主题对应的路由信息并且该主题为顺序消息,则从 NameServer KVconfig 中获取关于顺序消息相关的配置填充路由信息 。如果找不到路由信息

    response.setCode(ResponseCode.TOPIC_NOT_EXIST);
    response.setRemark("No topic route info in name server for the topic: " + 		  						requestHeader.getTopic()+ FAQUrl.suggestTodo(FAQUrl.APPLY_TOPIC_URL));
    return response;
    
路由注册哪些信息

client

producer,consumer都是nameserver的客户端

接口 MQAdmin , MQProducer 继承 MQAdmin

ClientConfig

producer

消息发送
  1. DefaultMQProducerImpl#sendDefaultImpl

  2. 校验 Validators#checkMessage

  3. 路由DefaultMQProducerImpl#tryToFindTopicPublishInfo

    private TopicPublishInfo tryToFindTopicPublishInfo(final String topic) {
        //从缓存中获得主题的路由信息(concurrentHashMap<topicstr,topicPublishInfo>)
        TopicPublishInfo topicPublishInfo = this.topicPublishInfoTable.get(topic);
        //路由信息为空,则从NameServer获取路由
        if (null == topicPublishInfo || !topicPublishInfo.ok()) {
            this.topicPublishInfoTable.putIfAbsent(topic, new TopicPublishInfo());
            this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic);
            topicPublishInfo = this.topicPublishInfoTable.get(topic);
        }
    
        if (topicPublishInfo.isHaveTopicRouterInfo() || topicPublishInfo.ok()) {
            return topicPublishInfo;
        } else {
            //如果未找到当前主题的路由信息,则用默认主题继续查找
            this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic, true, this.defaultMQProducer);
            topicPublishInfo = this.topicPublishInfoTable.get(topic);
            return topicPublishInfo;
        }
    }
    
    
    1. 选择队列

      TopicPublishInfo#selectOneMessageQueue(lastBrokerName)

      public MessageQueue selectOneMessageQueue(final String lastBrokerName) {
          //第一次选择队列
          if (lastBrokerName == null) {
              return selectOneMessageQueue();
          } else {
              //sendWhichQueue
              int index = this.sendWhichQueue.getAndIncrement();
              //遍历消息队列集合
              for (int i = 0; i < this.messageQueueList.size(); i++) {
                  //sendWhichQueue自增后取模
                  int pos = Math.abs(index++) % this.messageQueueList.size();
                  if (pos < 0)
                      pos = 0;
                  //规避上次Broker队列
                  MessageQueue mq = this.messageQueueList.get(pos);
                  if (!mq.getBrokerName().equals(lastBrokerName)) {
                      return mq;
                  }
              }
              //如果以上情况都不满足,返回sendWhichQueue取模后的队列
              return selectOneMessageQueue();
          }
      }
      
  4. 执行发送

    DefaultMQProducerImpl#sendKernelImpl

    private SendResult sendKernelImpl(
        final Message msg,	//待发送消息
        final MessageQueue mq,	//消息发送队列
        final CommunicationMode communicationMode,		//消息发送内模式
        final SendCallback sendCallback,	pp	//异步消息回调函数
        final TopicPublishInfo topicPublishInfo,	//主题路由信息
        final long timeout	//超时时间
        )
    

    代码:DefaultMQProducerImpl#sendKernelImpl

    //获得broker网络地址信息
    String brokerAddr = this.mQClientFactory.findBrokerAddressInPublish(mq.getBrokerName());
    if (null == brokerAddr) {
        //没有找到从NameServer更新broker网络地址信息
        tryToFindTopicPublishInfo(mq.getTopic());
        brokerAddr = this.mQClientFactory.findBrokerAddressInPublish(mq.getBrokerName());
    }
    //为消息分类唯一ID
    if (!(msg instanceof MessageBatch)) {
        MessageClientIDSetter.setUniqID(msg);
    }
    
    boolean topicWithNamespace = false;
    if (null != this.mQClientFactory.getClientConfig().getNamespace()) {
        msg.setInstanceId(this.mQClientFactory.getClientConfig().getNamespace());
        topicWithNamespace = true;
    }
    //消息大小超过4K,启用消息压缩
    int sysFlag = 0;
    boolean msgBodyCompressed = false;
    if (this.tryToCompressMessage(msg)) {
        sysFlag |= MessageSysFlag.COMPRESSED_FLAG;
        msgBodyCompressed = true;
    }
    //如果是事务消息,设置消息标记MessageSysFlag.TRANSACTION_PREPARED_TYPE
    final String tranMsg = msg.getProperty(MessageConst.PROPERTY_TRANSACTION_PREPARED);
    if (tranMsg != null && Boolean.parseBoolean(tranMsg)) {
        sysFlag |= MessageSysFlag.TRANSACTION_PREPARED_TYPE;
    }
    //如果注册了消息发送钩子函数,在执行消息发送前的增强逻辑
    if (this.hasSendMessageHook()) {
        context = new SendMessageContext();
        context.setProducer(this);
        context.setProducerGroup(this.defaultMQProducer.getProducerGroup());
        context.setCommunicationMode(communicationMode);
        context.setBornHost(this.defaultMQProducer.getClientIP());
        context.setBrokerAddr(brokerAddr);
        context.setMessage(msg);
        context.setMq(mq);
        context.setNamespace(this.defaultMQProducer.getNamespace());
        String isTrans = msg.getProperty(MessageConst.PROPERTY_TRANSACTION_PREPARED);
        if (isTrans != null && isTrans.equals("true")) {
            context.setMsgType(MessageType.Trans_Msg_Half);
        }
    
        if (msg.getProperty("__STARTDELIVERTIME") != null || msg.getProperty(MessageConst.PROPERTY_DELAY_TIME_LEVEL) != null) {
            context.setMsgType(MessageType.Delay_Msg);
        }
        this.executeSendMessageHookBefore(context);
    }
    

    代码:SendMessageHook

    public interface SendMessageHook {
        String hookName();
    
        void sendMessageBefore(final SendMessageContext context);
    
        void sendMessageAfter(final SendMessageContext context);
    }
    
msgBatch

consumer

消息消费以组的模式开展,一个消费组内可以包含多个消费者,每一个消费者组可订阅多个主题,消费组之间有ff式和广播模式两种消费模式。集群模式,主题下的同一条消息只允许被其中一个消费者消费。广播模式,主题下的同一条消息,将被集群内的所有消费者消费一次。消息服务器与消费者之间的消息传递也有两种模式:推模式、拉模式。所谓的拉模式,是消费端主动拉起拉消息请求,而推模式是消息达到消息服务器后,推送给消息消费者。RocketMQ消息推模式的实现基于拉模式,在拉模式上包装一层,一个拉取任务完成后开始下一个拉取任务。

集群模式下,多个消费者如何对消息队列进行负载呢?消息队列负载机制遵循一个通用思想:一个消息队列同一个时间只允许被一个消费者消费,一个消费者可以消费多个消息队列。

RocketMQ支持局部顺序消息消费,也就是保证同一个消息队列上的消息顺序消费。不支持消息全局顺序消费,如果要实现某一个主题的全局顺序消费,可以将该主题的队列数设置为1,牺牲高可用性。

消息推送模式

DefaultMQPushConsumer

//消费者组
private String consumerGroup;	
//消息消费模式
private MessageModel messageModel = MessageModel.CLUSTERING;	
//指定消费开始偏移量(最大偏移量、最小偏移量、启动时间戳)开始消费
private ConsumeFromWhere consumeFromWhere = ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET;
//集群模式下的消息队列负载策略
private AllocateMessageQueueStrategy allocateMessageQueueStrategy;
//订阅信息
private Map<String /* topic */, String /* sub expression */> subscription = new HashMap<String, String>();
//消息业务监听器
private MessageListener messageListener;
//消息消费进度存储器
private OffsetStore offsetStore;
//消费者最小线程数量
private int consumeThreadMin = 20;
//消费者最大线程数量
private int consumeThreadMax = 20;
//并发消息消费时处理队列最大跨度
private int consumeConcurrentlyMaxSpan = 2000;
//每1000次流控后打印流控日志
private int pullThresholdForQueue = 1000;
//推模式下任务间隔时间
private long pullInterval = 0;
//推模式下任务拉取的条数,默认32条
private int pullBatchSize = 32;
//每次传入MessageListener#consumerMessage中消息的数量
private int consumeMessageBatchMaxSize = 1;
//是否每次拉取消息都订阅消息
private boolean postSubscriptionWhenPull = false;
//消息重试次数,-1代表16次
private int maxReconsumeTimes = -1;
//消息消费超时时间
private long consumeTimeout = 15;

pullMessageService

pullRequest , ProccessQueue snapshot of MessageQueue

消息拉取基本流程
  1. 客户端发起拉取请求

    DefaultMQPushConsumerImpl#pullMessage

  2. 消息服务端Broker组装消息

    PullMessageProcessor#processRequest

  3. 消息拉取&客户端处理消息

消息拉取长轮询机制分析
  • RocketMQ未真正实现消息推模式,而是消费者主动向消息服务器拉取消息

  • RocketMQ推模式是循环向消息服务端发起消息拉取请求,如果消息消费者向RocketMQ拉取消息时,消息未到达消费队列时,如果不启用长轮询机制,则会在服务端等待shortPollingTimeMills时间后(挂起)再去判断消息是否已经到达指定消息队列,如果消息仍未到达则提示拉取消息客户端PULL—NOT—FOUND(消息不存在);如果开启长轮询模式,RocketMQ一方面会每隔5s轮询检查一次消息是否可达,同时一有消息达到后立马通知挂起线程再次验证消息是否是自己感兴趣的消息,如果是则从CommitLog文件中提取消息返回给消息拉取客户端,否则直到挂起超时,超时时间由消息拉取方在消息拉取是封装在请求参数中,PUSH模式为15s,PULL模式通过DefaultMQPullConsumer#setBrokerSuspendMaxTimeMillis设置。RocketMQ通过在Broker客户端配置longPollingEnable为true来开启长轮询模式。

  • 如果开启了长轮询机制,PullRequestHoldService会每隔5s被唤醒去尝试检测是否有新的消息的到来才给客户端响应,或者直到超时才给客户端进行响应,消息实时性比较差,为了避免这种情况,RocketMQ引入另外一种机制:当消息到达时唤醒挂起线程触发一次检查。

DefaultMessageStore$ReputMessageService机制

消息队列负载与重新分布机制

RocketMQ消息队列重新分配是由RebalanceService线程来实现。一个MQClientInstance持有一个RebalanceService实现,并随着MQClientInstance的启动而启动。

// 默认提供5中负载均衡分配算法
AllocateMessageQueueAveragely:平均分配
举例:8个队列q1,q2,q3,q4,q5,a6,q7,q8,消费者3个:c1,c2,c3
分配如下:
c1:q1,q2,q3
c2:q4,q5,a6
c3:q7,q8
AllocateMessageQueueAveragelyByCircle:平均轮询分配
举例:8个队列q1,q2,q3,q4,q5,a6,q7,q8,消费者3个:c1,c2,c3
分配如下:
c1:q1,q4,q7
c2:q2,q5,a8
c3:q3,q6
    
消息消费过程

PullMessageService负责对消息队列进行消息拉取,从远端服务器拉取消息后将消息存储ProcessQueue消息队列处理队列中,然后调用ConsumeMessageService#submitConsumeRequest方法进行消息消费,使用线程池来消费消息,确保了消息拉取与消息消费的解耦。ConsumeMessageService支持顺序消息和并发消息,核心类图如下:

并发消息消费

代码:ConsumeMessageConcurrentlyService#submitConsumeRequest

//消息批次单次
final int consumeBatchSize = this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();
//msgs.size()默认最多为32条。
//如果msgs.size()小于consumeBatchSize,则直接将拉取到的消息放入到consumeRequest,然后将consumeRequest提交到消费者线程池中
if (msgs.size() <= consumeBatchSize) {
    ConsumeRequest consumeRequest = new ConsumeRequest(msgs, processQueue, messageQueue);
    try {
        this.consumeExecutor.submit(consumeRequest);
    } catch (RejectedExecutionException e) {
        this.submitConsumeRequestLater(consumeRequest);
    }
}else{	//如果拉取的消息条数大于consumeBatchSize,则对拉取消息进行分页
       for (int total = 0; total < msgs.size(); ) {
   		    List<MessageExt> msgThis = new ArrayList<MessageExt>(consumeBatchSize);
   		    for (int i = 0; i < consumeBatchSize; i++, total++) {
   		        if (total < msgs.size()) {
   		            msgThis.add(msgs.get(total));
   		        } else {
   		            break;
   		        }
   		
   		    ConsumeRequest consumeRequest = new ConsumeRequest(msgThis, processQueue, messageQueue);
   		    try {
   		        this.consumeExecutor.submit(consumeRequest);
   		    } catch (RejectedExecutionException e) {
   		        for (; total < msgs.size(); total++) {
   		            msgThis.add(msgs.get(total));
   		 
   		        this.submitConsumeRequestLater(consumeRequest);
   		    }
   		}
}
定时消息消费

定时任务地从队列中取出数据到正常消费队列中

顺序消息
// 顺序消息实现类是org.apache.rocketmq.client.impl.consumer.ConsumeMessageOrderlyService
final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue);
synchronized(objLock)
消息消费小结

RocketMQ消息消费方式分别为集群模式、广播模式。

消息队列负载由RebalanceService线程默认每隔20s进行一次消息队列负载,根据当前消费者组内消费者个数与主题队列数量按照某一种负载算法进行队列分配,分配原则为同一个消费者可以分配多个消息消费队列,同一个消息消费队列同一个时间只会分配给一个消费者。

消息拉取由PullMessageService线程根据RebalanceService线程创建的拉取任务进行拉取,默认每次拉取32条消息,提交给消费者消费线程后继续下一次消息拉取。如果消息消费过慢产生消息堆积会触发消息消费拉取流控。

并发消息消费指消费线程池中的线程可以并发对同一个消息队列的消息进行消费,消费成功后,取出消息队列中最小的消息偏移量作为消息消费进度偏移量存储在于消息消费进度存储文件中,集群模式消息消费进度存储在Broker(消息服务器),广播模式消息消费进度存储在消费者端。

RocketMQ不支持任意精度的定时调度消息,只支持自定义的消息延迟级别,例如1s、2s、5s等,可通过在broker配置文件中设置messageDelayLevel。

顺序消息一般使用集群模式,是指对消息消费者内的线程池中的线程对消息消费队列只能串行消费。与并发消息消费最本质的区别是消息消费时必须成功锁定消息消费队列,在Broker端会存储消息消费队列的锁占用情况。

消息存储

固定大小

commitlog index consumerqueue都被设计成固定大小

  • commitlog 1G ;

  • consumerqueue/topic/queueid 5.72M ;

  • index 400M

被设置成固定大小更好地使用内存映射的性能 ,如果一个文件写满以后再创建一个新文件,文件名就为该文件第一条消息对应的全局物理偏移量。

内存映射

MappedFile

未开启transientStorePoolEnabletransientStorePoolEnable=truetrue表示数据先存储到堆外内存,然后通过Commit线程将数据提交到内存映射Buffer中,再通过Flush线程将内存映射Buffer中数据持久化磁盘。

MappedByteBuffer

实时更新消息消费队列与索引文件

消息消费队列文件、消息属性索引文件都是基于CommitLog文件构建的,当消息生产者提交的消息存储在CommitLog文件中,ConsumerQueue、IndexFile需要及时更新,否则消息无法及时被消费,根据消息属性查找消息也会出现较大延迟。RocketMQ通过开启一个线程ReputMessageService来准实时转发CommitLog文件更新事件,相应的任务处理器根据转发的消息及时更新ConsumerQueue、IndexFile文件。

  1. 转发到ConsumerQueue

    class CommitLogDispatcherBuildConsumeQueue  implements CommitLogDispatcher
    
  2. IndexFile

    class CommitLogDispatcherBuildIndex implements CommitLogDispatcher
    

消息队列和索引文件恢复

由于RocketMQ存储首先将消息全量存储在CommitLog文件中,然后异步生成转发任务更新ConsumerQueue和Index文件。如果消息成功存储到CommitLog文件中,转发任务未成功执行,此时消息服务器Broker由于某个原因宕机,导致CommitLog、ConsumerQueue、IndexFile文件数据不一致。如果不加以人工修复的话,会有一部分消息即便在CommitLog中文件中存在,但由于没有转发到ConsumerQueue,这部分消息将永远复发被消费者消费。

刷盘机制

handleDiskFlush

RocketMQ的存储是基于JDK NIO的内存映射机制(MappedByteBuffer)的,消息存储首先将消息追加到内存,再根据配置的刷盘策略在不同时间进行刷写磁盘。

一般和同步/异步刷盘配合使用 ( 异步刷盘+主从同步复制)

消息存储

同步刷盘

消息追加到内存后,立即将数据刷写到磁盘文件 。

同步5s

异步刷盘

在消息追加到内存后,立即返回给消息发送端。如果开启transientStorePoolEnable,RocketMQ会单独申请一个与目标物理文件(commitLog)同样大小的堆外内存,该堆外内存将使用内存锁定,确保不会被置换到虚拟内存中去,消息首先追加到堆外内存,然后提交到物理文件的内存映射中,然后刷写到磁盘。如果未开启transientStorePoolEnable,消息直接追加到物理文件直接映射文件中,然后刷写到磁盘中。

开启transientStorePoolEnable后异步刷盘步骤:

  1. 将消息直接追加到ByteBuffer(堆外内存)
  2. CommitRealTimeService线程每隔200ms将ByteBuffer新追加内容提交到MappedByteBuffer中
  3. MappedByteBuffer在内存中追加提交的内容,wrotePosition指针向后移动
  4. commit操作成功返回,将committedPosition位置恢复
  5. FlushRealTimeService线程默认每500ms将MappedByteBuffer中新追加的内存刷写到磁盘

过期文件删除机制

由于RocketMQ操作CommitLog、ConsumerQueue文件是基于内存映射机制并在启动的时候回加载CommitLog、ConsumerQueue目录下的所有文件,为了避免内存与磁盘的浪费,不可能将消息永久存储在消息服务器上,所以要引入一种机制来删除已过期的文件。RocketMQ顺序写CommitLog、ConsumerQueue文件,所有写操作全部落在最后一个CommitLog或者ConsumerQueue文件上,之前的文件在下一个文件创建后将不会再被更新。RocketMQ清除过期文件的方法时:如果当前文件在在一定时间间隔内没有再次被消费,则认为是过期文件,可以被删除,RocketMQ不会关注这个文件上的消息是否全部被消费。默认每个文件的过期时间为72小时,通过在Broker配置文件中设置fileReservedTime来改变过期时间,单位为小时。

代码:DefaultMessageStore#addScheduleTask

private void addScheduleTask() {
	//每隔10s调度一次清除文件
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            DefaultMessageStore.this.cleanFilesPeriodically();
        }
    }, 1000 * 60, this.messageStoreConfig.getCleanResourceInterval(), TimeUnit.MILLISECONDS);
	...
}

代码:DefaultMessageStore#cleanFilesPeriodically

private void cleanFilesPeriodically() {
    //清除存储文件
    this.cleanCommitLogService.run();
    //清除消息消费队列文件
    this.cleanConsumeQueueService.run();
}

删除文件操作的条件

  1. 指定删除文件的时间点,RocketMQ通过deleteWhen设置一天的固定时间执行一次删除过期文件操作,默认凌晨4点
  2. 磁盘空间如果不充足,删除过期文件
  3. 预留,手工触发。
posted @ 2021-06-27 17:00  沉梦匠心  阅读(394)  评论(0)    收藏  举报