结论先行
对单个下游的「限流」,正确的控制量是「同时打到它的在途请求数(in-flight)」,而不是 QPS、也不是线程数。 三者对应三个不同的阀门:线程池限的是「我有多少工人」,令牌桶限的是「每秒放行多少」,信号量限的才是「此刻有多少在飞」。选错阀门,下游照样被打爆。这篇文章复盘一次真实的 429 风暴,把三种方案的机制差异、选型判据和可复现代码一次讲清。
一、现象:QPS 没超、线程没满,下游还是 429
背景规模(JDK 17.0.10 / Spring Boot 3.2.4 / OkHttp 4.12.0,8 实例 × 8C16G,K8s 部署):
- 搜索聚合网关,单请求扇出 4 个下游:商品服务、价格服务、第三方物流接口、推荐服务;
- 入口正常 QPS 约 1400(单实例 175),大促峰值 2600;
- 第三方物流接口的合同限制:同一租户全局 50 个并发,超了直接返回 429(无排队);
- 该接口正常 P99 是 220ms,但间歇性抖到 1.2~2.5s(对方内部发布期)。
事故过程:某个下午对方接口开始抖动,P99 从 220ms 升到 1800ms。我们的现象是:
- 入口 QPS 只有 1900,没超任何限流阈值;
- Tomcat
maxThreads=200,active 线程峰值 130,线程池没打满; - 但 429 占比在 2 分钟内从 0% 飙到 31%,网关 P99 从 260ms 打到 2400ms;
- 更糟的是:429 之后我们的旧逻辑会同步重试 1 次,等于把并发又翻倍,429 占比进一步涨到 41%。
根因一句话:我们从来没有约束过「同时打到物流接口的在途请求数」。抖动前单请求 220ms、入口 1900 QPS,按 Little 定律在途并发 ≈ 1900 × 0.22 = 418 个——就已经远超对方的 50 了,平时对方内部排队扛住了,抖动一放大,排队扛不住,429 爆发。
二、三种阀门的机制差异(为什么选错都救不了)
用 Little 定律统一语言:在途并发 L = 入口吞吐 λ × 单请求占用 W。抖动时 W 从 220ms 涨到 1800ms,L 自动膨胀 8 倍——任何不直接钳制 L 的手段都是间接的。
| 手段 | 实际钳制什么 | 抖动时为什么失效 | |------|------------|----------------| | 独立线程池(core=max=50) | 同时能「发起调用」的工人数量 | 如果 worker 里还夹了重试、序列化、埋点,50 个线程的在途可能 >50;且线程池是进程内的,8 实例 × 50 = 400 个在途,全局照样超 | | 令牌桶限流(rate=50/s) | 每秒「放行」的请求数 | 它控制的是速率不是在途。W 涨到 1.8s 时,50/s 的放行速率照样积累出 50×1.8=90 个在途,还是超。而且限流器通常按实例配置,全局又是 8 倍放大 | | Semaphore(permits=50) | 此刻在途的请求数 | 直接钳制 L。W 涨多少都不影响——在途永远 ≤50,超出的请求在应用侧快速失败而不是打给对方 |
关键区别:线程池和令牌桶都在「入口侧」数数,信号量在「出口侧」数数。而下游的容量约束本质是出口侧的(对方能同时处理多少),所以只有出口侧的阀门才是对的。这和社区里《把线程池从 200 调到 2000 之后 P99 反而翻了 5 倍》(id=76) 的结论一脉相承——加线程放大的是排队,不是吞吐;阀门要开在离瓶颈最近的那一层。
三、具体做法:Semaphore + 快速失败 + 全局配额分摊
1. 配额怎么定:按全局配额反算单实例
对方限制是全局 50,8 实例不能各配 50(=400 在途)。正确做法是反向定标:
单实例 permits = (全局配额 / 实例数) × 安全系数
= (50 / 8) × 0.8 ≈ 5
安全系数取 0.8 而不是 1.0:实例数会因扩缩容变化,留 20% 余量给「瞬时 9 实例」的情况,宁可少 1 个也别超配。
2. 核心代码(JDK 17,tryAcquire 带超时 = 快速失败)
public class DownstreamGate {
// permits 按上面公式算出,本例 = 5
private final Semaphore permits = new Semaphore(5, true); // fair 模式防饿死
private final Duration acquireTimeout = Duration.ofMillis(200);
private final Counter acquireTimeouts = Counter.builder("gate.acquire_timeout").create();
public <T> T call(Supplier<T> call, Function<Exception, T> fallback) {
boolean acquired = false;
try {
// 关键:tryAcquire 带超时,拿不到许可 = 快速失败,绝不排队打给对方
acquired = permits.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
if (!acquired) {
acquireTimeouts.increment();
return fallback.apply(new RejectedByGateException("downstream gate full"));
}
return call.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return fallback.apply(e);
} finally {
if (acquired) permits.release();
}
}
}
使用方式(每个下游一个 gate 实例,permits 各算各的):
var result = logisticsGate.call(
() -> logisticsClient.query(orderId), // 真正的 RPC
ex -> FallbackLogistics.UNKNOWN // 降级值,绝不重试
);
429 之后的行为必须改:原来「429 同步重试 1 次」的逻辑全部删掉。gate 挡住的是「我方主动发起」,对方 429 是「对方拒绝」,两者都说明同一件事——在途超了,重试只会把在途再推高。
3. 可观测性(两个指标就够)
// 每次 tryAcquire 前后打点:
gate.active_permits = 5 - permits.availablePermits(); // gauge,>0 且持续高位 = 在途吃紧
gate.rejected_total = 被快速失败的请求计数 // counter,非 0 = 正在对下游做背压
Grafana 上把 active_permits / 5 做成百分比:持续 >80% 说明该调小上游 QPS 或找对方提配额;偶发打满、rejected 很少,说明阀门尺寸合适。
四、踩过的坑 / 反直觉之处
Semaphore(50)配在 8 个实例上 = 全局 400 在途——这是最容易犯的错误。进程内工具(线程池、本地限流器、本地信号量)的配额必须按全局/实例数反算,否则就是自欺欺人。- fair 模式别嫌慢:
new Semaphore(n, true)比非公平模式在高争抢下吞吐低约 10%~15%,但它保证「先来的先拿到许可」,不会出现某个业务方的调用被长期饿死。对支付/搜索这类多租户场景,公平模式值得那点开销。 tryAcquire(timeout)的 timeout 不是「等下游的时间」:它只是「等许可的时间」。拿不到就快速失败,绝不能把它配成 3s 然后指望「等一会儿就好了」——那等于把对方 50 个并发里的一部分「占着不用」,白白浪费配额。200ms 量级足够。- 降级值要参与后续逻辑:我们最初把
UNKNOWN直接返回给前端,用户看到「物流状态未知」的白屏。后来改成:UNKNOWN 时隐藏物流区块而不是展示占位符,投诉率降了 70%。背压的最后一环是「失败时用户看到什么」。 - gate 不能包在事务里:如果
call()在@Transactional内,快速失败返回 fallback 后事务照常提交,数据库里会存下「降级值」当成真实值。gate 必须放在事务边界之外(或失败路径直接抛异常回滚)。
五、结论与可复用清单
- [ ] 控制量选「在途并发」而不是 QPS 或线程数:下游的容量约束本质是并发的,用
L = λ × W验证你的配额是否真的能兜住 W 膨胀 5~10 倍的情况。 - [ ] 三种阀门的位置:线程池(进程内工人)→ 令牌桶(速率,适合限全局入口)→ 信号量(在途,适合限单个下游出口)。限单个下游,用信号量。
- [ ] 配额反算公式:
单实例 permits = 全局配额 / 实例数 × 0.8,实例数变化时同步调整(K8s HPA 场景要联动)。 - [ ]
tryAcquire带短超时(100~300ms)= 快速失败,拿不到就走 fallback,绝不排队、绝不重试。 - [ ] 429 触发后禁止同步重试——重试是在途的放大器,不是救场。
- [ ] 两个指标:
active_permits(gauge)和rejected_total(counter),持续高位 = 该调上游或提配额。 - [ ] fallback 值要过一遍产品逻辑:展示层怎么处理「未知」,比限流器本身更容易被用户感知。
- [ ] gate 放事务边界外,或失败路径必须回滚。
一句话收尾:线程池管「我有几个人」,令牌桶管「我每秒放几个」,信号量管「此刻有几个在飞」——下游的容量是「同时在飞」的约束,所以阀门要开在出口,而不是入口。