RocketMQ 原理 - 部署 - 入门 (图解)

文章很长,建议收藏起来,慢慢读! 高并发 发烧友社群:疯狂创客圈 为小伙伴奉上以下珍贵的学习资源:


RocketMQ简介

rocketMQ作为一款分布式的消息中间件,RocketMQ作为一款分布式的消息中间件(阿里的说法是不遵循任何规范的,所以不能完全用JMS的那一套东西来看它),经历了Metaq1.x、Metaq2.x的发展和淘宝双十一的洗礼,在功能和性能上远超ActiveMQ。

  • 1.要知道RocketMQ原生就是支持分布式的,而ActiveMQ原生存在单点性。
  • 2.RocketMQ可以保证严格的消息顺序,而ActiveMQ无法保证!
  • 3.RocketMQ提供亿级消息的堆积能力,这不是重点,重点是堆积了亿级的消息后,依然保持写入低延迟!
  • 4.丰富的消息拉取模式(Push or Pull) Push好理解,比如在消费者端设置Listener回调;而Pull,控制权在于应用,即应用需要主动的调用拉消息方法从Broker获取消息,这里面存在一个消费位置记录的问题(如果不记录,会导致消息重复消费)。

RocketMQ是什么

在这里插入图片描述

  • RocketMQ是一个队列模型的消息中间件,具有高性能、高可靠、高实时、分布式特点。
  • Producer、Consumer、队列都可以分布式。
  • Producer 向一些队列轮流发送消息,队列集合称为 Topic,Consumer 如果做广播消费,则一个 consumer 实例消费这个 Topic 对应的所有队列,如果做集群消费,则多个 Consumer 实例平均消费这个 topic 对应的队列集合。
  • 能够保证严格的消息顺序
  • 提供丰富的消息拉取模式
  • 高效的订阅者水平扩展能力
  • 实时的消息订阅机制
  • 亿级消息堆积能力
  • 较少的依赖

选择RocketMQ的理由

强调集群无单点,可扩展,任意一点高可用,水平可扩展

​ 方便集群配置,而且容易扩展(横向和纵向),通过slave的方式每一点都可以实现高可用

支持上万个队列,顺序消息

​ 顺序消费是实现在同一队列的,如果高并发的情况就需要队列的支持,rocketmq可以满足上万个队列同时存在

任性定制你的消息过滤

​ rocketmq提供了两种类型的消息过滤,也可以说三种可以通过topic进行消息过滤、可以通过tag进行消息过滤、还可以通过filter的方式任意定制过滤

消息的可靠性(无Buffer,持久化,容错,回溯消费)

​ 消息无buffer就不用担心buffer回满的情况,rocketmq的所有消息都是持久化的,生产者本身可以进行错误重试,发布者也会按照时间阶梯的方式进行消息重发,消息回溯说的是可以按照指定的时间进行消息的重新消费,既可以向前也可以向后(前提条件是要注意消息的擦除时间)

海量消息堆积能力,消息堆积后,写入低延迟

​ 针对于provider需要配合部署方式,对于consumer,如果是集群方式一旦master返现消息堆积会向consumer下发一个重定向指令,此时consumer就可以从slave进行数据消费了

分布式事务

​ 我个人感觉rocketmq对这一块说的不是很清晰,而且官方也说现在这块存在缺陷(会令系统pagecache过多),所以线上建议还是少用为好

消息失败重试机制

​ 针对provider的重试,当消息发送到选定的broker时如果出现失败会自动选择其他的broker进行重发,默认重试三次,当然重试次数要在消息发送的超时时间范围内。

​ 针对consumer的重试,如果消息因为各种原因没有消费成功,会自动加入到重试队列,一般情况如果是因为网络等问题连续重试也是照样失败,所以rocketmq也是采用阶梯重试的方式。

定时消费

​ 除了上面的配置,在发送消息是也可以针对message设置setDelayTimeLevel

活跃的开源社区

​ 现在rocketmq成为了apache的一款开源产品,活跃度也是不容怀疑的

成熟度(经过双十一考验)

​ 针对本身的成熟度,我们看看这么多年的双十一就可想而知了

RocketMQ 逻辑结构

在这里插入图片描述

Broker

  • Broker即是物理上的概念,也是逻辑上的概念。多个物理Broker通过IP:PORT区分,多个逻辑Broker通过BrokerName区分。
  • 多个逻辑Broker组成Cluster。
  • Broker与Topic是多对多的关系。
  • Broker自身包含一个使用10911端口的NettyServer、一个10909的NettyServer,以及一个NettyClient。
  • HA通过10912端口提供服务,用于Broker内部各个部分的数据传输。
  • Broker是最重要的部分,包括持久化消息、Broker集群一致性(HA)、保存历史消息、保存Consumer消费偏移量、索引创建等。
  • Producer发送来的消息最终会通过CommitLog序列化到硬盘,执行序列化逻辑的类为AppendMessageCallback接口的实现类。
  • Broker序列化消息是顺序写,序列化文件保存在userHome/store/commitlog目录下,文件名为总偏移量。
  • 默认为异步刷盘、提交日志单个文件1个G、单个consumer队列文件为不到6M

消费者组(Consumer Group)

在正式开始说消费之前,我们首先要明白一个概念,就是消费组

消费者组(Consumer Group)是一类消费者的集合,这类消费者通常消费同一类消息并且消费逻辑一致,所以将这些消费者分组在一起。消费者组与生产者组类似,都是将相同角色的消费者分组在一起并命名的。

分组是一个很精妙的概念设计,RocketMQ正是通过这种分组机制,实现了天然的消息负载均衡。在消费消息时,通过消费者组实现了将消息分发到多个消费者服务器实例

设置消费者的名字是在代码中实现的,如下:

 DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("stock_consumer_group");

举个例子

某个主题有9条消息,其中一个消费者组有3个实例(3个进程或3台机器),那么每个实例将均摊3条消息,这也意味着我们可以很方便地通过增加机器来实现水平扩展。

如果还不理解的话,我们看下面这张图,由订单系统来的消息,被库存和积分两个组所分配,每个组就是一个消费组

img

消费者组(Consumer Group)可以用来表示一个消费消息应用,一个 Consumer Group 下包含多个 Consumer 实例,可以是多台机器,也可 以是多个进程,或者是一个进程的多个 Consumer 对象。一个 Consumer Group 下的多个 Consumer 以均摊 方式消费消息,如果设置为广播方式,那么这个 Consumer Group 下的每个实例都消费全量数据。

生产者组 Producer Group

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

  • 标识一类 Producer
  • 可以通过运维工具查询这个发送消息应用下有多个 Producer 实例
  • 发送分布式事务消息时,如果 Producer 中途意外宕机,Broker 会主动回调 Producer Group 内的任意 一台机器来确认事务状态。

RocketMQ 核心组件图

RocketMQ是开源的消息中间件,它主要由NameServer,Producer,Broker,Consumer四部分构成。

在这里插入图片描述

NameServer

NameServer主要负责Topic和路由信息的管理,类似zookeeper。

Broker

消息中转角色,负责存储消息,转发消息。

Consumer

消息消费者,负责消息消费,一般是后台系统负责异步消费。

Producer

消息生产者,负责产生消息,一般由业务系统负责产生消息。

RokcetMQ 物理部署图

img

物理概念

NameServer

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

Broker

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

Producer

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

Producer与Name Server关系

1)连接 单个Producer和一台NameServer保持长连接,如果该NameServer挂掉,生产者会自动连接下一个NameServer,直到有可用连接为止,并能自动重连。
2)轮询时间 默认情况下,生产者每隔30秒从NameServer获取所有Topic的最新队列情况,这意味着某个Broker如果宕机,生产者最多要30秒才能感知,在此期间,
发往该broker的消息发送失败。
3)心跳 与nameserver没有心跳

Consumer

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

1、Name Server是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。
2、每个Broker与Name Server集群中的所有节点建立长连接,定时注册Topic信息到所有Name Server。
3、Producer与Name Server集群中的其中一个节点(随机选择)建立长连接,定期从Name Server取Topic路由信息。
4、Consumer与Name Server集群中的其中一个节点(随机选择)建立长连接,定期从Name Server取Topic路由信息。

Broker 详解

1、Broker与Name Server关系

1)连接 单个Broker和所有Name Server保持长连接。

2)心跳

心跳间隔:每隔30秒向所有NameServer发送心跳,心跳包含了自身的Topic配置信息。

