前言
从服务端来说,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协议做了铺垫(服务端内部使用同样的协议,不影响理解新架构):
本文主要分析,grpc客户端+proxy下普通消息的收发流程,broker侧逻辑将忽略。
注:本文服务端基于rocketmq 5.1.1 版本,客户端基于rocketmq-clients java-5.0.5版本;
一、案例
rocketmq-proxy
配置文件rmq-proxy.json:
- namesrvAddr:NameServer地址,分号分割;
- proxyMode:cluster模式;
- rocketMQClusterName:目标broker集群名称(proxy是集群级别的);
- remotingListenPort:remoting协议端口,默认8080;
- grpcServerPort:grpc协议端口,默认8081;
- useEndpointPortFromRequest:查询路由,是否使用入参endpoint端点的端口;
- 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实现只有一个ClientServiceProviderImpl 在rocketmq-client-java-noshade模块中。
ClientServiceProvider构造出来的客户端:
- producer有一种:ProducerImpl;
- consumer有两种:
-
- PushConsumerImpl:PUSH模式;
- 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构建ManagedChannel 和Stub。
这里需要注意,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):
- 缓存20s后自动刷新;
- 运行阶段,如果topic缓存不存在,从nameserver加载;
- 运行阶段,如果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:
- 支持启动阶段主动加载路由(producer设置topic、consumer启动前订阅topic),如果刷新路由失败,启动失败;(运行阶段新增topic懒加载)
- 后期每隔30s刷新路由缓存;
ClientImpl#fetchTopicRoute0:客户端发送topic+proxy的endpoints。
当然运行阶段,如果客户端新增topic,也会懒加载路由。
比如producer在运行阶段发送新topic,consumer运行阶段新增订阅topic。
路由在新版客户端中是非常重要的,所有动作都离不开路由,包括心跳。
3、proxy返回路由
RouteActivity#queryRoute:查询路由
- 入参模型转换;
- 查询;
- 出参模型转换;
RouteActivity#convertToAddressList:
客户端会携带自己配置的proxy端点进来,在broker侧会对端点的adress地址列表做转换。
默认情况下,host不变,port会修改为当前proxy实例配置的grpcServerPort。
但是如果经过端口转换(各类负载均衡,如nginx、k8s),可以通过设置useEndpointPortFromRequest=true,采用客户端传入port。
ClusterTopicRouteService#getTopicRouteForProxy:Cluster模式下
- 查询topic对应路由信息,先走本地caffeine缓存,cache miss走nameserver;
- 所有broker地址,都替换为客户端传入endpoint,即rocketmq-proxy实例地址;
注意,这里所有broker地址都是客户端传入endpoints,很重要,因为一个客户端实例只会对应一个Endpoints。
出参是一组MessageQueue。
- topic;
- id:queueId;
- permission:读写权限;
- broker:broker名、地址(proxy地址)等;
- 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是非常单纯的心跳包:
- group:消费组(PRODUCER不传);
- 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协议模型
- GrpcClientChannel,继承netty的AbstractChannel;
- 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发送一条心跳同步消息。
消息包含:
- ClientChannelInfo,客户端信息,包括clientId;
- consumerGroup,消费组;
- localProxyId,当前proxy的标识(本机ip+remoting端口+grpc端口);
- RemoteChannel,通过GrpcClientChannel转换而来,包含当前grpc客户端与proxy的连接信息,比如双边address等;
- 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:消费心跳同步消息
- 反序列化;
- 判断localProxyId,如果是自己发送的消息,忽略;
- 执行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)分为三类:
- PublishingSettings:PRODUCER;
- PushSubscriptionSettings:PUSH_CONSUMER;
- SimpleSubscriptionSettings:SIMPLE_CONSUMER(pull);
基类Settings,包含客户端类型,ClientId等属性。
ClientId由hostname+pid+index+startTime四部分组成,是客户端的唯一标识。
PublishingSettings:producer的Settings包含发布的topic。
PushSubscriptionSettings:Push消费者Settings
- group:消费组;
- subscriptionExpressions:订阅关系,key是topic;
- fifo:是否顺序消费;
- receiveBatchSize:批处理大小;
- longPollingTimeout:长轮询超时时间,30s;
2、客户端发送Settings
ClientManagerImpl#startUp:
每隔5分钟,producer/consumerImpl与proxy执行settings同步。
ClientImpl#syncSettings:
- 封装Settings到TelemetryCommand;
- 从路由收集proxy的endpoints;
- 发送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
- updateClientSettings:将客户端Settings放入内存table(GrpcClientSettingsManager.CLIENT_SETTINGS_MAP);
- getClientSettings:合并客户端Settings和proxy配置;
- 返回合并后的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:发消息,忽略前置各种校验
- 获取路由:一组可写MessageQueue,broker地址都是proxy端点;
这里和传统客户端不一样,传统客户端可以获取topic=TBW102作为默认路由,自动创建topic,但是新客户端不支持,所以新客户端一定要手动创建topic;
- 选队列:其实这里选哪个都不太重要,因为建立grpc长连接的proxy只有一个,后续在proxy侧会做实际负载均衡,这里主要是为了拿到路由返回的endpoints;
- send0:调用proxy发送消息;
ProducerImpl#send0:模型转换,发送proxy。
注:messageId还是由客户端生成,见Message模型PublishingMessageImpl构造。
2、proxy发送
SendMessageActivity#sendMessage:模型转换。
ProducerProcessor#sendMessage:
- 根据topic路由选queue;
- 调用queue对应broker,发送消息;
SendMessageQueueSelector#select:proxy负责发送消息queue级别负载均衡
- 普通消息,从可写队列里,轮询选一个queue;
- 顺序消息,根据messageGroup.hashCode,从可写队列里选一个queue;
3、客户端重试
如果客户端发送消息失败,按照策略执行重试。
ProducerImpl#send0:
- 隔离endpoints,直到proxy能正确处理心跳;
- 超出最大重试次数3(proxy指定,通过Settings同步),失败;
- 事务消息,快速失败;
- 如果因为服务端流控(TooManyRequests)失败,按照指数退避策略(proxy指定,通过Settings同步),延迟10ms、20ms、40ms重试;
- 其他,立即重试;
七、普通消息消费(Push)
1、概览
POP消费概览
新架构之下,消费流程都遵循POP消费模式( POP消费源码分析 ) :
- queryAssignment:查询Assignment。Push消费者需要,Simple消费者不需要;
- receiveMessage:拉消息;
- 处理消息:
- ackMessage:消费成功,ack;
- changeInvisibleTime:消费失败,nack,过n时长后重试;
新版客户端
从新版客户端的线程模型上来看,相较于传统客户端简单了很多,分为三组线程:
回顾broker
回顾POP消费模式下,broker对于pop消费的处理逻辑。
queryAssignment
以4.x的角度来说,这阶段也是客户端rebalance。
方法入口:QueryAssignmentProcessor#queryAssignment。
broker默认为每个consumer实例分配所有queue,只是queueId=-1。
(broker侧默认配置defaultPopShareQueueNum=-1的行为)
拉消息
pop拉消息特点:
- 客户端针对每个master broker提交一个Pop请求,而请求中未明确指定queueId ,也未明确指定消费进度;
- broker需要负责选择queue和消费进度;
- 对于一次pop请求,broker需要记录checkpoint 信息,topic=rmq_sys_REVIVE_LOG_{clusterName},queueId=random(8),tag=ck,要利用timer消息,目标投递时间戳=pop请求时间+invisibleTime(60s)-1s;
- 返回用户消息的properties中会追加checkpoint信息,用于后续ack和changeInvisibleTime;
处理消息
pop处理消息特点:
- 客户端做tag精确匹配(broker只能根据consumequeue记录的hascode过滤一遍),如果不匹配,直接异步ack不匹配消息;
- 无论消费成功还是失败,都需要回复broker,ack代表消费成功,changeInvisibleTime代表消费失败,需要重试,重试时间由consumer定夺;
- broker对于ack请求 ,发送一条ack消息 ,topic=rmq_sys_REVIVE_LOG_{clusterName},queueId=checkpoint消息所在queueId,tag=ack,要利用timer消息,目标投递时间戳=pop请求时间+invisibleTime(60s) ;
- 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:
- 从topic路由中收集endpoints;
- 请求包含:topic+endpoints+group;
TopicRouteData#pickEndpointsToQueryAssignments:从所有queue中选一个queue的endpoints,要求:
- master上线;
- 有权限;
proxy返回Assignments
RouteActivity#queryAssignment:proxy查询Assignment
- 将入参endpoints转换为地址列表,和路由查询一致;
- 查询路由,和路由查询一致;
- 出参转换;
注意:proxy没有将查询Assignment请求代理到broker ,如果使用传统客户端开启pop消费,查询Assignment逻辑走的是broker,见POP消费源码分析。
RouteActivity#queryAssignment:出参转换是重点
- 选择可读队列;
- 选master broker,与路由同理,地址是入参的endpoints,即proxy地址;
- 队列id都设置为-1(由broker做queue负载均衡);
从结果上来看,最终返回客户端的Assignments只包含topic下master broker数量个queueId=-1的MessageQueue,且地址都为proxy端点。
客户端处理Assignments
PushConsumerImpl#scanAssignments:
- 更新topic对应缓存Assignments;
- 更新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:
- 决策本次拉取消息数量;
- 向proxy发起拉消息请求,scheduler线程切rpc线程;
ProcessQueueImpl#getReceptionBatchSize:
拉取消息数量,包含一定流控逻辑,不超过32条(Settings同步由proxy确定)。
proxy处理拉消息
proxy向broker发起长轮询
ReceiveMessageActivity#receiveMessage:proxy处理receiveMessage请求入口
- actualInvisibleTime:pop消费的invisibleTime,proxy控制,60s;
- 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:
- push消费,有一个renew逻辑(autoRenew=true,默认开启),将checkpoint信息(receiptHandle)缓存;(后面再看,这段逻辑没有也不影响流程)
- 将响应写回客户端;
ReceiveMessageResponseStreamWriter#writeAndComplete:写回客户端。
可以看到拉消息走的是ServerStream,proxy会按顺序写三种包响应客户端:
- STATUS:1个状态包,标识本次receiveMessage响应是否成功;
- MESSAGE:0-n个消息包,即拉到的消息;
- DELIVERY_TIMESTAMP:1个响应时间戳包,这个主要是metrics采集用,可以忽略;
proxy侧,checkpoint信息 被转换为ReceiptHandle。
客户端收到消息
RpcClientImpl#receiveMessage:客户端收到proxy响应。
ConsumerImpl#receiveMessage:proto模型转换。
ProcessQueueImpl#onReceiveMessageResult:ProcessQueue处理拉到的消息
- 缓存消息;
- 提交消息到消费线程;
- 再次提交receiveMessage请求到rpc线程,形成循环;
StandardConsumeService#consume:普通并发消费(非顺序)
- 每条消息分别丢到消费线程池(20线程)执行;
- 处理消费结果;
ProcessQueueImpl#eraseMessage:
消费结果分成两种情况,成功-ack,失败-nack。
4、ack
客户端
ProcessQueueImpl#ackMessage:
客户端ack有重试逻辑,如果ack响应失败,延迟1s重试,无限次数重试。
ConsumerImpl#wrapAckMessageRequest:客户端会携带ReceiptHandle发起ack。
proxy
AckMessageActivity#processAckMessage:
- 移除缓存句柄(属于renew逻辑,后面看);
- 执行ack;
ConsumerProcessor#ackMessage:
校验句柄未过期(默认invisibleTime=60s,60s收到消息没ack,句柄就失效)。
转换remoting协议,请求broker,ReceiptHandle就是broker收到的ExtraInfo。
5、unack
客户端
ProcessQueueImpl#nackMessage:unack就是POP消费分析的changeInvisibleTime。
- 不可见时间由proxy通过settings同步返回,从1s到7200s。
- changeInvisibleTime请求也支持重试,延迟1s,无限次数。
ConsumerImpl#wrapChangeInvisibleDuration:
请求包含group、topic、ReceiptHandle、不可见时间。
proxy
ChangeInvisibleDurationActivity#changeInvisibleDuration:
proxy侧,和ack逻辑类似,只是调用broker方法不同。
6、renew
renew是proxy独有的逻辑。
如果consumer收到消息后,长时间不返回消费结果(ack/nack),proxy将主动向broker发起changeInvisibleTime(nack) 。
至于为什么这么做,我猜想是这样。假设下面这种场景:
- consumer-proxy-broker之间能正常通讯;
- 因为consumer消费慢,broker超时(invisibleTime)未收到消费结果(ack或nack都行);
- broker会持续向重试topic投递消息;
重试消息将进一步加重consumer的消息积压。
传统非pop消费模式,是否sendMessageBack重试,完全取决于客户端,不会有这种问题。
ReceiptHandleProcessor:负责管理缓存的receiptHandle(checkpoint)
ReceiptHandleProcessor只有两个public方法:
- receiveMessage时,缓存receiptHandle;
- ack/unack时,清除缓存的receiptHandle;
ReceiptHandleProcessor#init:
启动阶段,开启5s定时任务,扫描缓存handle。
ReceiptHandleProcessor#scheduleRenewTask:
- clientIsOffline:如果channel+group找不到consumer心跳,代表consumer失联,移除consumer下所有handle,对这些handle执行renew;
- 如果消息即将被重新投递,执行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实例完全分开了:
- rpc客户端:producer和consumer使用不同rpc客户端;
- 路由:producer和consumer各自缓存自己关心的topic的路由数据;
- 心跳:producer和consumer各自发送独立心跳;
2、路由
proxy侧,对路由做了caffeine本地缓存:
- 运行时cache miss从nameserver加载;
- 如果nameserver返回空,缓存空对象;
- 缓存写入20s后自动刷新;
client侧:
- 启动时,支持提前从proxy加载路由;
- 运行时,本地缓存cache miss,从proxy加载路由;
- 每隔30s,刷新本地缓存路由;
需要注意的是,client查询路由会传入proxy的endpoints地址,proxy会将所有broker地址替换为proxy地址。
3、心跳
客户端
客户端心跳依赖路由,发送端点决于路由返回的broker地址,即client传入的proxy地址。
客户端心跳包非常轻量:
- producer:只包含客户端类型(producer);
- consumer:除了客户端类型之外,还需要携带group消费组,但是不包含订阅关系;
新版客户端,每隔10s 发送心跳包给一个proxy端点。
注:remoting客户端心跳包较重,每隔30s才发送一个,且需要循环所有broker发送。
proxy端
proxy端收到心跳,需要先根据clientId拿到Settings,即心跳依赖Settings。
根据客户端类型执行不同的注册(续期)逻辑:
- producer:适配remoting协议,将客户端信息放入ProducerManager(rocketmq-broker模块);
- consumer:适配remoting协议,将客户端信息放入ConsumerManager(rocketmq-broker模块);
特殊的是,consumer的心跳需要通过HeartbeatSyncer同步至其他proxy节点。
consumer心跳同步方式是:
- 收到心跳的proxy节点,HeartbeatSyncer发送心跳同步消息 到一个系统topic(heartbeatSyncerTopicName=DefaultHeartBeatSyncerTopic);
- 其他proxy节点,HeartbeatSyncer广播消费心跳消息(remoting客户端) ,存储到ConsumerManager中;
4、Settings
Settings是新客户端和proxy之间出现的新模型,主要是一些配置信息。
两边开启grpc双向流,定时同步配置,部分配置由client决定,部分配置由proxy决定。
Settings同步由client发起,每隔5分钟执行一次。
- 客户端发送本地settings到proxy;
- proxy将自己的配置合并到客户端settings,返回客户端;
consumer的订阅关系是通过Settings同步给proxy的,这点比较重要。
5、发消息
发消息逻辑比较简单,主要区别在于:
- 负载均衡:producer不会选队列,所有消息都直接发送给proxy,普通消息由proxy轮询选择queue;
- 自动创建topic:新版客户端不支持自动创建topic;
- 重试策略:由proxy通过Settings同步确定,默认按照指数退避策略重试3次(10ms、20ms、40ms);
6、收消息
新架构下消费逻辑都走POP模式。
queryAssignments
客户端查询分配给自己的queue:
- 客户端,每隔5秒,刷新本地缓存Assignments;
- proxy端,根据topic,返回MessageQueue=master broker+可读queue(-1);
- 客户端,对于每个MessageQueue创建ProcessQueue,针对每个ProcessQueue开始拉消息,即一个topic+一个master broker有一个id=-1的ProcessQueue;
receiveMessage
- consumer,针对每个ProcessQueue向proxy发起拉消息请求,打开一个grpc server stream;
- proxy,用remoting协议,向broker发起长轮询,queueId=-1由broker随机选queue消费;
- broker,执行pop消费拉消息逻辑,返回消息;
- proxy,执行tag精确匹配,对于匹配失败消息向broker发送ack;
- proxy,将收到的消息通过grpc server stream响应consumer;
- consumer,提交消息集合到消费线程池(20线程),提交新的receiveMessage请求到rpc线程池;
处理消息
如果消费成功,客户端发送ack给proxy,proxy几乎透传给broker;
如果消费失败,客户端发送unack给proxy,本质上是changeInvisibleTime(延迟时间由Settings同步proxy确定,1s-7200s),proxy也是几乎透传给broker;
renew
proxy侧针对push消费者默认开启renew逻辑:
- 在客户端receiveMessage时,proxy会缓存broker返回的每条消息的句柄ReceiptHandle(checkpoint信息);
- proxy定时扫描缓存handle,如果consumer收到消息后,长时间(消费超时前10s)不返回消费结果(ack/nack),proxy将主动向broker发起changeInvisibleTime(nack),更新本地缓存ReceiptHandle;
- 在客户端ack/unack时,proxy会移除缓存句柄,使用renew后的句柄ack/unack broker;
参考文档:
- proxy支持gRPC协议:RIP-39
- proxy支持remoting协议:RIP-55
- 5.x部署方式:rocketmq.apache.org/docs/deploy...
- 5.x客户端SDK概览:rocketmq.apache.org/docs/sdk/01...
- 5.x客户端仓库:github.com/apache/rock...
欢迎大家评论或私信讨论问题。
本文原创,未经许可不得转载。
欢迎关注公众号【程序猿阿越】。