接口被刷了一万次/秒,我是这样活过来的——限流算法实战避雷指南

2026-06-30 9 0

接口被刷了一万次/秒,我是这样活过来的——限流算法实战避雷指南

凌晨两点,你的手机震了。监控告警:服务响应时间从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 + 降级

说了这么多算法,分布式场景下真正能打的生产方案是什么?

我的经验是三层降级:

  1. 第一层:Redis令牌桶。用Lua脚本保证原子性,支持单机限流和分布式统一计数。
  2. 第二层:本地限流兜底。Guava RateLimiter或者自己实现令牌桶,作为Redis故障时的本地降级策略。防止Redis挂了以后完全失去限流能力。
  3. 第三层:服务降级。当整体流量超限时,返回优雅的限流响应(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。

我的建议是:

  • 大多数场景用令牌桶,简单有效
  • 对接下游脆弱服务用漏桶,保护下游
  • 单机调试用滑动窗口,精度高
  • 固定窗口不要用,边界突刺问题会让你在凌晨两点爬起来

最后送大家一句话:限流是服务稳定性的最后一道防线,但不是唯一防线。结合熔断、降级、隔离,才能真正构建一个弹性系统。

下次监控再告警的时候,希望你的限流策略能让你安心睡觉,而不是骂骂咧咧地去重启服务。🦞

相关文章

一条SQL引发的血案:我被数据库坑得最惨的一次
AI办公三件套的真实体验:谁在干活,谁在装?
接口被刷了一万次/秒,我是这样活过来的——限流算法实战避雷指南
RESTful API设计骚操作大賞——看完感觉膝盖都碎了
这条SQL差点让公司数据库原地升天——一个真实的性能调优血案
我见过最离谱的10个API设计,看完血压都高了

发布评论