43.5 积压与背压
一句话
积压不是“消息多了”,而是消费速率持续低于生产速率:只要
C > P,积压总会被排空(时间 =L/(C−P));只要C ≤ P,积压永远排不空——所以判断要不要扩容,先算这两个数,而不是看 lag 曲线的形状。
三个数决定一切
| 符号 | 含义 | 怎么取 |
|---|---|---|
L | 当前积压条数(lag) | broker 的 consumer lag 指标 |
P | 生产速率(条/秒) | 入流量监控 |
C | 有效消费速率(条/秒) | 消费成功条数,不是拉取条数 |
C > P → 排空时间 = L / (C − P)
C ≤ P → 永远排不空(lag 单调增长或持平)
package main
import "fmt"
func main() {
const (
lag = 1_000_000.0 // 当前积压
produce = 10_000.0 // 生产速率
)
fmt.Printf("积压 %.0f 条,生产 %.0f 条/秒\n\n", lag, produce)
fmt.Printf("%-14s %-14s %-16s %s\n", "消费速率", "净消化", "排空时间", "判定")
for _, c := range []float64{8_000, 9_500, 10_000, 11_000, 12_000, 15_000, 20_000} {
net := c - produce
if net <= 0 {
fmt.Printf("%-14.0f %-14.0f %-16s %s\n", c, net, "∞", "排不空:必须扩容或降生产")
continue
}
sec := lag / net
fmt.Printf("%-14.0f %-14.0f %-16s %s\n", c, net,
fmt.Sprintf("%.0fs(%.1f 分钟)", sec, sec/60), "能排空")
}
// 分区倾斜:总 lag 看起来还行,但最慢的分区决定“真正排空”
fmt.Println("\n=== 分区倾斜下的排空(12 个分区)===")
parts := []float64{400_000, 200_000, 150_000, 100_000, 60_000, 40_000,
20_000, 15_000, 8_000, 4_000, 2_000, 1_000}
total := 0.0
maxLag := 0.0
for _, l := range parts {
total += l
if l > maxLag {
maxLag = l
}
}
avg := total / float64(len(parts))
// 每个消费者平均分到 1 个分区;最慢分区的消费能力 = 平均消费能力
perPartConsume := 12_000.0 / float64(len(parts))
fmt.Printf("总 lag %.0f,单分区最大 %.0f(是均值的 %.1f 倍)\n", total, maxLag, maxLag/avg)
fmt.Printf("按总 lag 算:%.0fs 排空\n", total/(12_000-produce))
fmt.Printf("按最大分区算:%.0fs 才排空(最慢分区拖后腿)\n", maxLag/(perPartConsume-produce/float64(len(parts))))
fmt.Printf("结论:lag 要按“分区最大”看,而不是只看总和\n")
}
本机实测(macOS,Apple M1,Go 1.27):
积压 1000000 条,生产 10000 条/秒
消费速率 净消化 排空时间 判定
8000 -2000 ∞ 排不空:必须扩容或降生产
9500 -500 ∞ 排不空:必须扩容或降生产
10000 0 ∞ 排不空:必须扩容或降生产
11000 1000 1000s(16.7 分钟) 能排空
12000 2000 500s(8.3 分钟) 能排空
15000 5000 200s(3.3 分钟) 能排空
20000 10000 100s(1.7 分钟) 能排空
=== 分区倾斜下的排空(12 个分区)===
总 lag 1000000,单分区最大 400000(是均值的 4.8 倍)
按总 lag 算:500s 排空
按最大分区算:2400s 才排空(最慢分区拖后腿)
结论:lag 要按“分区最大”看,而不是只看总和
读数:
| 消费速率 | 净消化 | 结论 |
|---|---|---|
| 8,000 / 9,500 | 负 | 永远排不空,必须扩容或限流生产 |
| 10,000 | 0 | 持平:lag 不涨也不降 |
| 12,000 | 2,000/s | 500 秒(8 分钟)排空 |
| 15,000 | 5,000/s | 200 秒排空 |
| 分区倾斜场景 | — | 按总 lag 算 500 秒,按最大分区算要 2,400 秒(4.8 倍) |
🔑 扩容的判据不是“lag 大”,而是“净消化速度够不够快”:
(C−P)/P至少要留 20–30% 余量,否则一次流量高峰就会把积压重新拉起来。另外,扩容加的是消费者,只有分区数 ≥ 消费者数时才有用——多出来的消费者会空闲。
背压的四种手段
| 手段 | 做法 | 代价 | 适用 |
|---|---|---|---|
| 扩消费者 | 加实例(≤ 分区数) | 成本;受分区数限制 | 消费能力不足 |
| 限生产 | 生产端令牌桶/队列水位 | 影响上游业务 | 突发流量、可延迟 |
| 降级丢弃 | 只处理关键消息,丢弃低优先级 | 数据丢失 | 有优先级、可容忍丢失 |
| 异步化 + 批处理 | 合并写库、批量提交 | 延迟上升 | IO 瓶颈型消费 |
⚠️ “扩消费者”的前提是分区数够。分区数 = 12、消费者 = 20 时,8 个消费者会完全空闲——很多“扩了没效果”的故障都是这个原因(43.3)。
反模式
| 反模式 | 后果 |
|---|---|
| 只看总 lag,不看分区最大 lag | 总 lag 下降,但热点分区永远追不上 |
| 消费者数超过分区数仍继续扩 | 白花钱,lag 不降 |
| 用“拉取速率”当消费速率 | 拉回来堆在内存里,实际没处理,越拉越糟 |
| 积压时重启消费组 | 触发再均衡,停顿 + 重放,雪上加霜(43.4) |
| 一直不清理死信/毒消息 | 一条消息反复重试,卡住整个分区 |
| 不设生产端限流 | 上游一个突发就把积压打回原点 |
自检
Q1 lag 从 100 万降到 10 万,但用户投诉“消息延迟还是很大”,可能是什么原因?
▸ 答案:总 lag 下降不代表端到端延迟下降。三种可能:① 分区倾斜:总 lag 降了,但某个热点分区仍有几十万积压,落在这个分区上的用户就是慢;② 消费速率刚好等于生产速率:lag 稳定在 10 万,新的消息排队 10 万/生产速率秒才被处理;③ 消费端处理慢但拉得快:lag 是“未提交位点”的差,如果消费者拉了很多在内存里没提交,lag 看起来低但实际延迟高。要看“消息生产时间到处理时间的分位数”,不是只看条数。
Q2 为什么“消费者数 > 分区数”时扩容无效?
▸ 答案:因为一个分区在同一时刻只能被消费组里的一个消费者消费(保证分区内顺序与位点唯一)。分区数 12 时,第 13 个消费者分不到分区,只能空闲。要突破这个上限,必须扩分区(会触发再均衡与顺序问题)或提高单消费者吞吐(批处理、异步化、减少下游 IO)。
Q3 积压时该先扩消费者还是先限生产?
▸ 答案:看 C 与 P 的关系。如果 C 已经接近分区数 × 单消费者上限(扩不动了),只能限生产或降级;如果 C 还有提升空间(分区够、实例少),先扩消费者。经验做法是“双管齐下”:先对生产端做软限流(延后非关键消息),同时扩消费者,避免“扩的过程中又被新流量淹没”。