3.1 限流设计:令牌桶与漏桶

场景:周五晚上的重试风暴,不能再有第二次

第 1.1 节那个故事是限流模块的直接起因:客户定时任务每秒重试两百次,数据库连接池打满,全站超时两小时。事后我们给所有对外接口定了硬指标:任何单一调用方的异常流量,必须在网关层被拦下,后端零感知。指标翻译成技术语言就是——按 appkey 维度的精确限流,外加按接口维度的总量保护,两道闸门缺一不可。

限流上线第一天就被上了一课。我们按经验值给某家客户配了每秒 500 的桶,结果当天上午他们做活动,真实流量就是 600,限流哗哗地拒绝,工单直接炸了。这件事教会我们:限流参数不是定一次完事,要随业务调整,且必须让客户在文档站看到自己的实时用量与档位。限流是治理工具,不是惩罚工具。

方案:令牌桶胜出

漏桶与令牌桶的区别在于:漏桶出口恒定,超量请求直接排队或丢弃,适合把流量熨平;令牌桶允许短暂突发,更适合真实业务抖动。我们最终选令牌桶,理由是客户业务有天然峰谷(如每天十点的集中同步),漏桶会把正常突发误杀。

限流算法 特点 适用场景 我们的选择
漏桶 出口恒定,流量熨平 平滑下游压力 不采用
令牌桶 允许突发,桶满即拒 真实业务峰谷 采用
滑动窗口 统计精确 严格配额场景 配额统计用
计数器 简单 低频接口 兜底用

再细一点看实现选择。纯 PHP 进程内实现令牌桶是错的——多机多 worker 下各算各的,限流形同虚设。必须把桶状态放共享存储,我们的选择是云数据库 Redis 加 Lua 脚本,原子性由 Redis 单线程模型保证,多台网关机器看到的桶永远一致。Redis 的文档入口 cloud.tencent.com/document/product/239,选主从版即可,限流对持久化不敏感,掉一秒状态无伤大雅。

配置:Lua 令牌桶

令牌桶的全部逻辑塞进一个 Lua 脚本,一次网络往返完成取令牌、补令牌、判余量。Redis 执行 Lua 是原子的,这一点是多机限流正确性的根基:

-- ratelimit.lua:KEYS[1]=桶名 ARGV[1]=容量 ARGV[2]=速率 ARGV[3]=当前时间戳
local capacity, rate, now = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local last_tokens, last_time = unpack(redis.call('HMGET', KEYS[1], 'tokens', 'ts'))
if last_tokens == nil then last_tokens = capacity end
if last_time == nil then last_time = now end

-- 按经过的时间补令牌
local elapsed = math.max(0, now - last_time)
local tokens = math.min(capacity, last_tokens + elapsed * rate)

if tokens < 1 then
    return 0                     -- 限流
end
redis.call('HMSET', KEYS[1], 'tokens', tokens - 1, 'ts', now)
redis.call('EXPIRE', KEYS[1], 3600)
return math.floor(tokens)        -- 返回剩余令牌,供监控用

PHP 侧调用极简,redis 实例预先加载脚本得到 sha,之后用 EVALSHA 调用,省去每次传脚本的带宽。剩余令牌数被推进监控曲线,这是第 4.3 节告警的数据源。

限流的分层与兜底

单一维度限流不够用,我们做了三层,各自保护不同的东西。层与层之间互不干扰,上层无论如何不能替下层做决定:

限流层 维度 目的
L1 全局总量 保护网关自身
L2 单 appkey 隔离调用方
L3 单接口加 appkey 保护特定后端

L1 是网关的最后一道保险,配得比集群峰值高百分之三十即可,主要防的是外部扫描式的冲击,而不是正常客户。L2 直接读契约文件里的 rate 与 burst。L3 用于保护个别脆弱接口(比如报表导出),单独压上限。三层的关系是漏斗:L1 在最外,L2 居中,L3 最内,请求依次穿过。任何一层拒绝都直接返回,绝不重复计数。兜底原则只有一条:L1 永远不能因为业务调整被关掉,它是网关的防弹衣。

限流参数的运营

参数怎么定是个长期工作。我们的经验是先压测(第 5.2 节)拿到后端单机容量,再按调用方合同配额打折取百分之八十作为 rate,burst 设为 rate 的两倍。上线后头两周每天看剩余令牌曲线:曲线长期贴着零说明配额不够(客户被压着),曲线长期在高位说明配额浪费(资源没利用),两种都要调整。