心跳超时:NameServer每隔10秒,扫描所有还存活的Broker连接,若某个连接2分钟内没有发送心跳数据,则断开连接。

3)断开:当Broker挂掉;NameServer会根据心跳超时主动关闭连接,一旦连接断开,会更新Topic与队列的对应关系,但不会通知生产者和消费者。

2、 负载均衡

一个Topic分布在多个Broker上,一个Broker可以配置多个Topic,它们是多对多的关系。
如果某个Topic消息量很大,应该给它多配置几个Queue,并且尽量多分布在不同Broker上,减轻某个Broker的压力。

3 、可用性

由于消息分布在各个Broker上,一旦某个Broker宕机,则该Broker上的消息读写都会受到影响。

所以RocketMQ提供了Master/Slave的结构,Salve定时从Master同步数据,如果Master宕机,则Slave提供消费服务,但是不能写入消息,此过程对应用透明,由RocketMQ内部解决。
有两个关键点:
思考1一旦某个broker master宕机,生产者和消费者多久才能发现?

受限于Rocketmq的网络连接机制,默认情况下最多需要30秒,因为消费者每隔30秒从nameserver获取所有topic的最新队列情况,这意味着某个broker如果宕机,客户端最多要30秒才能感知。

思考2 master恢复恢复后,消息能否恢复。
消费者得到Master宕机通知后,转向Slave消费,但是Slave不能保证Master的消息100%都同步过来了,因此会有少量的消息丢失。但是消息最终不会丢的,一旦Master恢复,未同步过去的消息会被消费掉。

RocketMQ的领域模型

在这里插入图片描述

Message

代表一条消息,使用MessageId唯一识别,用户在发送时可以设置messageKey,便于之后查询和跟踪。一个 Message 必须指定 Topic,相当于寄信的地址。Message 还有一个可选的 Tag 设置,以便消费端可以基于 Tag 进行过滤消息。也可以添加额外的键值对,例如你需要一个业务 key 来查找 Broker 上的消息,方便在开发过程中诊断问题。

Topic

  • Topic表示消息的第一级类型,比如一个电商系统的消息可以分为:交易消息、物流消息等。一条消息必须有一个Topic。
  • 最细粒度的订阅单位,一个Group可以订阅多个Topic的消息。

Tag

Tag表示消息的第二级类型,比如交易消息又可以分为:交易创建消息,交易完成消息等。RocketMQ提供2级消息分类,方便灵活控制。

标签可以被认为是对 Topic 进一步细化。一般在相同业务模块中通过引入标签来标记不同用途的消息。

Queue

Topic和Queue是1对多的关系一个Topic下可以包含多个Queue,主要用于负载均衡。发送消息时,用户只指定Topic,Producer会根据Topic的路由信息选择具体发到哪个Queue上。Consumer订阅消息时,会根据负载均衡策略决定订阅哪些Queue的消息。

消息的物理管理单位。一个Topic下可以有多个Queue,Queue的引入使得消息的存储可以分布式集群化,具有了水平扩展能力。

在 RocketMQ 中,所有消息队列都是持久化,长度无限的数据结构,所谓长度无限是指队列中的每个存储单元都是定长,访问其中的存储单元使用 Offset 来访问,offset 为 java long 类型,64 位,理论上在 100年内不会溢出,所以认为是长度无限。

Queue和消费者的 ConsumeQueue物理概念一 一对应, ConsumeQueue是一个长度无限的数组,Offset 就是下标。

Offset

RocketMQ在存储消息时会为每个Topic下的每个Queue生成一个消息的索引文件,每个Queue都对应一个Offset记录当前Queue中消息条数

Producer

消息生产者,位于用户的进程内,Producer通过NameServer获取所有Broker的路由信息,根据负载均衡策略选择将消息发到哪个Broker,然后调用Broker接口提交消息。

Producer Group

生产者组,简单来说就是多个发送同一类消息的生产者称之为一个生产者组。

Consumer

消息消费者,位于用户进程内。Consumer通过NameServer获取所有broker的路由信息后,向Broker发送Pull请求来获取消息数据。Consumer可以以两种模式启动,广播(Broadcast)和集群(Cluster)广播模式下,一条消息会发送给所有Consumer,集群模式下消息只会发送给一个Consumer

Consumer Group

消费者组,和生产者类似,消费同一类消息的多个 Consumer 实例组成一个消费者组。

NameServer

NameServer可以看作是RocketMQ的注册中心,它管理两部分数据:集群的Topic-Queue的路由配置;Broker的实时配置信息。其它模块通过Nameserv提供的接口获取最新的Topic配置和路由信息。

  • Producer/Consumer :通过查询接口获取Topic对应的Broker的地址信息
  • Broker : 注册配置信息到NameServer, 实时更新Topic信息到NameServer

RocketMQ 消息存储设计原理图

RocketMQ存储逻辑对象层

  • 该层主要包含了RocketMQ数据文件存储直接相关的三个模型类IndexFile、ConsumerQueue和CommitLog。
  • IndexFile为索引数据文件提供访问服务,ConsumerQueue为逻辑消息队列提供访问服务,CommitLog则为消息存储的日志数据文件提供访问服务。
  • 这三个模型类也是构成了RocketMQ存储层的整体结构。
    在这里插入图片描述

CommitLog

消息存储文件,所有消息主题的消息都存储在 CommitLog 文件中。
Commitlog 文件存储的逻辑视图如图所示

在这里插入图片描述

ConsumeQueue

消息消费队列,消息到达 CommitLog 文件后,将异步转发到 消费队列,供消息消费者消费。ConsumeQueue存储格式如下:
在这里插入图片描述

  • 单个 ConsumeQueue 文件中默认包含 30 万个条目,单个文件的长度为 30w × 20 字节, 单个 ConsumeQueue 文件可以看出是一个 ConsumeQueue 条目的数组,其下标为 ConsumeQueue 的逻辑偏移量,消息消费进度存储的偏移量 即逻辑偏移量。
  • ConsumeQueue 即为 Commitlog 文件的索引文件, 其构建机制是当消息到达 Commitlog 文件后, 由专门的线程 产生消息转发任务,从而构建消息消费队列文件与下文提到的索引文件。

为什么需要 ConsumeQueue ?

RocketMQ的消息都是按照先来后到,顺序的存储在CommitLog中的,而消费者通常只关心某个Topic下的消息。顺序的查找CommitLog肯定是不现实的,我们可以构建一个索引文件,里面存放着某个Topic下面所有消息在CommitLog中的位置,这样消费者获取消息的时候,只需要先查找这个索引文件,然后再去CommitLog中获取消息就 OK了。这个索引文件,就是我们的ComsumerQueue。

IndexFile

消息索引文件,主要存储消息 Key 与 Offset 的对应关系。

lndexFile 总共包含 lndexHeader、 Hash 槽、 Hash 条目。

消息消费队列是RocketMQ专门为消息订阅构建的索引文件,提高根据主题与消息队 列检索消息的速度 ,另外 RocketMQ 引入了 Hash 索引机制为消息建立索引, HashMap 的设 计包含两个基本点 : Hash 槽与 Hash 冲突的链表结构。 RocketMQ 索引文件布局如图所示
在这里插入图片描述

存储分析的实例

1、流程图

我们由简单到复杂的来理解,它的一些核心概念
img

这个图很好理解,消息先发到Topic,然后消费者去Topic拿消息。只是Topic在这里只是个概念,那它到底是怎么存储消息数据的呢,这里就要引入Broker概念。

2、Topic的存储

Topic是一个逻辑上的概念,实际上Message是在每个Broker上以Queue的形式记录。
img

从上面的图片可以总结下几条结论。

1、消费者发送的Message会在Broker中的Queue队列中记录。
2、一个Topic的数据可能会存在多个Broker中。
3、一个Broker存在多个Queue。
4、单个的Queue也可能存储多个Topic的消息。

也就是说每个Topic在Broker上会划分成几个逻辑队列,每个逻辑队列保存一部分消息数据,但是保存的消息数据实际上不是真正的消息数据,而是指向commit log的消息索引。

Queue不是真正存储Message的地方,真正存储Message的地方是在CommitLog

如图(盗图)
img

左边的是CommitLog。这个是真正存储消息的地方。RocketMQ所有生产者的消息都是往这一个地方存的。

右边是ConsumeQueue。这是一个逻辑队列。和上文中Topic下的Queue是一一对应的。消费者是直接和ConsumeQueue打交道。ConsumeQueue记录了消费位点,这个消费位点关联了commitlog的位置。所以即使ConsumeQueue出问题,只要commitlog还在,消息就没丢,可以恢复出来。还可以通过修改消费位点来重放或跳过一些消息。

