Skip to content

Commit c1f7605

Browse files
committed
docs: 修正变量对比与 RocketMQ 负载均衡说明
1 parent 9eab7aa commit c1f7605

2 files changed

Lines changed: 55 additions & 23 deletions

File tree

‎docs/high-performance/message-queue/rocketmq-questions.md‎

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -272,18 +272,20 @@ RocketMQ 中的消息模型就是按照 **主题模型** 所实现的。那么 *
272272

273273
**负载均衡策略对比**
274274

275+
下面对比的是**同一消费者组内的集群消费(共享消费)**。负载均衡粒度取决于客户端类型和消费协议,不能只看 Broker 的版本:传统 3.x/4.x Remoting SDK 的消费者采用队列粒度;5.x 新版 SDK 的 PushConsumer、SimpleConsumer 采用消息粒度;PullConsumer 仍采用队列粒度。
276+
275277
```mermaid
276278
flowchart TB
277-
subgraph Queue["队列粒度负载均衡 4.x"]
279+
subgraph Queue["队列粒度:3 个队列,4 个消费者"]
278280
style Queue fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
279281
direction TB
280282
Q1["队列1"] --> C1["消费者1"]
281283
Q2["队列2"] --> C2["消费者2"]
282284
Q3["队列3"] --> C3["消费者3"]
283-
Q4["队列4"] -.-> C4["消费者4<br/>(无队列可消费)"]
285+
C4["消费者4<br/>(无队列可分配)"]
284286
end
285287
286-
subgraph Message["消息粒度负载均衡 5.x Push/Simple"]
288+
subgraph Message["消息粒度:同一队列的不同普通消息由组内消费者分担"]
287289
style Message fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
288290
direction TB
289291
MQ1["队列1"] --> MC1["消费者1<br/>消费消息1"]
@@ -295,33 +297,46 @@ flowchart TB
295297
classDef consumer4x fill:#E99151,color:#fff,rx:10,ry:10
296298
classDef consumer5x fill:#00838F,color:#fff,rx:10,ry:10
297299
298-
class Q1,Q2,Q3,Q4,MQ1 queue
300+
class Q1,Q2,Q3,MQ1 queue
299301
class C1,C2,C3,C4 consumer4x
300302
class MC1,MC2,MC3 consumer5x
301303
302304
linkStyle default stroke-width:1.5px,opacity:0.8
303305
```
304306

305-
- **队列粒度负载均衡(4.x 默认策略)**:一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。队列数通常要大于或等于消费者数,消费者少于队列是常见情况,只是单个消费者会分到多个队列;如果消费者数多于队列数,多出来的消费者会空闲。这种模式的缺点是容易产生 **长尾效应**:如果某个消费者处理速度较慢,会导致其对应的队列消息堆积,而其他消费者却处于空闲状态。
306-
- **消息粒度负载均衡(5.x PushConsumer/SimpleConsumer 默认策略)**:RocketMQ 5.x 中,PushConsumer 和 SimpleConsumer 默认使用消息粒度负载均衡,同一消费者分组内的多个消费者可以按照消息粒度分摊主题中的消息;PullConsumer 仍然是队列粒度。消费者获取某条消息后,服务端会将该消息加锁,保证这条消息对其他消费者不可见,直到该消息消费成功或消费超时。这种模式有效解决了长尾效应问题,因为消息不再静态绑定到某个消费者,而是动态分配给空闲的消费者。
307+
- **队列粒度负载均衡**:在分配稳定时,同一消费者组内,一个队列只分配给一个消费者实例;一个消费者可以分到多个队列。如果某个消费者挂掉,会通过重平衡把它的队列分配给组内其他消费者。如果消费者数多于队列数,就会有消费者分不到队列。这种模式容易出现负载不均:某个消费者处理速度较慢,或者分到的队列消息较多,就可能出现该消费者积压、其他消费者空闲的情况。不同消费者组可以分别消费同一个队列,不受组内独占分配的限制。
308+
- **消息粒度负载均衡**:5.x 新版 SDK 的 PushConsumer、SimpleConsumer 可以让同一消费者组内的多个消费者分担同一个队列中的不同消息。图中只是一次分配示例,消息不会固定绑定到某个消费者。消息被取走后,在不可见时间内不会再投递给**同组**其他消费者;消费失败或超时仍可能触发重试,因此不代表绝不会重复消费。这种方式可以缓解队列分配不均和消费者处理能力差异造成的积压,但不能消除业务处理慢等所有瓶颈。顺序消息还受消息组顺序约束,不能把同一消息组内的消息任意并行处理。
307309