客户侧的透明度同样重要。文档站给每家客户出一张实时用量面板,显示当前 QPS、配额剩余、被限流次数。客户看得见自己的用量,就不会在被告限流时还以为是我们故意卡他。运营层面的透明度,往往比技术层面的精度更能减少纠纷。我们还把被限流的请求采样一百条给客户看(脱敏后),让客户自己定位是哪个脚本在刷,合作定位问题的效率高得多。

踩坑:客户端时间、脚本缺失、Redis 故障

第一个坑是时间戳。Lua 脚本里取的 now 由 PHP 传入而非用 Redis 的 TIME,早期有台网关机器时钟漂了四十秒,令牌计算整体错乱,那台机器的限流彻底失效。规范做法是所有网关机器强制 NTP 同步,且监控告警把时钟漂移纳入巡检项,超过两秒即告警。第二个坑是 EVALSHA 的脚本缺失:Redis 主从切换或重启后 sha 失效,PHP 侧会收到 NOSCRIPT 错误。兜底逻辑是每次 NOSCRIPT 就重新 LOAD 一次脚本再重试,这段逻辑必须写进客户端封装而不是业务代码里。

第三个坑是 Redis 故障时限流怎么办。初期我们的代码是 Redis 连不上直接放行(怕影响业务),等于限流模块在关键时刻消失了;后来加了兜底开关:Redis 不可达时切到进程内本地令牌桶,精度差但保证限流存在,同时疯狂告警。宁可误杀不可不防,这是限流设计的第一信条。另外限流的拒绝响应要带 Retry-After 头,告诉客户端多久后再试,这是减少客户重试风暴的有效手段。

顺带讲一次被限流救下的真实事件。某客户的办公楼中了挖矿蠕虫,感染后的机器开始疯狂轮询我们的接口(同一个 appkey 一秒钟上千次)。限流层在事发三秒内把该 appkey 切到拒绝状态,告警同时打到值班同学与客户负责人,客户那边排查蠕虫,我们这边业务零影响。如果没有按 appkey 的精确限流,这个故事会是又一次全站事故。限流平时不显眼,出事时它是唯一站着的那堵墙。

补充一个容量规划的方法。我们每季度复盘一次限流配额与实际用量:客户连续两个月用量超过配额百分之八十,主动找商务建议升档(客户也得到了被关心的感觉);连续三个月不足百分之十,说明档位虚高,可以下调释放资源。让数据驱动商业对话,比年底谈判桌上来回拉锯轻松太多。限流系统同时也是一套商业运营数据的天然采集器,这一点很多团队没意识到。

最后给一个迁移小步骤:从无限流到有限流的过渡不要一刀切。我们上线限流的第一周把所有 appkey 的档位都放宽了三倍,让客户无感知地习惯,第二周开始逐步收到配额,对超配客户提前一周发通知。这种软着陆让限流推行的工单量几乎为零,客户关系也没有因为新规则受损。技术制度落地的方式,有时比制度本身更决定成败。

最后讲个限流与签名的联动禁区。有同事提出在限流中间件里顺手校验签名,被打回来了:限流层的职责是数数,签名校验失败会让计数与拒绝原因混淆,事后从日志里根本分不清一个拒绝到底是超配还是验签失败。每个中间件只做一件事,失败原因分层清晰,这条原则后来被写进团队的架构规范第一条。简单不等于简陋,单一职责换来的是故障可读性,而可读性在故障时刻比任何花哨功能都值钱。

小结

限流方案的核心选择:令牌桶胜过漏桶(容忍突发),共享存储胜过进程内计算(多机一致),Lua 脚本胜过多次连接(原子性与性能)。三层限流各司其职,参数随业务调整且对客户透明。限流不是一锤子买卖,它需要监控配合、兜底设计、与客户端的契约,三者一起才构成可用的限流系统。

给读者的最后一个心法:限流系统最好的状态是存在感为零——客户感觉不到它,但它每天都在岗。要做到这一点,参数要跟着业务走、面板要给客户看、兜底要假设 Redis 会挂。把这三件事做扎实,限流模块就能安静地守护这个系统很多年。

再补一句关于限流参数的来源:所有阈值调整必须留下审批记录与调整理由。我们给限流参数加了一张变更表,谁调的、为什么调、预期影响,写清楚才生效。限流是把流量当钱的系统,参数的可审计性与正确性同等重要。

延伸阅读