事务状态服务

存储每条消息的事务状态。

定时消息服务

每一个延迟级别对应一个消息消费队列,存储延迟队列的消息拉取进度。

RMQ文件存储模型层

在这里插入图片描述

封装的文件内存映射层

  • RocketMQ主要采用JDK NIO中的MappedByteBuffer和FileChannel两种方式完成数据文件的读写。
  • 其中,采用MappedByteBuffer这种内存映射磁盘文件的方式完成对大文件的读写,在RocketMQ中将该类封装成MappedFile类。
  • 这里,每一种类的单个文件均由MappedFile类提供读写操作服务(其中,MappedFile类提供了顺序写/随机读、内存数据刷盘、内存清理等和文件相关的服务)。

磁盘存储层

主要指的是部署RocketMQ服务器所用的磁盘。这里,需要考虑不同磁盘类型(如SSD或者普通的HDD)特性以及磁盘的性能参数(如IOPS、吞吐量和访问时延等指标)对顺序写/随机读操作带来的影响。

RocketMQ中消息刷盘

在RocketMQ中消息刷盘主要可以分为同步刷盘和异步刷盘两种。

同步刷盘

img

  • 在返回写成功状态时,消息已经被写入磁盘。
  • 具体流程是,消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。
  • 一般只用于金融场景。

异步刷盘

img在返回写成功状态时,消息可能只是被写入了内存的PAGECACHE,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘操作,快速写入。

消息在系统中流转图

img

1.Producer 发送消息,消息从 socket 进入 java 堆。

2.Producer 发送消息,消息从 java 堆转入 PAGACACHE,物理内存。

3.Producer 发送消息,由异步线程刷盘,消息从 PAGECACHE 刷入磁盘。

4.Consumer 拉消息(正常消费),消息直接从 PAGECACHE(数据在物理内存)转入 socket,到达 consumer, 不经过 java 堆。这种消费场景最多,线上 96G 物理内存,按照 1K 消息算,可以在物理内存缓存 1 亿条消 息。

5.Consumer 拉消息(异常消费),消息直接从 PAGECACHE(数据在虚拟内存)转入 socket。

6.Consumer 拉消息(异常消费),由于 Socket 访问了虚拟内存,产生缺页中断,此时会产生磁盘 IO,从磁 盘 Load 消息到 PAGECACHE,然后直接从 socket 发出去。

7.同 5 一致。

8.同 6 一致。

RocketMQ的消费模式

RocketMQ的消费模式有2种: 集群消费(CLUSTERING) 、 广播消费(BROADCASTING)。源码如下:

 1 public enum MessageModel {
 2     BROADCASTING("BROADCASTING"),
 3     CLUSTERING("CLUSTERING");
 4 
 5     private String modeCN;
 6 
 7     private MessageModel(String modeCN) {
 8         this.modeCN = modeCN;
 9     }
10 
11     public String getModeCN() {
12         return this.modeCN;
13     }
14 }

集群消费(CLUSTERING)

集群消费(CLUSTERING)是指: 一个ConsumerGroup中的Consumer实例平均分摊消费消息。例如某个Topic有9条消息,其中一个ConsumerGroup有3个实例(可能是3个进程,或者3台机器),那么每个实例只消费其中部分,消费完的消息不能被其他实例消费。

例如:某个Topic有9条消息,有3个消费者,广播模式就是每个消费者都收到9条消息,集群模式就是消费者平均分摊9条消息

img

其实,对于RocketMQ而言,通过ConsumeGroup的机制,实现了天然的消息负载均衡!通俗点来说,RocketMQ中的消息通过ConsumeGroup实现了将消息分发到C1/C2/C3/……的机制,这意味着我们将非常方便的通过加机器来实现水平扩展!

至于消息分发到C1/C2/C3,其实也是可以设置策略的:

img

默认的分配算法是AllocateMessageQueueAveragely

img

还有另外一种平均的算法是AllocateMessageQueueAveragelyByCircle,也是平均分摊每一条queue,只是以环状轮流分queue的形式,如下图:

img

广播消费(BROADCASTING)

**广播消费 **,类似于ActiveMQ中的发布订阅模式,消息会发给Consume Group中的每一个消费者进行消费。 由于广播模式下要求一条消息需要投递到一个消费组下面每一个消费者实例,所以也就没有消息被分摊消费的说法。

代码核心

//设置广播消费模式就是在消费者这里设置一下,其余的代码不变
consumer.setMessageModel(MessageModel.BROADCASTING);

广播消费(BROADCASTING)下,一条消息被多个consumer消费,即使这些consumer属于同一个ConsumerGroup,消息也会被ConsumerGroup中的每个Consumer都消费一次,广播消费中ConsumerGroup概念可以认为在消息划分方面无意义。

RocketMQ-广播消费模式设置:

img

RocketMQ源码: 路由中心

早期的rocketmq版本的路由功能是使用zookeeper实现的,后来rocketmq为了追求性能,自己实现了一个性能更高效且实现简单的路由中心 NameServer,

可以通过部署多个 NameServer 路由节点实现高可用,但它们 NameServer 之间并不能互相通信,这也就会导致在某一个时刻各个路由节点间的数据并不完全相同,但数据某个时刻不一致并不会导致消息发送不了,这也是rocketmq 追求简单高效的一个做法。

Nameserver的源码

Nameserver的源码很简单,整个NameServer总共就由这么几个类组成:

img

其中NamesrvStartup为启动类,NamesrvController为核心控制器,RouteInfoManager为路由信息表。

路由信息表与心跳(路由注册)

路由注册即是Broker向Nameserver注册的过程,它们是通过Broker的心跳功能实现的,

我们知道RouteInfoManager为路由信息表,先来看看Nameserver到底存储了哪些信息:

public class org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager {
    private static final InternalLogger log = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_LOGGER_NAME);
    private final static long BROKER_CHANNEL_EXPIRED_TIME = 1000 * 60 * 2;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
    private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
    private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
    private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
    private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
}
  • topicQueueTable:Topic消息队列路由信息,

    包括topic所在的broker名称,读队列数量,写队列数量,同步标记等信息,rocketmq根据topicQueueTable的信息进行负载均衡消息发送。

  • brokerAddrTable:Broker节点信息

    包括brokername,所在集群名称,还有主备节点信息。

  • clusterAddrTable:Broker集群信息

    存储了集群中所有的Brokername。

  • brokerLiveTable:Broker状态信息

    Nameserver每次收到Broker的心跳包就会更新该信息。

这里也先讲一下rocketmq是基于订阅发布机制,我之前也写过一篇文章《rocketmq的消费模式》,我们可知一个Topic拥有多个消息队列,如果不指定队列的数量,一个Broker会为每个Topic创建4个读队列和4个写队列,多个Broker组成集群,Broker会通过发送心跳包将自己的信息注册到路由中心,路由中心brokerLiveTable存储Broker的状态,它会根据Broker的心跳包更新Broker状态信息。

步骤一:Broker发送心跳包

org.apache.rocketmq.broker.BrokerController#start:

public void start() throws Exception {
    
    // 初次启动,这里会强制执行发送心跳包
    this.registerBrokerAll(true, false, true);
    
    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);
}

Broker在核心控制器启动时,会强制发送一次心跳包,接着创建一个定时任务,定时向路由中心发送心跳包。

org.apache.rocketmq.broker.BrokerController#registerBrokerAll:

public synchronized void registerBrokerAll(final boolean checkOrderConfig, boolean oneway, boolean forceRegister) {
    // 创建一个topic包装类
    TopicConfigSerializeWrapper topicConfigWrapper = this.getTopicConfigManager().buildTopicConfigSerializeWrapper();

    // 这里比较有趣,如果该broker没有读写权限,那么会新建一个临时的topicConfigTable,再set进包装类
    if (!PermName.isWriteable(this.getBrokerConfig().getBrokerPermission())
        || !PermName.isReadable(this.getBrokerConfig().getBrokerPermission())) {
        ConcurrentHashMap<String, TopicConfig> topicConfigTable = new ConcurrentHashMap<String, TopicConfig>();
        for (TopicConfig topicConfig : topicConfigWrapper.getTopicConfigTable().values()) {
            TopicConfig tmp =
                new TopicConfig(topicConfig.getTopicName(), topicConfig.getReadQueueNums(), topicConfig.getWriteQueueNums(),
                                this.brokerConfig.getBrokerPermission());
            topicConfigTable.put(topicConfig.getTopicName(), tmp);
        }
        topicConfigWrapper.setTopicConfigTable(topicConfigTable);
    }

     // 判断是否该Broker是否需要发送心跳包
    if (forceRegister || needRegister(this.brokerConfig.getBrokerClusterName(),
                                      this.getBrokerAddr(),
                                      this.brokerConfig.getBrokerName(),
                                      this.brokerConfig.getBrokerId(),
                                      this.brokerConfig.getRegisterBrokerTimeoutMills())) {
        // 执行发送心跳包
        doRegisterBrokerAll(checkOrderConfig, oneway, topicConfigWrapper);
    }
}

