半夜被叫醒修Bug后,我终于搞懂了RocketMQ

80 阅读7分钟

半夜被叫醒修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,建议多看看监控指标,尤其是消息堆积量和消费延迟,这两个指标能提前发现很多问题。