想象一下这个场景:凌晨三点,你被短信轰炸叫醒,报警显示服务器CPU打满,数据库连接数爆表。你赶紧打开监控一看——好家伙,某IP在用每秒几千次的频率疯狂调用你们的搜索接口。
这不是DDOS,但效果差不多。你的接口没做任何防护,就这么裸奔着。
欢迎来到真实的后端世界。在这个世界里,你永远不要相信调用方的善意。每一个没人管的接口,都可能成为别人眼中的免费资源。
今天咱们就好好聊聊接口限流(Rate Limiting)这件事,从算法原理到实战落地,一次讲透。
为什么你的接口需要限流?
很多人觉得限流是大公司才需要操心的事,自己项目用户量又不大,装什么安全措施?
我懂这种想法。但现实会教你做人:
- 你的某个接口响应特别快,正好被爬虫看上了,一夜之间被抓取几十万条
- 某个产品搞秒杀活动,流量瞬间十倍暴增,没限流的接口直接雪崩
- 有人手滑写了死循环调用你的接口,QPS直接起飞
限流不只是防攻击,它是保护你自己系统的最后一道防线。就像开车要系安全带,不是为了防止出车祸,是为了万一出事还能保住一条命。
固定窗口算法:入门级方案
最简单的限流思路:规定一个时间窗口内,只允许N次请求。这就是固定窗口(Fixed Window)算法。
// 伪代码示例
const WINDOW_SIZE = 60 * 1000; // 1分钟
const MAX_REQUESTS = 100;
const records = {}; // { ip: [timestamp1, timestamp2...] }
function isAllowed(ip) {
const now = Date.now();
const windowStart = Math.floor(now / WINDOW_SIZE) * WINDOW_SIZE;
if (!records[ip]) records[ip] = [];
// 清理过期的记录
records[ip] = records[ip].filter(t => t >= windowStart);
if (records[ip].length >= MAX_REQUESTS) {
return false;
}
records[ip].push(now);
return true;
}
看起来很简单对吧?但固定窗口有个致命问题——边界突变。
假设限制是每分钟100次。一个用户在第59秒发送了100次请求,然后在第61秒又发送了100次请求。表面上他在2秒内请求了200次,但因为跨越了窗口边界,两次都通过了。
更坑的是,如果同一时刻大量用户的请求集中在一个窗口边界上,会产生惊群效应——流量突然翻倍,系统被打个措手不及。
滑动窗口算法:更精确的控制
滑动窗口(Sliding Window)算法可以解决边界问题。它不固定窗口边界,而是动态往前滑动。
function isAllowed(ip) {
const now = Date.now();
const windowStart = now - WINDOW_SIZE; // 永远以当前时间为基准往前推
if (!records[ip]) records[ip] = [];
// 只保留窗口内的请求
records[ip] = records[ip].filter(t => t > windowStart);
if (records[ip].length >= MAX_REQUESTS) {
return false;
}
records[ip].push(now);
return true;
}
滑动窗口比固定窗口更平滑,但需要更多的存储空间——每次判断都要遍历整个窗口内的请求列表。如果你的QPS很高,这个list可能会很长。
而且,这两种方案在单机环境下没问题,一旦上了分布式,要共享这个records就麻烦了——你总不能把所有请求记录存在内存里吧?
令牌桶算法:允许一定程度的突发
令牌桶(Token Bucket)是一种更聪明的算法。核心思想是:
- 系统以固定速率往桶里放令牌
- 每次请求需要从桶里取一个令牌
- 桶有容量上限,拿光了就得等
这意味着什么?允许一定程度的突发流量。如果桶是满的,你可以一次性取出多个令牌处理突发请求,而不是傻等着。
class TokenBucket {
constructor(rate, capacity) {
this.rate = rate; // 每秒补充的令牌数
this.capacity = capacity; // 桶的容量
this.tokens = capacity; // 当前令牌数
this.lastRefill = Date.now();
}
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.rate);
this.lastRefill = now;
}
tryConsume() {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
}
令牌桶的好处是:平时流量低的时候积累令牌,流量高峰的时候消耗。这种削峰填谷的特性让它成为很多场景下的首选。
Guava的RateLimiter就是令牌桶实现。Nginx的limit_req模块也用的是类似思想。
漏桶算法:严格的均匀输出
漏桶(Leaky Bucket)和令牌桶正好相反——不管进来多少水,漏出去的速度是固定的。
class LeakyBucket {
constructor(rate, capacity) {
this.rate = rate; // 漏出速率(每秒处理数)
this.capacity = capacity; // 桶的容量
this.water = 0; // 当前水量
this.lastLeak = Date.now();
}
leak() {
const now = Date.now();
const elapsed = (now - this.lastLeak) / 1000;
const leaked = elapsed * this.rate;
this.water = Math.max(0, this.water - leaked);
this.lastLeak = now;
}
tryConsume() {
this.leak();
if (this.water < this.capacity) {
this.water += 1;
return true;
}
return false;
}
}
漏桶算法保证的是绝对均匀的输出。不管流量高峰多猛,系统总是以固定速率处理请求。这对保护后端慢服务特别有用。
但它的代价是:不允许任何突发。如果你的桶满了,新请求直接被拒,哪怕前一秒系统完全空闲。
分布式环境下怎么玩?
单机限流?那不叫限流,叫自欺欺人。
真正上了规模,你需要在多台机器之间共享限流状态。这时候就得靠Redis了。
-- Redis实现滑动窗口限流
local key = KEYS[1] -- 用户标识
local window = tonumber(ARGV[1]) -- 窗口大小(ms)
local limit = tonumber(ARGV[2]) -- 限制数量
local now = tonumber(ARGV[3]) -- 当前时间戳
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return 1 -- 允许
end
return 0 -- 拒绝
这段Lua脚本实现了一个基于Redis的有序集合(ZSET)的滑动窗口限流:
- 移除窗口外的历史记录
- 统计窗口内请求数
- 未超限则加入新请求,设置过期时间
用Redis的好处是:天然支持过期自动清理,而且多实例共享状态。代价是每次请求都要访问Redis,网络延迟是躲不过去的坎。
实战中的几个坑
算法会了,代码能跑了,是不是就完事了?too young。
坑1:限流返回什么状态码?
很多人返回200然后在body里说"请求太频繁"——这是错误的。正确做法是返回429 Too Many Requests,并在响应头里加上Retry-After告诉客户端多久后重试。
坑2:限流粒度选错了
按IP限流可能被NAT困住(一公司的人共用一个IP),按用户ID限流可能被并发请求困住(同一用户开多个标签页)。最好组合多种维度:IP + UserID + 接口路径。
坑3:忽略了预热和动态调整
固定的限流阈值在很多场景下不够灵活。大促期间临时提高阈值?某个接口特别重要要单独保护?建议使用配置中心来动态调整限流规则,不要把阈值写死在代码里。
总结
限流不是什么高大上的技术,但绝对是后端开发必须掌握的基本功。
选型建议:
- 单机场景 → 内存+滑动窗口,简单够用
- 需要支持突发 → 令牌桶(Guava RateLimiter)
- 保护慢后端 → 漏桶算法
- 分布式场景 → Redis + Lua,滑动窗口或令牌桶皆可
最后送大家一句话:永远不要相信调用方的数量,永远不要假设流量会按照你的预期来。限流是防御性编程的精髓,宁可少放一个请求,也不要让整个系统为它陪葬。
好了,今天的分享就到这里。如果觉得有用,转发给你那个写接口从不考虑限流的同事。