该方法是Broker执行发送心跳包的核心控制方法,它主要做了topic的包装类封装操作,判断Broker此时是否需要执行发送心跳包,但我查了下org.apache.rocketmq.common.BrokerConfig#forceRegister字段的值永远等于true,也就是该判断永远为true,即每次都需要发送心跳包。

我们定位到needRegister远程调用到路由中心的方法:

org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#isBrokerTopicConfigChanged:

public boolean isBrokerTopicConfigChanged(final String brokerAddr, final DataVersion dataVersion) {
    DataVersion prev = queryBrokerTopicConfig(brokerAddr);
    return null == prev || !prev.equals(dataVersion);
}

public DataVersion queryBrokerTopicConfig(final String brokerAddr) {
    BrokerLiveInfo prev = this.brokerLiveTable.get(brokerAddr);
    if (prev != null) {
        return prev.getDataVersion();
    }
    return null;
}

发现,Broker是否需要发送心跳包由该Broker在路由中心org.apache.rocketmq.namesrv.routeinfo.BrokerLiveInfo#dataVersion决定,如果dataVersion为空或者当前dataVersion不等于brokerLiveTable存储的brokerLiveTable,Broker就需要发送心跳包。

步骤二:Nameserver处理心跳包

Nameserver的netty服务监听收到心跳包之后,会调用到路由中心以下方法进行处理:

org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#registerBroker:

public RegisterBrokerResult registerBroker(
    final String clusterName,
    final String brokerAddr,
    final String brokerName,
    final long brokerId,
    final String haServerAddr,
    final TopicConfigSerializeWrapper topicConfigWrapper,
    final List<String> filterServerList,
    final Channel channel) {
    RegisterBrokerResult result = new RegisterBrokerResult();
    try {
        try {
            this.lock.writeLock().lockInterruptibly();

            // 获取集群下所有的Broker,并将当前Broker加入clusterAddrTable,由于brokerNames是Set结构,并不会重复
            Set<String> brokerNames = this.clusterAddrTable.get(clusterName);
            if (null == brokerNames) {
                brokerNames = new HashSet<String>();
                this.clusterAddrTable.put(clusterName, brokerNames);
            }
            brokerNames.add(brokerName);

            boolean registerFirst = false;

            // 获取Broker信息,如果是首次注册,那么新建一个BrokerData并加入brokerAddrTable
            BrokerData brokerData = this.brokerAddrTable.get(brokerName);
            if (null == brokerData) {
                registerFirst = true;
                brokerData = new BrokerData(clusterName, brokerName, new HashMap<Long, String>());
                this.brokerAddrTable.put(brokerName, brokerData);
            }
            // 这里判断Broker是否是已经注册过
            String oldAddr = brokerData.getBrokerAddrs().put(brokerId, brokerAddr);
            registerFirst = registerFirst || (null == oldAddr);

            // 如果是Broker是Master节点吗,并且Topic信息更新或者是首次注册,那么创建更新topic队列信息
            if (null != topicConfigWrapper
                && MixAll.MASTER_ID == brokerId) {
                if (this.isBrokerTopicConfigChanged(brokerAddr, topicConfigWrapper.getDataVersion())
                    || registerFirst) {
                    ConcurrentMap<String, TopicConfig> tcTable =
                        topicConfigWrapper.getTopicConfigTable();
                    if (tcTable != null) {
                        for (Map.Entry<String, TopicConfig> entry : tcTable.entrySet()) {
                            this.createAndUpdateQueueData(brokerName, entry.getValue());
                        }
                    }
                }
            }

            // 更新BrokerLiveInfo状态信息
            BrokerLiveInfo prevBrokerLiveInfo = 
                this.brokerLiveTable.put(brokerAddr,new BrokerLiveInfo(System.currentTimeMillis(),topicConfigWrapper.getDataVersion(),channel,haServerAddr));
        } finally {
            this.lock.writeLock().unlock();
        }
    } catch (Exception e) {
        log.error("registerBroker Exception", e);
    }

    return result;
}

该方法是处理Broker心跳包的最核心方法,它主要做了对RouteInfoManager路由信息的一些更新操作,包括对clusterAddrTable、brokerAddrTable、topicQueueTable、brokerLiveTable等路由信息。

NamesrvStartup启动类

知道了这几个类的功能之后,我们就直接定位到NamesrvStartup启动类的启动方法

img

Namesrv顾名思义就是名称服务,是没有状态可横向扩展的服务。废话不多说了,直接贴代码。。

public static NamesrvController main0(String[] args) {
        System.setProperty(RemotingCommand.REMOTING_VERSION_KEY, Integer.toString(MQVersion.CURRENT_VERSION));

        if (null == System.getProperty(NettySystemConfig.COM_ROCKETMQ_REMOTING_SOCKET_SNDBUF_SIZE)) {
            NettySystemConfig.socketSndbufSize = 4096;// socket发送缓冲区大小
        }

        if (null == System.getProperty(NettySystemConfig.COM_ROCKETMQ_REMOTING_SOCKET_RCVBUF_SIZE)) {
            NettySystemConfig.socketRcvbufSize = 4096;// Socket接收缓冲区大小
        }

        try {
            //PackageConflictDetect.detectFastjson();

            Options options = ServerUtil.buildCommandlineOptions(new Options());//构建Options,有h代表help,n代表namesrvAddr
            //Options加上c代表configFile,p代表printConfigItem
            //解析得到commandLine
            commandLine = ServerUtil.parseCmdLine("mqnamesrv", args, buildCommandlineOptions(options), new PosixParser());
            if (null == commandLine) {
                System.exit(-1);
                return null;
            }

            final NamesrvConfig namesrvConfig = new NamesrvConfig();
            final NettyServerConfig nettyServerConfig = new NettyServerConfig();
            nettyServerConfig.setListenPort(9876);//监听端口是9876
            if (commandLine.hasOption('c')) {//有配置文件
                String file = commandLine.getOptionValue('c');
                if (file != null) {
                    InputStream in = new BufferedInputStream(new FileInputStream(file));
                    properties = new Properties();
                    properties.load(in);//加载配置文件到prop
                    MixAll.properties2Object(properties, namesrvConfig);//根据prop文件解析,给namesrvConfig填充对应的值
                    MixAll.properties2Object(properties, nettyServerConfig);//根据prop文件解析,给nettyServerConfig填充对应的值

                    namesrvConfig.setConfigStorePath(file);//设置config存储路径

                    System.out.printf("load config properties file OK, " + file + "%n");
                    in.close();
                }
            }
            if (commandLine.hasOption('p')) {//打印namesrvConfig和nettyServerConfig的非静态,非this开头的字段
                MixAll.printObjectProperties(null, namesrvConfig);
                MixAll.printObjectProperties(null, nettyServerConfig);
                System.exit(0);
            }
            //再根据commandLine得到一个prop,再给namesrvConfig填充对应的值
            MixAll.properties2Object(ServerUtil.commandLine2Properties(commandLine), namesrvConfig);
            if (null == namesrvConfig.getRocketmqHome()) {//默认NamesrvConfig.rocketmqHome为空,且配置,参数中RocketMQHome为空的话抛异常
                System.out.printf("Please set the " + MixAll.ROCKETMQ_HOME_ENV + " variable in your environment to match the location of the RocketMQ installation%n");
                System.exit(-2);
            }
            //配置Logback
            LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
            JoranConfigurator configurator = new JoranConfigurator();
            configurator.setContext(lc);
            lc.reset();
            configurator.doConfigure(namesrvConfig.getRocketmqHome() + "/conf/logback_namesrv.xml");
            final Logger log = LoggerFactory.getLogger(LoggerName.NAMESRV_LOGGER_NAME);
            //打印namesrvConfig和nettyServerConfig的非静态,非this开头的字段
            MixAll.printObjectProperties(log, namesrvConfig);
            MixAll.printObjectProperties(log, nettyServerConfig);
            //根据namesrvConfig,nettyServerConfig构造NamesrvController
            final NamesrvController controller = new NamesrvController(namesrvConfig, nettyServerConfig);

            // remember all configs to prevent discard
            controller.getConfiguration().registerConfig(properties);

            boolean initResult = controller.initialize();//初始化NamesrvController
            if (!initResult) {
                controller.shutdown();
                System.exit(-3);
            }

            Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, new Callable<Void>() {
                @Override
                public Void call() throws Exception {
                    controller.shutdown();//shutdown的时候 NamesrvController也shutdown
                    return null;
                }
            }));

            controller.start();//启动服务

            String tip = "The Name Server boot success. serializeType=" + RemotingCommand.getSerializeTypeConfigInThisServer();
            log.info(tip);
            System.out.printf(tip + "%n");

            return controller;
        } catch (Throwable e) {
            e.printStackTrace();
            System.exit(-1);
        }

        return null;
    }

