RocketMQ5源码(五)新架构下的普通消息收发

前言

从服务端来说,rocketmq5.x的部署方式和之前有所不同,新增了rocketmq-proxy 角色。具体部署方式,参考官方文档(rocketmq.apache.org/docs/deploy...)。

从客户端来说,官方建议使用新版grpc轻量级客户端github.com/apache/rock...),原来的私有remoting协议将作为服务端内部协议使用 ,具体兼容性问题见官方文档(rocketmq.apache.org/docs/sdk/01...)。

在前面的几个章节,我使用的都是remoting客户端+nameserver+broker的部署方式,其实兼容性没有什么大问题。

新架构之下,依赖两个重要特性,之前已经基于remoting协议做了铺垫(服务端内部使用同样的协议,不影响理解新架构):

  1. POP消费(依赖任意时间延迟消息)
  2. 任意时间延迟消息

本文主要分析,grpc客户端+proxy下普通消息的收发流程,broker侧逻辑将忽略。

注:本文服务端基于rocketmq 5.1.1 版本,客户端基于rocketmq-clients java-5.0.5版本;

一、案例

rocketmq-proxy

配置文件rmq-proxy.json:

  1. namesrvAddr:NameServer地址,分号分割;
  2. proxyMode:cluster模式;
  3. rocketMQClusterName:目标broker集群名称(proxy是集群级别的);
  4. remotingListenPort:remoting协议端口,默认8080;
  5. grpcServerPort:grpc协议端口,默认8081;
  6. useEndpointPortFromRequest:查询路由,是否使用入参endpoint端点的端口;
  7. enableACL:是否开启acl(client和proxy之间),这里为了说明proxy职责范围,所以开启了;
json 复制代码
{
  "namesrvAddr": "127.0.0.1:9876",
  "proxyMode": "cluster",
  "rocketMQClusterName": "DefaultCluster",
  "remotingListenPort": 8080,
  "grpcServerPort": 8081,
  "useEndpointPortFromRequest": true,
  "enableACL": true
}

本地启动vm参数:

ini 复制代码
-Dcom.rocketmq.proxy.configPath=rmq-proxy.json
-Drocketmq.client.localOffsetStoreDir=/tmp/rmqstore/proxy1/local-offset
-Drocketmq.home.dir=path/to/rocketmq_home

生产者

使用生产者发送简单消息。

ini 复制代码
// 1. 通过SPI加载客户端实现
final ClientServiceProvider provider = ClientServiceProvider.loadService();
// 2. ACL 设置ak/sk
String accessKey = "RocketMQ";
String secretKey = "12345678";
SessionCredentialsProvider sessionCredentialsProvider =
    new StaticSessionCredentialsProvider(accessKey, secretKey);
// 3. 设置proxy端点,分号分割
String endpoints = "127.0.0.1:8081;127.0.0.1:8091";
ClientConfiguration clientConfiguration = ClientConfiguration.newBuilder()
    .setEndpoints(endpoints) // proxy端点,分号分割
    .setRequestTimeout(Duration.ofDays(1)) // 请求超时时间
    .setCredentialProvider(sessionCredentialsProvider) // ak sk
    .build();
String topic = "MyTopic";
// 4. 创建producer
final Producer producer = provider.newProducerBuilder()
    .setClientConfiguration(clientConfiguration)
    .setTopics(topic)
    .build();
byte[] body = "Hello".getBytes(StandardCharsets.UTF_8);
String tag = "TagA";
// 5. 创建消息
final Message message = provider.newMessageBuilder()
    .setTopic(topic)
    .setTag(tag)
    .setKeys("123")
    .setBody(body)
    .build();
while (true) {
    try {
        Thread.sleep(1000);
        // 6. 发送消息
        final SendReceipt sendReceipt = producer.send(message);
        log.info("Send message successfully, messageId={}", sendReceipt.getMessageId());
    } catch (Throwable t) {
        log.error("Failed to send message", t);
        break;
    }
}
// 7. 关闭生产者
producer.close();

消费者

普通Push消费者。

ini 复制代码
// 1. 通过SPI加载客户端实现
final ClientServiceProvider provider = ClientServiceProvider.loadService();
// 2. ACL 设置ak/sk
String accessKey = "RocketMQ";
String secretKey = "12345678";
SessionCredentialsProvider sessionCredentialsProvider =
    new StaticSessionCredentialsProvider(accessKey, secretKey);
// 3. 设置proxy端点,分号分割
String endpoints = "127.0.0.1:8081;127.0.0.1:8091";
ClientConfiguration clientConfiguration = ClientConfiguration.newBuilder()
    .setEndpoints(endpoints)
    .setCredentialProvider(sessionCredentialsProvider)
    .build();
