上周五晚上,线上告警炸了。运维群里艾特全员,说服务 P99 延迟飙到 10 秒,用户开始骂街。我赶紧上机器看,一查日志——某个接口的 QPS 直接冲到了平时的 20 倍。再一追查,是个爬虫在疯狂抓数据,顺便把正常用户的请求也拖下水。
我当时心里就一句:兄弟,你的接口没有限流吗?
对方沉默三秒,打出一行字:限流是什么?
我当场血压升高 30 毫米汞柱。
限流,不是有手就行的吗?
很多人觉得限流很简单,不就是计数器吗?来一个请求加一,到阈值了就拒绝。没错,这是最朴素的限流思路。但问题是——
你以为你在做限流,其实你在做「看起来限流但实际毛用没有」的玄学工程。
我见过太多「假限流」的实现:单机内存计数器,服务重启计数器清零;多节点部署但各记各的数,5 台机器每台限 100 QPS,实际扛了 500 QPS;还有用固定窗口算法,上一秒最后 100 毫秒涌进来 200 个请求,系统纹丝不动直接放行……
这些场景在面试里是加分项,在生产环境里是扣分项,在线上故障报告里是高频词汇。
限流算法:几种流派你站哪个?
1. 固定窗口计数器——穷人的限流方案
这是最简单粗暴的方案。把时间切成固定窗口,每个窗口独立计数。
// 固定窗口计数器(伪代码)
const limit = 100;
const windowSize = 60 * 1000; // 60秒
function isAllowed(key) {
const now = Date.now();
const windowKey = Math.floor(now / windowSize);
const current = cache.get(key + ":" + windowKey) || 0;
if (current >= limit) {
return false;
}
cache.incr(key + ":" + windowKey);
return true;
}
优点:实现简单,存一个数字就行。
缺点:临界问题严重。假设限制 100 QPS,在 59s 来了 100 个请求,61s 又来 100 个——其实 2 秒内来了 200 个,但两个窗口各算 100,都通过了。这就是「突刺流量」问题的经典来源。
2. 滑动窗口日志——精确但贵
滑动窗口的核心思路是:不再按固定窗口算,而是看最近 N 秒内有多少请求。
// 滑动窗口日志(伪代码)
function isAllowed(key) {
const now = Date.now();
const windowStart = now - windowSize;
// 清理过期的请求记录
const timestamps = cache.lrange(key + ":log", 0, -1);
const valid = timestamps.filter(t => t > windowStart);
cache.del(key + ":log");
valid.forEach(t => cache.rpush(key + ":log", t));
if (valid.length >= limit) {
return false;
}
cache.rpush(key + ":log", now);
return true;
}
优点:精确,没有临界问题。
缺点:存一堆时间戳,内存爆炸;每次请求都要扫描清理,性能开销大。除非你有 Redis Sorted Set,否则别轻易尝试。
3. 令牌桶——我最喜欢的一个
令牌桶的核心思想是:系统以固定速率往桶里放令牌,每个请求必须拿到一个令牌才放行。桶有容量,放满了就不再加了。
// 令牌桶核心逻辑
class TokenBucket {
constructor(private capacity: number, private refillRate: number) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
allowRequest(): boolean {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
private refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
const toAdd = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + toAdd);
this.lastRefill = now;
}
}
// 使用:限制每秒 100 个请求,桶容量 200
const limiter = new TokenBucket(200, 100);
if (!limiter.allowRequest()) {
throw new TooManyRequestsError();
}
优点:允许一定程度的突发流量(桶容量就是你的「余粮」),同时长期速率稳定。这是它最优雅的地方——平时积累的令牌可以让突发请求也得到服务,而不是一刀切全拒绝。
缺点:实现比固定窗口复杂一点,但绝对值得。
4. 漏桶——稳如老狗
漏桶的思路跟令牌桶相反:请求进来就入桶,桶里的请求以固定速率「漏」出去处理,不管你来多少,我处理速度就这么多。
// 漏桶(伪代码)
class LeakyBucket {
constructor(private capacity: number, private leakRate: number) {
this.water = 0;
this.lastLeak = Date.now();
}
allowRequest(): boolean {
this.leak();
if (this.water < this.capacity) {
this.water += 1;
return true;
}
return false;
}
private leak() {
const now = Date.now();
const elapsed = (now - this.lastLeak) / 1000;
const leaked = elapsed * this.leakRate;
this.water = Math.max(0, this.water - leaked);
this.lastLeak = now;
}
}
优点:流量输出非常平稳,适合对接下游有严格限速的系统。
缺点:不支持突发流量,来多少吞多少,超了就丢。如果你对外提供 API,用户会骂你「我一秒发 5 个请求你就拒绝 4 个,什么垃圾服务」。
所以我的推荐是:对外公开 API 用令牌桶,对内调用下游服务用漏桶。
单机限流 vs 分布式限流——你的机器知道你有多少兄弟吗?
单机限流最简单,内存里开个计数器就能跑。但问题是——你的服务不会是单机的。
当你有多台机器时,麻烦来了:每台机器各记各的数,怎么协同?
方案一:Redis 计数器(最常见)
用 Redis 的 INCR 命令做集中计数,配合过期时间自动清理窗口。
// Redis 令牌桶(Lua 脚本保证原子性)
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call("HMGET", key, "tokens", "lastRefill")
local tokens = tonumber(data[1]) or capacity
local lastRefill = tonumber(data[2]) or now
-- 计算应补充的令牌
local elapsed = now - lastRefill
local newTokens = math.min(capacity, tokens + elapsed * refillRate)
if newTokens >= requested then
newTokens = newTokens - requested
redis.call("HMSET", key, "tokens", newTokens, "lastRefill", now)
redis.call("EXPIRE", key, 60)
return 1
else
return 0
end
`;
const result = await redis.eval(luaScript, 1,
`ratelimit:user:${userId}`,
capacity, refillRate, now, 1
);
if (result === 0) {
throw new TooManyRequestsError("请求太频繁,请稍后再试");
}
这里用 Lua 脚本是关键——把读取、计算、写回打包成一个原子操作,否则在并發场景下会出现 race condition,你的限流会形同虚设。
方案二:Redis + 滑动窗口(更精确)
用 Redis Sorted Set,score 存时间戳,每次请求只保留窗口内的记录数。
const windowMs = 60000; // 60秒窗口
const maxRequests = 100;
async function isAllowed(userId: string): Promise<boolean> {
const now = Date.now();
const windowStart = now - windowMs;
const key = `ratelimit:slide:${userId}`;
// 原子事务
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, windowStart); // 清理过期
pipeline.zcard(key); // 数一下现在有几个
pipeline.zadd(key, now, `${now}:${Math.random()}`); // 加当前请求
pipeline.pexpire(key, windowMs); // 设置过期
const results = await pipeline.exec();
const currentCount = results[1][1];
return currentCount < maxRequests;
}
这个方案精确度比令牌桶更高,但每次请求都要操作 Redis sorted set,开销稍大。对于极端高性能要求的场景,慎重选择。
限流粒度:你限的是谁?
这个问题比算法本身更值得思考。
常见维度:
- IP 限流:简单粗暴,但问题在于NAT背后的N个用户会被误伤,大型出口 IP 会被无辜限流。
- 用户 ID 限流:登录后最准,但游客接口没法用。
- API Key 限流:对开放平台最合理,每个 key 独立配额。
- 设备指纹:防爬虫神器,但实现复杂,误伤也高。
我的实际经验是:分层限流。IP 层做兜底(防 DDoS),用户层做精细控制,接口层做差异化限流——读接口可以放宽,写接口要收紧,核心接口要更严。
被限流了怎么办?用户的体验你管吗?
很多工程师限流做完了就交差,结果用户收到 429 状态码,一脸懵逼,不知道是网络问题还是服务器挂了。
限流响应的 HTTP 头,是你对用户的基本尊重:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712668800
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "请求过于频繁,请在 30 秒后重试",
"retryAfter": 30
}
}
Retry-After 是最重要的,告诉用户等多久再试。缺少这个头,用户的重试逻辑就是瞎子摸象——要么等不够时间继续被拒,要么等太久浪费生命。
另一个思路是「优雅降级」:限流之后,给用户返回一个「简化版」响应。比如正常返回 100 条数据,限流后返回 10 条关键数据,用户感知到的是「变少了」而不是「挂了」。这种策略在 C 端产品里非常实用。
说点扎心的
我见过太多团队的限流是「事后补救」——被爬虫打爆了才加上限流,被薅羊毛了才想起风控。
限流应该是架构设计的第一天就考虑的事情,而不是上线之后打补丁。接口一旦对外开放,你永远不知道下一秒钟会有什么流量冲进来。
最讽刺的是,限流这种东西,平时没人关心,出了事才想起来——然后赶紧上一个「临时方案」,接着又没人关心了,直到下一次故障。
希望这篇文章能让你下次设计接口的时候,多花五分钟想想限流这事。
真的,不难,但做了和没做,差一个故障的距离。