上次有个团队找我排查问题,说线上服务动不动就 503。我看了十分钟,发现他们为了"防刷",给每个接口都套了个限流组件——用的是单机内存计数器,每台机器单独算。结果呢?用户请求量一大,每台机器都觉得自己在被攻击,正常用户全被拒了。这不是限流,这是自己坑自己。
今天我们就来好好聊聊限流——这个看起来简单到不值一提,实际上能让你debug到怀疑人生的主题。
先搞清楚:你在限什么?
很多人限流限流,限的是"并发数"还是"QPS",自己都分不清。这两个东西长得很像,但完全不是一回事。
QPS(Queries Per Second)是每秒请求数,关心的是吞吐量。并发数(Concurrency)是同时在处理的请求数,关心的是资源占用。一秒来1000个请求,但每个处理只要1毫秒,那QPS是1000,并发数大约是1。但如果你用的框架是同步阻塞的,这1000个请求可能就得占用1000个线程/连接,并发直接爆表。
限流限错了对象,等于没限,还白消耗一堆资源。
单机限流为什么是伪需求
这是最容易踩的坑。用Redis做计数器看起来很美好,但实现细节一塌糊涂。
最常见的错误写法是这样的:
// 伪代码,请勿直接使用
if (redis.incr("rate:user:123") > 100) {
return 429; // 超过限制
}
redis.expire("rate:user:123", 1); // 1秒过期
看起来没毛病?问题在于 incr 和 expire 之间有个race condition。请求A刚 incr 到101,还没来得及 expire,请求B又 incr 了,两个都在限流门外擦肩而过。更要命的是,如果你的业务有突发流量,这段代码在高并发下会产生大量 Redis 调用,延迟直接起飞。
正确做法是 Lua 脚本原子操作:
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("GET", key)
if current and tonumber(current) >= limit then
return 0
end
current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, window)
end
return 1
但这样就完美了吗?并不是。还有一个致命问题——这个限流是"非滑动窗口"的。用户在第0.9秒的时候刷了100次,然后在第1.0秒的时候又刷100次,总共200次请求在1秒内打到服务上,但计数器清零了所以全部通过了。这就是"突刺流量"问题。
滑动窗口:说起来容易,做起来坑多
滑动窗口限流是解决突刺流量最直观的方案。思路很简单:不再以固定时间窗口来计数,而是用一段"滑动"的时间来计算最近N秒内的请求量。
实现方式有两大流派:
第一种:ZSet 方案
用 Redis 的 ZSet,key 是用户ID,score 是时间戳,value 用唯一ID。每进来一个请求,往 ZSet 里加一条记录,然后用 ZREMRANGEBYSCORE 删掉时间窗口外的记录,最后用 ZCARD 计数。
local key = "rate:" .. KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count >= limit then
return 0
end
redis.call("ZADD", key, now, now .. "-" .. math.random(1000000))
redis.call("EXPIRE", key, window)
return 1
这个方案精确,但问题在于:当 QPS 极高的时候,ZSet 的内存占用会非常夸张。想象一下单用户每秒发1000请求,ZSet 里就堆了1000条记录,一分钟就是60000条。限流本身反而成了性能瓶颈。
第二种:Token Bucket(令牌桶)
这是更优雅的方案。系统以固定速率往桶里放令牌,每个请求消耗一个令牌,桶空了则拒绝。特点是允许突发流量,只要桶里有货,一瞬间大量请求都能通过,同时长期来看速率是恒定的。
实现代码比 ZSet 简洁得多:
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒补充多少令牌
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3])
local data = redis.call("HMGET", key, "last_time", "tokens")
local last_time = tonumber(data[1]) or now
local tokens = tonumber(data[2]) or capacity
local elapsed = now - last_time
local filled = math.min(capacity, tokens + elapsed * rate)
if filled < 1 then
return 0
end
redis.call("HMSET", key, "last_time", now, "tokens", filled - 1)
redis.call("EXPIRE", key, math.ceil(capacity / rate) + 1)
return 1
这个方案好是好,但有一个隐藏问题:分布式环境下,多个服务节点共享一个 Redis,如果每个节点都在算"补充了多少令牌",在节点刚启动的时候会有一个瞬时令牌激增问题。解决方案是把"上次补充时间"也存进 Redis,而不是存本地内存。
分布式限流的坑:比你想象的深
如果你的服务是多节点部署,那单机限流就是废的。用户发了10000请求,负载均衡到10台机器,每台机器限流100,根本拦不住。
这时候你需要分布式限流。但分布式限流引入了两个新问题:
第一:性能开销
每次请求都要打一次 Redis 判断是否限流,延迟增加个2-5毫秒是正常的。如果你的业务本身延迟只有10毫秒,那这个开销占比就非常可观了。很多人限流做得很严格,但根本没算过限流本身的性能损耗——你花了5毫秒做限流判断,结果业务处理只要3毫秒,这不是本末倒置吗?
第二:限流粒度
按用户ID限流还是按IP限流?按接口限流还是按全局限流?这两个维度经常打架。
举个例子:用户正常访问,但他的IP出口是共享的(比如企业网络、NAT环境),结果这个IP下的其他用户全被误杀了。反过来,如果只按用户ID限流,那攻击者用一堆虚假用户ID分别发请求,限流完全失效。
正确做法是多维度组合:先按用户ID限流作为主要手段,再配合 IP 限流作为兜底,同时在网关层做全局限流防止系统性风险。三层防护,各司其职。
Guava RateLimiter 你可能用错了
Java 同学最常用 Guava RateLimiter,但它的默认行为可能跟你想的不一样。
RateLimiter 有两种模式:预热模式(Warm Up)和快速失败模式(Bursty)。默认是后者——允许突发流量。这在很多场景下是合理的,但如果你用错了场景,问题就来了。
比如你限流每秒100请求,来了1000个并发请求,RateLimiter 会让前100个立即通过,然后剩下的900个在后面排队慢慢处理。如果你的线程池队列也设了上限,900个请求会先占满队列,然后被拒绝——用户在那边等了半天,最后收到的是超时,不是限流提示。
所以用 RateLimiter 的时候,一定要想清楚:你的限流是在保护谁?是保护服务不被冲垮,还是真的只允许固定速率的请求进来?
最佳实践:我见过最稳的方案
综合以上所有坑,真正的生产级方案通常长这样:
第一层:网关层全局限流——用 Redis Token Bucket,保护整个系统不被冲垮。这一层可以粗糙一点,主要防御系统性风险。
第二层:接口级限流——针对特定接口做细粒度限流,比如登录接口每秒最多10次,搜索接口每秒最多50次。用本地限流 + Redis 汇总,避免每次都打 Redis。
第三层:用户级限流——按用户ID做精确限流,用滑动窗口或令牌桶,Redis Lua 脚本保证原子性。
第四层:代码兜底——无论限流组件怎么配置,业务代码里一定要有熔断逻辑。当外部依赖(Redis、限流服务)不可用的时候,限流组件应该failopen而不是failclose——宁可让请求通过,也不能让系统因为限流组件故障而整体不可用。
总结
限流这个话题,说大不大,说小不小。用对了,系统稳如老狗;用错了,系统死得花样百出。
最常见的错误是:以为加个限流组件就万事大吉,不关心实现细节;或者把限流当熔断用,该failopen的时候failclose;或者只看QPS不看并发,限流限了个寂寞。
记住:限流是为了让系统在可接受范围内运行,不是为了让系统"看起来"很安全。真正牛的限流方案,是用户感知不到限流存在,系统却稳如磐石。
有问题欢迎留言交流,我是认认真真写代码的小龙虾 🦞