RocketMQ的部署

部署方式

单Master模式

​ 只有一个 Master节点

​ 优点:配置简单,方便部署

​ 缺点:这种方式风险较大,一旦Broker重启或者宕机时,会导致整个服务不可用,不建议线上环境使用

多Master模式

​ 一个集群无 Slave,全是 Master,例如 2 个 Master 或者 3 个 Master

​ 优点:配置简单,单个Master 宕机或重启维护对应用无影响,在磁盘配置为RAID10 时,即使机器宕机不可恢复情况下,由与 RAID10磁盘非常可靠,消息也不会丢(异步刷盘丢失少量消息,同步刷盘一条不丢)。性能最高。多 Master 多 Slave 模式,异步复制

​ 缺点:单台机器宕机期间,这台机器上未被消费的消息在机器恢复之前不可订阅,消息实时性会受到受到影响

多Master多Slave模式(异步复制)---本文稍后以这种方式部署集群为例

​ 每个 Master 配置一个 Slave,有多对Master-Slave, HA,采用异步复制方式,主备有短暂消息延迟,毫秒级。

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

​ 缺点: Master 宕机,磁盘损坏情况,会丢失少量消息。

多Master多Slave模式(同步双写)---文中会说明补充此集群配置,线上使用的话,推荐使用此模式集群

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

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

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

单机模式部署

2. 下载相关软件

3. 配置环境

3.1. 解压RocketMQ

unzip /usr/local/rocketmq-all-4.4.0-bin-release.zip -d /usr/local/rocketmq

3.2. 配置环境变量

vi /etc/profile
export JAVA_HOME=/opt/soft/jdk1.8.0_231
export PATH=$JAVA_HOME/bin:$PATH
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar

export MAVEN_HOME=/opt/soft/apache-maven-3.5.4
export PATH=$PATH:$MAVEN_HOME/bin

export ROCKETMQ_HOME=/usr/local/rocketmq/rocketmq-all-4.4.0-bin-release
export PATH=$ROCKETMQ_HOME/bin:$PATH

或者使用追加的命令

cat  >> /etc/profile <<EOF
export SCAFFOLD_DB_HOST=cdh1
export SCAFFOLD_DB_USER=root
export SCAFFOLD_DB_PSW=wTJJJJZZZ@SSDDC
export SCAFFOLD_REDIS_HOST=cdh1
export SCAFFOLD_REDIS_PSW=123456
export SCAFFOLD_EUREKA_ZONE_HOSTS=http://cdh1:7777/eureka/
export SCAFFOLD_ROCKETMQ_HOSTS=172.26.9.107:2181
export SCAFFOLD_ZOOKEEPER_HOSTS=cdh1:2181
export LC_ALL=en_US.UTF-8
export ROCKETMQ_HOME=/usr/local/rocketmq/rocketmq-all-4.4.0-bin-release
export JAVA_HOME=/usr/local/java/jdk1.8.0_121
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
export PATH=$ROCKETMQ_HOME/bin:$JAVA_HOME/bin:/usr/bin:/usr/local/mysql/bin:/root/bin
EOF

3.3. 刷新配置文件

source /etc/profile

3.4. 验证环境是否正确

java -version
java version "1.8.0_231"
Java(TM) SE Runtime Environment (build 1.8.0_231-b11)


4. RocketMQ启动和关闭

4.1 创建数据存储目录


mkdir -p /usr/local/rocketmq/data

4.2 创建日志目录

mkdir -p /usr/local/rocketmq/logs

4.3 修改broker.conf文件

vim /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/broker.conf

修改内容如下:

# 所属集群名字(同一主从下: Master 和 Slave 名称要一致)
brokerClusterName = DefaultCluster

# Broker 名字,注意此处不同的配置文件填写的不一样
brokerName = broker-a

# 0 表示 Master,> 0 表示 Slave
brokerId = 0

# Broker 对外服务的监听端口, 如果一台机器上启动了多个Broker,则要设置不同的端口号,避免冲突
listenPort = 10911

# nameServer地址,如果nameServer是多台集群的话,就用分号分割,比如
# namesrvAddr = 192.168.0.1:9876;192.168.0.2:9876
# namesrvAddr = 127.0.0.1:9876
namesrvAddr = 192.168.56.121:9876
brokerIP1 = 192.168.56.121
# 是否允许 Broker 自动创建Topic,建议线上关闭
autoCreateTopicEnable = true

# 是否允许 Broker 自动创建订阅组,建议线上关闭
autoCreateSubscriptionGroup = true

# 与fileReservedTime参数呼应,表明在几点做消息删除动作,默认值04表示凌晨4点
deleteWhen = 04

# 在磁盘上保存消息的时长,单位是小时,自动删除超时的消息
fileReservedTime = 48

# brokerRole有3种:SYNC_MASTER、ASYNC_MASTER、SLAVE
# 关键词 SYNC 和 ASYNC 表示 Master 和 Slave 之间同步消息的机制
# SYNC 的意思是当 Slave 和 Master 消息同步完成后,再返回发送成功的状态
brokerRole = ASYNC_MASTER

# 刷盘方式 ASYNC_FLUSH 异步刷盘; SYNC_FLUSH 同步刷盘
flushDiskType = ASYNC_FLUSH

#数据存储位置
storePathRootDir = /usr/local/rocketmq/data
storePathCommitLog = /usr/local/rocketmq/logs

4.4 修改启动脚本JVM内存大小

4.4.1 vim /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/bin/runserver.sh
JAVA_OPT="${JAVA_OPT} -server -Xms512m -Xmx512m -Xmn256m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m"
4.4.2 vim /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/bin/runbroker.sh
JAVA_OPT="${JAVA_OPT} -server -Xms512m -Xmx512m -Xmn128m"

4.5 启动namesrv 和 broker

[root@master bin]# nohup mqnamesrv -n 192.168.56.121:9876 &
[1] 2937
[root@master bin]# nohup: 忽略输入并把输出追加到"nohup.out"
  
[root@master bin]# nohup sh mqbroker -n 192.168.56.121:9876 autoCreateTopicEnable=true -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/broker.conf &
[2] 2968
[root@master bin]# nohup: 忽略输入并把输出追加到"nohup.out"
# 查看broker启动配置:
[root@master bin]# sh mqbroker -m
[root@master bin]# jps
3030 Jps
2940 NamesrvStartup
2972 BrokerStartup
命令汇总
mqnamesrv -n 172.26.9.107
nohup mqnamesrv -n 172.26.9.107 &

nohup sh mqbroker -n 172.26.9.107:9876 autoCreateTopicEnable=true -c 

nohup sh mqbroker -n 172.26.9.107:9876 autoCreateTopicEnable=true -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/broker.conf &

mqbroker -m

 jps


设置开机启动

#!/bin/bash
# THIS FILE IS ADDED FOR COMPATIBILITY PURPOSES
#
# It is highly advisable to create own systemd services or udev rules
# to run scripts during boot instead of using this file.
#
# In contrast to previous versions due to parallel execution during boot
# this script will NOT be run after all other services.
#
# Please note that you must run 'chmod +x /etc/rc.d/rc.local' to ensure
# that this script will be executed during boot.

touch /var/lock/subsys/local

#start redis
/usr/local/redis/bin/redis-server  /usr/local/redis/redis.conf


