上周五晚上,我的手机炸了。不是微信炸,是监控报警炸。
一个内部接口,QPS 从来没超过50,突然飙到了 8000。DB 连接池瞬间打满,整个服务雪崩,前端页面全体阵亡。
查日志,找原因——不是黑客,是运营提了一个数据导出需求,本地脚本跑了 200 个并发请求。
没有限流。没有熔断。就这么裸奔着上了生产。
那一刻我意识到:限流这件事,不到出事那天,你永远觉得它不重要。
第一层:单机限流——你的第一道护城河
先从最简单说起。单机限流就是在单机维度控制请求速率,算法主要有三种:
计数器:最朴素,也最容易被穿
原理:用计数器记录时间窗口内的请求数,超了就拒绝。
let count = 0;
const windowMs = 1000; // 1秒
function rateLimit() {
const now = Date.now();
if (now - windowStart > windowMs) {
windowStart = now;
count = 0;
}
if (count >= limit) {
return false; // 拒绝
}
count++;
return true;
}
这代码有什么问题?临界突变。假设限制 100 QPS,在窗口最后一毫秒来了 100 个请求,下一窗口第一毫秒又来 100 个——实际上 2 毫秒内处理了 200 个请求,算法还觉得自己很称职。
但说实话,如果不是什么高并发场景,这招也够用了。毕竟不是每个系统都会被人专门盯着薅。
滑动窗口:计数器的进化版
滑动窗口把时间切成多个小段,每段记录请求数,统计时看最近这一整段的总量。
const slots = new Array(10).fill(0); // 10个槽,每槽100ms
let currentSlot = 0;
function slidingWindow() {
const now = Date.now();
const slotMs = 100;
const expectedSlot = Math.floor(now / slotMs) % 10;
// 重置过期的槽
while (currentSlot !== expectedSlot) {
currentSlot = (currentSlot + 1) % 10;
slots[currentSlot] = 0;
}
const total = slots.reduce((a, b) => a + b, 0);
if (total >= limit) return false;
slots[currentSlot]++;
return true;
}
滑动窗口比固定窗口平滑得多,但实现复杂度也上来了一个档次。
令牌桶:允许一定程度的突发流量
令牌桶是我最喜欢的单机限流算法。核心思想:桶里有令牌,每个请求消耗一个令牌,没令牌就等或者拒绝。同时,令牌会以固定速率往桶里放。
class TokenBucket {
constructor(private capacity: number, private refillRate: number) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
allow(): 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;
}
}
令牌桶妙在哪?平时桶是满的,突然来一波流量可以直接拿令牌处理,不至于把合法请求误杀。这对有突发流量场景的服务非常友好。
第二层:分布式限流——当单机不够用的时候
单机限流只能保护单机,但生产环境都是多节点部署。用户请求打进来,Nginx 分发到不同机器,每台机器各自限流——等于没限流。
这时候你需要分布式限流。
Redis + Lua:最常见的组合
思路很简单:用 Redis 存计数器,原子操作保证多节点读到同一个数。
-- slide_window.lua
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 窗口大小 ms
local limit = tonumber(ARGV[2]) -- 上限
local now = tonumber(ARGV[3])
local window_start = now - window
-- 移除窗口外的旧记录
redis.call(ZREMRANGEBYSCORE, key, 0, window_start)
-- 统计当前窗口内请求数
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 -- 拒绝
const result = await redis.eval(
slideWindowScript,
1,
`rate_limit:${userId}`,
window,
limit,
Date.now()
);
if (!result) {
ctx.status = 429;
ctx.body = { error: 请求太频繁,请稍后再试 };
return;
}
Redis ZSET 的 score 存时间戳,ZREMRANGEBYSCORE 清理过期数据,ZCARD 计数。这一套下来,多节点共享同一个限流状态,稳。
Redisson + 滑动窗口:更优雅的方案
如果你用 Redisson,它自己就带 RRateLimiter,基于令牌桶实现,API 极度友好:
RedissonClient redisson = Redisson.create(config);
RRateLimiter limiter = redisson.getRateLimiter("api:user:123");
// 初始化:每秒补充5个令牌,最多存10个
limiter.trySetRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);
if (!limiter.tryAcquire(1)) {
throw new RateLimitException("触发限流");
}
// 业务逻辑
分布式令牌桶,不需要自己写 Lua,几行搞定。唯一的代价是你得引入 Redisson 这个大家伙,轻量级项目慎用。
第三层:网关限流——统一入口,统一策略
分布式限流解决了多节点协同问题,但每个接口各自配置又带来了新的混乱——A 接口限制 100 QPS,B 接口限制 200 CUR,用户问起来你也说不清楚依据是什么。
所以需要网关层统一限流。所有流量过网关,网关统一配置策略。
用 Kong 或者 APISIX 的话,配置非常简单:
plugins:
- name: rate-limiting
config:
policy: redis
redis_host: redis-master
minute: 60
fault_tolerant: true
hide_client_headers: false
APISIX 的好处是支持自定义 клю,你可以用用户 ID、IP、接口名做不同维度的限流:
plugins:
- name: rate-limiting
config:
key: remote_addr # 按IP限
policy: redis
rate: 100
burst: 50
网关限流还有个隐藏福利:它能扛住一些基础攻击。恶意爬虫、扫描工具,通常不会带 Authorization header,直接 IP 维度限掉就行。
限流之外的配套:熔断和降级
光有限流不够。流量被拦了,用户还在刷,服务依赖的 DB/Redis 本身可能已经半死不活。
限流是入口拦截,熔断是出口保护。
熔断器模式(Circuit Breaker)的核心逻辑:统计调用失败率,超过阈值就「跳闸」,不再发起真实调用,直接返回降级数据。等系统恢复,再慢慢放流量进来试探。
// 伪代码:熔断器三状态
enum State { CLOSED, OPEN, HALF_OPEN }
function call() {
switch (state) {
case CLOSED:
try {
return realCall();
} catch (e) {
failureCount++;
if (failureCount > threshold) {
state = OPEN;
}
}
case OPEN:
return fallback(); // 直接降级
case HALF_OPEN:
// 放一个请求试试水
const result = realCall();
state = CLOSED;
return result;
}
}
常用的熔断库:Sentinel(阿里)、Hystrix(Netflix,已停止维护但还有很多公司在用)、Resilience4j(Java生态)。选哪个看你技术栈,Java 用 Sentinel 或 Resilience4j,Node 用 Opossum。
说点真心话
限流这个话题,很多人觉得「简单,不就是计数器吗」。
但真正线上出过事的都知道,限流做不好,要么是误杀正常用户(限流算法不平滑),要么是形同虚设(单机限流没同步),要么是连带崩全站(没配熔断)。
建议每个后端服务都配上这三层:
- 第一层:网关限流 — 统一入口,按接口/IP/用户配置
- 第二层:分布式限流 — 多节点协同,保护共享资源
- Third层:单机限流 + 熔断 — 最后防线,保护进程不崩溃
不怕一万,就怕万一。那些「我们的接口不会被薅的」的服务,最后都上了新闻。
限流这事儿,宁可备而不用,不可用时无备。