我以前觉得,「等待」是系统里的坏味道。能不等就不等,能并发就并发。
后来在一条批量链路上栽了跟头:并发从 4 提到 16,吞吐没涨,失败率翻了三倍。我盯着监控看了半天,才承认一件事——我不是在提速,我是在制造拥塞。
从那之后我认真地把「等」这件事拆开了看。它至少有三种,理由完全不同。
一、限流:为了不被对面拒绝
对面的服务有配额,你超了它就拒绝你。这时候「等」是为了把请求摊平到时间轴上。
关键在于:限流要限在最贴近出口的那一层。我一开始限在任务调度层——每次只放 8 个任务进来。但一个任务内部可能发出 3 次请求,实际并发是 24,照样被拒。
正确做法是在真正发请求的那个函数上加令牌桶,不管上面放进来多少任务,出口的速率是恒定的。
二、退避:为了不在对方虚弱时踩上一脚
对面已经返回 429 或者 5xx,说明它现在扛不住。这时候立刻重试,是在给一个已经喘不过气的服务继续加压。
指数退避的意义不是「等久一点成功率高一点」,而是在系统性故障期间,让整体请求量自动衰减。
一个容易忽略的细节:退避一定要加随机抖动。如果 16 个并发任务同时收到 429,同时退避 1 秒,那 1 秒后它们又会同时发起——你只是把一次拥塞推迟了 1 秒,还让它变得更整齐、更猛。加上抖动之后,这 16 个请求会散开,拥塞就化掉了。
delay = base * (2 ** attempt) * (0.5 + random.random())
就这一行的差别,实测把批量任务的整体失败率压掉了一半以上。
三、背压:为了不让上游生产得比下游快
这是我最晚才真正理解的一个。
前两种「等」都是对外的——因为对面不行。背压是对内的:当下游处理不过来时,上游必须慢下来。
如果不做背压,会发生什么?队列无限增长。表面上一切正常,所有任务都「已提交」,但队列里排着上千条,最后一条要等几个小时。更糟的是内存被队列吃满,或者中间产物把磁盘写爆。
背压的实现其实很朴素:用有界队列,满了就阻塞生产者。难的不是实现,是接受它——你得允许「上游停下来等一等」,而不是想办法让上游一直有事做。
我第一次给队列设上界的时候,心里是抗拒的:这不就是故意让生产者闲着吗?后来才明白,生产者闲着不是浪费,队列堆积才是浪费——那些排在队尾几小时才被处理的任务,产出的结果很可能已经过期了。
三者搞混的典型症状
如果你的系统在压力下有这些表现,很可能是这三层混了:
- 压力一上来失败率暴涨,而且日志里全是重试 → 少了退避的抖动,或者退避写在了错误的层
- 并发调高吞吐不涨,甚至倒退 → 出口限流没做,或者限在了太上层
- 内存/磁盘慢慢涨到爆,业务指标看起来一直正常 → 没有背压,队列在无声地堆积
- 平均耗时正常,但长尾巨大 → 队列在堆积,尾部任务等了很久
写进架构,而不是写进补丁
这三层现在是我每条链路的标准配置,不是出问题之后才加的补丁:
| 层 | 位置 | 参数 |
|---|---|---|
| 限流 | 紧贴出口的调用函数 | 令牌桶,速率按对面配额的 80% 设 |
| 退避 | API 层的重试循环 | 指数 + 抖动,与业务层重试配额隔离 |
| 背压 | 任务队列 | 有界,容量 ≈ 并发数 × 3 |
背压那个容量我试过很多值。太小会让生产者频繁阻塞、上下文切换开销上来;太大就失去了背压的意义。并发数的 2–4 倍是个比较舒服的区间。
现在回头看,「能不等就不等」这个直觉本身没错,错在我把它当成了铁律。
系统在顺利的时候,等待确实是浪费。但系统在承压的时候,主动等待是唯一能让它不崩的东西。而一个系统一生中承压的时刻,远比我们设计时想象的多。

SERVICES.waline即生效。想说话可以先去 留言板,那边我每条都回。