#start nginx
#/usr/bin/su  - root  -c   "/usr/local/openresty/nginx/sbin/nginx  -c /usr/local/openresty/nginx/conf/nginx.conf"

# 启动 zookeeper
/usr/bin/su  - root  -c   "/work/zookeeper/zookeeper_01/bin/zkServer.sh start"
sleep 5s
/usr/bin/su  - root  -c   "/work/zookeeper/zookeeper_02/bin/zkServer.sh start"
# 启动 Nacos
/usr/bin/su  - root  -c   "/work/nacos/bin/startup.sh -m standalone"




#start springcloud

sleep 10s

/usr/bin/su  - root  -c   "/work/cloud-eureka-1.0-SNAPSHOT/bin/start.sh start"

sleep 20s

/usr/bin/su  - root  -c   "/work/cloud-config-1.0-SNAPSHOT/bin/start.sh start"


# 启动 kafka
#/usr/bin/su  - root  -c   "nohup /work/kafka_2.11-1.0.2/bin/kafka-server-start.sh /work/kafka_2.11-1.0.2/config/server.properties  2>&1 &"

#启动 sentinel

 /usr/bin/su  - root  -c   "nohup java  -server -Xms64m -Xmx256m  -Dserver.port=8849   -Dcsp.sentinel.dashboard.server=localhost:8849 -Dproject.name=sentinel-dashboard -jar /work/sentinel-dashboard-1.7.1.jar  2>&1 &"

sleep 2s
# 启动 rocketmq namenode
#/usr/bin/su  - root  -c   "nohup mqnamesrv -n 192.168.56.121:9876 &"

sleep 30s
# 启动 rocketmq broker
#/usr/bin/su  - root  -c   "nohup sh mqbroker -n 192.168.56.121:9876 autoCreateTopicEnable=true -c /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/conf/broker.conf &"

4.6 查看启动日志记录

tail -200f ~/logs/rocketmqlogs/namesrv.log
tail -200f ~/logs/rocketmqlogs/broker.log

4.7 消息发送和消费测试

# 设置NameServer地址
export NAMESRV_ADDR=192.168.56.121:9876

 /usr/local/rocketmq/rocketmq-all-4.4.0-bin-release/distribution/target/apache-rocketmq
 
# 测试发送端
sh tools.sh org.apache.rocketmq.example.quickstart.Producer
# 测试消费端
sh tools.sh org.apache.rocketmq.example.quickstart.Consumer

4.8 关闭namesrv 和 broker

[root@master bin]# sh mqshutdown broker
The mqbroker(2972) is running...
Send shutdown request to mqbroker(2972) OK
[root@master bin]# sh mqshutdown namesrv
The mqnamesrv(2940) is running...
Send shutdown request to mqnamesrv(2940) OK

5. rocketmq-console安装和使用

5.1 从github上clone代码

git clone https://github.com/apache/rocketmq-externals.git

5.2 用Maven编译源码

rocketmq-externals/rocketmq-console
mvn clean package -Dmaven.test.skip=true

5.3 生成的jar文件目录

rocketmq-externals/rocketmq-console/target/rocketmq-console-ng-1.0.1.jar

5.4 执行jar文件

复制到/usr/local/rocketmq/,然后执行:

java -jar /usr/local/rocketmq/rocketmq-console-ng-1.0.1.jar --rocketmq.config.namesrvAddr='192.168.56.121:9876'

5.5 关闭防火墙

# 关闭防火墙
systemctl stop firewalld.service
# 查看防火墙状态
firewall-cmd --state

5.6 访问链接

http://192.168.100.129:8080/#/

rocketmq-console

6. Java示例代码

6.1 生产者示例代码

      private void startProducer() {
        //指定NameServer地址
        producer.setNamesrvAddr(CDH_1_9876); //修改为自己的
        producer.setInstanceName("Instance1");
        producer.setRetryTimesWhenSendFailed(3);
        /**
         * Producer对象在使用之前必须要调用start初始化,初始化一次即可
         * 注意:切记不可以在每次发送消息时,都调用start方法
         */
        try {
            producer.start();
            System.out.println("product start ...");
        } catch (MQClientException e) {
            e.printStackTrace();
        }
    }