308-
消费者个数小于队列个数并不是问题,只是并发能力受消费者数量限制。真正需要避免的是队列数太少,导致后续扩容消费者时没有足够的队列可分配。如下图。
310+
下面这张图仅适用于**队列粒度负载均衡**。消费者个数小于队列个数并不是问题,一个消费者可以消费多个队列;如果希望扩容后的消费者都能分到队列,就需要有足够的队列。消息粒度负载均衡下,消费者实例数则不受队列数的直接限制。
309311

310312
![](https://oss.javaguide.cn/github/javaguide/high-performance/message-queue/16ef3850c808d707.jpg)
311313

312-
**每个消费组在每个队列上维护一个消费位置** ,为什么呢?
314+
**为什么不同消费者组要分别记录每个队列的消费进度?**
315+
316+
因为不同消费者组订阅同一个 Topic 时,需要各自消费这些消息,消费速度也可能不同。以传统队列粒度的集群消费为例,消费位点按 **消费者组 + Topic + 队列** 分别记录,组内消费者共享该进度;发生重平衡时,接手队列的消费者可以从已提交的位点继续消费。
317+
318+
消费位点(ConsumerOffset)表示该组在该队列上下一次继续消费的位置。它不是消息 ID,也不是整个 Topic 共用的序号。下面假设两个组订阅同一个 Topic,没有过滤和重试,消息按队列顺序消费并已提交位点:
319+
320+
| 消费者组 | 队列 Q0 的消费进度 | 队列 Q1 的消费进度 |
321+
| -------- | ----------------------------------------- | ----------------------------------------- |
322+
| Group A | offset 为 3:已消费 0~2,下一条从 3 开始 | offset 为 1:已消费 0,下一条从 1 开始 |
323+
| Group B | offset 为 1:已消费 0,下一条从 1 开始 | offset 为 4:已消费 0~3,下一条从 4 开始 |
324+
325+
Q0 和 Q1 的 offset 分别在各自队列内递增。Group A 推进 Q0 的位点,不会改变 Group B 的位点,也不会改变自身 Q1 的位点。
326+
327+
**位点更新不能简单理解为“每成功一条就加一”,也不能保证不重复消费。** 批量拉取、并发处理、消息过滤和位点提交都会影响进度的推进;例如前面的消息还没处理完,后面的消息即使先完成,也不能直接越过前面的消息提交进度。消费已成功但位点尚未持久化时发生故障,就可能再次消费。5.x 新版 SDK 的 PushConsumer、SimpleConsumer 通过逐条消息确认,由服务端管理消费状态,也不能套用“客户端每成功一条就将 offset 加一”的解释。
313328

314-
因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要呀),它仅仅是为每个消费者组维护一个 **消费位移(offset)** ,每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。
329+
消息被某个组消费成功后不会立即从存储中删除,也不是必须等所有组都消费完才删除。RocketMQ 按保存时间、存储空间等清理策略回收消息,未消费的消息也可能被清理。消费进度管理、消息保留和业务幂等是不同的问题。
315330

