让我们再一次进入深水区,投入知识的海洋:我们分别从 RocketMQ 的事务机制 、Kafka 的重平衡(Rebalance)痛点 以及 Kafka 的极致吞吐量优化 三个维度,进行深度解剖。
RocketMQ 事务消息:分布式事务的"和事佬"
在微服务架构里,最头疼的就是"本地事务"和"远程消息"的一致性。比如:订单扣款成功了,但发积分的消息没发出去,或者发了但扣款回滚了。
- RocketMQ 的事务消息,本质上是一个**"两阶段提交 + 本地事务回查"** 的变种协议。它解决的核心问题是:如何让"发消息"和"执行本地数据库事务"要么同时成功,要么同时失败。
核心原理:半消息(Half Message)
RocketMQ 引入了一个中间状态------半消息。这是一种"暂不能投递"的消息。
-
第一阶段(发送半消息):
- 生产者先给 Broker 发送一条"半消息"。
- Broker 收到后,会持久化这条消息,但不会把它推给消费者(此时对消费者不可见)。
- Broker 返回"发送成功"给生产者。
-
第二阶段(执行本地事务):
- 生产者收到半消息成功的确认后,开始执行本地的数据库事务(比如扣减库存)。
- 情况 A(本地事务成功) :生产者向 Broker 发送
Commit指令。Broker 将半消息标记为"可投递",消费者就能收到了。 - 情况 B(本地事务失败) :生产者向 Broker 发送
Rollback指令。Broker 直接删除这条半消息,消费者永远收不到。
-
第三阶段(兜底回查机制):
- 极端情况:如果生产者在执行完本地事务后,还没来得及发 Commit/Rollback 就挂了(断网、宕机)。这时候 Broker 里的半消息就成了"孤儿"。
- 解决方案 :Broker 会定时(默认几十秒后)扫描未确认的半消息,并反向回调生产者接口(
checkLocalTransaction)。 - 生产者收到回调,去检查本地数据库事务的状态,然后告诉 Broker 到底是 Commit 还是 Rollback。
// 1. 定义事务监听器
TransactionListener listener = new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 【关键步骤】:执行本地业务逻辑(如更新数据库)
try {
updateDatabase(msg);
return LocalTransactionState.COMMIT_MESSAGE; // 成功则提交
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE; // 失败则回滚
}
}@Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 【兜底步骤】:Broker 没收到确认,来反查了 // 检查本地数据库,看这笔交易到底成没成 boolean isSuccess = checkDbStatus(msg.getId()); return isSuccess ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; }};
// 2. 发送事务消息
TransactionMQProducer producer = new TransactionMQProducer("my_group");
producer.setTransactionListener(listener);
// 发送时,消息对消费者暂时不可见
producer.sendMessageInTransaction(msg, null);
就是把 XA 协议简化了,利用 MQ 做协调者,保证了最终一致性
Kafka Rebalance:那个让人又爱又恨的"暂停键"
Rebalance(重平衡)是 Kafka 消费者组(Consumer Group)用来实现负载均衡的核心机制。当消费者加入、退出,或者 Topic 分区数变化时,Kafka 会重新分配分区归属。
为什么说它是"性能杀手"?
Rebalance 的本质是 "Stop The World" (STW) 。
在 Rebalance 期间,整个消费者组会停止消费。所有消费者必须等待新的分配方案确定后,才能继续拉取消息。
- 触发时机 :
- 新消费者加入组。
- 消费者崩溃或主动关闭(心跳超时)。
- Topic 分区数增加。
- 后果 :
- 消费停顿:如果是大规模集群或处理慢的消费者,Rebalance 可能持续几分钟,导致消息积压。
- 重复消费:Rebalance 发生时,Offset 可能还没提交,新的消费者接手后会从旧 Offset 开始读,导致数据重复。
如何优化与避免不必要的 Rebalance?
-
静态成员资格(Static Membership):
- 痛点:容器重启、网络抖动导致的短暂断连,会被误判为消费者退出,触发 Rebalance。
- 解法 :配置
group.instance.id。告诉 Broker:"我是固定的实例 ID,虽然我 IP 变了或者断线了一会儿,但我还是我,别踢我。" - 配合参数 :调大
session.timeout.ms(会话超时时间),给网络波动留出缓冲期。
-
调整心跳与会话超时:
heartbeat.interval.ms(心跳间隔):默认 3s。session.timeout.ms(会话超时):默认 10s~45s。- 策略:如果你的消费者处理逻辑很重(GC 时间长),适当调大这两个值,防止被误杀。
-
控制处理时间:
max.poll.interval.ms:两次poll()调用之间的最大间隔。如果你的业务逻辑处理太慢,超过了这个时间,Broker 会认为你挂了,把你踢出组,强制触发 Rebalance。- 解法:优化代码逻辑,减少阻塞;或者调大这个参数。
-
增量式 Rebalance(Cooperative Sticky Assignor):
- 老版本:全量重算,所有分区都要重新分配,停顿时间长。
- 新版本(Kafka 2.4+ / 3.x) :使用
CooperativeStickyAssignor。只有变动的分区才会重新分配,其他分区不受影响,大大减少了停顿时间。
Kafka 极致吞吐量:如何做到每秒百万级?
Kafka 之所以快,是因为它把 I/O 做到了极致。以下是架构师级别的调优清单:
1. 生产者端:批量与压缩的艺术
不要一条一条发!那是自杀行为。
-
批处理(Batching):
linger.ms:默认是 0(即来即发)。建议设为5ms或10ms。意思是"攒一攒再发",让多条消息凑成一个批次。batch.size:默认 16KB。在高吞吐场景下,可以调大到128KB甚至1MB。- 效果:网络请求次数指数级下降,TCP 利用率飙升。
-
压缩(Compression):
compression.type:开启lz4或snappy。- 原理:CPU 换带宽。现代 CPU 压缩/解压速度极快,但网络带宽往往是瓶颈。压缩后,磁盘 I/O 和网络传输量大幅减少。
-
异步发送:
- 永远不要用
producer.send().get()(同步阻塞)。要用回调函数send(record, callback),让主线程只管发,Netty 线程在后台负责传输。
- 永远不要用
2. Broker 端:零拷贝与页缓存
这是 Kafka 快的核心秘密武器。
-
Page Cache(页缓存):
-
很多新手有个误区,觉得 Kafka 快是因为它把数据都放内存里了。错!Kafka 是持久化到磁盘的。那为什么比纯内存的某些系统还快?因为它利用了操作系统的 PageCache(页缓存)。
-
你可以把 PageCache 理解为操作系统为了讨好应用程序,特意划出来的一块**"公共缓冲区"**(空闲内存)。
- 读的时候:如果你要读的数据刚好在 PageCache 里(命中),OS 直接给你,根本不用去碰慢吞吞的磁盘。
- 写的时候:你把数据扔给 OS,OS 把它放进 PageCache 就告诉你"好了",然后 OS 会在后台找个空闲时间(比如半夜或者系统不忙时)再慢慢刷到磁盘上。
-
Kafka 极度依赖操作系统的 Page Cache。写入时,数据先写进 OS 的内存(Page Cache),并不直接刷盘(由 OS 决定何时刷)。读取时,如果数据在 Page Cache 里,直接从内存读,比磁盘快几个数量级。
- 顺序写(Sequential Write):Kafka 只追加写入,不修改。这就像写日记,永远写在最后一页。机械硬盘的顺序写速度极快(接近内存),而且 OS 对顺序写的预读机制非常友好。
- 读也是顺序的:消费者拉取消息通常是顺着 Offset 往后读。这意味着,当你要读第 100 条消息时,OS 早就通过"预读机制"把第 101、102、103 条也加载到 PageCache 里了。
-
调优:给 OS 留足内存做 Cache,不要把所有内存都给 JVM 堆。
-
虽然 Kafka 说是写磁盘,但实际上大部分读写操作都发生在内存里的 PageCache 中。Kafka 几乎不需要自己维护缓存,直接把 OS 的内存拿来用,既省内存又高效。
-
-
零拷贝(Zero Copy):
-
传统传输:磁盘 -> 内核缓冲区 -> 用户缓冲区 -> Socket 缓冲区 -> 网卡。
- DMA 拷贝:磁盘 -> 内核缓冲区(PageCache)。
- CPU 拷贝:内核缓冲区 -> 用户缓冲区(你的 Java 堆内存)。
- CPU 拷贝:用户缓冲区 -> Socket 缓冲区(内核里)
- DMA 拷贝:Socket 缓冲区 -> 网卡(发送出去)。
- 上下文切换:CPU 在内核态和用户态之间反复横跳,开销很大。
- 无效拷贝 :步骤 2 和 3 纯粹是瞎折腾。数据从内核拿出来放到用户空间,下一秒又原封不动地塞回内核准备发走。Kafka Broker 在这里就像一个只会传话的中间商,没干任何业务逻辑,纯属浪费 CPU。
// 这是一个典型的传统 IO 写法,效率低 File file = new File("message.log"); byte[] buffer = new byte[1024]; // 用户态缓冲区 try (FileInputStream in = new FileInputStream(file)) { // 1. 数据从磁盘 -> 内核 -> 这里的 buffer (用户态) int bytesRead = in.read(buffer); // 模拟处理(其实什么都没做) // 2. 数据从 buffer (用户态) -> Socket 缓冲区 (内核态) -> 网卡 outputStream.write(buffer, 0, bytesRead); } -
Kafka (
sendfile系统调用):磁盘 -> 内核缓冲区 -> 网卡;既然你只是路过,为什么要下车?- DMA 拷贝:磁盘 -> 内核缓冲区(PageCache)
- CPU 拷贝(带偏移量的拷贝):内核缓冲区 -> 网卡。
// Kafka 底层使用的 NIO 方式 FileChannel fileChannel = new FileInputStream("message.log").getChannel(); // 核心魔法:transferTo // 参数:起始位置,传输长度,目标通道(SocketChannel) // 这个方法会触发底层的 sendfile() 系统调用 // 数据直接从 FileChannel (内核态) 传输到 target (内核态),完全不经过这里! fileChannel.transferTo(0, fileChannel.size(), socketChannel); -
效果 :减少了 CPU 上下文切换和数据拷贝,极大提升吞吐量。
- 跳过用户态:数据压根没有进入 Kafka 进程的用户内存(Java Heap)
- 减少切换:上下文切换次数从 4 次降到了 2 次。
- CPU 解放:CPU 只需要告诉 DMA 控制器:"嘿,把 PageCache 里这块数据直接搬到网卡去",然后 CPU 就可以去干别的事了。
-
-
顺序写磁盘(Append Only):
- 机械硬盘的随机读写很慢(磁头寻道),但顺序写速度和内存差不多。Kafka 把所有消息追加写入日志文件,完美利用了这一特性。
3. 消费者端:并行度
- 增加分区数:Kafka 的并行度上限等于 Topic 的分区数。如果你想加机器提高消费速度,必须先增加 Partition。
- 多线程消费:虽然 Kafka 消费者是单线程拉取,但你可以在拿到一批消息后,扔到内部线程池里去处理(注意要手动管理 Offset 提交)。
| 领域 | 核心要点 |
|---|---|
| RocketMQ 事务 | 利用半消息 + 本地事务回查,保证 DB 操作与发消息的最终一致性。 |
| Kafka Rebalance | 它是STW过程。通过静态成员ID、 CooperativeStickyAssignor 策略来减少频率和停顿。 |
| Kafka 吞吐量 | 批量发送 + LZ4压缩 + 零拷贝 + 顺序写 + PageCache。 |