常用限流算法详解(含落地方案与代码)
适用场景:API 网关 / Spring Boot 服务 / MQ 消费者 / 定时任务触发 / 防刷防爆破
目标:在不拖垮系统的前提下,给用户稳定、可预期的服务质量(SLA)。
0. 先讲清楚:限流到底在限制什么?
你要先选"限流维度",否则算法再好也会变成玄学。
常见维度:
- 按接口 :
/pay/create每秒最多 200 次 - 按用户/账号:同一 userId 每分钟最多 60 次
- 按 IP:同一 IP 每秒最多 20 次(防刷常用)
- 按商户/租户:同一 mchNo 每秒最多 50 次(多租户系统很常见)
- 按资源:同一 DB 慢查询通道每秒最多 N 次
- 按组合键 :
(userId + endpoint)/(ip + endpoint)/(tenant + endpoint)
配套策略(非常实用):
- 拒绝(Fail-Fast):直接 429 / 503
- 排队(Queue):短队列 + 超时(别无限排队)
- 降级(Degrade):返回缓存/默认值/简化版本
- 熔断(Circuit Breaker):这是另一套东西,但经常和限流一起用
1. 固定窗口(Fixed Window Counter)
思路
把时间切成固定窗口(比如 1 秒一格),每个窗口计数,超过阈值就拒绝。
优点
- 实现简单,性能高
- 适合粗粒度限流(比如网关层)
缺点(致命点)
- 窗口边界问题:在窗口末尾和下一个窗口开头可能"瞬间双倍放行"
伪代码
- key =
resource + windowStart - incr(key) > limit -> reject
Redis 实现(Lua,原子性)
lua
-- KEYS[1] = key
-- ARGV[1] = limit
-- ARGV[2] = expireSeconds
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0
end
return 1
适用
- 对"边界突刺"不敏感的场景:日志上报、非核心接口、简单防刷
2. 滑动窗口计数(Sliding Window Counter)
思路
仍然基于计数,但用"多个小窗口"近似滑动窗口:例如 1 分钟窗口拆成 60 个 1 秒桶。
窗口内总和 = 最近 60 个桶计数之和。
优点
- 边界突刺大幅缓解(比固定窗口稳)
- 实现相对可控
缺点
- 需要维护多个桶(更多 Redis key / 内存桶)
- 统计需要 sum(可用 ZSET/Hash 优化)
Redis ZSET 版本(更精确的滑动窗口)
做法 :每个请求写入一个时间戳 score;窗口内通过 ZREMRANGEBYSCORE 清理旧数据 + ZCARD 计数
Lua(高并发下建议用 Lua 保证原子)
lua
-- KEYS[1] = zsetKey
-- ARGV[1] = nowMillis
-- ARGV[2] = windowMillis
-- ARGV[3] = limit
-- ARGV[4] = expireSeconds
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local min = now - window
redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, min)
local count = redis.call("ZCARD", KEYS[1])
if count >= tonumber(ARGV[3]) then
return 0
end
redis.call("ZADD", KEYS[1], now, tostring(now) .. "-" .. tostring(redis.call("INCR", KEYS[1]..":seq")))
redis.call("EXPIRE", KEYS[1], ARGV[4])
redis.call("EXPIRE", KEYS[1]..":seq", ARGV[4])
return 1
适用
- 防刷、防爆破、短信验证码、登录、发送邮件等强需求"精确窗口"的场景
3. 漏桶(Leaky Bucket)
思路
请求先进入"桶"(队列),桶以固定速率漏水(处理请求)。桶满则拒绝。
关键特征:输出速率恒定。
优点
- 把突发流量"磨平",保护下游
- 输出稳定,非常适合保护 DB / 依赖服务
缺点
- 不能很好利用空闲时的余量(输出永远固定)
- 需要队列/调度器(实现复杂一些)
适用
- 下游很脆(DB/第三方 API)需要稳定 QPS
- 例如:写库、发券、下单后置处理
4. 令牌桶(Token Bucket)⭐ 最常用
思路
系统按固定速率往桶里加 token(最多 cap)。请求来消耗 token,有 token 就放行,没有就拒绝(或等待)。
关键特征:允许突发(桶里攒的 token 可以一次性用掉)。
优点
- 既能控制长期速率,又能允许突发(比漏桶更友好)
- 工程里最常见:网关、服务端接口、SDK
缺点
- 分布式实现要处理一致性/时钟/并发(通常用 Redis/Lua)
Redis Lua(令牌桶)
核心参数:
rate:每秒产生 token 数cap:桶容量- 状态:
tokens:当前 token 数(可为小数)ts:上次刷新时间(毫秒)
lua
-- KEYS[1] = key
-- ARGV[1] = nowMillis
-- ARGV[2] = ratePerSec
-- ARGV[3] = capacity
-- ARGV[4] = cost (usually 1)
-- ARGV[5] = expireSeconds
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cap = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local tokens = tonumber(redis.call("HGET", key, "tokens"))
local ts = tonumber(redis.call("HGET", key, "ts"))
if tokens == nil then tokens = cap end
if ts == nil then ts = now end
local delta = math.max(0, now - ts) / 1000.0
local filled = math.min(cap, tokens + delta * rate)
local allowed = 0
if filled >= cost then
allowed = 1
filled = filled - cost
end
redis.call("HSET", key, "tokens", filled, "ts", now)
redis.call("EXPIRE", key, tonumber(ARGV[5]))
return allowed
适用
- 大部分接口限流都用它:既稳又不浪费
5. 并发限流(Concurrency Limiting)
思路
限制"同时在处理的请求数",而不是 QPS。
实现方式:
Semaphore/ThreadPool直接限并发- 或者把慢请求/IO 绑定资源限制住(比如 DB 连接池)
优点
- 对"慢请求拖垮系统"特别有效
- 能直接对应资源瓶颈(线程池/连接池/CPU)
缺点
- 不能直接控制"每秒多少请求"
- 需要合理的超时/降级,否则会排队堆积
Java 示例(Servlet/Controller 层)
java
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public class ConcurrencyLimiter {
private final Semaphore sem;
public ConcurrencyLimiter(int maxConcurrent) {
this.sem = new Semaphore(maxConcurrent);
}
public boolean tryAcquire(long timeoutMs) throws InterruptedException {
return sem.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS);
}
public void release() {
sem.release();
}
}
6. 分布式限流:你绕不开的坑
6.1 单机 vs 分布式
- 单机:Guava RateLimiter、Bucket4j(内存)
- 分布式:Redis/Lua、网关(Nginx/Envoy/Kong)、服务网格
6.2 核心坑
- 原子性:多个实例同时判断+更新,必须原子(Lua / Redis script)
- 时钟漂移:用毫秒时间戳时要小心(通常"误差可接受",但别跨机器用本地时间做对账)
- key 爆炸:按 userId/ip/endpoint 组合很容易爆 key,记得加过期和降维
- 热 key :某一个接口/租户超热,导致 Redis 单点热点(可分片:
key:{hashTag})
7. Spring Boot 落地:最实用的三种方式
7.1 AOP + 注解(服务内限流)
适合:单体/少量实例、或配合 Redis 做分布式。
注解:
java
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key(); // SpEL 或自定义 key
long limit(); // 最大次数
long windowMs() default 1000; // 窗口大小
}
AOP(示意:用 Redis ZSET 滑动窗口 / 或 token bucket Lua):
java
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String key = "rl:" + rateLimit.key(); // 实际建议用 SpEL 解析 userId/ip/endpoint
boolean allowed = redisSlidingWindowAllow(key, rateLimit.limit(), rateLimit.windowMs());
if (!allowed) {
throw new TooManyRequestsException("Too many requests");
}
return pjp.proceed();
}
7.2 网关层限流(推荐)
- Nginx
limit_req(简单粗暴) - Spring Cloud Gateway
RequestRateLimiter(令牌桶,常搭 Redis) - Envoy / Kong / APISIX 也都成熟
网关限流的好处:把坏流量挡在外面,后端不被打爆。
7.3 线程池/队列保护(并发+排队)
- 对"慢依赖"用并发限流
- 对"异步任务"用队列长度 + 拒绝策略
8. 算法怎么选:一张不废话的对照表
| 需求 | 推荐算法 | 典型场景 |
|---|---|---|
| 实现最简单 | 固定窗口 | 简单防刷、粗限 |
| 更平滑,减少边界突刺 | 滑动窗口 | 登录/验证码/敏感接口 |
| 输出速率必须稳定 | 漏桶 | 写库/调用第三方 |
| 允许突发但控长期速率 | 令牌桶 | API、网关限流(最常用) |
| 防止慢请求拖垮 | 并发限流 | DB/外部依赖/IO 重接口 |
9. 真实工程建议(你用得上)
- 先定义"失败体验":429?重试提示?还是返回降级数据?
- 限流一定要配监控:放行数、拒绝数、等待数、P99 延迟
- 给白名单/内部流量开绿灯:比如内部回调、管理员、健康检查
- 分层限流:网关挡 80%,服务再挡 20%(更稳)
- 别忘了幂等:限流不是幂等的替代品;支付/下单必须幂等
- 把 key 设计好 :
tenant:mchNo:api:/path:userId这种组合要加过期 + 降维策略
10. 附:单机常用库(不分布式)
- Guava RateLimiter:令牌桶,简单好用(单机)
- Bucket4j:功能更强(多策略、JCache、Redis 扩展等)
- Resilience4j:偏容错(限流/隔离/熔断都有),适合微服务