3.4 限流:单机与分布式令牌桶
熔断防下游拖死自己,降级接住熔断后的流量,超时重试管调用行为,最后一道闸是限流:流量本身太大时,自己先说「不」。我们用的是「单机令牌桶加 Redis 分布式配额」双层模型,这一篇给两层各自的参数、配合方式,以及一次配置错把正常流量限掉的事故。限流的取舍与 go-micro-cps-guide 的保护章节同源,本篇的重点在双层配合的参数联动。
场景:为什么要两层
| 层 | 管什么 | 失效场景 |
|---|---|---|
| 单机限流 | 本实例不被打穿 | 流量全打在一个实例上仍会挂 |
| 分布式限流 | 全集群总量不超 | 每实例都合规但总量爆表 |
两层都过才放行,任一层拒绝即拒绝。两层的关系可以类比保险丝与总闸:单机是每机一条保险丝,毫秒内断;分布式是楼层总闸,靠 Redis 记账。只装保险丝不装总闸时,11 台机器各自合规,总量仍能顶穿数据库。
被限的流量去哪也要想清楚:429 出去的请求,调用方要么退避重试、要么降级,重试的量会再进限流器,所以限流阈值必须给「重试回流」留 20% 余量,不然限流会把自己的重试打回去形成空转。这个余量在 5.1 压测里单独验过。
方案:双层令牌桶参数
| 参数 | 单机层 | 分布式层 | 依据 |
|---|---|---|---|
| 速率 | 3200 QPS/实例 | 12000 QPS/全集群 | 压测天花板 5.1 |
| 桶容量 | 64 | 256 | 吸收 20ms 毛刺 |
| 判定 | 本地 atomic | Redis Lua 原子扣减 | |
| 拒绝行为 | 直接 429 | 直接 429 | 带 Retry-After |
分布式层的扣减用 Lua 脚本保证原子性:
-- KEYS[1]=svc配额键, ARGV[1]=请求数, ARGV[2]=当前桶余量
local used = redis.call('GET', KEYS[1])
if used and tonumber(used) + tonumber(ARGV[1]) > tonumber(ARGV[2]) then
return 0 -- 拒绝
end
redis.call('INCRBY', KEYS[1], ARGV[1])
return 1
配额键按服务加窗口(1s 滚动)设 TTL,避免 Redis 里堆积历史 key。Redis 不可用时分布式层降级为「只看单机层」,宁松勿错——限流是保护性机制,误杀正常流量比不限流更伤。
两层参数怎么定:单机 3200 来自 5.1 压测的单机天花板减 20% 安全余量(实测单机极限 4000,QPS 再高 P99 开始抬头);分布式 12000 是峰值业务需求 10000 加 20% 余量。桶容量 64 与 256 吸收的是毛刺:秒杀开闸瞬间的流量尖峰持续约 20ms,容量公式是「速率 × 毛刺时长 × 2」。参数全部写进 govkit 默认配置,按服务覆盖走 3.2 的 etcd 通道。
判定链路的顺序也要固定:先本地单机(零外部依赖),过单机再走 Redis 分布式。反过来先查 Redis 会让每一次正常请求都多一跳网络,Redis 抖动直接全链路变慢,这个顺序错误我们实测过,Redis 一次 5s 抖动让入口 P99 从 60ms 顶到 900ms。
配置与代码:429 要有下文
拒绝不是终点,要有可执行的下游行为:
| 拒绝场景 | 响应 | 调用方行为 |
|---|---|---|
| 单机 429 | 429 加 Retry-After 200ms | 退避后重试 1 次 |
| 分布式 429 | 429 加 Retry-After 1s | 转降级(3.2) |
| 灰度期间被限 | 429 加 gray=true 标记 | 观察放量是否过快 |
单机被限说明本实例热点,重试有意义;分布式被限说明总量超,重试只会加剧,直接走降级。两条 Retry-After 的数字来自 5.1:单机热点平均 200ms 内回落,全集群超限平均要 1s 窗口翻转。
限流阈值本身也走 3.2 的 etcd 热切通道 gov/limit/{svc},大促前手动抬 50%,平时保持压测天花板加 20% 余量。热切后 1.2s 内全集群生效,与大促开闸节奏匹配。
// 双层限流判定(伪代码,govkit 内实现)
if !localBucket.Allow() {
return 429, RetryAfter(200ms) // 单机热点,可重试
}
if !redisBucket.Allow(1) {
return 429, RetryAfter(1s) // 总量超,走降级
}
限流与其他保命件的顺序
限流器在 interceptor 链里的位置与 1.2 定死的一致:观测 → 限流 → 熔断 → 超时 → 业务。这个顺序的实战含义:
| 顺序 | 行为 | 理由 |
|---|---|---|
| 先限流后熔断 | 被限的流量不进熔断统计 | 防限流本身污染错误率 |
| 先熔断后超时 | 熔断开时直接拒,不占超时等待 | 熔断是快路径 |
| 429 不重试 | 分布式 429 的调用方直接降级 | 重试加剧超限 |
「被限流量不进熔断统计」这条是踩过坑才立的:早期 429 计入错误率,一次大促限流高峰直接把熔断器打开,全链路进降级态,比限流本身影响大得多。429 的打点单独用 limited=1 标记,4.3 看板里与错误率曲线分开展示,两条线同屏看才能分清「被限」与「真错」。
降级配额也在这里闭环:分布式 429 的调用方走 3.2 的兜底,兜底命中量与 429 量应基本等量,两者对不上说明有流量既没被限也没被兜住,是漏,必须查。这个对账口径进 4.3 的周检。
踩坑:一次把正常流量限了
阈值 12000 是按 11 台机器的实例数算的,后来扩到 15 台,单机 3200 乘 15 台总量能到 48000,但分布式配额没跟着改,仍是 12000。大促压到 13000 时全集群 429 率 8%,正常流量被误杀 10 分钟。暴露的规则是:分布式配额必须与实例数联动——扩容脚本改实例数的同时自动重算分布式配额写回 etcd,人只确认不手填。误杀那次之后这条进了 5.2 的变更清单。
第二个坑:分布式层的 Redis RTT 加进调用延迟。Redis 与集群同可用区 RTT 约 0.3ms,可接受;早期 Redis 在另一可用区 RTT 4ms,限流判定本身拖慢了正常请求 5%,把 Redis 迁回同区后消失。限流组件的依赖延迟要单独打点,别混进业务延迟里看不出来。
第三个坑:限流维度单一。最初全服务只用 QPS 一个维度,某次爬虫按单用户刷接口,总量没超但单用户占 40% 连接。补了「单 UID 配额」维度(每 UID 100 QPS 加突发 200),爬虫占用的份额被单独压住,正常用户无感。维度加进去之后 Redis 配额键从 1 个变 1 万 2 千个,key 数量要提前算进容量(1.4 篇 Redis 估算方法直接复用)。
第四个坑是限流与灰度的交互:灰度桶流量集中在少数 v=2 实例上,单机 3200 的阈值按全量流量算,灰度期间 v=2 实例单位流量密度高,会先于分布式层触发单机 429。规则是灰度期间 v=2 实例的单机阈值临时乘 2(流量密度高但实例少,总盘子没变),放量收口时随灰度控制器自动还原。
验收:429 率的健康区间
限流上线后不是 429 越少越好:
| 429 率(日峰) | 解读 | 动作 |
|---|---|---|
| 0 | 阈值过松或没流量 | 核对阈值与真实峰值 |
| 1%~3% | 健康,偶发削峰 | 保持 |
| >10% | 阈值过紧或阈值没联动扩容 | 检查配额联动 |
我们的稳态 429 率常年 1.2% 左右,大促前抬阈值 50% 后回落。这条区间写进 4.3 看板,偏离两端都告警。
大促前的限流演练固定动作:把分布式配额临时压到真实峰值的 60%,观察 429 曲线与降级命中量的对账(上文闭环),确认「被限的流量全有下文」再恢复。演练发现过一次灰度被限的 429 漏打了 gray 标记,那批被限请求按普通 429 重试,放大了一次灰度桶内的瞬时压力,标记补上后该场景归零。
小结
限流双层:单机 3200 快闸、分布式 12000 总闸,Redis 不可用降级为只看单机。429 分场景给不同下文(重试或降级),配额与实例数联动自动重算,单 UID 维度防刷,被限流量不进熔断统计。429 率盯健康区间 1%~3%,过松过紧都告警,大促前做配额压缩演练。保命四件(熔断、降级、超时重试、限流)到此齐,四件的参数全部压测反推、全部 etcd 热切、全部打点进 4.3。下一篇进入第四篇:灰度与可观测,发布与排障提速层开工。
延伸阅读
- 令牌桶与漏桶的更多对照,见 go-micro-cps-guide。
- Redis 作为分布式配额的容量与高可用规划,见 tencent-cloud-practice。
- 限流事件进日志检索,见 rust-logpipe-guide。
