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 还有提升空间(分区够、实例少),先扩消费者。经验做法是“双管齐下”:先对生产端做软限流(延后非关键消息),同时扩消费者,避免“扩的过程中又被新流量淹没”。

关联