限流:别让你的系统在流量洪峰中裸奔

从一次血案说起

去年双十一,凌晨刚过,我突然收到告警——订单服务的 RT 飙到 8 秒,CPU 直接 100%。根本原因?前端一个抢购按钮没做防重,用户发了疯似的点击,流量瞬间放大 20 倍。数据库连接池撑爆,整个链路雪崩。那天晚上,我们一群人坐在屏幕前,看着监控面板一片血红,鸦雀无声。

后来复盘,CTO 只丢下一句话:“限流呢?你们做的限流是纸糊的吗?”

说实话,限流这东西,谁都听过,谁都做过。但真正在高压场景下顶得住的,没几个。对吧?因为多数人理解的限流就是“调个阈值,超过就拒”,可这背后牵扯的算法细节、分布式协调、自适应策略……唉,处处是坑。

令牌桶那点事,你真搞明白了吗?

最经典的限流算法,当属令牌桶漏桶。但很多人用着 Guava 的 RateLimiter,却说不清楚预热模式到底在做什么。我来拆一拆。

令牌桶的核心是以恒定速率往桶里放令牌,请求拿到令牌才能通过。漏桶则是恒定速率流出请求,桶满则溢。区别在哪?令牌桶允许突发流量,漏桶强制平滑。比如你设了每秒 1000 的 QPS,令牌桶可以瞬间消费完 1000 个积攒的令牌,而漏桶只会老老实实 1ms 一个往外吐。所以在接口限流上,令牌桶用得更广——谁还没个突发的促销活动呢?

但问题来了:单机的 RateLimiter 是存储当前令牌数在内存里的,分布式环境怎么办?每个服务实例各自为政,限流全局 QPS 根本不准确。你想想,假如 10 个实例,每个实例限 1000 QPS,总 QPS 就变成了 10000,后端数据库还是得挂。于是只好上 Redis,搞个全局计数。可以用 INCR + EXPIRE 吗?不行,这是典型的固定窗口问题。

令牌桶算法与固定窗口计数器对比示意图
令牌桶算法与固定窗口计数器对比示意图

举例:我们设置一个计数器 key,过期时间 1 秒。在时间窗 [10:00:00.000, 10:00:00.999] 内,计数器到 1000 就限流。可如果在第 0.999 秒突增 1000 个请求,然后在下一秒的 0.001 秒又突增 1000 个请求,实际上在 2ms 内通过了 2000 个请求,完全冲垮下游。这叫双倍突发。我们压测时发现,固定窗口能让实际通过量达到限流值的 2 倍,在 10 并发下,数据库 CPU 从 30% 直接飙到 95%,错误率超过 40%。

滑动窗口与自适应限流——优雅吗?

要解决固定窗口的毛刺,业界普遍用滑动日志滑动窗口。比如 Sentinel 的 LeapArray,把时间窗口切分成多个小格子,每个格子独立计数。判断总 QPS 时取当前时间点往前滑动的所有格子之和。这样统计粒度更细,边界问题缓解不少。但依然有瑕疵——格子的数量与精度是权衡:格子太少退化为固定窗口,太多则内存和计算开销变大。

我们线上压测过 Sentinel 的默认格子数 2,在 2000 QPS 下,实际通过 QPS 依然有 15% 的偏差。后来调整到 20 个格子,偏差降到 3% 以内,但 RT 增加了 0.2ms(因为每次需要遍历更多格子)。这就是工程上的取舍。

滑动窗口限流格子划分与请求计数示意图
滑动窗口限流格子划分与请求计数示意图

更狠的是自适应限流,比如 TCP BBR 的拥塞控制,或者阿里开源的 Sentinel 系统自适应限流。它不依赖预设阈值,而是根据系统负载(Load、CPU、RT 等)动态调整通过量。数学模型?本质上是一个 PID 控制器。测量系统负载与目标负载的偏差,通过比例、积分、微分项计算出一个新的限流值。我们在网关层落地过,相比人工设置的固定限流,触发限流的次数减少了 70%,而且在流量突发时能更快恢复,无需人工干预。

不过说实话,自适应限流调试起来就像在漆黑的海上调整船帆——参数 P、I、D 要是没设好,系统要么震荡得厉害,要么反应迟钝。有一次我们 Kp 设太大,刚一压测 QPS 就猛降,然后反弹,再降,像心电图一样,服务极不稳定。最佳实践:先离线模拟,用系统辨识方法找到临界增益和振荡周期,再套用 ZN 公式。不会?那别瞎调,老老实实用滑动窗口。

落地三大坑,踩过一个算你赢

落地三大坑,踩过一个算你赢
落地三大坑,踩过一个算你赢

坑一:分布式限流的数据不一致。用 Redis 做全局计数器,最简单的是原子操作。但 INCR + EXPIRE 两步不是原子的,万一执行完 INCR 后进程挂了,这个 key 永远不过期,限流就废了。必须用 Lua 脚本确保原子性:

local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current

这样就好了?天真。如果 Redis 是主从架构,主从同步延迟可能导致从库读到旧值,限流窗口出现裂缝。土豪方案:上 RedLock 或者用 Redis Cluster 的 WAIT 命令强制同步,但性能会下降。 我们最终妥协,允许轻微的超量,通过下游熔断兜底。

坑二:限流阈值拍脑袋设置。“压测一下,给个 80% 的值”,这太理想化了。线上流量模型多变,促销期间某个不起眼的接口 QPS 可能翻 10 倍。我们有一次活动,商品详情页的“库存查询”接口,平时 QPS 200,活动日飙到 5000,直接触发限流,导致页面库存显示不准确,客诉爆炸。后来我们做了链路级的自动压测,每天凌晨对全接口进行小流量探测,动态调整限流阈值。同时引入了预热机制——刚启动时阈值逐步爬升,避免冷启动误杀。

坑三:限流后的处理过于粗暴。很多新手直接抛一个 429 Too Many Requests,或者返回“系统繁忙”。用户一脸懵。你得想想,这请求是从哪个入口进来的?是不是核心链路?能不能排队?我们在网关层实现了分级限流:核心接口返回一个 ticket,客户端可以凭票延迟重试;非核心接口直接降级为缓存数据或默认值;营销类接口……对不起,直接屏蔽。 配合一个轻量级内存队列做削峰,用户体验好了不是一点半点。

还记得开头那位 CTO 的质问吗?后来我们把整个限流体系重做了一遍,结合了滑动窗口、自适应算法、分布式原子计数、预热与优雅降级。今年双十一,系统稳如老狗,他拍了拍我肩膀说:“这才像样。”

可我心里清楚,下一个坑,就在前面等着呢。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:限流:别让你的系统在流量洪峰中裸奔
文章链接:https://lfdjt.com/info_23_7600.html