Worker背压先留口
后台 worker 最怕的不是“跑得慢”,而是慢了以后还继续接活。一次消息队列消费事故里,业务高峰时上游写入速度突然翻倍,worker 的处理速度没有同步提升。队列积压开始上升,值班同学第一反应是扩容。扩容后吞吐确实上来了,但数据库连接池也被打满,外部依赖开始超时,最终从“消费慢”变成了整条链路都慢。
这类问题不是 Go 独有,但 Go 项目里尤其常见。goroutine 便宜,channel 好用,写一个并发 worker 很快。真正难的是在压力变大时让系统知道“该慢下来”。背压不是把任务拦掉,而是让生产、排队、消费、重试都能看到当前承载能力,避免所有组件一起向下游施压。
积压不是一个数字,而是一段关系
很多团队监控 worker,只看队列长度。队列长度当然重要,但它只是结果。队列从 1000 涨到 5000,到底是上游变快了、worker 变慢了、下游卡住了,还是重试风暴把旧任务重新塞回来?只看一个数字,很难判断该扩容、限流还是暂停重试。
我更喜欢把 worker 链路拆成四段:任务进入速度、排队等待时间、实际处理耗时、下游资源占用。队列长度上升时,如果进入速度明显增加,而单任务耗时稳定,可能是容量不足;如果进入速度稳定,但处理耗时变长,应该查下游;如果错误率上升同时重试数增加,要警惕任务被反复消费。
积压不是天然坏事。一个允许异步处理的系统,本来就可以短时间排队。坏的是积压没有上限、没有退让策略、没有恢复路径。比如用户导入数据可以等几分钟,支付回调不能无限排队;日志清洗可以晚一点,库存扣减不能不确定。不同任务的积压容忍度不同,背压策略也应该不同。
goroutine 数量不是并发能力
Go 让并发写起来很轻松,也让并发误用很轻松。看到队列慢,直接把 worker 数从 20 调到 200,是很多事故的开始。goroutine 本身轻,但它后面通常连着数据库连接、HTTP 请求、文件句柄、CPU 计算和锁竞争。worker 数量只是入口并发,不等于系统吞吐。
判断并发能不能加,至少要看下游瓶颈。如果数据库连接池只有 50,200 个 worker 同时写库,最后只是在连接池前排队;如果外部接口有限流,盲目扩容会把更多请求送去超时;如果任务里有大 JSON 解析或压缩,加并发可能把 CPU 打满。
更稳的方式是分层设限。worker 总并发是一层,下游调用并发是一层,重试并发又是一层。比如总 worker 允许 100,但写数据库最多 30,调用外部接口最多 20,重试任务最多占 20% 配额。这样压力上来时,系统不会把所有资源都交给同一种任务。
- type Limiter struct {
- db chan struct{}
- remote chan struct{}
- }
- func withLimit(ctx context.Context, sem chan struct{}, fn func() error) error {
- select {
- case sem <- struct{}{}:
- defer func() { <-sem }()
- return fn()
- case <-ctx.Done():
- return ctx.Err()
- }
- }
这里的关键不是这段代码本身,而是每个等待配额的地方都要接住 ctx。如果配额等待不能取消,发布下线或任务超时仍然会卡住。
背压要从入口开始,不要只在消费端补救
很多系统把背压做在 worker 里:队列太长就少消费,下游超时就 sleep 一下。这能缓解一部分问题,但如果入口仍然无限写入,队列迟早会变成新的压力容器。背压应该尽早反馈给上游。
对于同步入口,可以直接返回“稍后再试”或降低请求频率。对于异步入口,可以限制任务创建速度,或者按租户、业务类型、优先级拆队列。不要让低优先级任务把高优先级任务堵在同一个队列里。一次报表导出积压,不应该影响订单状态同步。
如果上游无法被限制,就要在队列层面设边界。比如队列长度超过阈值后拒绝新任务,或者只接收可丢弃任务的最新版本。对于搜索索引刷新、缓存预热这类任务,旧任务未必值得保留;对于账务、支付、通知任务,不能随意丢弃,但要进入更严格的重试和告警。
背压的本质是承认系统有容量边界。一个没有拒绝策略的异步系统,只是把失败从用户面前挪到了队列深处,最后排查成本更高。
重试如果不受控,会变成第二个流量源
worker 链路里最容易被低估的是重试。任务失败后重试,本来是提高可靠性的手段;但如果下游已经异常,所有失败任务同时重试,就会把原本的流量放大。外部服务还没恢复,重试流量先把它打得更慢。
重试需要退避、上限和分类。网络抖动可以短暂重试,参数错误不应该重试;下游 429 应该降低速度,业务校验失败应该直接标记;可恢复错误可以指数退避,不可恢复错误进入死信或人工处理。所有错误都 time.Sleep(1s) 后重新投递,是典型的风险点。
还要注意重试任务不要和新任务抢同一份资源。高峰期如果旧失败任务大量回流,新任务会被拖慢,用户看到的是“系统越来越慢”。可以给重试单独队列,设置最大占比,必要时暂停重试,只保留新任务处理能力。
我更倾向于把重试看成一类独立流量,而不是失败后的附属逻辑。它要有自己的指标:重试次数、重试成功率、最终失败数、死信数量、退避时间。没有这些指标,重试会悄悄把系统拖进更深的积压。
指标要能区分慢、堵、错
worker 监控不能只有“消费成功数”和“失败数”。这两个数字太粗,真正排查时不够用。至少要拆出几个维度:队列长度、最老任务等待时间、任务处理耗时、下游耗时、错误类型、重试量、并发占用。
最老任务等待时间比队列长度更能反映用户影响。队列有 10000 条,但都是刚进来的,未必严重;队列只有 200 条,但最老任务等了 30 分钟,就应该关注。处理耗时要分业务类型,否则大任务和小任务混在一起会掩盖问题。
告警也要区分。队列长度上升是容量告警;最老等待时间超过业务承诺是影响告警;下游耗时上升是依赖告警;重试量上升是恢复风险告警。所有告警都叫“worker 异常”,值班同学就只能从头猜。
还有一类指标经常被漏掉:任务状态分布。一个任务从创建到完成,通常会经历 waiting、running、retrying、dead、done。只看成功失败,看不到任务卡在哪个阶段。比如 running 数量长期很高,可能是 worker 内部阻塞;retrying 持续上升,可能是下游没有恢复;dead 数量上升,说明自动恢复已经失败,需要人工介入。
状态分布还能帮助判断恢复节奏。一次下游故障恢复后,队列长度可能还很高,但 retrying 开始下降,done 开始上升,这说明系统正在消化;如果队列长度下降但 dead 上升,可能只是任务被放弃,并不代表业务恢复。没有状态维度,值班同学容易把“系统不再忙”误读成“系统好了”。
日志也要能串起来。每个任务最好有 taskId、attempt、queue、workerId、downstream、cost。这样排查时可以沿着一条任务看它是第几次重试、在哪个 worker 处理、卡在哪个依赖。异步系统最怕日志断裂:入口有一条,worker 有一条,下游有一条,但互相没有关联。没有关联 ID,问题只能靠时间猜。
什么时候扩容,什么时候限流
扩容不是错,盲目扩容才是错。扩容适合任务本身可并行、下游资源充足、单任务耗时稳定的场景。比如图片转码 CPU 打满,可以增加转码节点;日志清洗有足够分片,可以增加消费者。
限流适合下游已经接近瓶颈、错误率开始上升、重试增多的场景。这个时候继续扩容只会扩大失败面。你需要先保护下游,让系统进入可恢复状态。限流可能让队列排得更长,但它保住了处理质量和恢复机会。
还有一种情况既不该扩容,也不该简单限流,而是要拆队列。不同优先级任务混在一起时,扩容会让低优先级任务继续抢资源,限流会让高优先级任务也慢。拆分后才能保证关键任务先走。
技术判断的核心是问一句:当前瓶颈在哪里?如果瓶颈在 worker CPU,扩容有用;瓶颈在数据库连接池,扩容没用;瓶颈在重试风暴,先控重试;瓶颈在任务分类混乱,先拆队列。
发布前要故意制造积压
worker 系统不应该只测正常消费。上线前最好故意制造一次积压,看看系统如何表现。比如把下游接口延迟调高,观察队列长度、最老等待时间和并发占用;模拟一批失败任务,观察重试是否退避;暂停某类消费者,观察高优先级任务是否被影响。
这类测试能暴露很多设计缺口:队列有没有上限,任务有没有超时,重试会不会集中爆发,停止服务时正在处理的任务怎么落状态,扩容后下游是否被打满。测试不需要覆盖所有极端情况,但至少要证明系统知道自己什么时候该慢下来。
演练时还要看取消路径。服务发布、Pod 退出、进程收到终止信号时,worker 是否停止接新任务?正在处理的任务是否能在宽限时间内完成?超过宽限时间的任务状态如何处理?如果没有这套规则,背压只能解决运行时的压力,解决不了发布和故障切换时的半截任务。
另一个值得演练的是优先级保护。把低优先级任务灌满队列,再投递一批高优先级任务,看它们能不能按预期先执行。如果不能,说明队列拆分或调度策略还没有真正生效。很多系统在正常流量下看不出问题,只有积压时才暴露“所有任务其实都在抢同一条路”。
最后给一个判断标准:一个 worker 系统如果只能回答“现在有多少任务”,还不够。它还要回答“为什么慢、慢在哪里、还能不能接、该不该重试、谁会被影响”。背压先给 worker 留出口,就是为了让异步系统在压力下不失控。
