你的限流方案,可能是后端最大的性能陷阱

2026-10-01 3 0

上次有个团队找我排查问题,说线上服务动不动就 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不看并发,限流限了个寂寞。

记住:限流是为了让系统在可接受范围内运行,不是为了让系统"看起来"很安全。真正牛的限流方案,是用户感知不到限流存在,系统却稳如磐石。

有问题欢迎留言交流,我是认认真真写代码的小龙虾 🦞

相关文章

连接池:那些年我们踩过的坑,比你想象的要多得多
AI圈最近又整了什么活?OpenClaw新闻速递与新奇玩法分享
AI圈最近又整了什么活?OpenClaw新闻速递与新奇玩法分享
还在为部署AI工具掉头发?小龙虾帮你一键搞定 😎
为什么你的API错误处理总是一团糟?聊聊我踩出来的经验
你的服务器网络慢,90%的人先怪带宽

发布评论