// 4. 创建Push消费者
String tag = "*";
FilterExpression filterExpression = new FilterExpression(tag, FilterExpressionType.TAG);
String consumerGroup = "groupA";
String topic = "MyTopic";
PushConsumer pushConsumer = provider.newPushConsumerBuilder()
    .setClientConfiguration(clientConfiguration)
    // 消费组
    .setConsumerGroup(consumerGroup)
    // 订阅
    .setSubscriptionExpressions(Collections.singletonMap(topic, filterExpression))
    .setMessageListener(messageView -> {
        // 业务逻辑
        log.info("Consume message={}", messageView);
        return ConsumeResult.SUCCESS;
    })
    .build();
Thread.sleep(Long.MAX_VALUE);
pushConsumer.close();

二、客户端概览

客户端实现

rocketmq-client-apis模块定义了producer、consumer、message抽象api接口。

ClientServiceProvider是所有客户端api入口,loadService通过JDK SPI加载ClientServiceProdiver实现。

目前ClientServiceProvider实现只有一个ClientServiceProviderImplrocketmq-client-java-noshade模块中。

ClientServiceProvider构造出来的客户端:

  1. producer有一种:ProducerImpl
  2. consumer有两种:
    1. PushConsumerImpl:PUSH模式;
    2. SImpleConsumerImpl:忽略;

RPC客户端

往往一个进程中既有生产者又有消费者。

MQClientManager#getOrCreateMQClientInstance:

传统remoting协议客户端 (DefaultMQProducer/DefaultMQPushConsumer等)默认instanceName都是DEFAULT,最终底层都会公用一个netty客户端MQClientInstance(MQClientAPIImpl)。

新版客户端,ProducerImpl和ConsumerImpl都继承自ClientImpl。

ClientImpl中各自会维护一个ClientManagerImpl实例。

每个ClientManagerImpl按照proxy端点Endpoints纬度,独自维护RpcClient。

grpc生产和消费者实例独立维护一套底层rpc客户端

在RpcClient的实现是RpcClientImpl

RpcClientImpl构造操作grpc api构建ManagedChannelStub

这里需要注意,grpc客户端负载均衡策略 并没有指定(defaultLoadBalancePolicy ),默认会取pick_first,选择一个可用的address建立长连接,即除非出现问题,将只与这个proxy通讯。

这意味着,通过简单ip列表指定proxy,客户端无负载均衡

如下面单元测试:虽然地址列表有多个address,最终传入grpc api的端点是ipv4:127.0.0.1:8080,127.0.0.2:8081,由grpc客户端通过pick_first策略选择一个可用proxy建立长连接。

ini 复制代码
@Test
public void testEndpointsWithMultipleIpv4() {
    final Endpoints endpoints = new Endpoints("127.0.0.1:8080;127.0.0.2:8081");
    Assert.assertEquals(AddressScheme.IPv4, endpoints.getScheme());
    Assert.assertEquals(2, endpoints.getAddresses().size());
    final Iterator<Address> iterator = endpoints.getAddresses().iterator();

    final Address address0 = iterator.next();
    Assert.assertEquals("127.0.0.1", address0.getHost());
    Assert.assertEquals(8080, address0.getPort());

    final Address address1 = iterator.next();
    Assert.assertEquals("127.0.0.2", address1.getHost());
    Assert.assertEquals(8081, address1.getPort());

    Assert.assertEquals(AddressScheme.IPv4, endpoints.getScheme());
    Assert.assertEquals("ipv4:127.0.0.1:8080,127.0.0.2:8081", endpoints.getFacade());
    Assert.assertEquals("ipv4:127.0.0.1:8080,127.0.0.2:8081", endpoints.getGrpcTarget());
}

Endpoints也可以通过域名构造 ,在服务端实现负载均衡,如nginx。

最终传入grpc api的端点是rocketmq.apache.org:80

ini 复制代码
@Test
public void testEndpointsWithDomain() {
    final Endpoints endpoints = new Endpoints("rocketmq.apache.org");
    Assert.assertEquals(AddressScheme.DOMAIN_NAME, endpoints.getScheme());
    final Iterator<Address> iterator = endpoints.getAddresses().iterator();

    final Address address = iterator.next();
    Assert.assertEquals("rocketmq.apache.org", address.getHost());
    Assert.assertEquals(80, address.getPort());

    Assert.assertEquals("dns:rocketmq.apache.org:80", endpoints.getFacade());
    Assert.assertEquals("rocketmq.apache.org:80", endpoints.getGrpcTarget());
}

具体逻辑见org.apache.rocketmq.client.java.route.Endpoints构造。

三、路由

1、proxy路由管理

TopicRouteService:proxy侧路由管理服务。

构造时定义topic路由本地缓存(caffeine):

  1. 缓存20s后自动刷新;
  2. 运行阶段,如果topic缓存不存在,从nameserver加载;
  3. 运行阶段,如果nameserver返回topic不存在,本地缓存一个空对象WRAPPED_EMPTY_QUEUE;

