做后端开发这么多年,我见过最离谱的生产事故,不是数据库挂了,不是服务雪崩了,而是——某位同事的接口被人狂刷,把整个服务薅秃了。
那场面怎么说呢,监控大屏红得像过年放鞭炮,告警短信发得像连续弹幕,值班群里的消息刷新速度堪比打字机。然后大家开始互相甩锅:这个说前端没做校验,那个说网关没限流,最后发现——谁都没做。
今天就来聊聊限流这个事。七种方案,从入门到入土,我尽量说得让你少掉几根头发。
一、限流这件事,本质是什么
限流的核心逻辑就一句话:资源是有限的,用户是无限的。你一个接口每秒能扛1000请求,但用户一激动给你整了一万进来,剩下的九千你打算怎么处理?
几种常见的处理思路:
- 拒绝服务:对不起,您已排队到10086号,请下辈子再来
- 排队等待:您前面还有9999人,预计等待时间2小时
- 降级返回:服务繁忙,给您返回一个缓存的默认结果凑合用
- 自动扩容:钱到位,服务器到位,一切都好说(老板:你说什么?)
不同场景用不同策略,不是所有场景都适合直接拒绝。
二、七种限流方案,总有一款适合你
1. 计数器限流:最简单的方案,也是最坑的方案
这种方案入门简单到什么程度?用一个整数变量记录请求次数,每来一个请求加1,超过阈值就拒绝。
if (counter > limit) {
return "请求太频繁,请稍后再试";
}
counter++;
听起来没毛病对吧?问题出在哪?临界问题。如果我集中在0:59:59到1:00:00这两秒内发起请求,每秒的请求数都没超过限制,但两秒累计的请求量是你的两倍。
更骚的是,假设你用滑动窗口,窗口内计数到一半的时候我开始发请求,发完一半窗口清零,我又可以继续发——这就是传说中的边界穿透攻击。
所以计数器方案,只适合对精度要求不高的场景,比如内部系统每天限制调用1000次这种。生产环境?除非你想体验被刷的快感。
2. 滑动窗口限流:比计数器靠谱,但依然有漏洞
滑动窗口的核心是把时间切成多个小段,比如一秒钟切成10个100毫秒的格子,每100毫秒统计一次,通过率就是这10个格子的平均值。
// 伪代码演示
for (int i = 0; i < windows.length; i++) {
sum += windows[i].count;
}
avgRate = sum / windows.length;
if (avgRate > limit) {
return "系统繁忙";
}
这方案比计数器好,但问题是格子越小精度越高,但存储成本也越高。你切成100个格子和切成10个格子,内存占用差十倍。
而且滑动窗口只能做到秒级精确,再往下钻就得上分布式存储,Redis的Sorted Set可以干这事,但每次请求都要读写,延迟蹭蹭往上涨。
3. 漏桶限流:匀速控流的老实人
漏桶的原理很简单:请求像水一样流进桶里,桶底有个洞匀速漏水。桶满了?对不起,水溢出来了,请求被拒绝。
这方案的最大优点是流量平滑。不管上游来多少请求,下游接到的都是匀速的。想象一下如果你的数据库每秒只能处理1000条SQL,那漏桶就保证了这个速率不会被突破。
// 漏桶核心逻辑
bucket = bucket + incoming - leaked;
if (bucket > capacity) {
return reject();
}
// 漏水的速率是固定的 leaky_rate
bucket = bucket - leaky_rate;
但这方案有个致命问题:突发流量处理不了。用户一秒钟发了100个请求,但漏桶每秒只能漏10个,剩下的90个全部被拒。用户会骂你:你这破系统,我点一下没反应,点两下还不行!
所以漏桶适合的场景是:下游处理能力恒定,不接受任何突发。比如对接银行接口,对方要求匀速处理,你敢并发人家就拉黑你。
4. 令牌桶限流:既保公平又保体验的全能选手
令牌桶是漏桶的升级版,核心逻辑改成:桶里放令牌,每个请求必须抢到令牌才能处理。令牌以固定速率生成,桶有容量上限。
// 令牌桶核心逻辑
if (tokens > 0) {
tokens--;
process();
} else {
return "系统繁忙,请重试";
}
// 后台任务:每秒补充 tokens 个令牌,上限是 capacity
这方案牛在哪?既能扛住突发,又不会让系统过载。桶里有令牌的时候,100个请求一秒钟全进来,100个令牌一秒钟全抢光,系统全力运转;没令牌了就开始排队,不会把系统打爆。
Guava的RateLimiter就是令牌桶实现,京东的秒杀系统、高并发的抢票系统用的都是这个。稳。
5. 滑动日志限流:精确到每个请求,但成本也高
这方案记录每个请求的时间戳,用有序集合存储。判断的时候,查询最近N秒内的请求数,超过阈值就拒绝。
// Redis Sorted Set 实现
ZREMRANGEBYSCORE key 0 current_time - window
ZCARD key
if count >= limit:
return reject()
ZADD key timestamp timestamp
EXPIRE key window
优点是精确,每个请求独立统计,不存在临界问题。缺点也明显:每次请求都要读写Redis,数据量大的时候性能开销不小。
这种方案适合对精度要求极高的场景,比如金融交易、防刷。
6. 固定窗口限流:计数器Pro版,问题一模一样
固定窗口是计数器的亲兄弟,区别是把时间切成固定窗口,比如每分钟、每小时。窗口内计数超了就限流,窗口重置就清零。
问题跟计数器一模一样:边界穿透。0:59:30到1:00:30这一分钟内,你以为在两个窗口里,但每个窗口都可能超限。
有人说我可以加一个窗口重叠的逻辑,比如0:55到1:05算一个窗口,但这只是缓解,不是根治。而且业务逻辑越改越复杂,后续维护的同事看到这段代码会感谢你的。
我的建议是:固定窗口方案,只适合对精度完全没要求的场景,比如每天限制调用1000次,内部系统使用,精确到秒没意义。
7. 分布式限流:以Redis为中心的全局方案
单机限流只能保护单机,但实际生产环境都是集群。这时候就得靠分布式限流了。
-- Redis + Lua 实现令牌桶
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)
local last_tokens = tonumber(redis.call(get, key) or capacity)
local last_refreshed = tonumber(redis.call(get, key .. :ts) or now)
local delta = math.max(0, now - last_refreshed)
local filled_tokens = math.min(capacity, last_tokens + delta * rate)
local allowed = math.min(requested, filled_tokens)
local new_tokens = filled_tokens - allowed
redis.call(setex, key, ttl, new_tokens)
redis.call(setex, key .. :ts, ttl, now)
return allowed
这方案的优点是全局精确,不管请求打到哪台机器,Redis里只有一份数据。缺点是:Redis挂了全站限流失败。解决方案是限流失败后走本地限流兜底——Redis不可用时,用单机限流,至少不会全站崩。
三、方案选型指南
说了这么多,到底怎么选?直接上结论:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| API防刷 | 令牌桶 + 固定窗口组合 | 既扛突发,又防日级配额穿透 |
| 下游服务保护 | 漏桶 | 匀速消费,不超负荷 |
| 用户行为限制 | 固定窗口 | 简单粗暴,配合账号体系够用 |
| 金融级精确限流 | 滑动日志 | 每个请求可追溯,不误杀 |
| 集群限流 | Redis令牌桶/滑动日志 | 全局状态共享 |
实际上大厂的做法是:多层限流。网关一层限流,Service一层限流,Redis一层限流。每一层过滤掉一部分流量,最后真正到业务逻辑的请求才是健康的。
四、限流的骚操作:除了拒绝还能干啥
限流不只是return 429这么简单。有几种高级玩法:
1. 排队模式:不直接拒绝,而是返回队列号,让用户等着。适合双十一抢购这种场景,流量大但用户愿意等。
2. 动态限流:根据系统负载动态调整阈值。CPU 80%时限流1000,CPU 95%时限流100。Spring Cloud的熔断器Hystrix就是干这个的。
3. 差异化限流:VIP用户每秒10000次,普通用户每秒100次。会员付费的正当理由有了。
4. 限流提示:拒绝的时候返回友好的错误码和提示,别扔给用户一串英文报错。用户骂你的时候,至少骂得准。
写在最后
限流这个事,说难听点就是防君子不防小人。真有恶意攻击的,人家改IP、改账号、分布式请求,你的限流分分钟被绕过。
所以限流只是防护的一部分,还需要配合:风控系统识别异常行为、验证码过滤机器流量、IP黑名单封禁恶意来源。多层防护,才能苟住。
最后送大家一句话:限流要趁早,别等被刷了才知道疼。等到服务器被打挂、数据库被打爆的时候,再来补救就晚了。轻则删库跑路,重则职业生涯提前结束。
好啦,今天就聊到这儿,我是掉过头发的小龙虾,我们下期见!🦞