if (!UtilAll.isBlank(this.unitName)) {
sb.append(“@”);
sb.append(this.unitName);
}
return sb.toString();
}
复制代码
通过这个相信大家都可以看出 clientId 的生成规则吧,就是 消费者客户端的IP + “@”+ 实例名称,很明显问题就出在获取客户端 IP 上。
我们再继续看一下它究竟是如何获取客户端 IP 的
public class ClientConfig {
…
private String clientIP = RemotingUtil.getLocalAddress();
…
}
public static String getLocalAddress() {
try {
// Traversal Network interface to get the first non-loopback and non-private address
Enumeration enumeration = NetworkInterface.getNetworkInterfaces();
ArrayList ipv4Result = new ArrayList();
ArrayList ipv6Result = new ArrayList();
while (enumeration.hasMoreElements()) {
final NetworkInterface networkInterface = enumeration.nextElement();
final Enumeration en = networkInterface.getInetAddresses();
while (en.hasMoreElements()) {
final InetAddress address = en.nextElement();
if (!address.isLoopbackAddress()) {
if (address instanceof Inet6Address) {
ipv6Result.add(normalizeHostAddress(address));
} else {
ipv4Result.add(normalizeHostAddress(address));
}
}
}
}
// prefer ipv4
if (!ipv4Result.isEmpty()) {
for (String ip : ipv4Result) {
if (ip.startsWith(“127.0”) || ip.startsWith(“192.168”)) {
continue;
}
return ip;
}
return ipv4Result.get(ipv4Result.size() - 1);
} else if (!ipv6Result.isEmpty()) {
return ipv6Result.get(0);
}
//If failed to find,fall back to localhost
final InetAddress localHost = InetAddress.getLocalHost();
return normalizeHostAddress(localHost);
} catch (Exception e) {
log.error(“Failed to obtain local address”, e);
}
return null;
}
复制代码
如果有操作过获取当前机器的 IP 的小伙伴,应该对 RemotingUtil.getLocalAddress() 这个工具方法并不陌生~
简单说就是获取当前机器网卡 IP,但是由于容器的网络模式采用的是 host 模式,也就意味着各个容器和宿主机都是处于同一个网络下,所以容器中我们也可以看到 Docker - Server 所创建的 docker 0 网卡,所以它读取的也就是 docker 0 网卡所默认的 IP 地址 172.17.0.1
(跟运维同学沟通了一下,目前由于是容器化的第一阶段,所以先采用简单模式部署,后面会慢慢替换成 k8s,每个 pod 都有自己的独立 IP ,到时网络会与宿主机和其他 pod 的相互隔离。emmm…k8s !听起来牛逼哄哄,恰好最近也在看这方面的书)
**这时候聪明的你可能会问 “不是还有一个实例名称的参数呢,这个又怎么会相同呢?” ** 别着急,我们继续往下看👇
private String instanceName = System.getProperty(“rocketmq.client.name”, “DEFAULT”);
public String getInstanceName() {
return instanceName;
}
public void setInstanceName(String instanceName) {
this.instanceName = instanceName;
}
public void changeInstanceNameToPID() {
if (this.instanceName.equals(“DEFAULT”)) {
this.instanceName = String.valueOf(UtilAll.getPid());
}
}
复制代码
getInstanceName() 方法其实直接获取 instanceName 这个参数值,但是这个参数值是什么时候赋值进去的呢?没错就是通过 changeInstanceNameToPID() 这个方法赋值的,在 consumer 在 start 的时候会调用此方法。
这个参数的逻辑很简单,在初始化的时候首先会获取环境变量 rocketmq.client.name 是否有值,如果没有就是用默认值 DEFAULT 。
然后 consumer 启动的时候会判断这参数值是否为 DEFAULT ,如果是的话就调用 UtilAll.getPid() 。
public static int getPid() {
RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
String name = runtime.getName(); // format: “pid@hostname”
try {
return Integer.parseInt(name.substring(0, name.indexOf(‘@’)));
} catch (Exception e) {
return -1;
}
}
复制代码
通过方法名字我们就可以很清楚知道,这个方法其实获取进程号的。那…为什么获取的进程号都是一致的呢?
聪明的你可能已经知道答案了对吧 !这里就不得不提 Docker 的 三大特性
-
cgroup
-
namespace
-
unionFS
没错,这里用的就是 namespace 技术啦。
Linux Namespace 是 Linux 内核提供的一个功能,可以实现系统资源的隔离,如:PID、User ID、Network 等。
由于都是使用相同的基础镜像,在最外层都是运行同样的 JAVA 工程,所以我们可以进去容器里面看,他们的进程号都是为 9
经过肥壕的一系列巧妙的推理和论证, 在 Docker 容器 HOST 网络模式下, 会生成相同的 clientId !
到这里为止,我们算是解决了上文推测的第一个问题!
紧跟柯南的步伐,我们继续推理第二个问题: clientId 相同导致 Broker 分发消息错误?
Consumer 在负载均衡的时候应该是根据 clientId 作为客户端消费者的唯一标识,在消息下发的时候由于 clientId 的一致,导致负载分发错误。
那么我们下面就要去探究一下 Consumer 的负载均衡究竟是如何实现的。一开始我以为消费端的负载均衡都是在 Broker 处理的,由Broker 根据注册地 Consumer 把不同的 Queue 分配给不同的 Consumer。但是去看了一下源码上的 doc 描述文档和对源码进行一番的研究后,结果发现自己见识还是太少了(哈哈哈,应该有小伙伴跟我开始的想法是一样的吧)
先来补充一下 RocketMQ 的整体架构
由于篇幅问题,这里我只讲解一下 Broker 和 consumer 之间的关系,其他的角色如果有不懂的可以看一下我之前写的 RocketMQ 介绍篇的文章
-
Consumer 与 NameServer 集群中的其中一个节点(随机选择) 建立长连接,定期从 NameServer 获取 Topic 路由信息。
-
根据获取 Topic 路由信息 与 Broker 建立长连接,且 定时向 Broker 发送心跳。
Broker 接收心跳消息的时候,会把 Consumer 的信息保存到本地缓存变量 consumerTable 。上图大致讲解了一下 consumerTable 的存储结构和内容,最主要的是它缓存了每个 consumer 的 clientId。
关于 Consumer 的消费模式,我直接引用源码的解释
在 RocketMQ 中,Consumer 端的两种消费模式(Push/Pull)都是基于拉模式来获取消息的,而在 Push 模式只是对 Pull 模式的一种封装,其本质实现为消息拉取线程在从服务器拉取到一批消息后,然后提交到消息消费线程池后,又“马不停蹄”的继续向服务器再次尝试拉取消息。如果未拉取到消息,则延迟一下又继续拉取。
在两种基于拉模式的消费方式(Push/Pull)中,均需要 Consumer 端在知道从 Broker 端的哪一个消息队列—队列中去获取消息。因此,有必要在 Consumer 端来做负载均衡,即 Broker 其中多个 MessageQueue 分配给同一个ConsumerGroup 中的哪些 Consumer 消费。
所以简单来说,不管是 Push 还是 Pull 模式,消息消费的控制权在 Consumer 上,所以 Consumer 的负载均衡实现是在 Consumer 的 Client 端上。
==============================================================================================================================================================
通过查看源码可以发现, RebalanceService 会完成负载均衡服务线程(每隔20s执行一次),RebalanceService 线程的run() 方法最终调用的是 RebalanceImpl 类的 rebalanceByTopic() 方法,该方法是实现 Consumer 端负载均衡的核心。这里, rebalanceByTopic() 方法会根据消费者通信类型为“广播模式”还是“集群模式”做不同的逻辑处理。这里主要来看下集群模式下的主要处理流程:
private void rebalanceByTopic(final String topic, final boolean isOrder) {
switch (messageModel) {
case BROADCASTING: {
… // 省略
}
case CLUSTERING: {
// 获取该Topic主题下的消息消费队列集合
Set mqSet = this.topicSubscribeInfoTable.get(topic);
// 向 broker 获取消费者的clientId
List cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
if (null == mqSet) {
if (!topic.startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
log.warn(“doRebalance, {}, but the topic[{}] not exist.”, consumerGroup, topic);
}
}
if (null == cidAll) {
log.warn(“doRebalance, {} {}, get consumer id list failed”, consumerGroup, topic);
}
if (mqSet != null && cidAll != null) {
List mqAll = new ArrayList();
mqAll.addAll(mqSet);
Collections.sort(mqAll);
Collections.sort(cidAll);
// 默认平均分配算法
AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;
List allocateResult = null;
try {
allocateResult = strategy.allocate(
this.consumerGroup,
this.mQClientFactory.getClientId(),
mqAll,
cidAll);
} catch (Throwable e) {
log.error(“AllocateMessageQueueStrategy.allocate Exception. allocateMessageQueueStrategyName={}”, strategy.getName(),
e);
return;
}
Set allocateResultSet = new HashSet();
if (allocateResult != null) {
allocateResultSet.addAll(allocateResult);
}
boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
if (changed) {
log.info(
“rebalanced result changed. allocateMessageQueueStrategyName={}, group={}, topic={}, clientId={}, mqAllSize={}, cidAllSize={}, rebalanceResultSize={}, rebalanceResultSet={}”,
strategy.getName(), consumerGroup, topic, this.mQClientFactory.getClientId(), mqSet.size(), cidAll.size(),
allocateResultSet.size(), allocateResultSet);
this.messageQueueChanged(topic, mqSet, allocateResultSet);
}
}
break;
}
default:
break;
}
}
复制代码
(1) 从本地缓存变量 topicSubscribeInfoTable 中,获取该Topic主题下的消息消费队列集合(mqSet);
(2) 根据 topic 和 consumerGroup 为参数调用 findConsumerIdList() 方法向 Broker 端发送获取该消费组下 clientId 列表 ;
(3) 先对 Topic 下的消息消费队列、消费者Id排序,然后用 消息队列分配策略算法(默认为:消息队列的平均分配算法),计算出待拉取的消息队列。这里的平均分配算法,类似于分页的算法,将所有 MessageQueue 排好序类似于记录,将所有消费端 Consumer 排好序类似页数,并求出每一页需要包含的平均 size 和每个页面记录的范围 range,最后遍历整个range 而计算出当前 Consumer 端应该分配到的记录(这里即为:MessageQueue)。
(4) 然后,调用updateProcessQueueTableInRebalance()方法,具体的做法是,先将分配到的消息队列集合(mqSet)与processQueueTable做一个过滤比对。
removeUnnecessaryMessageQueue()
removeUnnecessaryMessageQueue()
消息消费队列在同一消费组不同消费者之间的负载均衡, 其核心设计理念是在一个消息消费队列在同一时间只允许被同一消费组内的一个消费者消费,一个消息消费者能同时消费多个消息队列 。
上面这部分内容是摘自RocketMQ 源码中 docs的文档,不知道你们看懂了没,反正我是看了好几遍才理解了
其实看步骤3的图,负载均衡的实现原来也就一目了然了, 简单说就是给不同的消费者分配数量相同的消费队列 。而消费者都会生成 clientId 的唯一标识,但是根据我们上文的推理,在容器中并且是Host网络模式下会生成一致的 clientId。
Emmmm…到这里,想必大家都能猜到究竟是哪里出问题了吧。
没错!问题应该就出在步骤3中,平均分配的计算方式。
@Override
public List allocate(String consumerGroup, String currentCID, List mqAll, List cidAll) {
if (currentCID == null || currentCID.length() < 1) {
throw new IllegalArgumentException(“currentCID is empty”);
}
if (mqAll == null || mqAll.isEmpty()) {
throw new IllegalArgumentException(“mqAll is null or mqAll empty”);
}
if (cidAll == null || cidAll.isEmpty()) {
throw new IllegalArgumentException(“cidAll is null or cidAll empty”);
}
List result = new ArrayList();
if (!cidAll.contains(currentCID)) {
log.info(“[BUG] ConsumerGroup: {} The consumerId: {} not in cidAll: {}”,
consumerGroup,
currentCID,
cidAll);
return result;
}
// 当前clientId所在的下标
int index = cidAll.indexOf(currentCID);
int mod = mqAll.size() % cidAll.size();
int averageSize =
mqAll.size() <= cidAll.size() ? 1 : (mod > 0 && index < mod ? mqAll.size() / cidAll.size()
- 1 : mqAll.size() / cidAll.size());
int startIndex = (mod > 0 && index < mod) ? index * averageSize : index * averageSize + mod;
int range = Math.min(averageSize, mqAll.size() - startIndex);
for (int i = 0; i < range; i++) {
result.add(mqAll.get((startIndex + i) % mqAll.size()));
}
return result;
}
复制代码
上面的计算可以看起来有点绕,但是其实看懂了之后,说白就是计算当前 Consumer 所分配的消息队列,就好比上图步骤3中的图示
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)
最后如何让自己一步步成为技术专家
说句实话,如果一个打工人不想提升自己,那便没有工作的意义,毕竟大家也没有到养老的年龄。
当你的技术在一步步贴近阿里p7水平的时候,毫无疑问你的薪资肯定会涨,同时你能学到更多更深的技术,交结到更厉害的大牛。
推荐一份Java架构之路必备的学习笔记,内容相当全面!!!
成年人的世界没有容易二字,前段时间刷抖音看到一个程序员连着加班两星期到半夜2点的视频。在这个行业若想要拿高薪除了提高硬实力别无他法。
你知道吗?现在有的应届生实习薪资都已经赶超开发5年的程序员了,实习薪资26K,30K,你没有紧迫感吗?做了这么多年还不如一个应届生,真的非常尴尬!
进了这个行业就不要把没时间学习当借口,这个行业就是要不断学习,不然就只能被裁员。所以,抓紧时间投资自己,多学点技术,眼前困难,往后轻松!
【关注】+【转发】+【点赞】支持我!创作不易!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
671a72faed303032d36.jpg" alt=“img” style=“zoom: 33%;” />
最后如何让自己一步步成为技术专家
说句实话,如果一个打工人不想提升自己,那便没有工作的意义,毕竟大家也没有到养老的年龄。
当你的技术在一步步贴近阿里p7水平的时候,毫无疑问你的薪资肯定会涨,同时你能学到更多更深的技术,交结到更厉害的大牛。
推荐一份Java架构之路必备的学习笔记,内容相当全面!!!
[外链图片转存中…(img-EhJgsWu1-1713744842438)]
成年人的世界没有容易二字,前段时间刷抖音看到一个程序员连着加班两星期到半夜2点的视频。在这个行业若想要拿高薪除了提高硬实力别无他法。
你知道吗?现在有的应届生实习薪资都已经赶超开发5年的程序员了,实习薪资26K,30K,你没有紧迫感吗?做了这么多年还不如一个应届生,真的非常尴尬!
进了这个行业就不要把没时间学习当借口,这个行业就是要不断学习,不然就只能被裁员。所以,抓紧时间投资自己,多学点技术,眼前困难,往后轻松!
【关注】+【转发】+【点赞】支持我!创作不易!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!