Files
cs-note/hhs/MQ/08-消息设计模式/31-MQ-背压与流控.md
T
2026-05-24 20:51:06 +08:00

5.4 KiB
Raw Blame History

tags, create time
tags create time
MQ
2026-05-24 19:52

MQ 背压与流控

概述

当生产速率持续超过消费速率时,消息队列会面临内存溢出、磁盘满甚至消息丢失的风险。背压(Backpressure)机制让上游感知下游的处理能力,流控则是系统在过载时保护自身的手段。本文从 Broker 端和 Consumer 端两个维度,剖析主流 MQ 的背压与流控策略。

正文

问题:生产太快,消费太慢

想象一个场景:大促期间订单量暴增,Producer 疯狂写入消息,而下游 Consumer 处理能力有限。如果不加控制,会发生什么?

  1. 内存溢出:Broker 将消息堆积在内存中,最终 OOM
  2. 磁盘满:持久化消息写满磁盘,Broker 宕机
  3. 消息丢失:触发淘汰策略(TTL / 队列满丢弃),消息悄无声息地消失

[!question] 如果 MQ 天生就是一个缓冲区,消息堆积不是它的基本能力吗?为什么堆积到一定程度反而会出问题?

这涉及到一个关键认知:缓冲区是有限的。任何系统都有资源上限——内存、磁盘、CPU。无限制的堆积只是把问题延后,而不是解决。

背压(Backpressure)概念

背压的核心思想:让上游感知下游的处理能力,主动降速。

Producer → Broker → Consumer
   ↑                    ↓
   └──── 处理能力反馈 ────┘

这不是 MQ 的专利。TCP 的滑动窗口、HTTP/2 的流控、Reactive Streams 的 request(n) 都是背压的不同实现形式。

Broker 端流控

RabbitMQ 的信用机制(Credit Flow)

RabbitMQ 使用 信用机制 控制消息流速。Producer 发送消息前需要有足够的 credit,Broker 处理完后归还 credit。当 Broker 积压过多,会暂停归还 credit,从而让 Producer 阻塞。

Producer --msg1--> Broker (credit: 10→9)
Producer --msg2--> Broker (credit: 9→8)
...积压严重...
Broker 暂停归还 credit
Producer 阻塞,停止发送

Kafka 的 Producer 端背压

Kafka 通过两个参数实现背压:

  • buffer.memory:Producer 端缓冲区大小(默认 32MB)
  • max.block.ms:缓冲区满时,send() 方法的阻塞时间

当缓冲区满且超过 max.block.ms,Producer 抛出 TimeoutException。这是硬性的背压信号。

// Kafka Producer 配置中的背压参数
config := sarama.Config{}
config.Producer.RequiredAcks = sarama.WaitForAll
config.Producer.Return.Successes = true
// 缓冲区满时阻塞 1 秒,超时则报错
// 这就是 Kafka 的背压触发点

内存告警与磁盘告警

多数 Broker 有内置的资源监控:

  • RabbitMQ:内存高水位触发 flow control,阻塞所有连接
  • Kafka:log.retention.bytes 限制分区大小,磁盘满时拒绝写入
  • RocketMQ:diskMaxUsedSpaceRatio 触发磁盘保护

Consumer 端反压

Consumer 端同样需要流控,核心手段包括:

拉取速率控制:Pull 模式天然支持背压——Consumer 按自己的节奏拉取,拉多少处理多少。RabbitMQ 的 basicQos(prefetchCount) 就是限制未 ACK 消息数的典型手段。

处理能力反馈:Consumer 可以动态上报自己的处理延迟或队列深度,上游据此调整推送速率。

动态调整消费并发:根据处理延迟自动扩缩 Consumer 实例数。Kubernetes HPA 基于队列深度的自动伸缩是常见方案。

graph LR
    P["Producer"] -->|"生产消息"| B["Broker"]
    B -->|"推送/拉取"| C["Consumer"]
    C -->|"ACK / 处理反馈"| B
    B -->|"Credit / 阻塞信号"| P
    style P fill:#4CAF50,color:#fff
    style B fill:#2196F3,color:#fff
    style C fill:#FF9800,color:#fff

限流降级策略

当背压来不及响应时,需要更积极的流控手段:

令牌桶(Token Bucket):以恒定速率产生令牌,请求必须持有令牌才能通过。允许一定的突发流量(桶内预存令牌)。

漏桶(Leaky Bucket):请求进入桶中,以恒定速率流出。严格平滑流量,但不允许突发。

动态调整生产速率:根据 Broker 的健康指标(队列深度、内存使用率)动态调整 Producer 的发送速率。

// 带背压控制的 Producer 示例
// 利用 channel 的天然阻塞特性实现背压
func producer(ch chan<- string, done <-chan struct{}) {
    for {
        select {
        case <-done:
            return
        case ch <- "message":
            // channel 满时自动阻塞,实现背压
            // 上游感知到下游处理不过来,自然降速
        }
    }
}

func consumer(ch <-chan string) {
    for msg := range ch {
        // 模拟慢消费
        time.Sleep(100 * time.Millisecond)
        _ = msg
    }
}

func main() {
    // 有界 channel 就是一个天然的背压缓冲区
    ch := make(chan string, 100)
    done := make(chan struct{})

    go producer(ch, done)
    go consumer(ch)

    // 当 consumer 处理不过来时,
    // channel 满后 producer 自动阻塞
    time.Sleep(5 * time.Second)
    close(done)
}

这段代码的精髓在于 make(chan string, 100)——有界 channel 就是一个天然的背压装置。当缓冲区满时,发送方自动阻塞,不需要额外的信号传递。

[!question] 令牌桶和漏桶看起来很像,它们的核心区别是什么?什么场景下该用哪个?

关联笔记