TopicRouteCacheLoader通过remoting协议与nameserver通讯。

原来nameserver要为n个客户端提供路由服务,现在只需要为m个proxy提供路由服务。

往往m<n吧,proxy能减轻nameserver压力。

但是现在client和proxy都有本地缓存。

2、客户端查询路由

每个producer和consumer实例针对自己关心的topic独自缓存一份路由数据

注:传统客户端生产和消费路由混合在一个MQClientInstance实例里。

ClientImpl#startUp:

  1. 支持启动阶段主动加载路由(producer设置topic、consumer启动前订阅topic),如果刷新路由失败,启动失败;(运行阶段新增topic懒加载)
  2. 后期每隔30s刷新路由缓存;

ClientImpl#fetchTopicRoute0:客户端发送topic+proxy的endpoints

当然运行阶段,如果客户端新增topic,也会懒加载路由。

比如producer在运行阶段发送新topic,consumer运行阶段新增订阅topic。

路由在新版客户端中是非常重要的,所有动作都离不开路由,包括心跳

3、proxy返回路由

RouteActivity#queryRoute:查询路由

  1. 入参模型转换;
  2. 查询;
  3. 出参模型转换;

RouteActivity#convertToAddressList:

客户端会携带自己配置的proxy端点进来,在broker侧会对端点的adress地址列表做转换。

默认情况下,host不变,port会修改为当前proxy实例配置的grpcServerPort

但是如果经过端口转换(各类负载均衡,如nginx、k8s),可以通过设置useEndpointPortFromRequest=true,采用客户端传入port。

ClusterTopicRouteService#getTopicRouteForProxy:Cluster模式下

  1. 查询topic对应路由信息,先走本地caffeine缓存,cache miss走nameserver;
  2. 所有broker地址,都替换为客户端传入endpoint,即rocketmq-proxy实例地址

注意,这里所有broker地址都是客户端传入endpoints,很重要,因为一个客户端实例只会对应一个Endpoints

出参是一组MessageQueue

  1. topic;
  2. id:queueId;
  3. permission:读写权限;
  4. broker:broker名、地址(proxy地址)等;
  5. MessageType:5.x多了MessageType,在topic纬度定义消息类型,分成四类:普通NORMAL、顺序FIFO、延迟DELAY、事务TRANSACTION。

四、心跳

1、客户端

ClientManagerImpl#startUp:

每个客户端实例 (每个producer和consumer实例)每隔10s 向proxy发送独立心跳

注:传统客户端producer和consumer的instanceName相同,每隔30s 会发送一个心跳(包含producer和consumer信息)所有相关master broker。

ClientImpl#doHeartbeat:

值得注意的是,心跳包目标端点需要从路由数据中获取 ,即心跳依赖路由

而路由中的endpoints是客户端查路由传入的endpoints(见路由部分),所以理论上只有一个

grpc客户端的心跳包HeartbeatRequest是非常单纯的心跳包:

  1. group:消费组(PRODUCER不传);
  2. client_type:客户端类型,PRODUCER/PUSH_CONSUMER/SIMPLE_CONSUMER;

注:传统客户端 的心跳包HeartbeatData有很多数据,包括consumer的订阅关系

ini 复制代码
message HeartbeatRequest {
  optional Resource group = 1;
  ClientType client_type = 2;
}

ClientImpl#doHeartbeat:客户端侧多了一个隔离逻辑,如果心跳成功,endpoints将从隔离集合中移除,这与producer相关。

2、proxy端

ClientActivity#heartbeat:

proxy端处理心跳,依赖于一个新概念Settings。(后面再看)

对于producer和consumer执行不同的注册逻辑。

GrpcClientSettingsManager#getClientSettings:

从缓存CLIENT_SETTINGS_MAP中获取clientId对应Settings,按照producer或consumer分别合并返回。

注:每个客户端请求都会在header里传入一些信息,其中包含clientId,见客户端Signature#sign。

producer注册

ClientActivity#heartbeat:producer注册,循环settings中的topic。

ClientActivity#registerProducer:grpc模型适配remoting协议模型

  1. GrpcClientChannel,继承netty的AbstractChannel;
  2. ClientChannelInfo,和remoting协议的模型一致;

注:事务消息逻辑后面再说。

org.apache.rocketmq.broker.client.ProducerManager#registerProducer:

最终走的是broker模块的ProducerManager,即和broker原始逻辑一致,将ClientChannelInfo放入内存table。

需要注意的是,producer本身心跳请求不包含group,这里传入的group是Settings中的topic,言外之意,producer没有生产组概念了

也能理解,5.x在topic纬度设置了消息类型TRANSACTIONAL,直接将topic作为producerGroup。

consumer注册

ClientActivity#heartbeat:

consumer注册,依赖于Settings中的订阅关系 ,但是updateSubscription=false,不更新订阅关系

ClientActivity#registerConsumer:同样适配remoting协议模型。

