半夜被叫醒修Bug后,我终于搞懂了RocketMQ
那个让我崩溃的凌晨三点
几年前的双十一前夕,我被一通电话吵醒,运维说线上消息队列炸了。当时脑子还懵着呢,穿着睡衣就开始远程排查问题。后来发现是某台机器挂了,但我压根不知道该怎么处理,因为对RocketMQ的理解还停留在"能用就行"的阶段。
那次之后我痛定思痛,决定好好研究一下这玩意儿到底是怎么工作的。现在回过头看,其实也没那么复杂,只是当时没人跟我说清楚而已。
我最开始的错误认知
刚接触消息队列的时候,我脑子里的画面特别简单:
flowchart LR
A[发消息的] --> B((消息队列))
B --> C[收消息的]
就是往里扔东西,然后从里面拿东西,跟快递柜差不多。我甚至觉得"这也太简单了吧,为啥要搞这么复杂"。
然后现实给了我一巴掌。
第一次上线的时候,我们的QPS上来之后系统直接卡死,消息堆积了几十万条。当时我完全不知道该怎么办,只能重启服务,然后眼睁睁看着它又卡死。
那会儿我才意识到,消息队列背后的东西远比我想象的复杂。
真正存消息的地方
后来我花了好几天时间翻文档、看源码,才搞明白一个事:消息不是存在什么抽象的"队列"里,而是存在一台台实实在在的服务器上。
RocketMQ把这些服务器叫Broker。你可以理解成它就是一个个仓库,专门用来存放和分发消息:
graph LR
subgraph 实际存储
B[Broker服务器] --> D[硬盘上的消息文件]
end
A[我们的应用] -->|发送| B
B -->|读取| C[消费端应用]
我当时想,那如果只有一台Broker,它挂了不就全完了?后来发现确实会挂,而且就是那个凌晨三点叫醒我的罪魁祸首。
多台机器怎么配合
生产环境肯定不可能只部署一台Broker,一般会有好几台:
graph TB
subgraph 集群
B1[Broker-A]
B2[Broker-B]
B3[Broker-C]
end
P[应用发送] --> B1
P --> B2
P --> B3
消息会分散到这些机器上存储。我之前以为是随机分的,后来发现不对,如果真是随机分的话,某台机器挂了,那部分消息不就找不到了?
所以RocketMQ用了另一套逻辑。
Topic这个东西让我纠结了好久
我们发消息的时候要指定一个Topic,比如订单消息发到"order-topic",支付消息发到"pay-topic"。
Message msg = new Message("order-topic", "订单创建了".getBytes());
producer.send(msg);
一开始我以为Topic就是个文件夹,所有订单相关的消息都扔到这个文件夹里。但问题来了,这个文件夹放在哪台Broker上?
如果放在一台Broker上,那这台机器的压力肯定特别大。如果分散到多台Broker上,那怎么保证消息不丢?
我被这个问题困扰了好久,直到看到了Queue的概念。
Queue才是关键
其实一个Topic下面会有多个Queue(我习惯叫它队列),这些Queue才是真正分散在不同Broker上的:
graph TB
subgraph "order-topic"
Q1[Queue-0]
Q2[Queue-1]
Q3[Queue-2]
Q4[Queue-3]
end
Q1 --> B1[Broker-A]
Q2 --> B1
Q3 --> B2[Broker-B]
Q4 --> B2
比如订单Topic有4个Queue,Queue-0和Queue-1在Broker-A上,Queue-2和Queue-3在Broker-B上。
消息发送的时候会选择其中一个Queue塞进去,消费的时候也是从某个Queue里拿。这样就把压力分散开了,不会所有消息都挤在一台机器上。
我当时测试的时候故意把一个Broker停掉,发现消息还能正常发送,只不过只能发到剩下那台Broker的Queue里。这才理解了为什么要这么设计。
但还是会丢数据啊
有一天我在想,假设Broker-A挂了,Queue-0和Queue-1里的消息不还是丢了吗?虽然不会影响整个服务,但丢数据也不行啊。
后来发现RocketMQ搞了主从模式:
graph LR
M[Master主机] -->|实时复制| S[Slave备机]
A[应用] -->|写入| M
M -->|存储| D1[(磁盘)]
S -->|备份| D2[(磁盘)]
Master负责接收和发送消息,Slave就在旁边默默同步数据。Master挂了的话,至少数据还在Slave上。
但这个方案有个大问题,Master挂了之后不会自动切换,需要人工操作。我那次凌晨三点就是因为这个,手忙脚乱地切换主备,差点把线上搞崩。
听说新版本有个叫Dledger的东西可以自动切换,但我们还没升级上去,这块还没试过。
怎么知道该连哪台机器
现在又有个新问题了:我们的应用启动的时候,怎么知道集群里有哪些Broker?总不能把IP地址写死在配置文件里吧?
这时候就需要NameServer了。它的作用就是记录整个集群的信息:
sequenceDiagram
participant B as Broker
participant N as NameServer
participant A as 应用程序
B->>N: 我上线了,我的信息是xxx
B->>N: 每30秒发个心跳
A->>N: 集群里有哪些Broker?
N->>A: 返回Broker列表
A->>B: 连接并发送消息
Broker启动后会主动向NameServer注册,并且定期发心跳。我们的应用连接NameServer就能拿到整个集群的信息,知道该往哪发消息。
我之前配置NameServer的时候只填了一个地址,结果那台机器网络抖动了一下,整个服务就连不上了。后来才知道要配多个:
producer.setNamesrvAddr("192.168.1.1:9876;192.168.1.2:9876;192.168.1.3:9876");
NameServer也会挂的
既然Broker要做集群,NameServer肯定也得做集群:
graph TB
subgraph NameServer集群
N1[NameServer-1]
N2[NameServer-2]
N3[NameServer-3]
end
B1[Broker-A] -.注册.-> N1
B1 -.注册.-> N2
B1 -.注册.-> N3
B2[Broker-B] -.注册.-> N1
B2 -.注册.-> N2
B2 -.注册.-> N3
APP[应用] --> N1
APP --> N2
有意思的是,这几个NameServer之间是不通信的,每个都是独立工作的。Broker会把信息同时注册到所有NameServer上,所以每个NameServer都有完整的数据。
这样设计的好处是简单,坏处是如果某个Broker的注册信息没同步到某个NameServer,可能会出现短暂的不一致。不过问题不大,因为Broker会持续发心跳更新信息。
整个流程串起来
现在把所有东西串起来,整个消息的流转过程是这样的:
sequenceDiagram
participant P as Producer
participant N as NameServer
participant B as Broker集群
participant C as Consumer
Note over B,N: 启动阶段
B->>N: 注册Broker信息
Note over P,N: 发送消息
P->>N: 查询路由信息
N->>P: 返回Broker地址和Queue信息
P->>B: 选择Queue发送消息
B->>B: 消息落盘
Note over C,B: 消费消息
C->>N: 查询路由信息
N->>C: 返回Broker地址
C->>B: 拉取消息
B->>C: 返回消息内容
我踩过的几个坑
连接超时问题
刚开始用的时候,我发现有时候发消息会卡住很久才超时。后来发现是因为Broker挂了,但NameServer还没来得及更新信息,应用拿到的是过期的地址。
解决办法是把心跳检测间隔调短一点,让NameServer更快地发现Broker挂了:
// Broker配置
brokerHeartbeatInterval = 5000 // 5秒发一次心跳
消息消费不过来
有一次业务量突然暴增,消息堆积了几万条。我一看监控,发现Consumer的消费速度太慢了。
后来才知道要增加Consumer的线程数:
consumer.setConsumeThreadMin(20); // 最小20个线程
consumer.setConsumeThreadMax(64); // 最大64个线程
还有个办法是增加Queue的数量,让多个Consumer可以并行消费:
# 创建Topic时指定Queue数量
./mqadmin updateTopic -n 192.168.1.1:9876 -t order-topic -q 8
消息丢失的血泪教训
有一次线上出现了消息丢失,排查了半天才发现是因为消息发送失败了,但代码里没做重试。
现在我都会这么写:
try {
SendResult result = producer.send(msg);
if (result.getSendStatus() != SendStatus.SEND_OK) {
// 记录日志,手动重试或者报警
log.error("消息发送失败: {}", result);
}
} catch (Exception e) {
// 发送异常也要记录
log.error("消息发送异常", e);
}
另外就是要开启同步刷盘,虽然性能会差一点,但至少数据不会丢:
// Broker配置
flushDiskType = SYNC_FLUSH
现在的理解
经过这段时间的折腾,我对RocketMQ的理解大概是这样的:
- Broker是实际干活的,负责存储和分发消息
- Queue把消息分散到不同的Broker上,避免单点压力
- NameServer充当注册中心,让大家知道集群的情况
- 主从模式保证数据不丢,但需要注意自动切换的问题
说实话,刚开始觉得这东西好复杂,组件这么多,概念也绕。但真正用起来之后,发现这些设计都是有道理的,都是为了解决实际问题。
现在再遇到半夜报警,我至少知道该往哪个方向排查了,不会像之前那样慌得一批。
如果你也在用RocketMQ,建议多看看监控指标,尤其是消息堆积量和消费延迟,这两个指标能提前发现很多问题。