一、为什么需要限流:场景与防护目标
去年双十一零点,我们的电商网关被打挂了——一个爬虫在 5 秒内从 1 个 IP 发出 12 万次请求,直接把认证服务拖到不可用。事后复盘,问题不是出在认证服务的性能,而是它在最前面没有任何"水龙头"挡住这些洪水。
限流(Rate Limiting)解决的就是这类问题:在资源(CPU、连接数、第三方 API 配额)被耗尽之前,主动丢弃超额请求,保证核心业务可用。它的典型场景包括:
- 防爬虫/防刷:登录、注册、短信验证码、抽奖等接口的恶意调用
- 防雪崩:当下游服务出现慢响应时,限流避免上游请求堆积把整个链路压垮
- 业务削峰:秒杀、抢购、抢券场景,把瞬时峰值削平成平台可承受的曲线
- 保护第三方:调用微信支付、Stripe、短信网关等有 QPS 配额的接口,避免被封号
- 成本控制:调用 OpenAI、Stable Diffusion 等按量计费的 AI 服务,限流能直接省银子
一个完整的限流设计要回答四个问题:在哪一层做、用什么算法、按什么维度限、超额了怎么处理。下面逐个拆开讲。
二、四种经典限流算法详解
限流算法是地基,方案选错,后面再优化也是事倍功半。下面四种算法从简单到精准依次展开。
2.1 固定窗口(Fixed Window)
最朴素的实现:把时间切成等长的窗口(比如每分钟一个),每个窗口维护一个计数器。计数器超过阈值就拒绝。代码大概长这样:
// Node.js 单机版固定窗口
const buckets = new Map();
function fixedWindow(key, limit, windowMs) {
const now = Date.now();
const windowStart = Math.floor(now / windowMs) * windowMs;
const bucketKey = `${key}:${windowStart}`;
const count = (buckets.get(bucketKey) || 0) + 1;
buckets.set(bucketKey, count);
// 清理过期
if (Math.random() < 0.01) {
for (const k of buckets.keys()) {
if (parseInt(k.split(':')[1]) < now - windowMs) buckets.delete(k);
}
}
return count <= limit;
}
// 使用:每个 IP 每分钟 100 次
app.use((req, res, next) => {
if (!fixedWindow(req.ip, 100, 60_000)) {
return res.status(429).json({ error: 'Too Many Requests' });
}
next();
});
它的致命问题是窗口边界的 2 倍突刺:假设阈值是 100/分钟,攻击者可以在 0:59 发送 100 个请求,再在 1:01 发送 100 个请求,两秒内打出 200 个,绕过限制。Redis 的 INCR + EXPIRE 是分布式版本最常见的实现。
2.2 滑动窗口(Sliding Window)
为了解决突刺问题,把"窗口"从一个固定时段变成"最近 N 秒"。常见做法是维护一个有序队列,记录每次请求的时间戳,超过窗口最旧的就出队:
import time
from collections import deque
class SlidingWindow:
def __init__(self, limit: int, window_seconds: int):
self.limit = limit
self.window = window_seconds
self.timestamps = deque()
def allow(self) -> bool:
now = time.time()
# 弹出窗口外的旧时间戳
while self.timestamps and now - self.timestamps[0] > self.window:
self.timestamps.popleft()
if len(self.timestamps) >= self.limit:
return False
self.timestamps.append(now)
return True
精度比固定窗口好很多,但代价是 O(N) 内存(N 是窗口内的请求数),且不能完全消除突刺——只是把突刺窗口缩小到 N 倍率。在 Redis 分布式场景下,可以用 ZADD + ZREMRANGEBYSCORE + ZCARD 的三步操作,Lua 脚本原子化即可。
2.3 令牌桶(Token Bucket)——最常用 ★
想象一个桶,令牌以固定速率往里灌(refill rate),桶满就溢出。请求来了先取令牌,取到就通过、取不到就拒绝。桶有最大容量 burst,允许短时间内的流量暴增。
这是绝大多数生产系统的默认选择,Guava 的 RateLimiter、Nginx 的 limit_req、AWS API Gateway 都是令牌桶。它的两个核心参数:
refill_rate:平均速率(个/秒),决定长期流量burst:桶容量,决定能扛多大突刺
伪代码实现非常简洁:
class TokenBucket {
constructor(capacity, refillPerSec) {
this.capacity = capacity; // 桶大小(最大突刺)
this.tokens = capacity; // 初始满
this.refillRate = refillPerSec; // 每秒补充
this.lastRefill = Date.now();
}
allow(cost = 1) {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
if (this.tokens >= cost) {
this.tokens -= cost;
return true;
}
return false;
}
}
// 每秒 10 个,桶 20 个 = 允许 2 秒的满负载
const limiter = new TokenBucket(20, 10);
2.4 漏桶(Leaky Bucket)
和令牌桶"反着来":请求先进桶(队列),然后以固定速率流出处理。桶满就拒绝。
它完全消除了突刺,输出永远是匀速的——这在需要平滑下游保护(比如数据库连接池、消息生产速率)时很有用。但代价是无法应对合理突刺,双十一秒杀前用户集中点击会被它无情丢弃。Guava 没有原生的漏桶,resilience4j 的 RateLimiter 同时支持令牌桶和漏桶两种模式。
2.5 四种算法核心对比
| 算法 | 精度 | 抗突刺 | 内存 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 低 | 差(2x 突刺) | O(1) | 粗粒度防护、近似统计 |
| 滑动窗口 | 中 | 较好 | O(N) | API 配额、统计面板 |
| 令牌桶 | 高 | 极好(可配置 burst) | O(1) | 绝大多数场景 |
| 漏桶 | 高 | 不允许突刺 | O(1)+队列 | 平滑输出、保护下游 |
四种算法的关系图如下,可以一眼看出演进:
三、Redis + Lua 令牌桶:分布式限流方案
单进程限流够用的情况很少——Node.js 8 个实例 + Java 20 个 Pod 一起来,进程内计数器立刻就废了。必须把计数放到底层共享存储,Redis 是最常见的选择。
Redis 单条命令是原子的,但"取令牌、判断、扣减"三步必须原子执行,否则并发下会超发。Lua 脚本是唯一干净的方案:
-- token_bucket.lua
-- KEYS[1]: 桶 key
-- ARGV[1]: 容量
-- ARGV[2]: 每秒补充速率
-- ARGV[3]: 当前时间(毫秒)
-- ARGV[4]: 申请令牌数
-- ARGV[5]: TTL(秒)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3]) -- current ms
local cost = tonumber(ARGV[4])
local ttl = tonumber(ARGV[5])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local last_ts = tonumber(data[2]) or now
-- 补充令牌
local elapsed = math.max(0, now - last_ts) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl)
-- 返回:allowed, remaining, retry_after_ms
local retry_after = (cost - tokens) / rate * 1000
return { allowed, tokens, math.ceil(retry_after) }
Node.js 调用层(使用 ioredis):
const Redis = require('ioredis');
const fs = require('fs');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
const SCRIPT = fs.readFileSync('./token_bucket.lua', 'utf8');
const SHA = await redis.script('LOAD', SCRIPT);
async function takeToken(userId, capacity = 20, rate = 5) {
const key = `rl:tb:${userId}`;
const [allowed, remaining, retryAfter] = await redis.evalsha(
SHA, 1, key, capacity, rate, Date.now(), 1, 60
);
return { allowed: allowed === 1, remaining, retryAfter };
}
// Express 中间件
app.use(async (req, res, next) => {
const r = await takeToken(req.user?.id || req.ip, 20, 5);
res.setHeader('X-RateLimit-Remaining', r.remaining);
if (!r.allowed) {
res.setHeader('Retry-After', Math.ceil(r.retryAfter / 1000));
return res.status(429).json({ error: 'rate_limited' });
}
next();
});
几个生产踩坑:
- SHA 缓存:重启 Redis 后
EVALSHA会失败,要捕获NOSCRIPT错误回退到EVAL,或订阅__keyevent@0__:expired重载 - 时钟漂移:Lua 里用 Redis 服务器时间(
redis.call('TIME'))比用应用服务器时间更安全,多实例不会出现时间差 - 热 key:大 V 用户被单独限流时,一个 key 会被高频打。可以加一层进程内 LRU cache,把
allowed结果缓存 100ms,分散压力
四、Node.js 应用层:express-rate-limit 实战
如果你的限流需求不复杂(单机、不要求分布式一致),直接用现成库最快。express-rate-limit 是事实标准:
npm i express-rate-limit rate-limit-redis ioredis
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// 全局限流:每 IP 每分钟 100 次
export const globalLimiter = rateLimit({
windowMs: 60_000,
limit: 100,
standardHeaders: 'draft-7', // 返回 RateLimit-* 头
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'rl:',
}),
keyGenerator: (req) => req.user?.id || req.ip,
handler: (req, res) => {
res.status(429).json({
error: 'rate_limited',
message: '请求过于频繁,请稍后再试',
retryAfter: res.getHeader('Retry-After'),
});
},
});
// 重点接口单独限流
export const loginLimiter = rateLimit({
windowMs: 15 * 60_000,
limit: 5, // 15 分钟最多 5 次登录
skipSuccessfulRequests: true, // 成功登录不计数
store: new RedisStore({ sendCommand: (...args) => redis.call(...args), prefix: 'rl:login:' }),
});
app.use('/api/', globalLimiter);
app.post('/login', loginLimiter, loginHandler);
进阶技巧:
- 用户优先于 IP:登录后用
req.user.id,未登录用req.ip,避免同一公司 NAT 出口被误伤 - 差异化阈值:VIP 用户的 limit 可以从 Redis 动态读取,
limit: async (req) => getUserQuota(req.user.id) - 白名单:监控/对账脚本用
skip: (req) => req.ip === '10.0.0.5'跳过
五、Java 微服务:Sentinel 完整实战
阿里开源的 Sentinel 是 Java 生态里最成熟的限流/熔断框架,比 Resilience4j 多了可视化 Dashboard 和规则动态推送两大杀器。
Maven 依赖:
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
<version>1.8.6</version>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId;sentinel-transport-simple-http</artifactId>
<version>1.8.6</version>
</dependency>
代码层:
// 1. 初始化规则(应用启动时执行一次)
List<FlowRule> rules = new ArrayList<>();
FlowRule rule = new FlowRule("orderService")
.setGrade(RuleConstant.FLOW_GRADE_QPS) // QPS 维度
.setCount(50) // 阈值 50 QPS
.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP) // 冷启动
.setWarmUpPeriodSec(10); // 10 秒预热
rules.add(rule);
FlowRuleManager.loadRules(rules);
// 2. 包裹业务代码
try (Entry entry = SphU.entry("orderService")) {
// 业务逻辑
return orderService.createOrder(req);
} catch (BlockException e) {
// 被限流了
return ResponseEntity.status(429).body("系统繁忙");
}
// 3. 注解式更优雅
@SentinelResource(value = "payCallback",
blockHandler = "handleBlock",
fallback = "handleFallback")
@PostMapping("/pay/callback")
public Result handlePayCallback(@RequestBody PayNotify req) {
return payService.handle(req);
}
public Result handleBlock(@RequestBody PayNotify req, BlockException e) {
log.warn("支付回调被限流: {}", req.getOrderId());
return Result.failure("RATE_LIMITED");
}
Sentinel 的流控效果有四种,对应不同业务诉求:
| 效果 | 行为 | 适用场景 |
|---|---|---|
| 快速失败(默认) | 直接抛 BlockException | 普通 API |
| Warm Up(冷启动) | 阈值从小逐渐放大 | 系统刚启动、缓存冷 |
| 排队等待 | 匀速通过、超时拒绝 | 消息消费、批量任务 |
| 预热+排队组合 | 兼有二者的平滑曲线 | 高 QPS 大流量 |
生产部署建议跑一个 Sentinel Dashboard,规则从 Dashboard 推送,不用重启应用就能调整阈值。这是它相比手写 AOP 限流最大的优势。
六、Nginx 网关层:limit_req 实战
网关层限流是性价比最高的位置——所有请求都从这里过,一次配置顶住所有上游服务。
# /etc/nginx/nginx.conf
# 1. 定义限流区(10M 大约能存 16 万 IP,需根据 QPS 调整)
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;
limit_req_zone $http_x_forwarded_for zone=per_user:10m rate=20r/s;
# 2. 定义日志格式
log_format limit_log '$remote_addr [$time_iso8601] '
'$status $limit_req_status $http_referer';
server {
listen 80;
server_name api.example.com;
access_log /var/log/nginx/limit.log limit_log;
# 3. 全局限流:每 IP 每秒 10 个,桶 20(允许 2 秒突刺)
location /api/ {
limit_req zone=per_ip burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
# 4. 登录接口单独更严:每秒 1 个,桶 5
location /api/login {
limit_req zone=per_ip burst=5 nodelay;
proxy_pass http://backend;
}
# 5. 白名单不限流
location /api/internal/ {
limit_req zone=per_ip burst=100 nodelay;
# 仅允许内网
allow 10.0.0.0/8;
deny all;
}
}
三个关键参数必须理解清楚:
- rate=10r/s:平均速率是每秒 10 个
- burst=20:桶容量,瞬时可超 20 个
- nodelay:桶里有令牌就立刻放行(不延迟),删掉这个就是排队模式(匀速)
压测验证配置:
# 用 ab 压测 1000 个请求 50 并发
ab -n 1000 -c 50 https://api.example.com/api/users
# 预期看到 429 状态码分布
# Non-2xx responses: 250 (25% 被限流)
七、四种方案压测对比与选型
用 wrk 压测同一接口(返回 hello world 即可)做横向对比,环境是 4 核 8G 单机,Redis 6.0:
| 方案 | 延迟 P99 | CPU 占用 | 分布式一致 | 复杂度 |
|---|---|---|---|---|
| Nginx limit_req | 2ms | 5% | 不依赖 | 配置即生效 |
| Node.js express-rate-limit(内存) | 3ms | 15% | 多实例失效 | 10 行代码 |
| Redis Lua 令牌桶 | 8ms | 20% | ✅ 强一致 | 30 行 Lua + SDK |
| Sentinel(应用内) | 1ms | 10% | 需集群版 | Java 专属 |
选型决策建议:
- 单体小项目 → 进程内 RateLimiter(Guava/令牌桶实现)
- Node.js 集群 → express-rate-limit + Redis Store
- Java 微服务 → Sentinel(自带 Dashboard 是杀手锏)
- 网关层全局防护 → Nginx limit_req 永远是第一道
- 需要精确扣减第三方配额 → Redis Lua 自写
八、常见陷阱与最佳实践
8.1 八大常见陷阱
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 限流键选错 | 同一公司 NAT 出口共用 IP,正常用户被误伤 | 登录后改用 user_id |
| 没有 429 状态码 | 客户端无法识别重试 | 必须返回 429 + Retry-After 头 |
| 客户端不重试 | 偶发抖动直接失败 | 客户端做指数退避(1s, 2s, 4s) |
| 没有降级页 | 前端白屏 | 限流触发时返回缓存的旧数据 |
| 冷启动被打 | 重启后瞬间被洪流打挂 | 用 Warm Up 模式慢慢放开 |
| 单一维度被绕过 | 攻击者换 IP/换账号 | IP + user_id + device 三维组合 |
| 不监控命中率 | 限流形同虚设 | 必接 Prometheus alert(allowed_ratio<0.7) |
| 规则未走灰度 | 新阈值直接全量 | 先 10% 流量跑 5 分钟 |
8.2 上线前 Checklist
- □ 限流键是否包含用户身份(不止 IP)
- □ 是否返回 429 + 标准 RateLimit-* 头(RFC 6585 / draft-7)
- □ 客户端是否有指数退避重试
- □ 限流降级是否返回旧数据/友好页面
- □ 是否接入监控:QPS、限流命中率、P99 延迟
- □ 告警规则:限流命中率突增、Redis 内存水位
- □ 规则变更是否走配置中心,灰度发布
- □ 压测报告:2x 峰值流量下系统稳定
8.3 监控指标最小集
无论选哪种方案,这五个指标必接:
# Prometheus 告警规则示例
groups:
- name: rate_limit_alerts
rules:
- alert: RateLimitHitHigh
expr: sum(rate(rate_limit_blocked_total[5m])) > 100
for: 2m
labels: { severity: warning }
annotations:
summary: "限流触发率过高: {{ $value }} req/s"
- alert: RedisMemoryHigh
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
for: 5m
labels: { severity: warning }
九、真实案例:限流救了我两次事故
讲两个亲身经历的故事,比纯理论更能帮你理解为什么"宁可多配一层"。
案例一:短信网关被打爆。某次营销活动,短信验证码接口被一个羊毛党工具扫了 2 小时,单接口累计发了 14 万条短信,直接被运营商黄牌警告。事后第一件事就是在网关层加了 limit_req zone=per_ip burst=3 nodelay——每 IP 只能瞬时打 3 个请求,桶一空就必须等。之后 3 个月,类似扫描请求全部被挡在网关之外,短信成本直降 60%。
案例二:缓存击穿引发雪崩。Redis 大 key 失效那一秒,几千个并发请求同时打到数据库,连接池瞬间耗尽,所有依赖该缓存的接口超时。我们上了 Sentinel + 排队等待模式,把瞬时打 DB 的请求匀速到 100 QPS,P99 延迟从 8 秒降到 200 毫秒。事故复盘时 CTO 说了句让我印象很深的话:"限流不是性能优化,是 SLA 的底线。"
这两次之后我们形成了一个固定动作:新接口上线 Checklist 里必带"限流策略"一栏,不写不允许 merge。看似形式主义,几次之后真的救了几次场。
写在最后
限流是个"平时感觉不到,出事才知道重要"的东西。生产环境里至少要做两层:网关层兜底(Nginx)+ 应用层精细化(Redis Lua / Sentinel),单点都不够稳。
算法选型记住一句话——不确定时选令牌桶,它既允许合理的业务突刺,又能严格控制平均速率,是工业界最通用的答案。
下一篇会聊限流的"高级形态":动态自适应限流、热点参数限流,以及在 K8s + Service Mesh 环境下的全局限流方案,敬请期待。