为什么需要接口限流?
在生产环境中,接口限流是保护系统稳定性的第一道防线。无论是防止恶意刷接口、控制第三方调用频率,还是保护下游服务不被压垮,限流都扮演着关键角色。
常见的限流算法有四种:固定窗口、滑动窗口、令牌桶和漏桶。其中令牌桶和滑动窗口是最实用的两种——前者允许突发流量,后者精度更高。
本文将从零实现一个生产级的分布式限流方案:基于 Redis + Lua 脚本保证原子性操作,通过自定义注解实现零侵入接入,开箱即用。
一、技术选型与架构设计
为什么用 Redis + Lua?
单机限流(如 Guava RateLimiter)在多实例部署时无法共享状态。Redis 作为集中式存储天然适合分布式场景,而 Lua 脚本在 Redis 中可以原子性执行,避免了竞态条件。
核心架构:
1
|
请求 → Spring Interceptor → 限流注解检查 → Redis Lua脚本 → 放行/拒绝
|
项目依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
<!-- pom.xml 核心依赖 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
</dependencies>
|
1
2
3
4
5
6
7
|
# application.yml
spring:
redis:
host: localhost
port: 6379
password:
database: 0
|
二、限流算法实现
2.1 滑动窗口限流(Sliding Window)
滑动窗口将时间轴划分为细小的格子,精确统计窗口内的请求数。我们用 Redis 的 Sorted Set 实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
/**
* 滑动窗口限流器
* 基于 Redis Sorted Set,score 为请求时间戳
*/
public class SlidingWindowRateLimiter {
private final StringRedisTemplate redisTemplate;
private final int maxRequests; // 窗口内最大请求数
private final long windowMillis; // 窗口大小(毫秒)
// Lua脚本:原子性执行滑动窗口限流逻辑
private static final String LUA_SCRIPT =
"local key = KEYS[1]\n" +
"local now = tonumber(ARGV[1])\n" +
"local window = tonumber(ARGV[2])\n" +
"local limit = tonumber(ARGV[3])\n" +
"\n" +
"-- 移除窗口外的过期记录\n" +
"redis.call('ZREMRANGEBYSCORE', key, 0, now - window)\n" +
"\n" +
"-- 统计当前窗口内的请求数\n" +
"local count = redis.call('ZCARD', key)\n" +
"\n" +
"if count < limit then\n" +
" -- 未超限,添加当前请求\n" +
" redis.call('ZADD', key, now, now .. '-' .. math.random(100000))\n" +
" redis.call('PEXPIRE', key, window)\n" +
" return 1 -- 放行\n" +
"else\n" +
" return 0 -- 拒绝\n" +
"end";
public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate,
int maxRequests, long windowMillis) {
this.redisTemplate = redisTemplate;
this.maxRequests = maxRequests;
this.windowMillis = windowMillis;
}
/**
* 尝试获取令牌
* @param key 限流键(如接口路径)
* @return true=放行, false=拒绝
*/
public boolean tryAcquire(String key) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList(key),
String.valueOf(System.currentTimeMillis()),
String.valueOf(windowMillis),
String.valueOf(maxRequests)
);
return result != null && result == 1L;
}
}
|
Lua 脚本的关键优势:ZREMRANGEBYSCORE + ZCARD + ZADD 三步操作在 Redis 中是原子执行的,不会出现并发下的计数不一致问题。
2.2 令牌桶限流(Token Bucket)
令牌桶允许突发流量——桶内有令牌时可以快速通过,令牌按固定速率补充。适合 API 网关等需要弹性限流的场景:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
/**
* 令牌桶限流器
* 基于 Redis Hash 存储桶状态
*/
public class TokenBucketRateLimiter {
private final StringRedisTemplate redisTemplate;
// Lua脚本:原子性执行令牌桶逻辑
private static final String LUA_SCRIPT =
"local key = KEYS[1]\n" +
"local capacity = tonumber(ARGV[1]) -- 桶容量\n" +
"local rate = tonumber(ARGV[2]) -- 每秒补充速率\n" +
"local now = tonumber(ARGV[3]) -- 当前时间(ms)\n" +
"local requested = tonumber(ARGV[4]) -- 请求令牌数\n" +
"\n" +
"local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')\n" +
"local tokens = tonumber(bucket[1]) or capacity\n" +
"local last_refill = tonumber(bucket[2]) or now\n" +
"\n" +
"-- 计算应该补充的令牌数\n" +
"local elapsed = (now - last_refill) / 1000.0\n" +
"local refill = math.floor(elapsed * rate)\n" +
"\n" +
"if refill > 0 then\n" +
" tokens = math.min(capacity, tokens + refill)\n" +
" last_refill = now\n" +
"end\n" +
"\n" +
"if tokens >= requested then\n" +
" tokens = tokens - requested\n" +
" redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)\n" +
" redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)\n" +
" return 1 -- 放行\n" +
"else\n" +
" redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)\n" +
" redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)\n" +
" return 0 -- 拒绝\n" +
"end";
public TokenBucketRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 尝试获取令牌
* @param key 限流键
* @param capacity 桶容量
* @param rate 每秒补充速率
* @return true=放行
*/
public boolean tryAcquire(String key, int capacity, int rate) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList(key),
String.valueOf(capacity),
String.valueOf(rate),
String.valueOf(System.currentTimeMillis()),
String.valueOf(1)
);
return result != null && result == 1L;
}
}
|
三、自定义注解实现零侵入接入
硬编码调用限流器不够优雅。我们设计两个注解,让限流配置像 Spring Security 一样声明式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
/**
* 接口限流注解
* 标注在 Controller 方法上即可生效
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/** 限流键,默认使用方法全限定名 */
String key() default "";
/** 限流算法:sliding_window 或 token_bucket */
String algorithm() default "sliding_window";
/** 滑动窗口:最大请求数 */
int maxRequests() default 100;
/** 滑动窗口:窗口大小(秒) */
int windowSeconds() default 1;
/** 令牌桶:桶容量 */
int bucketCapacity() default 50;
/** 令牌桶:每秒补充速率 */
int refillRate() default 10;
/** 限流后的提示信息 */
String message() default "请求过于频繁,请稍后再试";
}
|
四、AOP 切面处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
/**
* 限流切面 - 自动拦截带 @RateLimit 注解的方法
*/
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RateLimitAspect {
@Resource
private SlidingWindowRateLimiter slidingWindowLimiter;
@Resource
private TokenBucketRateLimiter tokenBucketLimiter;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
// 生成限流键:接口全限定名 + 可选自定义key
String key = buildKey(joinPoint, rateLimit);
boolean allowed;
String algorithm = rateLimit.algorithm();
if ("token_bucket".equals(algorithm)) {
allowed = tokenBucketLimiter.tryAcquire(
key, rateLimit.bucketCapacity(), rateLimit.refillRate());
} else {
allowed = slidingWindowLimiter.tryAcquire(key);
}
if (!allowed) {
throw new RateLimitException(rateLimit.message());
}
return joinPoint.proceed();
}
private String buildKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
String baseKey = joinPoint.getSignature().getDeclaringTypeName()
+ "." + joinPoint.getSignature().getName();
String customKey = rateLimit.key();
if (!customKey.isEmpty()) {
baseKey += ":" + customKey;
}
return "rate_limit:" + baseKey;
}
}
|
全局异常处理,返回友好的 HTTP 429 响应:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public Map<String, Object> handleRateLimit(RateLimitException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", 429);
body.put("message", ex.getMessage());
body.put("timestamp", System.currentTimeMillis());
return body;
}
}
|
五、使用示例
接入只需一行注解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
@RestController
@RequestMapping("/api/v1")
public class OrderController {
/**
* 滑动窗口限流:1秒内最多100次请求
*/
@GetMapping("/orders")
@RateLimit(maxRequests = 100, windowSeconds = 1)
public Result<List<Order>> listOrders() {
return Result.success(orderService.findAll());
}
/**
* 令牌桶限流:桶容量50,每秒补充10个令牌
*/
@PostMapping("/orders")
@RateLimit(
algorithm = "token_bucket",
bucketCapacity = 50,
refillRate = 10,
message = "下单太频繁,请3秒后再试"
)
public Result<Order> createOrder(@RequestBody OrderDTO dto) {
return Result.success(orderService.create(dto));
}
/**
* 针对特定用户的限流(用户维度)
*/
@GetMapping("/orders/my")
@RateLimit(key = "#{user.id}", maxRequests = 30, windowSeconds = 1)
public Result<List<Order>> myOrders(@CurrentUser User user) {
return Result.success(orderService.findByUserId(user.getId()));
}
}
|
六、压力测试验证
用 wrk 做简单压测,验证限流效果:
1
2
3
4
5
6
7
|
# 安装 wrk
sudo apt install wrk
# 200个并发,持续10秒,测试滑动窗口限流接口
wrk -t4 -c200 -d10s --latency http://localhost:8080/api/v1/orders
# 预期结果:约100 QPS 被放行,其余返回 429
|
也可以用 JMeter 写更精细的测试脚本,观察不同并发级别下的拒绝率变化。
七、进阶优化
7.1 限流键加 IP 前缀
实现基于客户端 IP 的限流,防止单个用户占用全局配额:
1
2
3
4
5
6
|
private String buildKey(HttpServletRequest request, ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
String clientIp = getClientIp(request);
String baseKey = joinPoint.getSignature().getDeclaringTypeName()
+ "." + joinPoint.getSignature().getName();
return "rate_limit:" + clientIp + ":" + baseKey;
}
|
7.2 多级限流组合
接口级 + 用户级 + 全局级三层限流,逐级收紧:
1
2
3
4
|
@RateLimit(key = "global", maxRequests = 10000, windowSeconds = 1) // 全局
@RateLimit(key = "#{user.id}", maxRequests = 100, windowSeconds = 1) // 用户
@GetMapping("/api/products")
public Result<List<Product>> listProducts() { ... }
|
7.3 限流指标监控
通过 Redis 的 INFO stats 或 MONITOR 命令观察限流 key 的读写频率,接入 Prometheus + Grafana 做可视化监控。
总结
本文实现了一个生产级的分布式限流方案,核心要点:
| 特性 |
实现方案 |
| 分布式一致性 |
Redis + Lua 脚本原子操作 |
| 滑动窗口 |
Sorted Set + 时间戳 |
| 令牌桶 |
Hash 存储桶状态 |
| 零侵入 |
自定义注解 + AOP 切面 |
| 多维度 |
支持接口级/用户级/全局级 |
完整代码已整理为可直接复用的 Spring Boot Starter,克隆项目后添加依赖即可使用。在生产环境中,建议根据实际业务场景选择合适的算法——令牌桶适合需要弹性突发的 API 网关,滑动窗口适合精确控制的业务接口。