KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 流控与运维:内存告警怎么变成背压 — keel 龙骨

前几章都在讲单条消息的命运,这一章讲整个 broker 的容量。RabbitMQ 有一个容易被忽视的设计:当内存或磁盘告急时,它不会丢掉最老的消息来减压,而是停止读取所有发布连接的数据,把压力原封不动地回推给你的应用。理解这条背压链路,是判断「队列堆积时到底该扩消费者还是该先止血」的前提。

前几章都在讲单条消息的命运,这一章讲整个 broker 的容量。RabbitMQ 有一个容易被忽视的设计:当内存或磁盘告急时,它不会丢掉最老的消息来减压,而是停止读取所有发布连接的数据,把压力原封不动地回推给你的应用。理解这条背压链路,是判断「队列堆积时到底该扩消费者还是该先止血」的前提。

一、现场:队列积压,然后发布端全卡住

大促期间某个队列的消费者挂了,消息开始堆积。半小时后运维发现不止这个队列有问题——所有往这个集群发布的服务都开始超时。发布端的日志里没有任何异常,就是「发不出去」;rabbitmqctl status 里多了一行 Memory alarm on node rabbit@node1。

事情的顺序是这样的:队列堆积 → 消息占用内存上升 → 超过内存高水位 → broker 触发内存告警 → broker 停止接收所有发布连接的数据 → 整个集群对写入方不可用。一条队列的积压,通过内存水位变成了整个集群的写故障。

二、告警到背压的链路

flowchart TD
    Q["queue 积压<br/>messages 持续上升"] --> MEM["broker 进程内存占用上升"]
    MEM --> WM{"超过内存高水位?<br/>默认 0.6 × 可用内存"}
    WM -->|"否"| OK["正常运行"]
    WM -->|"是"| ALARM["触发内存告警<br/>status 出现 Memory alarm"]
    ALARM --> BLOCK["broker 停止读取所有 publishing 连接"]
    BLOCK --> BLK["list_connections 里<br/>该连接 state=blocked"]
    BLK --> APP["发布应用 basic_publish 被卡住<br/>不报错,只是不返回"]
    ALARM -.->|"磁盘剩余低于 watermark"| DISKALARM["磁盘告警,同样阻塞发布"]
    DISKALARM -.-> BLOCK
    MEM -->|"下降回水位以下"| CLEAR["告警解除,连接恢复 running"]
    CLEAR --> OK

    style Q fill:#fff8e1,color:#8a4b00
    style MEM fill:#fff3e0,color:#8a4b00
    style WM fill:#fff3e0,color:#8a4b00
    style OK fill:#e8f5e9,color:#1b5e20
    style ALARM fill:#ffebee,color:#b71c1c
    style BLOCK fill:#ffebee,color:#b71c1c
    style BLK fill:#ffebee,color:#b71c1c
    style APP fill:#ffebee,color:#b71c1c
    style DISKALARM fill:#ffebee,color:#b71c1c
    style CLEAR fill:#e8f5e9,color:#1b5e20

三、制造一次内存告警,看背压怎么出现

把内存高水位调到 0.0001,任何内存占用都会触发告警。三步取证(08-memory-alarm-flow-control.txt)。

第一步,看告警生效前后的 status 字段变化:

设置前     Memory high watermark setting: 0.6 of available memory, computed to: 20.4148 gb
           Alarms
           (none)

设置 0.0001 后  Alarms
                Memory alarm on node rabbit@node1
                Memory high watermark setting: 0.0001 of available memory, computed to: 0.0034 gb

Alarms 段从 (none) 变成了 Memory alarm on node rabbit@node1。默认水位是 0.6(本机实测 20.4148 gb),调到 0.0001 后计算出来的阈值只剩 0.0034 gb,实际内存使用(约 0.13 gb)远超它,告警成立。

第二步,看连接状态。告警生效后起一个发布连接并持续 basic_publish,再从 broker 侧查:

$ rabbitmqctl list_connections name state
name                              state
127.0.0.1:23050 -> 127.0.0.1:5672  blocked
$ rabbitmqctl list_connections user state
user    state
guest   blocked

state 是 blocked——broker 主动停止读取这个连接的数据,连接还活着,只是不再处理它发来的发布。这就是背压的落点。

第三步,看客户端侧的表现。发布线程在告警期间尝试了 158 次 basic_publish,没有抛出任何异常,最后一条异常记录是「无:publish 被静默卡住」。这正是最难排查的地方:内存告警不会以错误的形式传到应用,而是让发布变成「慢慢卡住甚至完全不返回」。应用如果有超时和重试,会表现为超时升高;如果没有,就表现为请求堆积。

把水位改回 0.4 之后,告警解除,同一个连接恢复:

恢复后   Memory high watermark setting: 0.4 of available memory, computed to: 13.6099 gb
         name                              state
         127.0.0.1:23050 -> 127.0.0.1:5672  running

四、内存告警与磁盘告警

除了内存,磁盘空间也是一个独立的告警源。本机 status 里的相关字段(08b-alarm-watermarks.txt):

Free Disk Space
Low free disk space watermark: 0.05 gb
Free disk space: 65.5331 gb

Low free disk space watermark 是剩余磁盘的下限,默认 50 MB;低于它同样会触发告警并阻塞发布。内存和磁盘两类告警的处理动作相同(停止接收发布),但成因不同:内存告警通常来自积压的消息和连接缓冲,磁盘告警通常来自持久化消息堆积或日志膨胀。运维时要能区分,否则会把磁盘问题当成内存问题去调水位。

两者都有一条「只阻塞发布、不阻塞消费」的共同特性——这是有意为之:让已经进来的消息能被消费掉,从而把水位降回去。所以告警期间的止血手段是「加速消费」或「停止发布」,不是重启 broker。重启只会让未持久化的消息全丢,然后水位因为重启前的那些消息被消费掉而回落,问题看起来「修好了」,其实数据已经丢了一批。

五、容量怎么估

第 02 章测过一条有用的数字:20000 条 1KiB 的消息,在磁盘的消息存储目录里占约 23.5 MiB(09-persistence-disk.txt),换算下来每条 1KiB 的消息大约占 1.2KiB 的存储(含队列索引等开销)。这个系数可以做粗略容量规划:预期积压条数 × 消息平均大小 × 1.2 就是需要预留的磁盘;内存侧则要留出「单队列工作集 + 连接缓冲」的空间。

要注意第 02 章已经验证过的那个反直觉点:非持久化消息在量大时也会落盘。所以「用非持久化消息来省磁盘」这个想法在积压场景下不成立,省不了多少。

六、积压时该看什么、按什么顺序处理

盯着 list_queues 和 status,按这个顺序判断:

  1. 先看是「消费不动」还是「生产暴涨」。 对比 messages 的增长方向和消费速率。生产暴涨用限流上游解决,消费不动去查消费者。
  2. 再看 messages_unacknowledged。 如果它高而 messages_ready 低,说明消费者拿到了消息但处理不完(下游慢或 prefetch 太大);如果 messages_ready 高、unacked 低,说明消费者根本没在拉(消费者掉线或 prefetch 太小卡住了)。
  3. 看告警是否已经触发。 status 的 Alarms 段一旦不是 (none),集群已经进入背压状态,这时最大的风险不是这个队列,而是全部发布端。
  4. 查消费者的真实耗时。 不要只看消费速率,要看单条处理耗时分布——慢的往往是下游依赖,而不是消费者本身。

七、常见误判

常见误解 对着哪条输出核对 结论
内存告警会让发布端报错 发布端 158 次 basic_publish 无异常 背压是「卡住」不是「报错」,需要有超时和监控才能发现
告警时重启 broker 就好了 恢复后连接重新 running,但非持久化消息在重启中丢失 重启是止血假象;先做的是停止发布或加速消费
一条队列积压只影响这条队列 告警触发后所有 publishing 连接都是 blocked 内存是进程级的,一条队列能拖垮整个集群的写
用非持久化消息就不会占磁盘 09-persistence-disk.txt 里非持久化那步同样涨了约 23 MiB 量大时同样被 paging 到磁盘

生产边界

动手

  1. 把 vm_memory_high_watermark 调到 0.0001,起一个发布连接,list_connections state 应出现 blocked;再改回 0.4,确认恢复 running。(改完务必改回去。)
  2. 用 rabbitmqctl status 的 Alarms 段确认告警的产生与解除,记录两类水位字段的数值。
  3. 造 20000 条 1KiB 消息,对比 messages 与实际磁盘占用,算出你这个环境的「每条消息存储系数」。
  4. 故意让一个消费者只拉不 ack(收到消息后 sleep 不确认),观察 messages_ready 与 messages_unacknowledged 如何变化,判断你能否用这两个数区分「消费不动」与「不消费」。

自测

  1. 内存告警触发后,broker 对发布连接和消费连接分别做了什么?为什么只阻塞发布?
  2. 为什么发布端在告警期间「不报错只是卡住」?这对应用的超时与重试设计有什么要求?
  3. 内存告警和磁盘告警的成因有什么不同?排查时怎么区分?
  4. 队列积压时,messages_ready 高和 messages_unacknowledged 高分别指向什么问题?
  5. 为什么说「告警期间重启 broker」是危险的止血手段?它会掩盖什么?

回到:课程导读 · 下一门相关课程:《可扩展性》05 章 · 消息队列

进入 keel 阅读