ClusterConsumerManager#registerConsumer:

最终consumer注册走ClusterConsumerManager。

和producer类似,底层逻辑走的也是broker的ConsumerManager ,将consumer信息保存到内存table,但是consumer多了HeartbeatSyncer

ConsumerManager#registerConsumer:

此处仅更新心跳时间,不会更新订阅关系。

HeartbeatSyncer#onConsumerRegister:向系统topic发送一条心跳同步消息

消息包含:

  1. ClientChannelInfo,客户端信息,包括clientId;
  2. consumerGroup,消费组;
  3. localProxyId,当前proxy的标识(本机ip+remoting端口+grpc端口);
  4. RemoteChannel,通过GrpcClientChannel转换而来,包含当前grpc客户端与proxy的连接信息,比如双边address等;
  5. subscriptionDataSet,消费组订阅关系;

AbstractSystemMessageSyncer#sendSystemMessage:

这个系统topic默认为heartbeatSyncerTopicName=DefaultHeartBeatSyncerTopic

proxy侧,按照broker纬度做负载均衡(onlyBroker=true),queueId都是-1;

broker侧,会按照可写queue数量做负载均衡(随机)。

consumer心跳同步

传统客户端,循环向所有broker发送心跳包。

proxy客户端,只会向一个proxy实例发送心跳包。

proxy之间需要相互同步consumer心跳

AbstractSystemMessageSyncer#start:

proxy启动阶段会启动一个广播消费者,订阅topic=DefaultHeartBeatSyncerTopic。

注:所以proxy也不是完全无状态,因为广播消费进度存储在本地

AbstractSystemMessageSyncer#createSysTopic:

proxy启动会判断心跳同步topic是否存在,如果不存在,会在指定cluster下创建,

cluster下每个broker一个queue。

HeartbeatSyncer#consumeMessage:消费心跳同步消息

  1. 反序列化;
  2. 判断localProxyId,如果是自己发送的消息,忽略;
  3. 执行consumer注册逻辑;

ConsumerManager#registerConsumer:数据同步需要更新订阅关系。

HeartbeatSyncer#onConsumerRegister:数据同步不需要再次发送心跳同步消息。

心跳超时

ClusterServiceManager#init:心跳超时由proxy扫描。

producer超时时间写死2分钟;

consumer超时时间默认2分钟,可以通过channelExpiredTimeout配置;

超时逻辑与broker原始逻辑大致一致,超时从内存table中移除producer或consumer。

不同的是,直连proxy的grpc客户端连接,不会因为扫描producer/consumer心跳超时而断开。

SimpleChannel#close:直连proxy的GrpcClientChannel和同步proxy的RemoteChannel都继承自SimpleChannel,close方法什么都不做。

GrpcServerBuilder:

grpc客户端连接是否断开,取决于grpc服务端配置的空闲时长grpcClientIdleTimeMills=120s。

五、Settings

1、模型

从客户端侧来看,settings按照客户端类型(ClientType)分为三类:

  1. PublishingSettings:PRODUCER;
  2. PushSubscriptionSettings:PUSH_CONSUMER;
  3. SimpleSubscriptionSettings:SIMPLE_CONSUMER(pull);

基类Settings,包含客户端类型,ClientId等属性。

ClientId由hostname+pid+index+startTime四部分组成,是客户端的唯一标识。

PublishingSettings:producer的Settings包含发布的topic。

PushSubscriptionSettings:Push消费者Settings

  1. group:消费组;
  2. subscriptionExpressions:订阅关系,key是topic;
  3. fifo:是否顺序消费;
  4. receiveBatchSize:批处理大小;
  5. longPollingTimeout:长轮询超时时间,30s;

2、客户端发送Settings

ClientManagerImpl#startUp:

每隔5分钟,producer/consumerImpl与proxy执行settings同步

ClientImpl#syncSettings:

  1. 封装Settings到TelemetryCommand;
  2. 从路由收集proxy的endpoints;
  3. 发送TelemetryCommand到proxy;

ClientSessionImpl:proxy的endpoints 会对应一个单例ClientSessionImpl

首次发送TelemetryCommand,客户端与proxy会建立一个grpc bistream双向流

ClientSessionImpl#write:操作grpc api向proxy发送Settings。

3、proxy收到Settings

ClientActivity#processAndWriteClientSettings:

无论是producer还是consumer的Settings过来,都会先执行一次注册逻辑。

所以心跳(注册/续期)依赖Settings,Settings依赖注册/续期。

确认注册完成后,将输出流注入GrpcClientChannel。

注意,consumer的订阅关系,会在Settings同步中更新,而不是在心跳部分

ClientActivity#processAndWriteClientSettings:

注册完成后,proxy会对settings做处理再写回客户端

