Redis 限流最佳实践:令牌桶与滑动窗口全流程实现
在分布式系统中,API 限流是保护系统稳定性的重大手段。本文将介绍如何使用 Spring Boot + Redis + 自定义注解 + AOP 实现**可选限流算法(滑动窗口 / 令牌桶)**的高效方案。
实现原理
- 滑动窗口算法:高精度,严格限制 QPS。
- 令牌桶算法:允许突发流量,平滑速率控制。
- Redis + Lua 脚本:保证分布式环境下的原子性和高性能。
- 自定义注解 + AOP:灵活接入,无侵入。
1. 添加依赖
<dependencies>
<!-- Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 工具包 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
2. 定义注解
支持选择 限流算法、维度、参数。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
/** 限流 key 前缀 */
String key() default "rate_limit:";
/** 算法模式:滑动窗口 / 令牌桶 */
Mode mode() default Mode.SLIDING_WINDOW;
/** ---------------- 滑动窗口参数 ---------------- */
int time() default 60; // 时间窗口(秒)
int count() default 100; // 窗口内允许的请求数
/** ---------------- 令牌桶参数 ---------------- */
long capacity() default 100; // 桶容量
long refillTokens() default 100; // 每次补充令牌数
long refillIntervalMs() default 1000; // 补充周期(毫秒)
long requestedTokens() default 1; // 单次请求消耗令牌数
long idleTtlMs() default 300000; // 空桶过期(毫秒)
/** 限流维度:方法、IP、用户 */
LimitType limitType() default LimitType.DEFAULT;
enum Mode {
SLIDING_WINDOW,
TOKEN_BUCKET
}
}
public enum LimitType {
DEFAULT, // 方法级别
IP, // 客户端 IP
USER // 用户ID
}
3. Redis 限流服务实现
(1) 滑动窗口 + Lua 脚本
@Service
public class SlidingWindowRateLimiter {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> script;
public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.script = new DefaultRedisScript<>();
this.script.setScriptText(buildLua());
this.script.setResultType(Long.class);
}
private String buildLua() {
return ""
+ "local key = KEYS[1]
"
+ "local now = tonumber(ARGV[1])
"
+ "local window = tonumber(ARGV[2])
"
+ "local limit = tonumber(ARGV[3])
"
+ "
"
+ "redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
"
+ "local count = redis.call('ZCARD', key)
"
+ "if count < limit then
"
+ " redis.call('ZADD', key, now, now)
"
+ " redis.call('PEXPIRE', key, window)
"
+ " return 1
"
+ "else
"
+ " return 0
"
+ "end";
}
public boolean allow(String key, int time, int count) {
long now = System.currentTimeMillis();
Long result = redisTemplate.execute(
script,
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(time * 1000L),
String.valueOf(count));
return result != null && result == 1;
}
}
(2) 令牌桶 + Lua 脚本
@Service
public class TokenBucketRateLimiter {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<List> script;
public TokenBucketRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.script = new DefaultRedisScript<>();
this.script.setScriptText(buildLua());
this.script.setResultType(List.class);
}
private String buildLua() {
return ""
+ "local key = KEYS[1]
"
+ "local now = tonumber(ARGV[1])
"
+ "local capacity = tonumber(ARGV[2])
"
+ "local refillTokens = tonumber(ARGV[3])
"
+ "local refillIntervalMs = tonumber(ARGV[4])
"
+ "local requested = tonumber(ARGV[5])
"
+ "local idleTtlMs = tonumber(ARGV[6])
"
+ "
"
+ "local tokens = tonumber(redis.call('HGET', key, 'tokens'))
"
+ "local lastTs = tonumber(redis.call('HGET', key, 'ts'))
"
+ "if tokens == nil then
"
+ " tokens = capacity
"
+ " lastTs = now
"
+ "else
"
+ " if lastTs == nil then lastTs = now end
"
+ " local delta = now - lastTs
"
+ " if delta > 0 then
"
+ " local add = math.floor(delta * refillTokens / refillIntervalMs)
"
+ " if add > 0 then
"
+ " tokens = math.min(capacity, tokens + add)
"
+ " lastTs = lastTs + math.floor(add * refillIntervalMs / refillTokens)
"
+ " end
"
+ " end
"
+ "end
"
+ "local allowed = 0
"
+ "if tokens >= requested then
"
+ " tokens = tokens - requested
"
+ " allowed = 1
"
+ "end
"
+ "redis.call('HSET', key, 'tokens', tokens, 'ts', now)
"
+ "if idleTtlMs > 0 then redis.call('PEXPIRE', key, idleTtlMs) end
"
+ "return {allowed, tokens, now}
";
}
public boolean allow(String key,
long capacity,
long refillTokens,
long refillIntervalMs,
long requestedTokens,
long idleTtlMs) {
long now = System.currentTimeMillis();
@SuppressWarnings("unchecked")
List<Long> ret = (List<Long>) redisTemplate.execute(
script,
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(capacity),
String.valueOf(refillTokens),
String.valueOf(refillIntervalMs),
String.valueOf(requestedTokens),
String.valueOf(idleTtlMs)
);
return ret != null && !ret.isEmpty() && ret.get(0) == 1L;
}
}
4. AOP 切面
@Aspect
@Component
public class RateLimiterAspect {
private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);
private final SlidingWindowRateLimiter slidingWindowLimiter;
private final TokenBucketRateLimiter tokenBucketLimiter;
private final HttpServletRequest request;
public RateLimiterAspect(SlidingWindowRateLimiter slidingWindowLimiter,
TokenBucketRateLimiter tokenBucketLimiter,
HttpServletRequest request) {
this.slidingWindowLimiter = slidingWindowLimiter;
this.tokenBucketLimiter = tokenBucketLimiter;
this.request = request;
}
@Around("@annotation(limit)")
public Object around(ProceedingJoinPoint pjp, RateLimiter limit) throws Throwable {
String key = buildKey(limit.key(), limit.limitType(), pjp);
boolean allowed;
if (limit.mode() == RateLimiter.Mode.SLIDING_WINDOW) {
allowed = slidingWindowLimiter.allow(key, limit.time(), limit.count());
} else {
allowed = tokenBucketLimiter.allow(
key,
limit.capacity(),
limit.refillTokens(),
limit.refillIntervalMs(),
limit.requestedTokens(),
limit.idleTtlMs()
);
}
if (allowed) {
return pjp.proceed();
} else {
log.warn("限流触发: key={}, mode={}", key, limit.mode());
Map<String, Object> result = new HashMap<>();
result.put("code", 429);
result.put("message", "请求过于频繁,请稍后再试");
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(result);
}
}
private String buildKey(String prefix, LimitType type, ProceedingJoinPoint pjp) {
StringBuilder sb = new StringBuilder(prefix);
if (type == LimitType.IP) {
sb.append(clientIp());
} else if (type == LimitType.USER) {
String userId = request.getHeader("X-User-Id");
sb.append(userId != null ? userId : "anonymous");
} else {
MethodSignature sig = (MethodSignature) pjp.getSignature();
Method m = sig.getMethod();
sb.append(m.getDeclaringClass().getName()).append(".").append(m.getName());
}
return sb.toString();
}
private String clientIp() {
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
int idx = ip.indexOf(',');
return idx > 0 ? ip.substring(0, idx).trim() : ip.trim();
}
ip = request.getHeader("X-Real-IP");
return StringUtils.isBlank(ip) || "unknown".equalsIgnoreCase(ip) ? request.getRemoteAddr() : ip;
}
}
5. 示例 Controller
@RestController
@RequestMapping("/api")
public class TestController {
/** 滑动窗口:10秒最多5次 */
@RateLimiter(key = "test:", mode = RateLimiter.Mode.SLIDING_WINDOW, time = 10, count = 5)
@GetMapping("/test1")
public String test1() {
return "ok";
}
/** 令牌桶:容量20,每秒补充10个,用户维度限流 */
@RateLimiter(key = "order:", mode = RateLimiter.Mode.TOKEN_BUCKET,
capacity = 20, refillTokens = 10, refillIntervalMs = 1000,
requestedTokens = 1, limitType = LimitType.USER)
@PostMapping("/order")
public String order() {
return "ok";
}
}
6. Redis 配置
spring:
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
7. 压测验证
# 滑动窗口
for i in {1..20}; do
curl -i http://localhost:8080/api/test1
done
# 令牌桶 (突发流量 + 稳态速率测试)
wrk -t2 -c20 -d30s http://localhost:8080/api/order
8. 总结
本文实现了基于 Redis 的 API 限流组件,支持:
- 滑动窗口限流:严格控制请求速率,高精度。
- 令牌桶限流:支持突发流量,速率可控。
- 多维度限流:方法 / IP / 用户。
- 分布式支持:Redis + Lua 保证原子性,适合微服务架构。
- 低侵入性:注解 + AOP 接入,业务无感知。
- 可扩展:可接入 Prometheus 监控,或增加降级策略。
这种方案在网关、微服务、接口保护场景都能直接落地,是企业级 API 防护的最佳实践之一。

© 版权声明
文章版权归作者所有,未经允许请勿转载。如内容涉嫌侵权,请在本页底部进入<联系我们>进行举报投诉!
THE END















暂无评论内容