2.3 背压控制:满了怎么办
通道有界就一定会遇到「队列满」的时刻。很多人把背压当成故障,其实背压是系统自我保护的正常机制——下游处理不过来时,上游主动慢下来,总比内存涨爆被 OOM 强。难的不是「有没有背压」,而是背压起来之后,整条链路怎么在各不相同的策略之间保持一致、怎么不打死采集端、怎么被人看见。本篇把背压的触发、传导与降级讲清楚。
背压的三种处理姿势
队列满的那一刻,上游 send 有三种选择,对应三种系统性格:
| 姿势 | 语义 | 后果 | 适用 |
|---|---|---|---|
| 等待(await 阻塞) | 满了就等 | 不丢、延迟涨 | 日志必须完整 |
| 丢弃(try_send 失败即弃) | 满了就扔 | 低延迟、会丢 | 可采样的日志 |
| 降级(采样/降精度) | 满了就少发点 | 折中 | 有冗余信息的日志 |
日志管道默认姿势是「等待」,因为日志丢一条少一条线索。但等待不是无脑等:无限等待遇到下游彻底卡死,采集端整条瘫痪。实战做法是等待加超时——超过阈值仍然满,说明积压不是瞬时波动,这时候转入降级或丢弃,并把这次降级行为计数到指标里。
try_send 与 send 的抉择
代码上就是两个 API 的差别,系统行为上却是两种哲学:
// 等待式:满了就阻塞,交给背压自然传导
tx.send(line).await?;
// 尝试式:满了立即返回错误,由调用方决定
match tx.try_send(line) {
Ok(()) => {}
Err(TrySendError::Full(line)) => { dropped.incr(); }
Err(TrySendError::Closed(line)) => { break; }
}
抉择的依据是「这条日志值多少」。错误日志与交易相关日志值钱,永远 send;调试与访问日志可以 try_send 加采样。一个管道里两类并存时,给值钱的日志配一个独立的高优先级通道,不值钱的走主通道,比在同一个通道里挤来挤去清晰得多。通道分级的思路与 Java 云原生订单实战 中订单消息分级是一脉相承的。
背压怎么往上游传导
背压只有在「能传导到最源头」时才有意义。传导链路是:聚合通道满 → 采集任务 send 阻塞 → 读取文件暂停 → 日志留在磁盘上。磁盘是天然的大缓冲,日志留在文件里几秒钟没有任何代价,这就是文件型日志管道的巨大优势——背压传导到尽头只是「文件变厚」,而不是数据丢失。这跟消息队列型管道不同:Kafka 类系统的积压在 broker 上也占磁盘,但生产端通常没有「源头天然可暂停」的便利,日志文件做源头的好处不能视而不见。
| 缓冲层 | 容量 | 背压表现 |
|---|---|---|
| 通道队列 | 内存级(小) | 最先满 |
| 文件系统 | 磁盘级(大) | 吸收主要波动 |
| 采集偏移 | 无穷 | 终极兜底 |
怕的是中间某一层「自作主张」开了无限缓冲:有的开发者见通道满就加个大数组自己兜着,等于把背压吃掉、积压藏进了不可见的角落,最终内存悄悄涨爆。背压传导链上任何一层都不能做隐形缓冲,这是原则问题。
限速:主动背压
除了被动等待,还有主动背压:让上游按令牌桶速率发送,从源头削峰。日志管道的骨干带宽有限(比如上行 COS 的写入带宽),采集端按令牌桶自我限制,比让通道在满与空之间震荡更平稳。令牌桶参数怎么设见下表:
| 参数 | 建议值 | 依据 |
|---|---|---|
| 桶容量 | 峰值的 1.2 倍 | 允许短突发 |
| 补充速率 | 日均值的 2 倍 | 留处理余量 |
| 超限动作 | 延迟到下个令牌 | 不丢 |
令牌桶的实现要点是「补令牌」这件事不能开定时器狂刷:每次消费时按时间差一次性补足,把推进时机交给数据流本身。核心几行如下:
// 数据流驱动的令牌桶
let now = Instant::now();
let elapsed = now.since(last_refill);
tokens = (tokens + elapsed.as_millis() * rate / 1000).min(burst);
last_refill = now;
if tokens >= 1.0 {
tokens -= 1.0;
Some(line)
} else {
None // 暂缓本条,下个周期再试
}
边角细节有两个:时钟要用单调时钟(Monotonic)而不是系统时钟,系统时钟回跳会让令牌突然变负;桶内令牌用浮点比整数更平滑,整数离散会导致速率抖动。
限速的代价是平均延迟略升,收益是全链路平滑。对日志这种「平时不急、高峰别崩」的负载,这笔交易很划算。限速器本身的状态(当前桶内令牌数)也值得做成指标,它能提前预示积压。限速在高并发网关里是标配,可参考 Go 微服务实战 的限流章节。
让背压可见
背压最怕的是「发生了却没人知道」。三个指标必须上墙:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| 通道积压深度 | 近似队列长度 | 持续 > 容量 80% |
| 丢弃计数 | try_send Full 次数 | 任意非零 |
| send 等待时长 | 背压持续时间 | p99 > 1 秒 |
丢弃计数非零是最刺眼的信号:它意味着系统已经放弃了「全量」承诺。这时候要立刻回答两个问题——丢的是哪类日志?为什么满?前一个问题靠按类别分别计数解决,后一个问题看等待时长与下游吞吐的对比。监控大盘的设计见 5.2。
背压调参实录
给一个真实的调参过程做参照。某服务日均日志 8000 万条,高峰约每秒 12 万条,初设容量 10000,watch 到的现象与调整如下:
| 阶段 | 观察 | 判断 | 调整 |
|---|---|---|---|
| v1 容量 10000 | 高峰期丢弃计数每分钟 2 万 | 峰值溢出容量 | 扩容 + 攒批 |
| v2 容量 50000 + 批 2000 | 丢弃归零,积压常态 30000 | 下游写入跟不上 | 提升并行度 |
| v3 写入并发 4 | 积压降到 5000 以下 | 吞吐匹配 | 收工 |
注意 v2 的动作不是继续扩容量——扩到 20 万只是把积压请进内存,不解决吞吐。真正的杠杆在下游并行度,这再次印证「背压是下游的镜子」。调参的顺序永远是:先看下游吞吐瓶颈,再决定上游容量,最后才谈丢弃阈值。
降级策略
持续背压到一定程度,就要执行降级,而不是继续傻等。降级梯度如下:
| 背压时长 | 降级动作 | 恢复条件 |
|---|---|---|
| < 5 秒 | 不动(正常波动) | 自动 |
| 5~60 秒 | DEBUG 级日志转采样 | 积压 < 50% |
| > 60 秒 | 只留 ERROR 与关键路径 | 人工确认 |
降级必须可逆,恢复条件写死在代码里而不是等运维手动开关。自动降级若不自动恢复,高峰期一过整条管道就废了一半——这种「降级卡住」的事故比背压本身还多。
踩坑提示
最隐蔽的坑是背压传导链被 async 任务边界截断:某个中间 transform 任务用了无界缓冲想「先收下慢慢处理」,积压全藏在这个任务的内存里,外部指标一切正常,直到 OOM。排查方法是内存指标按任务分标签,看哪个任务 RSS 异常。这类问题的外部症状特别有欺骗性——CPU 不高、延迟不高、积压指标不高,只有 RSS 在慢慢爬,等爬到容器限制被杀,日志里只剩一行莫名其妙的 exit code 137。第二个坑是丢弃计数与日志类别混在一起:ERROR 日志被当 DEBUG 一起丢了,告警全是噪声。解法是按级别分通道,ERROR 永远走独立高保通道。第三个坑是恢复条件设太严:要求积压降到 0 才恢复,结果永远在震荡中恢复不了,设 50% 这种滞后阈值更稳。第四个坑是背压期间改容量:见到积压就热调 capacity,新旧消息在两个容量语义下混跑,指标会跳变;容量调整应作为「配置变更走发布流程」,而不是随手热改。
小结
背压是朋友不是敌人:满了就等是默认姿势,值钱日志配独立通道,传导链上不许出现隐形缓冲,主动限速从源头削峰。三个上墙指标是积压深度、丢弃计数、send 等待时长;降级梯度要自动、可逆。背压处理好了,管道在高峰期的表现是「慢而不崩」,这才是稳定性的及格线。