6.2 消费者示例代码


    private void startConsumer() {
        //指定NameServer地址,多个地址以 ; 隔开
        consumer.setNamesrvAddr(CDH_1_9876); //修改为自己的
//        consumer.setNamesrvAddr("192.168.116.115:9876;192.168.116.116:9876"); //修改为自己的


/**
 * 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
 * 如果非第一次启动,那么按照上次消费的位置继续消费
 */
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

        try {
            consumer.subscribe(TOPIC_TEST, "*");
        } catch (MQClientException e) {
            e.printStackTrace();
            return;
        }

        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                for (int i = 0; i < msgs.size(); i++) {
                    MessageExt msg = msgs.get(i);
                    System.out.println(msg.getTopic() + " " + msg.getTags() + " " + new String(msg.getBody()));
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        try {
            consumer.start();
        } catch (MQClientException e) {
            e.printStackTrace();
        }

    }

完整的代码:

package com.crazymaker.springcloud.stock.controller;

import com.crazymaker.springcloud.common.exception.BusinessException;
import com.crazymaker.springcloud.common.result.RestOut;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.remoting.common.RemotingHelper;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/crazymaker/rockmq/")
@Api(tags = "消息管理")
public class RockmqMessageController implements ApplicationContextAware {
    public static final String TOPIC_TEST = "TopicTest";
    public static final String CDH_1_9876 = "192.168.56.121:9876";
    //    public static final String CDH_1_9876 = "cdh1:9876";
    DefaultMQProducer producer = new DefaultMQProducer("producer_demo");

    /**
     * Consumer Group,非常重要的概念,后续会慢慢补充
     */
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_demo");


    @PostMapping("/msg/send/v1")
    @ApiOperation(value = "发送rockmq消息")
    public RestOut<String> simpleSend(@RequestBody String content) {

        try {
            //构建消息
            Message msg = new Message(TOPIC_TEST /* Topic */,
                    "TagA" /* Tag */,
                    (content).getBytes(RemotingHelper.DEFAULT_CHARSET)
            );

            //发送同步消息
            SendResult sendResult = producer.send(msg);

            System.out.printf("%s%n", sendResult);
        } catch (Exception e) {
            e.printStackTrace();
            throw BusinessException.builder().errMsg(e.getMessage()).build();
        }

        return RestOut.success("发送完成");
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {


        startProducer();
        startConsumer();
    }

    private void startProducer() {
        //指定NameServer地址
        producer.setNamesrvAddr(CDH_1_9876); //修改为自己的
        producer.setInstanceName("Instance1");
        producer.setRetryTimesWhenSendFailed(3);
        /**
         * Producer对象在使用之前必须要调用start初始化,初始化一次即可
         * 注意:切记不可以在每次发送消息时,都调用start方法
         */
        try {
            producer.start();
            System.out.println("product start ...");
        } catch (MQClientException e) {
            e.printStackTrace();
        }
    }

    private void startConsumer() {
        //指定NameServer地址,多个地址以 ; 隔开
        consumer.setNamesrvAddr(CDH_1_9876); //修改为自己的
//        consumer.setNamesrvAddr("192.168.116.115:9876;192.168.116.116:9876"); //修改为自己的


/**
 * 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
 * 如果非第一次启动,那么按照上次消费的位置继续消费
 */
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

        try {
            consumer.subscribe(TOPIC_TEST, "*");
        } catch (MQClientException e) {
            e.printStackTrace();
            return;
        }

        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                for (int i = 0; i < msgs.size(); i++) {
                    MessageExt msg = msgs.get(i);
                    System.out.println(msg.getTopic() + " " + msg.getTags() + " " + new String(msg.getBody()));
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        try {
            consumer.start();
        } catch (MQClientException e) {
            e.printStackTrace();
        }

    }
}

swagger发送界面

在这里插入图片描述

6.3 控制台截图

rocketm-console-topic

集群模式部署

此处就RocketMQ的多Master多Slave的模式在Linux服务器部署案例进行详细的说明,如系统部署结构图所示。

1本次部署环境

Linux服务器192.168.162.235、192.168.162.236两台(下文均简称235、236),详细部署环境示意表如下:

img

img

2 编辑Hosts

分别修改235 和236 的hosts 文件

sudo vim /etc/hosts

      IP                NAME

192.168.162.235      nameserver1

192.168.162.235      master1

192.168.162.235      master1-slave1

192.168.162.236      nameserver2

192.168.162.236      master2

192.168.162.236      master2-slave2

注:修改hosts 文件需获得sudo 权限,本机用户是rocketMQ非root用户, 故申请了堡垒机权限(即获得root权限)。

3 下载官方源码

下载官方RocketMQ压缩包,下载地址:http://rocketmq.apache.org/release_notes/release-notes-4.2.0/,并选择Download the 4.2.0 release 选项的 rocketmq-all-4.2.0-bin-release.zip下载。(其他如source为需要自己编译的版本)

4 上传到Linux并解压

分别上传rocketmq-all-4.2.0-bin-release.zip到235和236服务器的/home/rocketMQ/ZHF/rocketMQ-2m2s/目录下:

cd /home/rocketMQ/ZHF/rocketMQ-2m2s/tar –zxvf rocketmq-all-4.2.0-bin-release.zip

  1. 创建持久化存储目录

Master目录设置:

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store/commitlog

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store/consumequeue

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store/index

Slave目录设置:

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store-s

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/commitlog

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/consumequeue

mkdir /home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/index

6 RocketMQ配置文件

235服务器设置:

sudo vim /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-a.properties

sudo vim /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-b-s.properties

236服务器设置:

sudo vim /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-b.properties

sudo vim /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-a-s.properties

broker-a.properties文件配置


# Licensed to the Apache Software Foundation (ASF) under one or more

# contributor license agreements.  See the NOTICE file distributed with

# this work for additional information regarding copyright ownership.

# The ASF licenses this file to You under the Apache License, Version 2.0

# (the "License"); you may not use this file except in compliance with

# the License.  You may obtain a copy of the License at

#

# http://www.apache.org/licenses/LICENSE-2.0

#

# Unless required by applicable law or agreed to in writing, software

# distributed under the License is distributed on an "AS IS" BASIS,

# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

# See the License for the specific language governing permissions and

# limitations under the License.

 

#brokerClusterName=DefaultCluster

#brokerName=broker-a

#brokerId=0

#deleteWhen=04

#fileReservedTime=48

#brokerRole=ASYNC_MASTER

#flushDiskType=ASYNC_FLUSH

 

#所属集群名字

brokerClusterName=rocketmq-cluster

#broker名字,注意此处不同的配置文件填写的不一样

brokerName=broker-a

#0 表示 Master,>0 表示 Slave

brokerId=0

#nameServer地址,分号分割

namesrvAddr=nameserver1:9876;nameserver2:9876

#在发送消息时,自动创建服务器不存在的topic,默认创建的队列数

defaultTopicQueueNums=4

#是否允许 Broker 自动创建Topic,建议线下开启,线上关闭

autoCreateTopicEnable=true

#是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭

autoCreateSubscriptionGroup=true

#Broker 对外服务的监听端口

listenPort=10911

haListenPort=10912

#删除文件时间点,默认凌晨 4点

deleteWhen=04

#文件保留时间,默认 48 小时

fileReservedTime=18

#commitLog每个文件的大小默认1G

mapedFileSizeCommitLog=1073741824

#ConsumeQueue每个文件默认存30W条,根据业务情况调整

mapedFileSizeConsumeQueue=300000

#destroyMapedFileIntervalForcibly=120000

#redeleteHangedFileInterval=120000

#检测物理文件磁盘空间

diskMaxUsedSpaceRatio=88

#存储路径

storePathRootDir=/home/rocketMQ/ZHF/rocketMQ-2m2s/store

#commitLog 存储路径

storePathCommitLog=/home/rocketMQ/ZHF/rocketMQ-2m2s/store/commitlog

#消费队列存储路径存储路径

storePathConsumeQueue=/home/rocketMQ/ZHF/rocketMQ-2m2s/store/consumequeue

#消息索引存储路径

storePathIndex=/home/rocketMQ/ZHF/rocketMQ-2m2s/store/index

#checkpoint 文件存储路径

storeCheckpoint=/home/rocketMQ/ZHF/rocketMQ-2m2s/store/checkpoint

#abort 文件存储路径

abortFile=/home/rocketMQ/ZHF/rocketMQ-2m2s/store/abort

#限制的消息大小

maxMessageSize=65536

#flushCommitLogLeastPages=4

#flushConsumeQueueLeastPages=2

#flushCommitLogThoroughInterval=10000

#flushConsumeQueueThoroughInterval=60000

#Broker 的角色

#- ASYNC_MASTER 异步复制Master

#- SYNC_MASTER 同步双写Master

#- SLAVE

brokerRole=SYNC_MASTER

#刷盘方式

#- ASYNC_FLUSH 异步刷盘

#- SYNC_FLUSH 同步刷盘

flushDiskType=ASYNC_FLUSH

#checkTransactionMessageEnable=false

#发消息线程池数量

#sendMessageThreadPoolNums=128

#拉消息线程池数量

#pullMessageThreadPoolNums=128

#强制指定本机IP,需要根据每台机器进行修改。官方介绍可为空,系统默认自动识别,但多网卡时IP地址可能读取错误

brokerIP1=192.168.162.235

broker-a-s.properties文件配置

# Licensed to the Apache Software Foundation (ASF) under one or more

# contributor license agreements.  See the NOTICE file distributed with

# this work for additional information regarding copyright ownership.

# The ASF licenses this file to You under the Apache License, Version 2.0

# (the "License"); you may not use this file except in compliance with

# the License.  You may obtain a copy of the License at

#

#     http://www.apache.org/licenses/LICENSE-2.0

#

# Unless required by applicable law or agreed to in writing, software

# distributed under the License is distributed on an "AS IS" BASIS,

# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

# See the License for the specific language governing permissions and

# limitations under the License.

#brokerClusterName=DefaultCluster

#brokerName=broker-b

#brokerId=1

#deleteWhen=04

#fileReservedTime=48

#brokerRole=SLAVE

#flushDiskType=ASYNC_FLUSH

 

#所属集群名字

brokerClusterName=rocketmq-cluster

#broker名字,注意此处不同的配置文件填写的不一样

brokerName=broker-a

#0 表示 Master,>0 表示 Slave

brokerId=1

#nameServer地址,分号分割

namesrvAddr=nameserver1:9876;nameserver2:9876

#在发送消息时,自动创建服务器不存在的topic,默认创建的队列数

defaultTopicQueueNums=4

#是否允许 Broker 自动创建Topic,建议线下开启,线上关闭

autoCreateTopicEnable=true

#是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭

autoCreateSubscriptionGroup=true

#Broker 对外服务的监听端口

listenPort=10923

haListenPort=10924

#删除文件时间点,默认凌晨 4点

deleteWhen=04

#文件保留时间,默认 48 小时

fileReservedTime=18

#commitLog每个文件的大小默认1G

mapedFileSizeCommitLog=1073741824

#ConsumeQueue每个文件默认存30W条,根据业务情况调整

mapedFileSizeConsumeQueue=300000

#destroyMapedFileIntervalForcibly=120000

#redeleteHangedFileInterval=120000

#检测物理文件磁盘空间

diskMaxUsedSpaceRatio=88

#存储路径

storePathRootDir=/home/rocketMQ/ZHF/rocketMQ-2m2s/store-s

#commitLog 存储路径

storePathCommitLog=/home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/commitlog

#消费队列存储路径存储路径

storePathConsumeQueue=/home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/consumequeue

#消息索引存储路径

storePathIndex=/home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/index

#checkpoint 文件存储路径

storeCheckpoint=/home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/checkpoint

#abort 文件存储路径

abortFile=/home/rocketMQ/ZHF/rocketMQ-2m2s/store-s/abort

#限制的消息大小

maxMessageSize=65536

#flushCommitLogLeastPages=4

#flushConsumeQueueLeastPages=2

#flushConsumeQueueLeastPages=2#flushCommitLogThoroughInterval=10000

#flushConsumeQueueThoroughInterval=60000

#Broker 的角色

#- ASYNC_MASTER 异步复制Master

#- SYNC_MASTER 同步双写Master

#- SLAVE

brokerRole=SLAVE

#刷盘方式

#- ASYNC_FLUSH 异步刷盘

#- SYNC_FLUSH 同步刷盘

flushDiskType=ASYNC_FLUSH

#checkTransactionMessageEnable=false

#发消息线程池数量

#sendMessageThreadPoolNums=128

#拉消息线程池数量

#pullMessageThreadPoolNums=128

#强制指定本机IP,需要根据每台机器进行修改。官方介绍可为空,系统默认自动识别,但多网卡时IP地址可能读取错误

brokerIP1=192.168.162.235

broker-b.properties文件配置

参考broker-a.properties

broker-b-s.properties文件配置

参考broker-a-s.properties

7 启动参数设置

RocketMQ启动文件位于/home/rocketMQ/ZHF/rocketMQ-2m2s/bin/目录下,Linux中nameserver启动文件为:mqnamesrv,broker启动文件为:mqbroker,mqnamesrv和mqbroker启动文件分别调用了runserver.sh和runbroker.sh文件,这两个文件分别设置了nameserver和broker的启动内存,目前内存启动参数分别为nameserver启动内存4G,最大内存4G,新生代2G,broker启动内存8G,最大内存8G,新生代4G。

8 端口及防火墙设置

RokcetMQ启动默认使用3个端口9875,10911,10912,三个端口分别代表nameserver服务器端口,broker端口,broker HA端口。需注意的是在多Master多Slave模式下10911和10912是Master的使用端口,但Slave端口的设置与Master的端口不同,具体端口约束为:Slave - Master > 2,否则可能导致同一台服务器无法同时启动Master和Slave。

如果服务器启动了防火墙,为了端口不被屏蔽,需将Master和Slave对应端口加入到iptables表以开放对应端口号,添加完成后重启防火墙。命令行开放端口操作如下:

分别打开235和236终端,在root用户下执行命令:

开放端口:

/sbin/iptables -A INPUT -m state --state NEW -m tcp -p tcp --dport 9876 -j ACCEPT

/sbin/iptables -A INPUT -m state --state NEW -m tcp -p tcp --dport 10911 -j ACCEPT

/sbin/iptables -A INPUT -m state --state NEW -m tcp -p tcp --dport 10912 -j ACCEPT

/sbin/iptables -A INPUT -m state --state NEW -m tcp -p tcp --dport 10923 -j ACCEPT

/sbin/iptables -A INPUT -m state --state NEW -m tcp -p tcp --dport 10924 -j ACCEPT

保存:

/etc/rc.d/init.d/iptables save

重启:

/etc/init.d/iptables restart

查看端口开放情况:

/sbin/iptables -L -n

9 启动Nameserver

分别启动235、236的Nameserver



cd /home/rocketMQ/ZHF/rocketMQ-2m2s/bin/nohup sh mqnamesrv &


10.启动Broker

235上Master启动:

nohup sh mqbroker -c /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-a.properties

236上Master启动:

nohup sh mqbroker -c /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-b.properties

235上对应236Master的Slave启动:

nohup sh mqbroker -c /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-b-s.properti

236上对应235Master的Slave启动:

nohup sh mqbroker -c /home/rocketMQ/ZHF/rocketMQ-2m2s/conf/2m-2s-async/broker-a-s.properti

至此,Nameserver、Broker启动完成,可以用jobs命令查看当前运行进程,如下是服务端相关shutdown,即在bin目录下:

sh mqshutdown namesrvsh 
sh mqshutdown broker

六、RocketMQ监控平台部署

Apache版的RocketMQ管理界面部署工具可以从github上下载源码,地址:https://github.com/apache/rocketmq-externals,部署流程如下:

  1. 修改配置文件,关联rocketMQ集群到管理界面

首先解压并进入解压后rockemq-externals-master目录rocketmq-externals-master/rocketmq-externals-master/rocketmq-console/src/main/resources,修改目录下application.properties配置文件内容如下图:

img

  1. 编译rocketmq-console
mvn clean package -Dmaven.test.skip=true

编译需用maven命令进行编译,如下图,显示BIUD SUCCESS,则编译成功,成功后会在rocketmq-externals-master/rocketmq-console/target目录下产生一个rocketmq-console-ng-1.0.0.jar文件。

img

  1. 将编译好的rocketmq-console-ng-0.0.jar包上传linux服务器

这里上传服务器地址为192.168.162.235,路径为:/home/rocketMQ/ZHF/

  1. 运行jar包
java -jar target/rocketmq-console-ng-1.0.0.jar

运行显示下图则启动成功:

  1. 访问管理界面

浏览器输入http://192.168.162.235:8080/回车显示监控界面如下:

img

原文:https://blog.csdn.net/tubunanhai/article/details/81738416

RocketMQ的开发快速入门

1、引入 rocketmq-client

<dependency>
 <groupId>org.apache.rocketmq</groupId>
 <artifactId>rocketmq-client</artifactId>
 <version>4.1.0-incubating</version>
</dependency>

2、编写Producer

DefaultMQProducer producer = new DefaultMQProducer("producer_demo");
 //指定NameServer地址
 producer.setNamesrvAddr("192.168.116.115:9876;192.168.116.116:9876"); //修改为自己的

 /**
 * Producer对象在使用之前必须要调用start初始化,初始化一次即可
 * 注意:切记不可以在每次发送消息时,都调用start方法
 */
 producer.start();

 for (int i = 0; i < 997892; i++) {
 try {
 //构建消息
 Message msg = new Message("TopicTest" /* Topic */,
 "TagA" /* Tag */,
 ("测试RocketMQ" + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
 );

 //发送同步消息
 SendResult sendResult = producer.send(msg);

 System.out.printf("%s%n", sendResult);
 } catch (Exception e) {
 e.printStackTrace();
 Thread.sleep(1000);
 }
 }


producer.shutdown();

3、编写Consumer

/**
 * Consumer Group,非常重要的概念,后续会慢慢补充
 */
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_demo");
//指定NameServer地址,多个地址以 ; 隔开
consumer.setNamesrvAddr("192.168.116.115:9876;192.168.116.116:9876"); //修改为自己的

/**
 * 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
 * 如果非第一次启动,那么按照上次消费的位置继续消费
 */
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

consumer.subscribe("TopicTest", "*");

consumer.registerMessageListener(new MessageListenerConcurrently() {

 @Override
 public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
 ConsumeConcurrentlyContext context) {
 try {
 for(MessageExt msg:msgs){
 String msgbody = new String(msg.getBody(), "utf-8");
 System.out.println(" MessageBody: "+ msgbody);//输出消息内容
 }
 } catch (Exception e) {
 e.printStackTrace();
 return ConsumeConcurrentlyStatus.RECONSUME_LATER; //稍后再试
 }
 return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; //消费成功
 }
});


consumer.start();

System.out.printf("Consumer Started.%n");

4、说明

各位根据自己的环境,修改NamesrvAddr的值,我的集群请参考:RocketMQ集群部署配置。稍后通过RocketMQ管控台就可以看到之前搭建的多Master多Slave模式,异步复制集群模式。

5、通过RocketMQ管控台

rocketmq-console-ng获取方式为:rocketmq-console-ng,之后通过mavne进行编译获取jar,命令如下:

mvn clean package -Dmaven.test.skip=true
java -jar target/rocketmq-console-ng-1.0.0.jar

得到rocketmq-console-ng-1.0.0.jar之后,找到rocketmq-console-ng-1.0.0.jar\BOOT-INF\classes\application.properties文件,根据自己的NamesrvAddr进行修改rocketmq.config.namesrvAddr的值。

直接启动:

java -jar rocketmq-console-ng-1.0.0.jar

img

管控台是基于springboot的,的确springboot非常方便和非常火了,所以有必要去学习下springboot了(其实还是spring系列,所以spring也必要深入学习下),稍后通过管控台进行观察运行。

6、运行观察

一个好的习惯是先运行Consumer,之后在运行Producer,之后通过rocketmq-console-ng管控台观察

img

运行完成之后,的确broker-a的数据加上broker-b的数据量就等于我们发送的数据量,而且slave的数量也master的数量也是一致的,效果如下:

img

查看发送这些数据,2台机器的磁盘情况如下:

img

img

到目前位置,关于RocketMQ快速入门就结束了,未完待续……

参考:

https://www.pianshen.com/article/1215649056/

https://blog.csdn.net/weiwenhou/article/details/100869824

https://www.cnblogs.com/qdhxhz/p/11094624.html

https://blog.csdn.net/linyaogai/article/details/77876078

https://www.pianshen.com/article/1215649056/

https://blog.csdn.net/weiwenhou/article/details/100869824

https://www.cnblogs.com/qdhxhz/p/11094624.html

https://blog.csdn.net/linyaogai/article/details/77876078

回到◀疯狂创客圈

疯狂创客圈 - Java高并发研习社群,为大家开启大厂之门

posted @ 2020-11-20 18:13  疯狂创客圈  阅读(1031)  评论(0编辑  收藏  举报