ClientActivity#processClientSettings:处理Settings

  1. updateClientSettings:将客户端Settings放入内存table(GrpcClientSettingsManager.CLIENT_SETTINGS_MAP);
  2. getClientSettings:合并客户端Settings和proxy配置
  3. 返回合并后的Settings;

GrpcClientSettingsManager#mergeProducerData:

对于producer的Settings,用proxy配置覆盖,比如消息体大小限制等。

GrpcClientSettingsManager#mergeSubscriptionData:

对于consumer的Settings,用proxy的配置broker的订阅组配置(caffeine缓存)覆盖。

4、客户端收到Settings

ClientSessionImpl#onNext:

客户端收到合并后的Settings。

ClientImpl#onSettingsCommand:producer和consumer执行不同的合并逻辑。

PublishingSettings#sync:

producer合并proxy返回的Settings。

PushSubscriptionSettings#sync:

push consumer类似。

六、普通消息发送

1、客户端发送

ProducerImpl#send:发消息,忽略前置各种校验

  1. 获取路由:一组可写MessageQueue,broker地址都是proxy端点;

这里和传统客户端不一样,传统客户端可以获取topic=TBW102作为默认路由,自动创建topic,但是新客户端不支持,所以新客户端一定要手动创建topic

  1. 选队列:其实这里选哪个都不太重要,因为建立grpc长连接的proxy只有一个,后续在proxy侧会做实际负载均衡,这里主要是为了拿到路由返回的endpoints;
  2. send0:调用proxy发送消息;

ProducerImpl#send0:模型转换,发送proxy。

注:messageId还是由客户端生成,见Message模型PublishingMessageImpl构造。

2、proxy发送

SendMessageActivity#sendMessage:模型转换。

ProducerProcessor#sendMessage:

  1. 根据topic路由选queue;
  2. 调用queue对应broker,发送消息;

SendMessageQueueSelector#select:proxy负责发送消息queue级别负载均衡

  1. 普通消息,从可写队列里,轮询选一个queue;
  2. 顺序消息,根据messageGroup.hashCode,从可写队列里选一个queue;

3、客户端重试

如果客户端发送消息失败,按照策略执行重试。

ProducerImpl#send0:

  1. 隔离endpoints,直到proxy能正确处理心跳;
  2. 超出最大重试次数3(proxy指定,通过Settings同步),失败;
  3. 事务消息,快速失败;
  4. 如果因为服务端流控(TooManyRequests)失败,按照指数退避策略(proxy指定,通过Settings同步),延迟10ms、20ms、40ms重试;
  5. 其他,立即重试;

七、普通消息消费(Push)

1、概览

POP消费概览

