由于 API 接口无法控制调用方的行为,因此当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。
限流(Ratelimiting)指对应用服务的请求进行限制,例如某一接口的请求限制为 100 个每秒,对超过限制的请求则进行快速失败或丢弃。
拦截器 vs 过滤器在限流中的使用选择
过滤器(Filter)更合理的理由:
-
执行时机更早
Filter在请求进入Spring MVC框架之前执行- 能够更早地拦截和拒绝过多请求,节省框架初始化开销
-
更底层的控制
- 基于Servlet规范,不依赖Spring MVC框架
- 可以拦截所有类型的请求(包括静态资源)
-
性能优势
- 避免不必要的Spring上下文初始化
- 减少请求处理链路长度
-
适用场景匹配
- 适合做通用的基础设施功能(如限流、安全检查)
- 对所有HTTP请求进行统一处理
拦截器(Interceptor)的局限性:
-
执行时机较晚
- 在
DispatcherServlet之后执行 - 已经完成了部分Spring MVC框架的初始化工作
- 在
-
依赖Spring MVC
- 只能处理经过Spring MVC的请求
- 无法处理静态资源等非Controller请求
结论
对于固定窗口限流 这种基础设施级别的功能,过滤器更为合理,因为:
- 能够在最早阶段拒绝过多请求
- 不依赖具体的应用框架
- 性能开销更小
- 控制粒度更粗,适合全局限流策略
使用redis作为中间件
xml
<!-- Spring Boot Starter Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
固定窗口计数器算法
固定窗口其实就是时间窗口,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率,即固定窗口计数器算法规定了系统单位时间处理的请求数量。
java
/**
* 限流器接口
*/
public interface RateLimiter {
boolean tryAcquire(String key, long limit, long windowMs);
}
java
import com.example.demo.infrastructure.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
/**
* 基于 Redis 的固定窗口限流器
*/
@Slf4j
@Component("redisRateLimiter")
public class RedisFixedWindowRateLimiter implements RateLimiter {
private final StringRedisTemplate stringRedisTemplate;
private final boolean failOpen;
private final String keyPrefix;
private static final String LUA_SCRIPT =
"local key = KEYS[1]\n" +
"local limit = tonumber(ARGV[1])\n" +
"local window_ms = tonumber(ARGV[2])\n" +
"local current = redis.call('INCR', key)\n" +
"if current == 1 then\n" +
" redis.call('PEXPIRE', key, window_ms)\n" +
"end\n" +
"if current <= limit then\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
private final DefaultRedisScript<Long> redisScript;
public RedisFixedWindowRateLimiter(
StringRedisTemplate stringRedisTemplate,
@Value("${rate.limit.fail-open:false}") boolean failOpen,
@Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
this.stringRedisTemplate = stringRedisTemplate;
this.failOpen = failOpen;
this.keyPrefix = keyPrefix;
this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
}
@Override
public boolean tryAcquire(String key, long limit, long windowMs) {
if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
log.warn("Invalid rate limit params: key={}, limit={}, windowMs={}", key, limit, windowMs);
return false;
}
// 防止窗口过小导致 TTL=0
if (windowMs < 10) {
log.warn("Window too small: {}ms, using 10ms", windowMs);
windowMs = 10;
}
String redisKey = keyPrefix + ":" + key + ":" + (System.currentTimeMillis() / windowMs);
try {
Long result = stringRedisTemplate.execute(
redisScript,
Collections.singletonList(redisKey),
String.valueOf(limit),
String.valueOf(windowMs)
);
boolean allowed = result != null && result == 1L;
if (!allowed) {
log.warn("Rate limit exceeded | key={}", key);
}
return allowed;
} catch (Exception e) {
log.error("Redis rate limiter error | key={}", key, e);
return failOpen;
}
}
}
滑动窗口计数算法
滑动窗口限流算法(Sliding Window Rate Limiting Algorithm) 是一种用于控制系统单位时间内请求频率的流量控制机制。它将时间划分为连续、动态移动的时间窗口,通过统计当前窗口内已发生的请求数量(或估算值),并与预设阈值比较,从而决定是否允许新请求通过
图示
上一窗口 当前窗口
|----------|----------|----------|----------|
↑ ↑
1672531200000 1672531201000 now→1672531201500
prev_count = 80 current_count = 60
←─────── offset = 500ms ───────┘
weight = 0.5 → 只算 prev_count 的一半 → 40
total = 60 + 40 = 100 ≤ limit(100) → 放行
java
/**
* 限流器接口
*/
public interface RateLimiter {
boolean tryAcquire(String key, long limit, long windowMs);
}
java
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* 基于 Redis 的滑动窗口计数器限流器、
* <p>
* 算法:Sliding Window Counter(非日志型,避免高内存消耗)
* 特点:
* - 使用两个 Redis Key:当前窗口 + 上一窗口
* - Lua 脚本原子计算估算请求数
* - 支持毫秒级窗口
* - 自动清理过期窗口
*/
@Slf4j
@Component("slidingWindowRateLimiter")
public class RedisSlidingWindowRateLimiter implements RateLimiter {
private final StringRedisTemplate stringRedisTemplate;
private final boolean failOpen;
private final String keyPrefix;
// Lua 脚本:实现滑动窗口计数逻辑
private static final String LUA_SCRIPT =
"-- KEYS[1]: current window key\n" +
"-- KEYS[2]: previous window key\n" +
"-- ARGV[1]: limit\n" +
"-- ARGV[2]: window_ms (毫秒)\n" +
"-- ARGV[3]: current_time_ms\n" +
"\n" +
"local current_key = KEYS[1]\n" +
"local prev_key = KEYS[2]\n" +
"local limit = tonumber(ARGV[1])\n" +
"local window_ms = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[3])\n" +
"\n" +
"-- 获取当前窗口和上一窗口的计数\n" +
"local current_count = redis.call('GET', current_key)\n" +
"local prev_count = redis.call('GET', prev_key)\n" +
"\n" +
"current_count = current_count and tonumber(current_count) or 0\n" +
"prev_count = prev_count and tonumber(prev_count) or 0\n" +
"\n" +
"-- 计算窗口偏移量(当前时间在窗口中的位置)\n" +
"local window_start = now - (now % window_ms)\n" +
"local offset = now - window_start -- [0, window_ms)\n" +
"\n" +
"-- 估算总请求数:当前窗口 + 上一窗口 * (1 - offset/window_ms)\n" +
"local weight = (window_ms - offset) / window_ms\n" +
"local estimated = current_count + (prev_count * weight)\n" +
"\n" +
"-- 如果超过限制,拒绝\n" +
"if estimated >= limit then\n" +
" return 0\n" +
"end\n" +
"\n" +
"-- 允许请求:增加当前窗口计数,并设置 TTL\n" +
"redis.call('INCR', current_key)\n" +
"redis.call('PEXPIRE', current_key, window_ms * 2) -- 保留两个窗口周期\n" +
"\n" +
"return 1";
private final DefaultRedisScript<Long> redisScript;
public RedisSlidingWindowRateLimiter(
StringRedisTemplate stringRedisTemplate,
@Value("${rate.limit.fail-open:false}") boolean failOpen,
@Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
this.stringRedisTemplate = stringRedisTemplate;
this.failOpen = failOpen;
this.keyPrefix = keyPrefix;
this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
}
@Override
public boolean tryAcquire(String key, long limit, long windowMs) {
if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
log.warn("Invalid rate limit params: key={}, limit={}, windowMs={}", key, limit, windowMs);
return false;
}
if (windowMs < 10) {
log.warn("Window too small: {}ms, using 10ms", windowMs);
windowMs = 10;
}
long now = System.currentTimeMillis();
// 当前窗口编号
long windowSlot = now / windowMs;
String currentKey = buildKey(key, windowSlot);
String prevKey = buildKey(key, windowSlot - 1);
try {
Long result = stringRedisTemplate.execute(
redisScript,
Arrays.asList(currentKey, prevKey),
String.valueOf(limit),
String.valueOf(windowMs),
String.valueOf(now)
);
boolean allowed = result != null && result == 1L;
if (!allowed) {
log.warn("Sliding window rate limit exceeded | key={}", key);
}
return allowed;
} catch (Exception e) {
log.error("Redis sliding window rate limiter error | key={}", key, e);
return failOpen;
}
}
private String buildKey(String key, long slot) {
return keyPrefix + ":sw:" + key + ":" + slot;
}
}
漏桶算法
漏桶算法(Leaky Bucket Algorithm) 是一种流量整形与限流机制,它将请求比作"水滴"注入一个固定容量的"桶"中,桶以恒定速率"漏水"(即处理请求);当桶满时,新请求被丢弃或拒绝,从而平滑突发流量、确保系统以稳定速率处理请求。
java
/**
* 限流器接口
*/
public interface RateLimiter {
boolean tryAcquire(String key, long limit, long windowMs);
}
limit → 桶容量(capacity)
rate = 1000 / windowMs → 每秒处理速率(req/s)
例如:tryAcquire("api", 5, 1000) 表示 桶容量=5,每秒匀速处理1个请求。
java
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
/**
* 基于 Redis 的漏桶限流算法(Leaky Bucket)实现
* <p>
* 特点:
* - 恒定输出速率,平滑突发流量
* - 使用惰性漏水策略(Lazy Leaking)
* - Lua 脚本保证原子性
* - 支持毫秒级精度
*/
@Slf4j
@Component("leakyBucketRateLimiter")
public class RedisLeakyBucketRateLimiter implements RateLimiter {
private final StringRedisTemplate stringRedisTemplate;
private final boolean failOpen;
private final String keyPrefix;
// Lua 脚本:实现漏桶惰性漏水逻辑
private static final String LUA_SCRIPT =
"-- KEYS[1]: bucket key\n" +
"-- ARGV[1]: capacity (桶容量)\n" +
"-- ARGV[2]: rate_per_sec (每秒处理请求数)\n" +
"-- ARGV[3]: current_time_ms\n" +
"\n" +
"local key = KEYS[1]\n" +
"local capacity = tonumber(ARGV[1])\n" +
"local rate = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[3])\n" +
"\n" +
"-- 获取当前桶状态: \"water:last_leak_time\"\n" +
"local bucket = redis.call('GET', key)\n" +
"local water = 0\n" +
"local last_leak_time = now\n" +
"\n" +
"if bucket then\n" +
" local parts = {}\n" +
" for part in string.gmatch(bucket, \"([^:]+)\") do\n" +
" table.insert(parts, part)\n" +
" end\n" +
" water = tonumber(parts[1]) or 0\n" +
" last_leak_time = tonumber(parts[2]) or now\n" +
"end\n" +
"\n" +
"-- 惰性漏水:计算应漏出的水量\n" +
"local delta = now - last_leak_time\n" +
"if delta > 0 and rate > 0 then\n" +
" local leaked = math.floor((delta * rate) / 1000)\n" +
" water = math.max(0, water - leaked)\n" +
" last_leak_time = now\n" +
"end\n" +
"\n" +
"-- 尝试加入新请求\n" +
"if water + 1 <= capacity then\n" +
" water = water + 1\n" +
" -- 更新桶状态,并设置合理 TTL(防止永久残留)\n" +
" redis.call('SET', key, water .. ':' .. last_leak_time, 'PX', 86400000) -- 24h\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
private final DefaultRedisScript<Long> redisScript;
public RedisLeakyBucketRateLimiter(
StringRedisTemplate stringRedisTemplate,
@Value("${rate.limit.fail-open:false}") boolean failOpen,
@Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
this.stringRedisTemplate = stringRedisTemplate;
this.failOpen = failOpen;
this.keyPrefix = keyPrefix;
this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
}
/**
* 语义映射:
* - limit → 桶容量(capacity)
* - windowMs → 处理一个请求所需毫秒数 → rate = 1000 / windowMs (req/s)
*/
@Override
public boolean tryAcquire(String key, long limit, long windowMs) {
if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
log.warn("Invalid leaky bucket params: key={}, capacity={}, processTimeMs={}", key, limit, windowMs);
return false;
}
// 桶容量
long capacity = limit;
// 每秒处理请求数
double ratePerSec = 1000.0 / windowMs;
// 防止 rate 过大导致精度问题
if (ratePerSec > 10000) {
log.warn("Rate too high: {} req/s, capped at 10000", ratePerSec);
ratePerSec = 10000;
}
String redisKey = keyPrefix + ":lb:" + key;
long now = System.currentTimeMillis();
try {
Long result = stringRedisTemplate.execute(
redisScript,
Collections.singletonList(redisKey),
String.valueOf(capacity),
String.valueOf(Math.round(ratePerSec)), // 取整避免 Lua 浮点问题
String.valueOf(now)
);
boolean allowed = result != null && result == 1L;
if (!allowed) {
log.warn("Leaky bucket overflow | key={}", key);
}
return allowed;
} catch (Exception e) {
log.error("Redis leaky bucket error | key={}", key, e);
return failOpen;
}
}
}
令牌桶算法
令牌桶算法(Token Bucket Algorithm) 是一种流量控制机制,它以恒定速率向一个固定容量的"桶"中添加令牌,每个请求需消耗一个令牌才能被处理;当桶中有足够令牌时,请求可立即通过(支持突发流量),若桶空则请求被限流。该算法既能限制平均请求速率,又能允许一定程度的突发流量,兼具灵活性与稳定性。
java
/**
* 限流器接口
*/
public interface RateLimiter {
boolean tryAcquire(String key, long limit, long windowMs);
}
limit → 桶容量(capacity)
windowMs → 生成 limit 个令牌所需时间(毫秒)
⇒ 令牌生成速率 rate = limit / (windowMs / 1000) = (limit * 1000) / windowMs (单位:tokens/sec)
💡 示例:tryAcquire("api", 10, 1000) 表示:
桶容量=10,每秒生成10个令牌(即 10 QPS),允许瞬间10个请求通过
java
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
/**
* 基于 Redis 的令牌桶限流算法(Token Bucket)实现
* <p>
* 特点:
* - 支持突发流量(桶内有令牌即可立即通过)
* - 恒定速率补充令牌
* - 惰性计算令牌(Lazy Token Refill)
* - Lua 脚本保证原子性
* - 符合阿里生产规范
*/
@Slf4j
@Component("tokenBucketRateLimiter")
public class RedisTokenBucketRateLimiter implements RateLimiter {
private final StringRedisTemplate stringRedisTemplate;
private final boolean failOpen;
private final String keyPrefix;
// Lua 脚本:实现令牌桶惰性补充逻辑
private static final String LUA_SCRIPT =
"-- KEYS[1]: token bucket key\n" +
"-- ARGV[1]: capacity (桶容量)\n" +
"-- ARGV[2]: rate_per_sec (每秒生成令牌数)\n" +
"-- ARGV[3]: current_time_ms\n" +
"\n" +
"local key = KEYS[1]\n" +
"local capacity = tonumber(ARGV[1])\n" +
"local rate = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[3])\n" +
"\n" +
"-- 获取当前桶状态: \"tokens:last_refill_time\"\n" +
"local bucket = redis.call('GET', key)\n" +
"local tokens = capacity\n" +
"local last_refill = now\n" +
"\n" +
"if bucket then\n" +
" local parts = {}\n" +
" for part in string.gmatch(bucket, \"([^:]+)\") do\n" +
" table.insert(parts, part)\n" +
" end\n" +
" tokens = tonumber(parts[1]) or capacity\n" +
" last_refill = tonumber(parts[2]) or now\n" +
"end\n" +
"\n" +
"-- 惰性补充令牌\n" +
"local delta = now - last_refill\n" +
"if delta > 0 and rate > 0 then\n" +
" local new_tokens = math.floor((delta * rate) / 1000)\n" +
" tokens = math.min(capacity, tokens + new_tokens)\n" +
" last_refill = now\n" +
"end\n" +
"\n" +
"-- 尝试获取1个令牌\n" +
"if tokens >= 1 then\n" +
" tokens = tokens - 1\n" +
" redis.call('SET', key, tokens .. ':' .. last_refill, 'PX', 86400000) -- TTL 24h\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
private final DefaultRedisScript<Long> redisScript;
public RedisTokenBucketRateLimiter(
StringRedisTemplate stringRedisTemplate,
@Value("${rate.limit.fail-open:false}") boolean failOpen,
@Value("${rate.limit.redis.key-prefix:myapp:rl}") String keyPrefix) {
this.stringRedisTemplate = stringRedisTemplate;
this.failOpen = failOpen;
this.keyPrefix = keyPrefix;
this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
}
/**
* 语义映射:
* - limit → 桶容量(capacity)
* - windowMs → 生成 limit 个令牌所需时间(毫秒)
* ⇒ rate = (limit * 1000) / windowMs (tokens/sec)
*/
@Override
public boolean tryAcquire(String key, long limit, long windowMs) {
if (key == null || key.isEmpty() || limit <= 0 || windowMs <= 0) {
log.warn("Invalid token bucket params: key={}, capacity={}, refillWindowMs={}", key, limit, windowMs);
return false;
}
long capacity = limit;
double ratePerSec = (limit * 1000.0) / windowMs;
// 防止速率过高导致精度问题或 Redis 压力
if (ratePerSec > 100000) {
log.warn("Token rate too high: {} tokens/s, capped at 100000", ratePerSec);
ratePerSec = 100000;
}
String redisKey = keyPrefix + ":tb:" + key;
long now = System.currentTimeMillis();
try {
Long result = stringRedisTemplate.execute(
redisScript,
Collections.singletonList(redisKey),
String.valueOf(capacity),
String.valueOf(Math.round(ratePerSec)), // 取整避免 Lua 浮点误差
String.valueOf(now)
);
boolean allowed = result != null && result == 1L;
if (!allowed) {
log.warn("Token bucket exhausted | key={}", key);
}
return allowed;
} catch (Exception e) {
log.error("Redis token bucket error | key={}", key, e);
return failOpen;
}
}
}
| 算法 | 核心思想 | 是否支持突发流量 | 输出是否匀速 | 内存/计算开销 | 精度 | 典型适用场景 |
|---|---|---|---|---|---|---|
| 固定窗口(Fixed Window) | 将时间划分为固定长度窗口,统计窗口内请求数 | ✅ 是(窗口切换时可能双倍突发) | ❌ 否 | ⭐ 极低(仅计数器) | ⭐ 低(存在边界突刺) | - 对精度要求不高的粗略限流- 内部系统简单防护 |
| 滑动窗口(Sliding Window) | 动态移动的时间窗口,精确统计最近 N 毫秒内请求量 | ✅ 是 | ❌ 否 | ⭐⭐ 中(需维护多个窗口或日志) | ⭐⭐⭐ 高 | - API 网关 QPS 控制- 开放平台接口限流- 防刷、防爬场景 |
| 漏桶(Leaky Bucket) | 请求入桶,以恒定速率"漏水"处理;桶满则丢弃 | ❌ 否 | ✅ 是 | ⭐⭐ 中(需记录水量和时间) | ⭐⭐ 中 | - 流量整形(Traffic Shaping)- 保护下游系统免受突发冲击- 消息队列匀速消费 |
| 令牌桶(Token Bucket) | 以恒定速率生成令牌,请求消耗令牌;桶空则拒绝 | ✅ 是(只要桶中有令牌) | ❌ 否 | ⭐⭐ 中(需记录令牌数和时间) | ⭐⭐⭐ 高 | - 通用限流(最常用)- 微服务接口保护- 支持突发 + 平均速率控制 |