API 限流算法与实战:从令牌桶到分布式限流的完整指南
凌晨三点,告警群炸了——某推广活动带来的瞬时流量把登录接口打挂了,DBA 疯狂在群里@你,CTO 已经打电话过来。这种事故的根因往往就一句话:接口没限流。今天这篇文章,咱们就把限流这件事从原理到生产一次性讲透。
限流不是新话题,但真正能在生产环境稳定落地的方案并不多。文章会先讲清四大算法的本质区别,再分别给出单机(Java Guava、Node.js bottleneck)、分布式(Redis + Lua)、网关层(Nginx、Sentinel、Kong)的实战方案,最后用两个真实场景的案例收尾。看完你就能搭建一套生产可用的多级限流体系。
一、为什么需要限流
限流解决的是"流量 > 容量"时的优雅降级问题。它不是消灭流量,而是让系统在过载时仍能稳定服务核心用户。常见的三大场景:
- 防刷防爆破:登录、短信验证码、注册等接口被恶意脚本狂打,限流能把攻击流量挡在 90% 以上;
- 资源保护:数据库连接池、CPU 密集型接口、下游第三方 API 都对瞬时并发敏感,需要在网关层先削峰;
- 成本控制:按调用次数计费的第三方 API、CDN 流量包,限流能直接省下一大笔账单。
很多人把限流和熔断、降级混为一谈,实际上三者关系是这样的:
| 机制 | 触发条件 | 作用点 | 典型实现 |
|---|---|---|---|
| 限流(Rate Limit) | 调用速率超阈值 | 入口处 | Nginx limit_req、Sentinel、Redis + Lua |
| 熔断(Circuit Breaker) | 下游错误率超阈值 | 调用方 | Hystrix、Resilience4j、Istio |
| 降级(Fallback) | 熔断触发或资源紧张 | 调用方 | 返回缓存、默认值、错误页 |
一句话总结:限流是入口的"门卫",熔断是调用链的"保险丝",降级是熔断后的"兜底服务"。三者协同才能形成完整的稳定性防线。
二、四大限流算法原理解析
算法决定限流的"性格"——是严格、宽松、还是带突发容忍。理解差异才能选对场景。
2.1 固定窗口(Fixed Window)
最简单粗暴:把时间切成 1 秒或 1 分钟的固定窗口,每个窗口内计数 +1,超过阈值就拒绝。Redis 的 INCR + EXPIRE 是典型实现。
致命缺陷:临界突刺。假设限流 100 req/min,前 1 分钟末尾 1 秒涌入 100 个,紧接着下 1 分钟开头 1 秒又涌入 100 个,2 秒内实际通过 200 个,远超阈值。
2.2 滑动窗口(Sliding Window)
为了解决临界突刺,把固定窗口再切细成 N 个小格(比如 60 个 1 秒格),按时间滚动计算最近窗口内的总和。精度和内存占用成正比。
它又分两种:滑动日志(精确记录每次请求时间戳,内存占用高)和滑动计数(每个小格只记计数,按权重合并,精度稍差但省内存)。生产中滑动计数用得最多。
2.3 令牌桶(Token Bucket)
系统以固定速率往桶里放令牌(比如 1 个/秒),桶容量有限。请求来时尝试拿 1 个令牌,有就放行,没有就拒绝。
最大优点:允许突发。平时攒令牌,突发来时一次性放行一波,兼顾"日常限速"和"突发放行"两个需求。Cloudflare、Stripe API 的限流都基于它。
2.4 漏桶(Leaky Bucket)
请求像水一样进桶,桶底以固定速率漏水(处理)。桶满则溢出(拒绝)。
它和令牌桶的区别是:令牌桶"攒"令牌来允许突发,漏桶"匀速"漏水来强制平滑。流量整形(Traffic Shaping)场景下漏桶更合适,但大多数 Web 限流场景都选令牌桶。
2.5 四大算法对比
| 算法 | 精度 | 允许突发 | 内存占用 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 固定窗口 | 低(临界突刺) | 否 | 极低 | 极低 | 统计型 QPS 监控 |
| 滑动日志 | 高 | 否 | 高 | 中 | 严格审计场景 |
| 滑动计数 | 中高 | 弱 | 低 | 中 | 通用 API 限流 |
| 令牌桶 | 高 | 强 | 低 | 中 | 需要突发的 API |
| 漏桶 | 高 | 否 | 低 | 中 | 流量整形 |
三、单机限流实战
如果你的服务是单实例或限流只在进程内有效,用内存级实现就够了,零依赖性能拉满。
3.1 Java Guava RateLimiter
Guava 的 RateLimiter 就是经典的令牌桶实现,API 极简:
// 每秒 5 个许可(即 5 QPS)
RateLimiter limiter = RateLimiter.create(5.0);
// 平滑限流:sleep 到拿到令牌为止
if (!limiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return ResponseEntity.status(429).body("Too Many Requests");
}
return doBusinessLogic();
// 突发场景:预创建桶,允许短时爆发
RateLimiter burstLimiter = RateLimiter.create(5.0, 5, TimeUnit.SECONDS);
注意点:create(permitsPerSecond) 默认使用平滑突发策略(Guava 28+),无预热时间。如果需要"预热桶"(Warming Up),升级到 Resilience4j 或 Sentinel。
3.2 Node.js bottleneck / p-limit
Node.js 生态里 bottleneck 是最专业的限流库,p-limit 更轻量适合并发控制:
const Bottleneck = require("bottleneck");
// 全局限流:每秒 10 个
const limiter = new Bottleneck({
maxConcurrent: 5, // 最大并发
minTime: 100, // 每次至少间隔 100ms
reservoir: 20, // 桶容量
reservoirRefreshAmount: 20,
reservoirRefreshInterval: 1000 * 60,
});
limiter.schedule({ id: userId }, async () => {
return await callExpensiveAPI(userId);
});
// 队列满则立即拒绝
limiter.on("failed", (err) => log.error(err));
单机实现的致命问题:无法跨实例共享状态。3 个实例每个限流 100 QPS,集群实际能扛 300 QPS,黑客只要分布到不同实例就能绕过。生产中必须上分布式。
四、分布式限流:Redis + Lua 原子操作
分布式限流的核心挑战是原子性——计数、过期、判断三步必须在一次操作里完成。Redis + Lua 脚本是公认的最佳方案,因为 Lua 脚本在 Redis 单线程模型下是原子执行的。
4.1 滑动窗口 Lua 实现
用 Redis 的 有序集合(ZSET) 存每次请求的时间戳,count 计算当前窗口内成员数:
-- KEYS[1] = 限流 key
-- ARGV[1] = 窗口大小(毫秒)
-- ARGV[2] = 阈值
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 唯一请求 ID(用于 ZADD 成员)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
-- 1. 清理窗口外的旧记录
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
-- 2. 统计当前窗口内请求数
local count = redis.call("ZCARD", key)
if count < limit then
-- 3. 记录本次请求
redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window)
return {1, limit - count - 1, 0} -- 通过, 剩余配额, 0ms 等待
else
-- 计算最早一条记录的过期时间,用于精确 Retry-After
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
local retryAfter = window
if #oldest >= 2 then
retryAfter = (tonumber(oldest[2]) + window) - now
end
return {0, 0, retryAfter} -- 拒绝, 剩余配额 0, 等待毫秒
end
返回值是个数组,能告诉调用方:是否放行、剩余配额、精确的 Retry-After 时间(毫秒)。这样客户端可以遵守 HTTP 429 规范礼貌退避。
4.2 令牌桶 Lua 实现
令牌桶用 Redis Hash 存桶状态:
-- KEYS[1] = 限流 key
-- ARGV[1] = 桶容量
-- ARGV[2] = 填充速率(每秒)
-- ARGV[3] = 当前时间戳(毫秒)
-- ARGV[4] = 申请的令牌数
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call("HMGET", key, "tokens", "lastRefill")
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
-- 1. 补充令牌
local elapsed = (now - lastRefill) / 1000.0
local refilled = math.floor(elapsed * rate)
tokens = math.min(capacity, tokens + refilled)
local effectiveLastRefill = refilled > 0 and now or lastRefill
-- 2. 尝试拿令牌
if tokens >= requested then
tokens = tokens - requested
redis.call("HMSET", key, "tokens", tokens, "lastRefill", effectiveLastRefill)
redis.call("PEXPIRE", key, 60000)
return {1, tokens, 0}
else
redis.call("HMSET", key, "tokens", tokens, "lastRefill", effectiveLastRefill)
redis.call("PEXPIRE", key, 60000)
local needed = requested - tokens
local waitMs = math.ceil((needed / rate) * 1000)
return {0, tokens, waitMs}
end
这里有几个细节值得说道:
- lastRefill 更新时机:只有真正补充了令牌才更新到
now,避免后续请求把"过去的时间"算进未来; - PEXPIRE 设置:60 秒过期足够应对所有合理场景,又避免 key 永久残留;
- 浮点精度:
math.floor截断,宁可少给令牌也不多给,保证严格性。
4.3 Node.js 调用封装
把 Lua 脚本封装成可复用的中间件:
const Redis = require("ioredis");
const { v4: uuidv4 } = require("uuid");
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
const slidingWindowLua = `
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count < limit then
redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window)
return {1, limit - count - 1, 0}
else
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
local retryAfter = window
if #oldest >= 2 then
retryAfter = (tonumber(oldest[2]) + window) - now
end
return {0, 0, retryAfter}
end
`;
redis.defineCommand("rateLimitSliding", {
numberOfKeys: 1,
lua: slidingWindowLua,
});
function rateLimit({ key, windowMs, limit }) {
return async (req, res, next) => {
const userKey = `rl:${key}:${req.userId || req.ip}`;
try {
const [allowed, remaining, retryAfter] = await redis.rateLimitSliding(
userKey, windowMs, limit, Date.now(), uuidv4()
);
res.set("X-RateLimit-Limit", limit);
res.set("X-RateLimit-Remaining", Math.max(0, remaining));
if (!allowed) {
res.set("Retry-After", Math.ceil(retryAfter / 1000));
return res.status(429).json({ error: "Too Many Requests" });
}
next();
} catch (err) {
log.error("rate limit error", err);
next(); // 限流失败时"放行+告警"优于直接拒绝
}
};
}
app.use("/api/", rateLimit({ key: "global", windowMs: 60_000, limit: 100 }));
关键的"限流失败时放行"是线上重要容灾策略——如果 Redis 抖动,限流不可用导致全站拒绝是更大的事故。要"fail open"配合告警,让运维第一时间介入。
五、网关层限流
当多个服务都需要限流时,把限流逻辑下沉到网关是最优雅的方案。
5.1 Nginx limit_req
Nginx 自带 limit_req 模块就是漏桶实现,零依赖性能最强:
# 定义限流区域:10MB 内存,每秒 10 个请求
limit_req_zone $binary_remote_addr zone=ip_limit:10m rate=10r/s;
limit_req_zone $http_x_api_key zone=key_limit:10m rate=100r/s;
server {
listen 80;
server_name api.example.com;
location /api/login {
# 允许突发 20 个,无延迟队列
limit_req zone=ip_limit burst=20 nodelay;
proxy_pass http://backend;
}
location /api/v1/ {
# 排队模式:超出 burst 排队等待
limit_req zone=key_limit burst=50;
limit_req_status 429;
proxy_pass http://backend;
}
}
burst=20 nodelay 表示允许短时 20 个突发但立即处理;不带 nodelay 则排队等待。Nginx 的 limit_req 在 HTTP 层做限流,比 Lua 早,能把压力挡在 7 层之前。
5.2 Sentinel / Spring Cloud Gateway
阿里 Sentinel 是 Java 生态最流行的限流组件,支持按 QPS、并发线程数、调用关系等多种维度,配 Dashboard 实时调参:
# application.yml
spring:
cloud:
gateway:
routes:
- id: order_route
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 50 # 每秒 50
redis-rate-limiter.burstCapacity: 100 # 桶容量
key-resolver: "#{@userKeyResolver}"
Spring Cloud Gateway 底层也是 Redis + Lua,但封装了配置化的资源管理。
5.3 Kong
Kong 的 rate-limiting 插件支持 local、cluster、redis 三种策略,与服务注册、认证、监控天然集成:
# 为 service 启用限流,每分钟 100 次
curl -X POST http://kong:8001/services/my-service/plugins \
-d "name=rate-limiting" \
-d "config.minute=100" \
-d "config.policy=redis" \
-d "config.redis_host=redis"
5.4 网关层方案对比
| 网关 | 语言生态 | 算法 | 配置化 | 集群共享 | 性能 |
|---|---|---|---|---|---|
| Nginx limit_req | C / 通用 | 漏桶 | 配置 | 需共享内存 | ★★★★★ |
| Sentinel | Java | 滑动/令牌/漏桶 | Dashboard | Token Server | ★★★★ |
| Spring Cloud Gateway | Java | 令牌桶 | YAML | Redis | ★★★★ |
| Kong | Lua / OpenResty | 固定窗口 | Admin API | Redis / Cluster | ★★★★ |
六、实战案例
案例 1:登录接口防爆破
场景:登录接口被撞库,每秒最高 5000 次尝试。需求:单 IP 每分钟最多 10 次尝试,单账号每分钟 5 次。
方案:网关层用 Nginx 按 IP 限流(10r/m,突发 5),应用层用 Redis + Lua 按账号二次限流(5r/m)。Nginx 把 99% 的攻击流量挡在门外,账号级限流兜底防分布式攻击。
关键细节:登录失败计数要按账号而非 IP,否则黑客用代理池可以绕开 IP 限制;限流响应要返回 Retry-After,让合规客户端礼貌退避;被限流的请求要写日志,作为后续风控的输入。
案例 2:开放 API 平台按用户配额
场景:API 平台按用户等级分发配额(免费 1000 次/天,付费 10 万次/天),需要全局精确计数。
方案:每天 0 点重置配额,用 Redis Hash 存每日用量。考虑到 free 用户量级大,用 Lua 脚本在 Redis 内做"读-判断-写" 避免并发竞态。付费用户单独走令牌桶路径,允许突发调用。
这里有个坑:INCR 不是原子的"读+判断",高并发下两个请求同时读到 999 都自增到 1000 后通过,超出 1 个。用 Lua 把"读、判断、+1"三步打包成原子操作才能解决。
七、选型决策
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单实例内部限流 | Guava RateLimiter / bottleneck | 零依赖,纳秒级 |
| 多实例分布式 | Redis + Lua 令牌桶 | 原子性、突发友好、运维成熟 |
| 网关层粗粒度 | Nginx limit_req | 7 层早期过滤,性能顶满 |
| Java 微服务 | Sentinel + Dashboard | 规则动态推送,可视化 |
| 开放 API 平台 | Kong rate-limiting | 与网关功能深度集成 |
| 需要审计严格性 | 滑动日志 + 持久化 | 可回溯,不丢请求 |
多级限流是趋势:Nginx 挡大流量、Redis 分布式精确限流、单机库兜底关键接口,三层叠加既扛得住 DDoS 又能精确控制配额。
八、监控与告警
限流上线后必须监控这些指标,否则等于盲飞:
| 指标 | 告警阈值 | 含义 |
|---|---|---|
| 限流触发率 | > 5% | 真实流量超阈值,需要扩容或调参 |
| Redis Lua 脚本延迟 | > 5ms | Redis 性能瓶颈或大 key |
| 限流错误率 | > 0.1% | Redis 不可用,需检查 fail open 策略 |
| 各 key 配额使用率 | > 80% | 识别热门用户/接口,提前扩容 |
| HTTP 429 状态码比例 | > 10% | 上游或客户端行为异常 |
建议把 X-RateLimit-Remaining 和 Retry-After 写到日志和 Prometheus,配合 Grafana 仪表盘可视化"剩余配额水位线"。当水位持续低于 20% 时,说明需要调高阈值或扩容。
九、常见陷阱
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 限流 key 未隔离 | 不同用户共享同一计数器 | key 拼接 userId / IP / 业务维度 |
| 忘记 fail open | Redis 抖动全站 500 | catch 异常后放行 + 告警 |
| 固定窗口临界突刺 | 边界 2 倍流量 | 改用滑动窗口或令牌桶 |
| 令牌桶无容量上限 | 长时间停服后所有请求瞬间通过 | 设置合理 burstCapacity |
| 时钟漂移 | 分布式节点时间不一致导致误判 | 统一用 Redis 时间,不用本地时钟 |
| 大 key 阻塞 Redis | ZSET 百万级成员 | 增加窗口细分粒度或定期清理 |
| 客户端忽略 Retry-After | 重试风暴 | 服务端主动 + 客户端重试退避 |
| 没区分读/写接口 | 读接口被写接口拖垮 | 按接口路径分别配置限流 |
十、总结
限流是稳定性体系的第一道防线,也是性价比最高的优化——几行代码就能挡掉 80% 的过载流量。核心要点回顾:
- 选算法:日常限速用滑动计数,需要突发用令牌桶,流量整形用漏桶;
- 多级组合:网关层粗粒度 + Redis 分布式精确 + 单机库兜底关键路径;
- 原子性:分布式限流必须用 Lua 脚本,
INCR+EXPIRE不是原子操作; - 容灾:限流服务本身要 fail open,不能因为限流挂掉导致全站雪崩;
- 可观测:必须监控触发率、延迟、配额使用率,配 Grafana 仪表盘;
- 规范响应:返回 429 +
Retry-After+X-RateLimit-Remaining,引导客户端礼貌退避。
凌晨三点的那通电话,归根结底不是流量太大,是没给系统装"门卫"。希望看完这篇,你的服务能睡个好觉。