新架构之下,消费流程都遵循POP消费模式( POP消费源码分析

  1. queryAssignment:查询Assignment。Push消费者需要,Simple消费者不需要;
  2. receiveMessage:拉消息;
  3. 处理消息:
    1. ackMessage:消费成功,ack;
    2. changeInvisibleTime:消费失败,nack,过n时长后重试;

新版客户端

从新版客户端的线程模型上来看,相较于传统客户端简单了很多,分为三组线程:

回顾broker

回顾POP消费模式下,broker对于pop消费的处理逻辑。

queryAssignment

以4.x的角度来说,这阶段也是客户端rebalance。

方法入口:QueryAssignmentProcessor#queryAssignment。

broker默认为每个consumer实例分配所有queue,只是queueId=-1

(broker侧默认配置defaultPopShareQueueNum=-1的行为)

拉消息

pop拉消息特点:

  1. 客户端针对每个master broker提交一个Pop请求,而请求中未明确指定queueId ,也未明确指定消费进度
  2. broker需要负责选择queue和消费进度;
  3. 对于一次pop请求,broker需要记录checkpoint 信息,topic=rmq_sys_REVIVE_LOG_{clusterName},queueId=random(8),tag=ck,要利用timer消息,目标投递时间戳=pop请求时间+invisibleTime(60s)-1s
  4. 返回用户消息的properties中会追加checkpoint信息,用于后续ack和changeInvisibleTime;

处理消息

pop处理消息特点:

  1. 客户端做tag精确匹配(broker只能根据consumequeue记录的hascode过滤一遍),如果不匹配,直接异步ack不匹配消息;
  2. 无论消费成功还是失败,都需要回复broker,ack代表消费成功,changeInvisibleTime代表消费失败,需要重试,重试时间由consumer定夺;
  3. broker对于ack请求发送一条ack消息 ,topic=rmq_sys_REVIVE_LOG_{clusterName},queueId=checkpoint消息所在queueId,tag=ack,要利用timer消息,目标投递时间戳=pop请求时间+invisibleTime(60s)
  4. broker对于changeInvisibleTime请求先创建一个新的checkpoint消息 ,目标投递时间戳=当前时间+invisibleTime(客户端指定);然后针对本次pop请求发送一个ack消息

consumer消费进度提交

broker启动PopBufferMergeService线程扫描缓存的checkpoint。

确认ck消息发送成功,对这些用户消息自动提交offset

重试

broker启动n个PopReviveService线程,消费revive topic的n个队列。

如果ck消息超过invisibleTime未匹配ack消息,重新投递用户消息到重试topic。

2、queryAssignment

Assignments模型

从领域上来说,Assignments也被归入路由,只针对PUSH_CONSUMER有用

从客户端来看,Assignments本质上是n个MessageQueue

客户端查询Assignments

PushConsumerImpl#startUp:

PUSH_CONSUMER客户端每隔5秒,刷新本地缓存Assignments。

PushConsumerImpl#scanAssignments:

循环每个订阅topic,调用proxy查询Assignments。

PushConsumerImpl#queryAssignment:

  1. 从topic路由中收集endpoints;
  2. 请求包含:topic+endpoints+group;

TopicRouteData#pickEndpointsToQueryAssignments:从所有queue中选一个queue的endpoints,要求:

  1. master上线;
  2. 有权限;

proxy返回Assignments

RouteActivity#queryAssignment:proxy查询Assignment

  1. 将入参endpoints转换为地址列表,和路由查询一致;
  2. 查询路由,和路由查询一致;
  3. 出参转换

注意:proxy没有将查询Assignment请求代理到broker ,如果使用传统客户端开启pop消费,查询Assignment逻辑走的是broker,见POP消费源码分析

RouteActivity#queryAssignment:出参转换是重点

  1. 选择可读队列;
  2. 选master broker,与路由同理,地址是入参的endpoints,即proxy地址;
  3. 队列id都设置为-1(由broker做queue负载均衡);

从结果上来看,最终返回客户端的Assignments只包含topic下master broker数量个queueId=-1的MessageQueue,且地址都为proxy端点

客户端处理Assignments

PushConsumerImpl#scanAssignments:

  1. 更新topic对应缓存Assignments;
  2. 更新ProcessQueue;

PushConsumerImpl#syncProcessQueue:

按照Assignment指示:

删除queue,标记ProcessQueue为drop,从table中移除。

新增queue,按照proxy返回,一个topic,一个master broker会有一个ProcessQueue,queueId=-1 ,对于每个queue执行fetchMessageImmediately立即开始拉消息。

3、receiveMessage

客户端拉消息

至此,consumer针对每个ProcessQueue向proxy发起拉消息请求。

ProcessQueueImpl#receiveMessageImmediately:

  1. 决策本次拉取消息数量;
  2. 向proxy发起拉消息请求,scheduler线程切rpc线程;

ProcessQueueImpl#getReceptionBatchSize:

拉取消息数量,包含一定流控逻辑,不超过32条(Settings同步由proxy确定)。

proxy处理拉消息

proxy向broker发起长轮询

ReceiveMessageActivity#receiveMessage:proxy处理receiveMessage请求入口

  1. actualInvisibleTime:pop消费的invisibleTime,proxy控制,60s;
  2. pollingTime:proxy与broker之间长轮询挂起时间,proxy控制,20s;

ConsumerProcessor#popMessage:先选队列,再向broker发起长轮询。

ReceiveMessageQueueSelector#select:基本上属于透传。

broker侧直接取consumer指定的broker的queue,queueId同样等于-1

之后由broker做pop消费负载均衡(随机选一个queue)。

ConsumerProcessor#popMessage:

proxy侧,协议转换,向broker发起长轮询。

broker返回消息

broker侧,pop消费处理逻辑见POP消费源码分析

重点是,broker将checkpoint信息放在返回的每条message的properties的POP_CK里

包含ack/unack消息需要的必要信息,比如queueId、offset等信息。

proxy收到broker返回消息

ConsumerProcessor#popMessage:

tag精确过滤逻辑放在了proxy侧,不匹配的消息会直接发送ack给broker。

MessagingProcessor#popMessage:

  1. push消费,有一个renew逻辑(autoRenew=true,默认开启),将checkpoint信息(receiptHandle)缓存;(后面再看,这段逻辑没有也不影响流程)
  2. 将响应写回客户端;

ReceiveMessageResponseStreamWriter#writeAndComplete:写回客户端。

可以看到拉消息走的是ServerStream,proxy会按顺序写三种包响应客户端:

  1. STATUS:1个状态包,标识本次receiveMessage响应是否成功;
  2. MESSAGE:0-n个消息包,即拉到的消息;
  3. DELIVERY_TIMESTAMP:1个响应时间戳包,这个主要是metrics采集用,可以忽略;

proxy侧,checkpoint信息 被转换为ReceiptHandle

客户端收到消息

RpcClientImpl#receiveMessage:客户端收到proxy响应。

ConsumerImpl#receiveMessage:proto模型转换。

ProcessQueueImpl#onReceiveMessageResult:ProcessQueue处理拉到的消息

  1. 缓存消息;
  2. 提交消息到消费线程;
  3. 再次提交receiveMessage请求到rpc线程,形成循环;

StandardConsumeService#consume:普通并发消费(非顺序)

  1. 每条消息分别丢到消费线程池(20线程)执行;
  2. 处理消费结果;

ProcessQueueImpl#eraseMessage:

消费结果分成两种情况,成功-ack,失败-nack。

4、ack

客户端

ProcessQueueImpl#ackMessage:

客户端ack有重试逻辑,如果ack响应失败,延迟1s重试,无限次数重试。

ConsumerImpl#wrapAckMessageRequest:客户端会携带ReceiptHandle发起ack。

proxy

AckMessageActivity#processAckMessage:

  1. 移除缓存句柄(属于renew逻辑,后面看);
  2. 执行ack;

ConsumerProcessor#ackMessage:

校验句柄未过期(默认invisibleTime=60s,60s收到消息没ack,句柄就失效)。

转换remoting协议,请求broker,ReceiptHandle就是broker收到的ExtraInfo。

5、unack

客户端

ProcessQueueImpl#nackMessage:unack就是POP消费分析的changeInvisibleTime。

  1. 不可见时间由proxy通过settings同步返回,从1s到7200s。
  2. changeInvisibleTime请求也支持重试,延迟1s,无限次数。

ConsumerImpl#wrapChangeInvisibleDuration:

请求包含group、topic、ReceiptHandle、不可见时间。

proxy

ChangeInvisibleDurationActivity#changeInvisibleDuration:

proxy侧,和ack逻辑类似,只是调用broker方法不同。

6、renew

renew是proxy独有的逻辑。

如果consumer收到消息后,长时间不返回消费结果(ack/nack),proxy将主动向broker发起changeInvisibleTime(nack)

至于为什么这么做,我猜想是这样。假设下面这种场景:

  1. consumer-proxy-broker之间能正常通讯;
  2. 因为consumer消费慢,broker超时(invisibleTime)未收到消费结果(ack或nack都行);
  3. broker会持续向重试topic投递消息;

重试消息将进一步加重consumer的消息积压。

传统非pop消费模式,是否sendMessageBack重试,完全取决于客户端,不会有这种问题。

ReceiptHandleProcessor:负责管理缓存的receiptHandle(checkpoint)

ReceiptHandleProcessor只有两个public方法:

  1. receiveMessage时,缓存receiptHandle;
  2. ack/unack时,清除缓存的receiptHandle;

ReceiptHandleProcessor#init:

启动阶段,开启5s定时任务,扫描缓存handle。

ReceiptHandleProcessor#scheduleRenewTask:

  1. clientIsOffline:如果channel+group找不到consumer心跳,代表consumer失联,移除consumer下所有handle,对这些handle执行renew;
  2. 如果消息即将被重新投递,执行renew;是否即将重新被投递,取决于pop时间、invisible时间、proxy配置renewAheadTimeMillis(默认10s),即broker重新投递前10s内renew;

ReceiptHandleProcessor#startRenewMessage:

renew底层是用handle回复broker一个changeInvisibleTime,代表consumer正常收到消息,需要过段时间重试(默认最多renew3次,不可见时间1m、3m、5m)。

注意,proxy内存中原始handle会映射到broker返回的新handle(checkpoint)

如果changeInvisibleTime的新handle不ack或nack,一样会导致消息重投。

所以肯定要在consumer随后的ack或nack时,将consumer的原始handle转换为renew后的handle,传给broker。

总结

读完新架构下的client和proxy,总体来说,模型和代码结构都更清晰易读了。

1、独立实例客户端

新版客户端将producer和consumer实例完全分开了

  1. rpc客户端:producer和consumer使用不同rpc客户端;
  2. 路由:producer和consumer各自缓存自己关心的topic的路由数据;
  3. 心跳:producer和consumer各自发送独立心跳;

2、路由

proxy侧,对路由做了caffeine本地缓存:

  1. 运行时cache miss从nameserver加载;
  2. 如果nameserver返回空,缓存空对象;
  3. 缓存写入20s后自动刷新;

client侧:

  1. 启动时,支持提前从proxy加载路由;
  2. 运行时,本地缓存cache miss,从proxy加载路由;
  3. 每隔30s,刷新本地缓存路由;

需要注意的是,client查询路由会传入proxy的endpoints地址,proxy会将所有broker地址替换为proxy地址。

3、心跳

客户端

客户端心跳依赖路由,发送端点决于路由返回的broker地址,即client传入的proxy地址。

客户端心跳包非常轻量

  1. producer:只包含客户端类型(producer);
  2. consumer:除了客户端类型之外,还需要携带group消费组,但是不包含订阅关系

新版客户端,每隔10s 发送心跳包给一个proxy端点

注:remoting客户端心跳包较重,每隔30s才发送一个,且需要循环所有broker发送。

proxy端

proxy端收到心跳,需要先根据clientId拿到Settings,即心跳依赖Settings

根据客户端类型执行不同的注册(续期)逻辑:

  1. producer:适配remoting协议,将客户端信息放入ProducerManager(rocketmq-broker模块);
  2. consumer:适配remoting协议,将客户端信息放入ConsumerManager(rocketmq-broker模块);

特殊的是,consumer的心跳需要通过HeartbeatSyncer同步至其他proxy节点

consumer心跳同步方式是:

  1. 收到心跳的proxy节点,HeartbeatSyncer发送心跳同步消息 到一个系统topic(heartbeatSyncerTopicName=DefaultHeartBeatSyncerTopic);
  2. 其他proxy节点,HeartbeatSyncer广播消费心跳消息(remoting客户端) ,存储到ConsumerManager中;

4、Settings

Settings是新客户端和proxy之间出现的新模型,主要是一些配置信息。

两边开启grpc双向流,定时同步配置,部分配置由client决定,部分配置由proxy决定。

Settings同步由client发起,每隔5分钟执行一次

  1. 客户端发送本地settings到proxy;
  2. proxy将自己的配置合并到客户端settings,返回客户端;

consumer的订阅关系是通过Settings同步给proxy的,这点比较重要。

5、发消息

发消息逻辑比较简单,主要区别在于:

  1. 负载均衡:producer不会选队列,所有消息都直接发送给proxy,普通消息由proxy轮询选择queue;
  2. 自动创建topic:新版客户端不支持自动创建topic;
  3. 重试策略:由proxy通过Settings同步确定,默认按照指数退避策略重试3次(10ms、20ms、40ms);

6、收消息

新架构下消费逻辑都走POP模式

queryAssignments

客户端查询分配给自己的queue:

  1. 客户端,每隔5秒,刷新本地缓存Assignments;
  2. proxy端,根据topic,返回MessageQueue=master broker+可读queue(-1);
  3. 客户端,对于每个MessageQueue创建ProcessQueue,针对每个ProcessQueue开始拉消息,即一个topic+一个master broker有一个id=-1的ProcessQueue

receiveMessage

  1. consumer,针对每个ProcessQueue向proxy发起拉消息请求,打开一个grpc server stream
  2. proxy,用remoting协议,向broker发起长轮询,queueId=-1由broker随机选queue消费
  3. broker,执行pop消费拉消息逻辑,返回消息;
  4. proxy,执行tag精确匹配,对于匹配失败消息向broker发送ack;
  5. proxy,将收到的消息通过grpc server stream响应consumer;
  6. consumer,提交消息集合到消费线程池(20线程),提交新的receiveMessage请求到rpc线程池;

处理消息

如果消费成功,客户端发送ack给proxy,proxy几乎透传给broker;

如果消费失败,客户端发送unack给proxy,本质上是changeInvisibleTime(延迟时间由Settings同步proxy确定,1s-7200s),proxy也是几乎透传给broker;

renew

proxy侧针对push消费者默认开启renew逻辑:

  1. 在客户端receiveMessage时,proxy会缓存broker返回的每条消息的句柄ReceiptHandle(checkpoint信息);
  2. proxy定时扫描缓存handle,如果consumer收到消息后,长时间(消费超时前10s)不返回消费结果(ack/nack),proxy将主动向broker发起changeInvisibleTime(nack),更新本地缓存ReceiptHandle;
  3. 在客户端ack/unack时,proxy会移除缓存句柄,使用renew后的句柄ack/unack broker;

参考文档

  1. proxy支持gRPC协议:RIP-39
  2. proxy支持remoting协议:RIP-55
  3. 5.x部署方式:rocketmq.apache.org/docs/deploy...
  4. 5.x客户端SDK概览:rocketmq.apache.org/docs/sdk/01...
  5. 5.x客户端仓库:github.com/apache/rock...

欢迎大家评论或私信讨论问题。

本文原创,未经许可不得转载。

欢迎关注公众号【程序猿阿越】。

相关推荐
逊嘘4 分钟前
【Java语言】抽象类与接口
java·开发语言·jvm
morris13111 分钟前
【SpringBoot】Xss的常见攻击方式与防御手段
java·spring boot·xss·csp
monkey_meng29 分钟前
【Rust中的迭代器】
开发语言·后端·rust
余衫马32 分钟前
Rust-Trait 特征编程
开发语言·后端·rust
monkey_meng35 分钟前
【Rust中多线程同步机制】
开发语言·redis·后端·rust
七星静香36 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
Jacob程序员37 分钟前
java导出word文件(手绘)
java·开发语言·word
ZHOUPUYU38 分钟前
IntelliJ IDEA超详细下载安装教程(附安装包)
java·ide·intellij-idea
stewie641 分钟前
在IDEA中使用Git
java·git
Elaine2023911 小时前
06 网络编程基础
java·网络