316-
![](https://oss.javaguide.cn/github/javaguide/high-performance/message-queue/16ef3857fefaa079.jpg)
331+
参考官方文档:[消费者负载均衡](https://rocketmq.apache.org/zh/docs/featureBehavior/08consumerloadbalance/)、[消费进度管理](https://rocketmq.apache.org/zh/docs/featureBehavior/09consumerprogress/)、[消息存储和清理机制](https://rocketmq.apache.org/zh/docs/featureBehavior/11messagestorepolicy/)。
317332

318333
可能你还有一个问题,**为什么一个主题中需要维护多个队列** ?
319334

320-
答案是 **提高并发能力** 。的确,每个主题中只存在一个队列也是可行的。你想一下,如果每个主题中只存在一个队列,这个队列中也维护着每个消费者组的消费位置,这样也可以做到 **发布订阅模式** 。如下图。
335+
答案是 **提高并发能力**。每个主题中只存在一个队列也是可行的,不同消费者组仍可以分别订阅它,并维护各自的消费进度。下图展示的是队列粒度负载均衡下的单队列场景。
321336

322337
![](https://oss.javaguide.cn/github/javaguide/high-performance/message-queue/16ef38600cdb6d4b.jpg)
323338

324-
但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。
339+
只有一个队列时,生产者的消息都写入这个队列,无法利用多个队列分散到不同 Broker。在队列粒度负载均衡下,同组也只有一个消费者实例能分到该队列,其他实例无法分担;这是队列分配策略的限制,不是维护消费位点必然导致的。消息粒度负载均衡允许组内多个消费者分担单队列中的不同消息,多队列仍有利于存储和读写负载的分散。
325340

326341
所以总结来说,RocketMQ 通过**使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置** 实现了 **主题模式/发布订阅模式** 。
327342

@@ -1010,7 +1025,7 @@ PushConsumer 消费时,不允许使用以下方式处理消息:
10101025

10111026
2. **错误方式二**:在消费监听器内将消息再次分发到自定义的其他线程,消费监听器提前返回消费结果。此时如果消息消费失败,RocketMQ 服务端同样无法感知,因此也不会进行消费重试。
10121027

1013-
**Push 模式工作原理:**
1028+
**传统 Remoting SDK 的 Push 模式工作原理(对应上面的 DefaultMQPushConsumer 示例):**
10141029

10151030
1. **负载均衡**:RebalanceService 线程根据队列数量和消费者个数做负载均衡,将分配到的队列发布 pullRequest 到 pullRequestQueue
10161031
2. **消息拉取**:PullMessageService 线程不断从 pullRequestQueue 获取 pullRequest,从 Broker 拉取消息并缓存到 ProcessQueue
@@ -1212,11 +1227,11 @@ flowchart TB
12121227
direction TB
12131228
style ConsumerGroup fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
12141229
1215-
subgraph Cluster["集群消费模式"]
1230+
subgraph Cluster["集群消费模式(以队列粒度为例)"]
12161231
direction TB
12171232
style Cluster fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
1218-
CG["消费者组"] --> C1["消费者1<br/>消费队列1、2"]
1219-
CG --> C2["消费者2<br/>消费队列3、4"]
1233+
CG["消费者组"] --> C1["消费者1<br/>消费队列1"]
1234+
CG --> C2["消费者2<br/>消费队列2"]
12201235
CG --> C3["消费者3<br/>空闲"]
12211236
Note1["任意一条消息<br/>只需被消费组内<br/>任意一个消费者处理"]
12221237
end
@@ -1623,9 +1638,9 @@ public class ConsumerAddViewHistory implements RocketMQListener<Message> {
16231638

16241639
我们可以从多个角度去思考解决这个问题,当流量到峰值的时候是因为生产者生产太快,我们可以使用一些 **限流降级** 的方法,当然你也可以增加多个消费者实例去水平扩展增加消费能力来匹配生产的激增。如果消费者消费过慢的话,我们可以先检查 **是否是消费者出现了大量的消费错误** ,或者打印一下日志查看是否是哪一个线程卡死,出现了锁资源不释放等等的问题。
16251640

1626-
> 当然,最快速解决消息堆积问题的方法还是增加消费者实例,不过 **同时你还需要增加每个主题的队列数量** 。
1641+
> 增加消费者实例通常能缓解消费能力不足造成的堆积,但是否需要增加队列,要看负载均衡策略和现有队列数量。
16271642
>
1628-
> **注意**:在 RocketMQ 4.x 及之前的版本中,**一个队列只会被一个消费者消费**,如果你仅仅是增加消费者实例就会出现我一开始给你画架构图的那种情况(部分消费者没有队列可消费)。
1643+
> **注意**:传统 Remoting SDK 的队列粒度负载均衡下,**同组内一个队列只分配给一个消费者实例**。只有扩容后的消费者数超过可分配队列数时,才会出现部分消费者没有队列可消费的情况。
16291644
>
16301645
> RocketMQ 5.x 的 PushConsumer 和 SimpleConsumer 默认使用**消息粒度负载均衡策略**,同一消费者分组内的多个消费者可以按照消息粒度共同消费同一个队列中的消息,因此即使消费者数量多于队列数量,仍能提升消费并行度。PullConsumer 仍是队列粒度,扩容时还是要关注队列数。
16311646

‎docs/java/basis/java-basic-questions-01.md‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -919,17 +919,34 @@ System.out.println(l + 1 == Long.MIN_VALUE); // true
919919
920920
### ⭐️ 成员变量与局部变量的区别?
921921
922-
![](https://oss.javaguide.cn/github/javaguide/java/basis/java-basis-variables-member-variable-vs-local-variable.png)
922+
| 对比维度 | 成员变量(字段) | 局部变量 |
923+
| -------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- |
924+
| 定义位置 | 定义在类中,方法之外;分为实例变量和静态变量 | 定义在方法、构造方法或代码块中 |
925+
| 修饰符 | 可以使用访问控制修饰符、`static`、`final` 等 | 不能使用访问控制修饰符和 `static`,可以使用 `final` |
926+
| 存储方式 | 实例字段属于对象状态;静态字段属于类,具体存储由 JVM 实现决定 | 在 JVM 的执行模型中保存在栈帧的局部变量表中 |
927+
| 生存时间 | 实例变量随对象存在;静态变量随类存在,直到类卸载 | 与所在代码块的执行及作用域有关,方法结束后对应栈帧被销毁 |
928+
| 默认值 | 有类型对应的默认值,如 `0`、`false`、`null`;`final` 字段还必须满足明确赋值规则 | 没有默认值;可以先声明不赋值,但读取前必须明确赋值 |
923929
924-
- **语法形式**:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 `public`,`private`,`static` 等修饰符所修饰,而局部变量不能被访问控制修饰符及 `static` 所修饰;但是,成员变量和局部变量都能被 `final` 所修饰。
925-
- **存储方式**:如果成员变量使用 `static` 修饰,那么它属于类;如果没有使用 `static` 修饰,那么它属于实例。实例字段是对象状态的一部分,方法参数和局部变量则保存在当前栈帧的局部变量表中。JIT 优化可能消除部分实际存储。
926-
- **生存时间**:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
927-
- **默认值**:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 `final` 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
930+
方法参数和构造方法参数在 JLS 中属于单独的变量类别,调用时由实参初始化,不能把它们和方法体内尚未赋值的局部变量混为一谈。另外,局部引用变量和它指向的对象也要区分:引用保存在局部变量表中,对象通常分配在堆中。JIT 优化可能消除部分实际存储。
928931
929932
**为什么成员变量有默认值?**
930933
931934
JLS 规定,类变量、实例变量和数组元素在创建时会被初始化为各自类型的默认值,例如数值类型为 0、`boolean` 为 `false`、引用类型为 `null`。局部变量不进行默认初始化,并受“明确赋值”(definite assignment)规则约束:在读取局部变量前,编译器必须能够确定它已经被赋值。这里是语言规范直接规定的两套初始化规则,并不是因为编译器无法预测成员变量何时赋值。
932935
936+
**`final` 成员变量并不是默认初始化的例外。** 默认初始化与编译器的明确赋值检查是两回事:`final` 字段仍然必须由程序完成一次明确赋值,可以在声明时初始化;未在声明时初始化的实例 `final` 字段必须在每个构造方法正常结束前完成赋值(也可通过实例初始化块完成),静态 `final` 字段则必须在静态初始化块中完成赋值。
937+
938+
局部变量可以先声明,再赋值,也可以一直不读取:
939+
940+
```java
941+
int unused; // 合法:只声明、不读取,不要求赋值
942+
int count;
943+
// System.out.println(count); // 编译错误:读取前没有明确赋值
944+
count = 10;
945+
System.out.println(count); // 合法
946+
```
947+
948+
参考:[JLS §4.12.5 变量的初始值](https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.12.5)、[JLS §8.3.1.2 final 字段](https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.3.1.2)。
949+
933950
成员变量与局部变量代码示例:
934951
935952
```java

0 commit comments

Comments
 (0)