0%

别再让接口被刷爆了!资深架构师的防刷实战笔记,附完整代码

高并发场景下,接口防刷是系统稳定性的重要保障。上一篇我们已经介绍了固定计数器限流和令牌桶限流,今天继续分享漏桶限流和滑动时间窗口限流这两种经典算法。

一、漏桶限流

1、什么是漏桶限流

漏桶算法(Leaky Bucket)是一种经典的流量整形算法,它模拟一个底部有固定大小出水口的桶。无论流入速度多快,流出速率都保持恒定,这确保了流量的平滑输出。

核心特点:

  • 固定流出速率:无论输入多么不稳定,输出始终保持恒定速率
  • 桶容量限制:当桶满时,新请求会被拒绝
  • 流量整形:能够将突发流量转换为平稳流量

适用场景:

  • 需要稳定输出速率的场景,如消息队列消费
  • 保护下游系统不被突发流量冲垮
  • 需要严格控制流出速率的API接口

2、代码实现

①、自定义防刷注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 漏桶限流注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LeakyBucketLimit {

/**
* 限流key,支持SpEL表达式
*/
String key() default "";

/**
* 桶的容量(最大请求数)
*/
int capacity() default 100;

/**
* 流出速率(每秒处理多少个请求)
*/
int rate() default 10;

}

②、Lua脚本

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
-- 漏桶限流算法 Lua 脚本

-- 参数说明:
-- 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])
local now = tonumber(ARGV[3])
local requestCount = tonumber(ARGV[4])

-- 获取桶的当前状态
local bucketInfo = redis.call('hmget', key, 'water', 'lastLeakTime')
local currentWater = 0
local lastLeakTime = now

-- 如果桶存在,获取当前水量和上次漏水时间
if bucketInfo[1] then
currentWater = tonumber(bucketInfo[1])
end

if bucketInfo[2] then
lastLeakTime = tonumber(bucketInfo[2])
end

-- 计算从上次漏水到现在的漏出量
local leakAmount = (now - lastLeakTime) * rate
if leakAmount > 0 then
currentWater = math.max(0, currentWater - leakAmount)
lastLeakTime = now
end

-- 检查桶是否有足够空间容纳新请求
if currentWater + requestCount <= capacity then
-- 允许请求,更新桶状态
currentWater = currentWater + requestCount
redis.call('hmset', key, 'water', currentWater, 'lastLeakTime', lastLeakTime)
redis.call('expire', key, 3600) -- 设置过期时间,防止内存泄漏
return 1 -- 允许访问
else
-- 桶已满,拒绝请求
return 0 -- 被限流
end

③、Lua脚本配置

1
2
3
4
5
6
7
8
9
10
/**
* 漏桶限流 Lua 脚本
*/
@Bean
public RedisScript<Long> leakyBucketScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(leakyLuaFile);
redisScript.setResultType(Long.class);
return redisScript;
}

④、漏桶限流服务类

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
@Slf4j
@Service
public class LeakyBucketRateLimiter {


@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private RedisScript<Long> leakyBucketScript;

/**
* 尝试获取通行证
* @param key 限流key
* @param capacity 桶容量
* @param rate 流出速率(每秒请求数)
* @param requestCount 请求数量
* @return true-允许访问,false-被限流
*/
public boolean tryAcquire(String key, int capacity, int rate, int requestCount) {
long now = System.currentTimeMillis() / 1000; // 使用秒级时间戳

Long result = redisTemplate.execute(leakyBucketScript,
Collections.singletonList(key),
capacity,rate,now,requestCount);

return result != null && result == 1;
}

}

