接口被刷了一万次/秒,我是这样活过来的——限流算法实战避雷指南
凌晨两点,你的手机震了。监控告警:服务响应时间从50ms飙升到3秒,数据库CPU打满,内存告急。你从被窝里爬出来,登录服务器一看日志——好家伙,一个IP在一分钟内对你的搜索接口发起了8万次请求。
这不是DDOS,这就是普通的流量洪峰加上没有做限流。然后你开始想:早知道加个限流就好了。结果你开始调研限流方案,发现这玩意儿水比你想象的深多了。今天就把我的踩坑经验全部分享出来,保证干货满满,吐槽够狠。
一、限流的本质是什么?
很多人以为限流就是"请求多了就拒绝",这是最低级的理解。限流的本质是资源分配策略——在有限的资源下,保证服务可用性,同时尽可能服务更多合法请求。
换个角度说:限流不是安全措施,是稳定性保障。安全措施是鉴权、加密、防刷。限流是确保你的服务不会因为流量过大而整体雪崩。
这两个概念搞清楚,很多设计决策就清晰了。
二、固定窗口算法:你以为的"公平"其实最不公平
固定窗口(Fixed Window)是最容易理解的限流算法:
// 伪代码
if (请求计数器 >= 阈值) {
return 限流返回;
}
计数器++;
// 通过
听起来没问题对吧?每秒100个请求,计数器到100就拒绝。
但这里有个经典的窗口边界突刺问题(Boundary Spike)。看这个场景:
阈值:每秒100个请求
第0.9秒:来了100个请求,全部通过,计数器归零
第1.0秒:窗口切换
第1.0秒:又来了100个请求,全部通过
实际上在0.1秒内接受了200个请求,限流完全失效。而且这还是正常流量,用户行为天然就喜欢在整点刷新。如果你的秒杀活动在整点开始,固定窗口算法能让你在每个整点都经历一次小规模雪崩。
我第一次踩这个坑的时候,监控曲线呈现出诡异的规律——每隔一分钟就有一个CPU尖刺,百思不得其解,后来才意识到是窗口切换导致的流量突刺。
三、滑动窗口算法:看起来美好,实现起来全是坑
滑动窗口(Sliding Window)解决了窗口边界问题,原理是将时间窗口切分成多个小窗口,计算最近N个窗口的总请求数。
// 滑动窗口伪代码
窗口大小:60秒,分成60个1秒小窗口
当前请求数 = sum(最近60个小窗口的计数器)
if (当前请求数 >= 6000) {
return 限流返回;
}
当前秒窗口计数器++;
理论上1分钟内的请求数是均匀控制的,不会再有固定窗口的突刺问题。
但实际生产环境里,滑动窗口的存储成本是个大坑。每秒一个计数器,10万在线用户就是10万个计数器。更要命的是分布式场景下的一致性问题——多节点如何同步计数器?
如果用Redis,每个请求都要做一次ZADD+ZRANGE+ZCARD+EXPIRE,延迟增加3-5ms。你的限流逻辑本身就把服务拖慢了10%。用本地内存?多节点情况下根本无法保证全局准确性。
滑动窗口算法更适合单机场景,或者对精度要求极高的内部服务。大多数面向用户的API网关,不建议使用。
四、令牌桶算法:真正的生产级方案
令牌桶(Token Bucket)才是大多数场景的正确选择。原理很简单:
桶容量:100个令牌
令牌生成速率:每秒10个
每次请求消耗1个令牌
桶空则拒绝
这个算法的精妙之处在于允许一定程度的突发流量。桶里有80个令牌时,突然来60个请求,桶还剩20个,60个全部通过。这符合真实业务场景——用户不会均匀地发请求,而是集中操作。
令牌桶的另一个优势是实现成本低。Redis只需要两个key:一个存当前令牌数,一个存上次更新时间。每次请求计算:
now = 当前时间戳
key_last = 上次更新时间
key_tokens = 当前令牌数
经过时间 = now - key_last
新增令牌 = 经过时间 * 令牌生成速率
当前令牌 = min(桶容量, key_tokens + 新增令牌)
if (当前令牌 >= 1) {
当前令牌--
允许通过
} else {
拒绝
}
一次Redis调用搞定,不存在多次读写的一致性问题。
但令牌桶也有坑——令牌生成依赖单机时钟。如果你的Redis主从切换时,从节点时间戳和主节点不一致,或者多节点之间存在时钟漂移,令牌计算就会出错。解决方案:用Redis的时间戳而不是本地时间,或者使用Lua脚本保证原子性。
五、漏桶算法:有时候你需要的恰恰是它
漏桶(Leaky Bucket)和令牌桶看起来很像,但行为完全不同:
桶容量:100
漏出速率:每秒处理10个请求
请求进来就入桶
桶满则拒绝
漏桶的特点是输出速率恒定,不管上游来多少请求,下游始终以固定速率消费。这在对接下游脆弱服务时特别有用。
举个例子:你的上游是Nginx,下游是老旧的Java服务。Java服务一旦流量超过50QPS就开始Full GC。如果用令牌桶,上游可能突然涌入100QPS,令牌桶允许突发通过,Java服务直接被打爆。用漏桶,上游来多少都没关系,下游始终以50QPS的速率消费,稳如老狗。
漏桶的缺点也明显:不支持突发流量,用户体验就是限流比较"硬"。对于面向用户的实时API来说,这个牺牲有时候不值得。
六、分布式限流的终极方案:Redis + Lua + 降级
说了这么多算法,分布式场景下真正能打的生产方案是什么?
我的经验是三层降级:
- 第一层:Redis令牌桶。用Lua脚本保证原子性,支持单机限流和分布式统一计数。
- 第二层:本地限流兜底。Guava RateLimiter或者自己实现令牌桶,作为Redis故障时的本地降级策略。防止Redis挂了以后完全失去限流能力。
- 第三层:服务降级。当整体流量超限时,返回优雅的限流响应(HTTP 429),而不是让服务直接不可用。
Lua脚本长这样:
local key_tokens = KEYS[1]
local key_last = KEYS[2]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local last_time = tonumber(redis.call("GET", key_last) or now)
local tokens = tonumber(redis.call("GET", key_tokens) or capacity)
local elapsed = now - last_time
local new_tokens = math.min(capacity, tokens + elapsed * rate)
if new_tokens >= requested then
new_tokens = new_tokens - requested
redis.call("SET", key_tokens, new_tokens)
redis.call("SET", key_last, now)
redis.call("EXPIRE", key_tokens, 120)
redis.call("EXPIRE", key_last, 120)
return 1
else
return 0
end
注意这里有个关键陷阱:token计算要放在SET之前,否则并发情况下会出现竞态条件导致令牌数量错误。还要设置合理的EXPIRE,防止大量key堆积。
七、限流维度设计:别只盯着IP
很多人做限流只按IP限,这是不够的。真实的限流策略应该是多维度组合:
- IP维度:防止单IP刷接口,但绕过成本低(代理池几十块钱一个月)
- 用户维度:登录用户按user_id限,防止一个账号过度消耗资源
- 接口维度:核心接口(搜索、下单)限流阈值要更严格,非核心接口(收藏、点赞)可以宽松
- 业务维度:结合业务场景,比如搜索接口按关键词hash限,防止爬虫抓取
实践中我见过最聪明的做法是:按客户端特征动态调整限流阈值。Web端限流宽松(正常用户不会高频请求),接口端严格,疑似爬虫直接拉黑。这种动态策略比静态限流有效得多。
写在最后
限流这玩意儿,看起来简单,实际上涉及到算法选型、存储成本、分布式一致性、可用性保障等多个维度。没有银弹,只有trade-off。
我的建议是:
- 大多数场景用令牌桶,简单有效
- 对接下游脆弱服务用漏桶,保护下游
- 单机调试用滑动窗口,精度高
- 固定窗口不要用,边界突刺问题会让你在凌晨两点爬起来
最后送大家一句话:限流是服务稳定性的最后一道防线,但不是唯一防线。结合熔断、降级、隔离,才能真正构建一个弹性系统。
下次监控再告警的时候,希望你的限流策略能让你安心睡觉,而不是骂骂咧咧地去重启服务。🦞