网关上限流配了 1000 QPS,压测也过了。上线后后端照样被打穿,日志里全是 503 和连接池耗尽。查下去发现:被打穿的不是入口流量,而是某个内部服务之间的调用——网关根本看不见那段流量。
这就是限流最典型的失败形态:限流只保护它看得见的流量。位置选错,后面的容量规划、算法选择、阈值调参全部白做。
1. 要保护的是哪种资源
| 资源 | 饱和后的表现 | 瓶颈通常所在 | 该用什么手段 |
|---|---|---|---|
| 带宽/入口连接 | 新建连接超时、TLS 握手慢 | 边缘、LB、NAT | 连接数上限、速率限制 |
| 线程/连接池 | 排队时间线性上涨,随后雪崩 | 服务进程内 | 并发数限制、排队 + 背压 |
| 后端计算(DB、下游 RPC) | 慢查询堆积、连接池耗尽 | 资源池一侧 | 客户端侧配额、池大小与隔离 |
| 外部配额/成本 | 402、429、账单飙升 | 第三方 | 配额分配 + 降级 |
一个服务同时受多种资源约束,要保护的是"最先饱和的那一种",而不是流量数字。判断方法:逐步加压看哪个指标先拐——线程池队列先涨,还是 DB 连接先耗尽。
2. 五个可能的位置与各自的能力边界
| 位置 | 看得见的维度 | 能力强项 | 盲区 |
|---|---|---|---|
| 客户端 | 自己的调用节奏 | 零成本、最先生效 | 不可信,别人可以不带 |
| CDN/边缘 | IP、UA、路径、地域 | 挡爬虫与刷量,成本最低 | 对已登录用户与内部调用无效 |
| 网关 | 路由、租户、API key | 全局限流、统一策略 | 看不到服务间调用;不知道下游真实负载 |
| 服务自身 | 接口、方法、租户上下文 | 能按真实成本限流(这条 SQL 多重) | 各自为政,全局阈值会漂移 |
| 资源池(连接池/线程池/信号量) | 并发占用 | 最后一道硬闸,永远有效 | 只能拒绝/排队,无法区分请求价值 |
结论:网关挡"不该进来的",服务挡"进来太多的",资源池兜底"已经太晚的"。三层不冗余,因为输入信息不同。服务侧不可替代,是因为只有它知道一次请求要花几次 DB 查询——网关按 QPS 计数,服务按成本计数。
3. 为什么单层限流必然漏
任何单层限流器只知道经过它的请求。三类流量天然绕过网关:
1. 内部调用 svc-a → svc-b 不经过入口网关
2. 重试与补偿 gateway 放过 1 次 → svc 内部 retry 3 次 → 实际 3 倍成本
3. 异步/定时 MQ 消费、定时任务、批处理 没有 HTTP 入口
# 本地限流的真实阈值 = 单实例配额 × 实例数;扩缩容即漂移
单实例 100 QPS × 12 实例 = 1200 # 昨天 10 实例,今天 12 实例,网关阈值没动
| 方案 | 阈值稳定性 | 每次请求开销 | 失效场景 |
|---|---|---|---|
| 本地(进程内计数器) | 随实例数线性漂移 | 纳秒级 | 扩缩容、滚动发布时新旧实例并存 |
| 全局(中心化计数) | 稳定 | 一次网络往返(亚毫秒到数毫秒) | 计数中心抖动 → 全站限流失效 |
| 混合(本地令牌 + 周期配额) | 近似稳定 | 分摊后接近本地 | 配额周期内突发分布不均 |
滚动发布是最容易忽略的漂移源:新旧版本各 6 个实例,实现或默认值不同,发布窗口里阈值就翻倍。
4. 维度选择与误伤
| 维度 | 挡得住 | 误伤 | 绕过成本 |
|---|---|---|---|
| 全局 | 整体过载 | 一个租户的大促拖垮所有人 | 无(本来就不是防单点) |
| 租户/用户 | 单租户刷量 | 共享出口 IP 的大客户被连带 | 注册新账号 |
| IP | 爬虫、脚本 | NAT 后的整个办公网 | 换代理 |
| 接口/路由 | 单接口热点 | 正常业务量的接口被压 | 换接口 |
| 接口 × 租户 | 单租户刷单接口 | 维度多 → 每格配额小 → 统计不稳 | 多租户轮换 |
误伤分析:先写下"哪些正常用户会被这条规则挡住"。若出现"NAT 后的公司"或"全部新用户",规则就该收窄。误伤不是边缘情况,是常态成本。
5. 算法选择
| 算法 | 允许突发 | 内存/状态 | 适用 |
|---|---|---|---|
| 固定窗口计数 | 窗口交界可双倍通过 | 1 个计数器 | 粗粒度配额(日调用量) |
| 滑动窗口 | 平滑,近似精确 | 分片计数或时间轮 | 网关级 QPS |
| 令牌桶 | 允许按桶容量突发 | 令牌数 + 时间戳 | 面向用户的 API 配额 |
| 漏桶 | 不允许突发,匀速放行 | 队列长度 | 保护脆弱下游(老 DB) |
| 并发数限制(信号量) | 与速率无关 | 一个计数器 | 线程/连接池这类资源 |
并发数限流最被低估:它约束"同时在飞的请求数",天然适配线程池与连接池,对长尾更有效——QPS 相同,慢请求会占满线程。
6. 背压与限流不是一回事
| 对比项 | 限流 | 背压 |
|---|---|---|
| 信号方向 | 入口 → 拒绝 | 下游 → 上游减速 |
| 依据 | 预设阈值 | 实时队列深度/延迟 |
| 失败表现 | 429/503,明确拒绝 | 生产者阻塞或丢弃,延迟升高 |
| 需要什么 | 计数与阈值 | 有界队列 + 阻塞或异步反馈 |
// 背压:有界队列 + 拒绝策略把压力反弹给调用方(而非无限堆积)
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 8, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(200), // 有界:背压的前提
new ThreadPoolExecutor.AbortPolicy()); // 满了就抛,让调用方感知
限流是开环的:阈值写死,不管下游死活。背压是闭环的:下游慢了上游自动减速。只有限流没有背压,阈值调大一点就重新打穿;只有背压没有限流,压力会一路顶到入口变成超时。
7. 队列的三种处置与延迟预算
| 处置 | 用户看到 | 成本 | 适用 |
|---|---|---|---|
| 排队(有界) | 变慢、可能超时 | 延迟预算 | 下游短时可恢复,请求可等待 |
| 拒绝(429/503) | 立刻失败 | 一次错误响应 | 请求不可等待,或过载会传染 |
| 降级(返回旧值/默认) | 结果不新鲜但可用 | 一致性 | 读路径、缓存可兜底 |
延迟预算决定排队深度:入口 P99 目标 500 ms、单请求 200 ms,队列只容得下 2–3 个等待周期。队列长度 = 可接受延迟 ÷ 单请求处理时间,比"队列设 1000"有依据。
8. 限流与重试的乘法关系
客户端重试 3 次、指数退避缺失 → 被拒请求立刻变成 3 倍入流量
1000 QPS 上限被触发 → 1000 × 3 = 3000 打在同一个入口 → 重新过载
// 重试只对幂等且可恢复的错误生效;429 必须遵守 Retry-After
if (resp.status() == 429) {
sleep(parseRetryAfter(resp)); // 服务端给的节流指令,客户端要照做
return retryOnce(); // 且只重试一次,不再放大
}
限流触发重试会形成正反馈:拒绝 → 重试 → 更多拒绝。两个约束:被限流的响应必须带 Retry-After;重试预算计入总配额。
9. 阈值怎么定,而不是怎么猜
# 1) 单实例容量测试:固定并发,逐步加压,记录拐点
for c in 50 100 150 200 250; do
wrk -t4 -c$c -d60s --latency http://TARGET/ENDPOINT > out-$c.txt
done
# 2) 找 P99 开始抬升但错误率仍为 0 的档位,记为单实例安全容量 C
# 3) 集群阈值 = C × 实例数 × 安全系数(0.6–0.8);预留余量给发布与抖动
按版本核对的三件事:限流器是否共享状态(本地/全局)、Retry-After 是否被网关透传、熔断与限流是否串联(顺序不同,被拒请求的统计口径就不同)。
10. 分布式限流的成本与近似替代
| 方案 | 每请求开销 | 精度 | 失效后果 |
|---|---|---|---|
| 每次请求查 Redis | 一次往返 | 高 | Redis 抖动 → 全站 5xx 或全放行 |
| 本地预取配额(每 100 ms 取一批) | 分摊到 1/100 | 近似 | 周期内分布不均,跨实例有小误差 |
| 一致性哈希分片计数 | 一次往返到固定节点 | 中 | 热点租户集中在一个分片 |
| 只做本地 + 事后对账 | 零 | 低 | 发布/扩缩容窗口漂移 |
判据不是精度,而是故障时要哪种失败:Redis 不可用时"全放行"还是"全拒绝"。必须显式写下来,否则默认谁先写谁赢。
11. 可观测:被拒之后发生了什么
| 指标 | 为什么要看 | 异常读法 |
|---|---|---|
| 限流命中率 | 阈值是否贴近真实容量 | 长期为 0 = 阈值虚高,等于没限流 |
| 被拒请求的后续行为 | 是重试、放弃还是换接口 | 重试占比高 = 第 8 节的放大在发生 |
| 拒绝与下游饱和的时间关系 | 拒绝是否真的保护了后端 | 拒绝很多但 DB 仍饱和 = 位置选错 |
| 单实例配额分布 | 是否均匀分摊 | 分布倾斜 = 哈希或路由不均 |
12. 排错
| 现象 | 最可能的原因 | 处理 |
|---|---|---|
| 网关限流生效,后端仍被打穿 | 内部调用/重试/异步流量绕过网关 | 在服务侧补一层按成本的限流 |
| 扩缩容后限流过松或过紧 | 本地限流阈值 × 实例数漂移 | 改全局或本地预取配额 |
| 上了限流,P99 反而变差 | 排队深度超出延迟预算 | 缩短队列、改拒绝或降级 |
| 被拒请求量远高于下游实际压力 | 维度选错(如按 IP 挡了 NAT) | 改租户/接口维度并加豁免 |
| 限流触发后总流量翻倍 | 客户端对 429 立即重试 | 返回 Retry-After,重试纳入预算 |
13. 边界:这些场景不该用限流
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 单实例、低流量内部服务 | 池大小 + 超时 + 背压 | 限流器的运维成本高于收益 |
| 请求不可拒绝(支付回调、消息投递) | 排队 + 削峰 + 幂等 | 拒绝等于丢数据 |
| 下游只是短时抖动 | 熔断 + 重试预算 | 限流会把抖动固化成长期拒绝 |
| 突发是业务本身(秒杀、开票) | 预约/排队/令牌发放 | 限流只是把失败提前 |
| 成本/配额类约束 | 配额分配与降级 | 与速率无关,需要按量核算 |
14. 自测
- 网关已限流,后端仍被打穿,最先怀疑哪三类流量?
- 为什么服务侧限流无法被网关替代?它多知道什么?
- 本地限流在滚动发布窗口里会发生什么?
- 并发数限流为什么对长尾请求比 QPS 限流更有效?
- 限流与背压各自的开环/闭环性质,决定了它们必须同时存在吗?
- 限流器本身故障时,"全放行"和"全拒绝"分别保护了什么?