⑤、漏桶限流切片核心逻辑

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
@Slf4j
@Aspect
@Component
public class LeakyBucketLimitAspect {

@Autowired
private LeakyBucketRateLimiter rateLimiter;

@Around("@annotation(leakyBucketLimit)")
public Object around(ProceedingJoinPoint joinPoint, LeakyBucketLimit leakyBucketLimit) throws Throwable {
String key = buildRateLimitKey(joinPoint, leakyBucketLimit);
int capacity = leakyBucketLimit.capacity();
int rate = leakyBucketLimit.rate();

LeakyBucketRateLimiter.BucketStatus bucketStatus = rateLimiter.getBucketStatus(key);
log.debug("bucket status: key={}, water={},lastLeakTime={},ttl={}",key,
bucketStatus.getCurrentWater(), bucketStatus.getLastLeakTime(), bucketStatus.getTtl());
if (!rateLimiter.tryAcquire(key, capacity, rate, 1)) {
throw new ApiException(ApiResultEnum.REQUEST_LIMIT);
}

return joinPoint.proceed();
}

/**
* 构建限流key
*/
private String buildRateLimitKey(ProceedingJoinPoint joinPoint, LeakyBucketLimit rateLimit) {
String key = rateLimit.key();

// 如果key为空,使用默认格式
if (key.isEmpty()) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String className = method.getDeclaringClass().getSimpleName();
String methodName = method.getName();

// 尝试获取用户信息
String userKey = getCurrentUserId();
return String.format("leaky_bucket:%s:%s:%s", className, methodName, userKey);
}

// 如果key包含SpEL表达式,进行解析
if (key.contains("#")) {
return AopUtil.parseSpel(key, joinPoint);
}
return key;
}

private String getCurrentUserId() {
// 实际项目中从安全上下文获取
return "user123";
}
}

⑥、controller示例

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
@RestController
@RequestMapping("/leakyRate")
public class LeakyRateController {

@Autowired
private LeakyBucketRateLimiter leakyBucketRateLimiter;


/**
* 测试
*/
@GetMapping("/test1")
@LeakyBucketLimit(rate = 1, capacity = 3)
public R test1() {
//TODO ...
return R.ok();
}

@GetMapping("/test2")
@LeakyBucketLimit(key = "leaky_rate:test2",rate = 2, capacity = 1)
public R test2() {
//TODO ...
return R.ok();
}

@LeakyBucketLimit(key = "'user :' + #username", rate = 2, capacity = 10)
@GetMapping("/search")
public R search(@RequestParam String username) {
// 搜索逻辑 - 这里key会根据username动态变化
return R.ok("username:" + username);
}

}

二、滑动时间窗口限流

1、什么是滑动时间窗口限流

滑动时间窗口算法将时间线划分为多个小的时间片段,通过滑动窗口的方式统计最近一段时间内的请求次数。相比固定窗口,它能更好地处理时间边界问题。

算法优势:

  • 平滑过渡:解决了固定窗口在边界时间的突发问题
  • 精准控制:精确统计每个时间窗口内的请求数
  • 灵活配置:支持任意时间窗口大小

适用场景:

  • API调用频率限制
  • 用户行为频率控制
  • 需要精确时间控制的限流场景

2、代码实现

①、自定义防刷注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 滑动时间窗口计数器限流注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SlidingWindowLimit {

/**
* 限流key,支持SpEL表达式
*/
String key() default "";

/**
* 时间窗口大小(秒)
*/
int window() default 60;

/**
* 时间窗口内允许的最大请求数
*/
int maxCount() default 100;

}

②、Lua脚本

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
-- 滑动时间窗口计数器限流算法

-- 参数说明:
-- KEYS[1]: 限流的key
-- ARGV[1]: 时间窗口大小(秒)
-- ARGV[2]: 时间窗口内允许的最大请求数
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 本次请求数量(默认为1)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local maxCount = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requestCount = tonumber(ARGV[4]) or 1

-- 计算时间窗口的起始时间戳
local windowStart = now - window

-- 移除时间窗口之前的数据
redis.call('zremrangebyscore', key, 0, windowStart)

-- 获取当前时间窗口内的请求总数
local currentCount = redis.call('zcard', key)

-- 检查是否超过限制
if currentCount + requestCount <= maxCount then
-- 没有超过限制,添加当前请求
for i = 1, requestCount do
-- 使用毫秒级时间戳+随机数确保成员唯一性
local member = now * 1000 + math.random(0, 999)
redis.call('zadd', key, member, member)
end

-- 设置key的过期时间为窗口大小+1秒,确保数据自动清理
redis.call('expire', key, window + 1)

return 1 -- 允许访问
else
-- 超过限制,拒绝请求
return 0 -- 被限流
end

③、Lua脚本配置

1
2
3
4
5
6
7
8
9
10
/**
* 滑动时间窗口计数器限流 Lua 脚本
*/
@Bean
public RedisScript<Long> slidingWindowScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(slidingWindowLuaFile);
redisScript.setResultType(Long.class);
return redisScript;
}

④、滑动时间窗口限流服务类

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
@Slf4j
@Service
public class SlidingWindowRateLimiter {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private RedisScript<Long> slidingWindowScript;

/**
* 尝试获取通行证
* @param key 限流key
* @param window 时间窗口大小(秒)
* @param maxCount 时间窗口内允许的最大请求数
* @param requestCount 请求数量
* @return true-允许访问,false-被限流
*/
public boolean tryAcquire(String key, int window, int maxCount, int requestCount) {
long now = System.currentTimeMillis() / 1000; // 使用秒级时间戳
Long result = redisTemplate.execute(slidingWindowScript,
Collections.singletonList(key), window, maxCount, now, requestCount);

return result != null && result == 1;
}

}

⑤、滑动时间窗口限流切片核心逻辑

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
@Slf4j
@Aspect
@Component
public class SlidingWindowLimitAspect {

private final SlidingWindowRateLimiter rateLimiter;

public SlidingWindowLimitAspect(SlidingWindowRateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}

@Around("@annotation(slidingWindowLimit)")
public Object around(ProceedingJoinPoint joinPoint, SlidingWindowLimit slidingWindowLimit) throws Throwable {
String key = buildRateLimitKey(joinPoint, slidingWindowLimit);
int window = slidingWindowLimit.window();
int maxCount = slidingWindowLimit.maxCount();

if (!rateLimiter.tryAcquire(key, window, maxCount, 1)) {
throw new ApiException(ApiResultEnum.REQUEST_LIMIT);
}

return joinPoint.proceed();
}

/**
* 构建限流key
*/
private String buildRateLimitKey(ProceedingJoinPoint joinPoint, SlidingWindowLimit rateLimit) {
String key = rateLimit.key();

// 如果key为空,使用默认格式
if (key.isEmpty()) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String className = method.getDeclaringClass().getSimpleName();
String methodName = method.getName();

// 尝试获取用户信息
String userKey = getCurrentUserId();
return String.format("sliding_window:%s:%s:%s", className, methodName, userKey);
}

// 如果key包含SpEL表达式,进行解析
if (key.contains("#")) {
return AopUtil.parseSpel(key, joinPoint);
}
return key;
}

private String getCurrentUserId() {
// 实际项目中从安全上下文获取
return "user123";
}
}

⑥、controller示例

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
@RestController
@RequestMapping("/SlidingWindowRate")
public class SlidingWindowRateController {

@Autowired
private SlidingWindowRateLimiter slidingWindowRateLimiter;

/**
* 测试
*/
@GetMapping("/test1")
@SlidingWindowLimit(window = 3, maxCount = 1)
public R test1() {
//TODO ...
return R.ok();
}

@GetMapping("/test2")
@SlidingWindowLimit(key = "sliding_window:test2",window = 60, maxCount = 5)
public R test2() {
//TODO ...
return R.ok();
}

@SlidingWindowLimit(key = "'user :' + #username")
@GetMapping("/search")
public R search(@RequestParam String username) {
// 搜索逻辑 - 这里key会根据username动态变化
return R.ok("username:" + username);
}

}

redis存储KEY的示例图

三、总结

限流算法选择指南

算法类型 适用场景 优点 缺点
漏桶限流 需要稳定输出速率、保护下游系统 输出稳定、平滑流量 无法应对突发流量、响应延迟
滑动窗口 精确控制时间段内的请求次数 边界处理优秀、精确控制 实现相对复杂、内存占用较多
令牌桶 允许一定程度的突发流量 支持突发流量、灵活性高 可能瞬间打满系统
固定窗口 简单计数场景、对精度要求不高 实现简单、性能高 边界时间可能超限

资源获取:

本文完整代码已上传至 GitHub,欢迎 Star ⭐ 和 Fork

原创不易,如有收获,请点赞收藏支持! 🔥

您的打赏,是我创作的动力!不给钱?那我只能靠想象力充饥了。