使用 Redis 实现接口节流

HYF Lv3

在现代互联网应用中,API 接口的稳定性和可用性至关重要。面对高并发的请求量,如何有效地管理和控制流量成为了开发者必须解决的问题。接口节流(Rate Limiting)作为一种流量控制手段,可以帮助我们防止系统过载,保护服务免受恶意请求的影响,并确保资源的公平使用。

什么是接口节流

接口节流的定义

接口节流(Rate Limiting)是一种控制系统流量的方法,用于限制特定时间段内允许访问API或服务的请求数量。通过限制请求速率,可以防止系统过载、提高系统的稳定性和可靠性,并确保所有用户能够公平地使用系统资源。接口节流通常通过限制单个客户端或用户在指定时间窗口内的请求次数来实现。

为什么需要接口节流

接口节流的主要目的是保护系统免受过载和滥用,其重要性体现在以下几个方面

接口节流的主要目的是保护系统免受过载和滥用,其重要性体现在以下几个方面

防止滥用和恶意攻击:接口节流可以有效地限制单个用户在单位时间内的请求次数,从而防止恶意用户对系统进行DDOS攻击或暴力破解等操作

保护系统资源:通过限制请求速率,可以避免某些关键资源被过度消耗,确保系统的稳定性和可靠性

提升用户体验:合理的接口节流策略可以避免因过载导致的服务不可用,从而提高整体用户体验

公平分配资源:在多用户环境中,接口节流可以确保每个用户都能公平地使用系统资源,防止个别用户的过度消耗

常见的接口节流策略

固定窗口计数器(Fixed Window Counter)

原理:在固定时间窗口内(如1分钟),对请求次数进行计数。如果请求次数超过预设的限制,则拒绝后续请求

优点:实现简单,易于理解

缺点:可能在窗口切换时出现瞬时请求突增的问题。

滑动窗口(Sliding Window)

原理:将时间窗口细分为多个小窗口,并记录每个小窗口的请求次数。通过滑动窗口计算当前时间段内的总请求次数,确保请求速率的平滑限制

优点:可以更平滑地控制请求速率,避免瞬时请求突增的问题

缺点:实现相对复杂,需要更多的存储和计算资源

令牌桶(Token Bucket)

原理:系统维护一个令牌桶,按固定速率向桶中添加令牌。每次请求需要消耗一个令牌,如果桶中没有令牌则拒绝请求。桶的容量和添加速率决定了请求速率的限制

优点:可以处理突发请求,同时保证长期请求速率的限制

缺点:需要维护令牌桶的状态,可能增加系统开销

漏桶(Leaky Bucket)

原理:系统维护一个漏桶,按固定速率漏出请求。每个请求会加入到桶中,如果桶满则拒绝请求。漏桶的容量和漏出速率决定了请求速率的限制

优点:能够平滑处理请求,适用于需要严格控制请求速率的场景

缺点:实现复杂度较高,需要维护漏桶的状态

以上是几种常见的接口节流策略,各有优缺点,开发者可以根据具体需求选择合适的策略来实现接口节流。在接下来的章节中,我们将具体介绍如何单纯利用 Redis 实现这些节流策略

使用 Redis 实现接口节流

工具类及枚举类

Redis 工具类
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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
/**
* @author -侑枫
* @date 2024/7/30 22:55:17
*/
@Component
public class RedisUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;

// ========================== Key Operations ==========================

/**
* 判断key是否存在
*
* @param key 键
* @return true 存在 false 不存在
*/
public boolean hasKey(String key) {
try {
return redisTemplate.hasKey(key);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

// ========================== Expiration Operations ==========================

/**
* 指定缓存失效时间
*
* @param key 键
* @param time 时间(秒)
* @return 操作是否成功
*/
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

/**
* 指定缓存失效时间
*
* @param key 键
* @param time 时间
* @param timeUnit 单位
* @return 操作是否成功
*/
public boolean expire(String key, long time, TimeUnit timeUnit) {
try {
if (time > 0) {
redisTemplate.expire(key, time, timeUnit);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

// ========================== String Operations ==========================

/**
* 普通缓存获取
*
* @param key 键
* @return
*/
public Object get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key);
}

/**
* 递增
*
* @param key 键
* @param delta 增量
* @return 增加后的值
*/
public long incr(String key, long delta) {
ExceptionTypeEnum.PARAMS.isTrue(delta > 0, "递增因子必须大于0");
return redisTemplate.opsForValue().increment(key, delta);
}

// ========================== Hash Operations ==========================

/**
* HashGet
*
* @param key 键 不能为null
* @param item 项 不能为null
* @return
*/
public Object hget(String key, String item) {
return redisTemplate.opsForHash().get(key, item);
}

/**
* 向一张hash表中放入数据,如果不存在将创建
*
* @param key 键
* @param item 项
* @param value 值
* @return 操作是否成功
*/
public boolean hset(String key, String item, Object value) {
try {
redisTemplate.opsForHash().put(key, item, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

// ========================== Set Operations ==========================

/**
* 根据value从一个set中查询,是否存在
*
* @param key 键
* @param value 值
* @return true 存在 false 不存在
*/
public boolean shaskey(String key, Object value) {
try {
return redisTemplate.opsForSet().isMember(key, value);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

// ========================== List Operations ==========================

/**
* 获取list缓存的长度
*
* @param key 键
* @return list的长度
*/
public long lgetListSize(String key) {
try {
return redisTemplate.opsForList().size(key);
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}

/**
* 将list放入缓存
*
* @param key 键
* @param value 值
* @return 操作是否成功
*/
public boolean lset(String key, Object value) {
try {
redisTemplate.opsForList().rightPush(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

/**
* 移除N个值为value
*
* @param key 键
* @param count 移除多少个
* @param value 值
* @return 移除的个数
*/
public long lremove(String key, long count, Object value) {
try {
Long remove = redisTemplate.opsForList().remove(key, count, value);
return remove;
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}

// ========================== Sorted Set Operations ==========================

/**
* 移除在指定键 `key` 的有序集合中,分数在 `min` 和 `max` 之间(包括 `min` 和 `max`)的所有成员。
*
* @param key 有序集合的键
* @param min 最小分数(包含)
* @param max 最大分数(包含)
*/
public void zremrangeByScore(String key, double min, double max) {
redisTemplate.opsForZSet().removeRangeByScore(key, min, max);
}

/**
* 向指定键 `key` 的有序集合中添加一个成员,成员的分数为 `score`。
* 如果成员已经存在,则更新其分数为新值。
*
* @param key 有序集合的键
* @param score 成员的分数
* @param member 要添加的成员
*/
public void zadd(String key, double score, Object member) {
redisTemplate.opsForZSet().add(key, member, score);
}

/**
* 返回指定键 `key` 的有序集合中成员的数量。
*
* @param key 有序集合的键
* @return 有序集合中成员的数量
*/
public long zcard(String key) {
return redisTemplate.opsForZSet().zCard(key);
}
}
时间类型枚举类
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
/**
* @author -侑枫
* @date 2024/7/30 23:09:30
*/
@Getter
@AllArgsConstructor
public enum TimeUnitEnum {
/**
* 秒
*/
SECONDS("4", TimeUnit.SECONDS),
/**
* 分钟
*/
MINUTES("3", TimeUnit.MINUTES),
/**
* 小时
*/
HOURS("2", TimeUnit.HOURS),
/**
* 天
*/
DAY("1", TimeUnit.DAYS);

/**
* 类型值
*/
private final String value;

/**
* 类型标签
*/
private final TimeUnit label;

public static TimeUnit getLabelByValue(String value) {
if (ObjectUtils.isEmpty(value)) {
return MINUTES.label;
}
for (TimeUnitEnum unit : TimeUnitEnum.values()) {
if (unit.value.equals(value)) {
return unit.label;
}
}
return MINUTES.label;
}
}

(为了更清晰地理解代码,以下示例将直接使用硬编码的值。未来在实际应用中,可自行将这些硬编码的值调整为常量或配置文件写入)

固定窗口计数器(Fixed Window Counter)

思路:在固定的时间窗口内对请求进行计数,例如每分钟最多允许 100 个请求。使用 Redis 的字符串(String)类型来存储计数器,每次请求时递增计数器,并设置一个到期时间以便在窗口结束时重置计数器。

实现步骤

  • 使用 Redis 的字符串类型存储计数器
  • 使用 RedisINCR 命令增加计数器
  • 使用 EXPIRE 命令设置计数器的到期时间,例如 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
/**
* @author -侑枫
* @date 2024/7/30 23:11:12
*/
@Component
@Slf4j
@AllArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {

private final RedisUtil redisUtil;

@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
try {
// 查看是否存在于白名单,是的话直接放行
if (redisUtil.shaskey("whitelist", request.getRequestURI())) {
return true;
}
} catch (Exception e) {
/*
这个异常捕获 主要是为了检查Redis是否有问题
但不能将下面校验是否放行的断言包起来,否则异常抛不出去 = 白写
*/
log.error("Redis 接口节流异常", e);
// 如果 redis 处理出现问题(宕机什么的),应直接放行才不会影响到主业务。
return true;
}
// 获取最大请求数
int maxRequestPer = redisUtil.hasKey("rateLimit") ?
(Integer) redisUtil.get("rateLimit") : 100;
String ipAddress = request.getRemoteAddr();
String key = String.format("%s::%s", "rateLimit", ipAddress);

// 获取当前已请求次数
long count = redisUtil.incr(key, 1);
// 如果是第一次请求,则设置过期时间
if (count == 1) {
// 第一次请求,设置过期时间
TimeUnit timeUnit = redisUtil.hasKey("timeUnit") ?
TimeUnitEnum.getLabelByValue((String) redisUtil.get("timeUnit")) :
TimeUnitEnum.getLabelByValue("3");
int expirationTime = redisUtil.hasKey("expirationTime") ?
(int) redisUtil.get("expirationTime") : 1;
redisUtil.expire(key, expirationTime, timeUnit);
}
ExceptionTypeEnum.BUSINESS.isTrue(count <= maxRequestPer, "请求过于频繁,请稍后重试");

return true;
}
}
这里的滑动窗口实现思路:每次处理请求时,从 Redis 获取最大请求数限制,并记录当前 IP 的请求次数。如果是第一次请求,则设置 Redis 键的过期时间。检查请求次数是否超过限制;如果超出则抛出异常,否则继续处理请求。

滑动窗口(Sliding Window)

思路:将时间窗口细分为多个小窗口,并记录每个小窗口的请求次数,通过滑动窗口计算当前时间段内的总请求次数。可以使用 Redis 的列表(List)或有序集合(Sorted Set)来记录请求的时间戳

实现步骤

  • 使用 RedisZADD 命令将请求的时间戳添加到有序集合中。
  • 使用 ZREMRANGEBYSCORE 命令移除超出时间窗口的旧请求。
  • 使用 ZCOUNT / ZCARD 命令统计当前时间窗口内的请求次数,并根据结果决定是否拒绝请求。

代码实现

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
/**
* @author -侑枫
* @date 2024/7/30 23:11:12
*/
@Component
@Slf4j
@AllArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {

private final RedisUtil redisUtil;

@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
try {
// 查看是否存在于白名单,是的话直接放行
if (redisUtil.shaskey("whitelist", request.getRequestURI())) {
return true;
}
} catch (Exception e) {
/*
这个异常捕获 主要是为了检查Redis是否有问题
但不能将下面校验是否放行的断言包起来,否则异常抛不出去 = 白写
*/
log.error("Redis 接口节流异常", e);
// 如果 redis 处理出现问题(宕机什么的),应直接放行才不会影响到主业务。
return true;
}
// 获取最大请求数
int maxRequestPer = redisUtil.hasKey("rateLimit")
? (Integer) redisUtil.get("rateLimit") : 10;
String ipAddress = request.getRemoteAddr();
String key = String.format("%s::%s", "rateLimit", ipAddress);
// 获取当前时间戳
long now = System.currentTimeMillis();
// 获取滑动窗口大小
TimeUnit timeUnit = redisUtil.hasKey("timeUnit")
? TimeUnitEnum.getLabelByValue((String) redisUtil.get("timeUnit")) : TimeUnitEnum.getLabelByValue("4");
// 清理滑动窗口之外的过期请求记录
int expirationTime = redisUtil.hasKey("expirationTime") ? (int) redisUtil.get("expirationTime") : 6;
long windowSizeInMillis = timeUnit.toMillis(expirationTime);
redisUtil.zremrangeByScore(key, 0, now - windowSizeInMillis);
// 添加当前请求记录
redisUtil.zadd(key, now, now);
// 获取当前滑动窗口内的请求数
long count = redisUtil.zcard(key);

ExceptionTypeEnum.BUSINESS.isTrue(count <= maxRequestPer, "请求过于频繁,请稍后重试");
// 设置key的过期时间,防止长期不访问导致Redis中存有大量无效数据
redisUtil.expire(key, windowSizeInMillis, TimeUnit.MILLISECONDS);
return true;
}
}
这里的滑动窗口实现思路:每次处理请求时,首先获取当前的时间戳,并清理掉窗口之外的过期请求记录。然后,将当前请求的时间戳添加到 Redis 的有序集合中。接着,统计滑动窗口内的请求数量。如果请求数超过了设定的限制,则抛出异常;否则,继续处理请求。为了避免 Redis 中长期存储无效数据,还需要为键设置过期时间。

令牌桶(Token Bucket)

思路:通过维护一个令牌桶,按固定速率向桶中添加令牌,每次请求需要消耗一个令牌。如果桶中没有令牌则拒绝请求。

实现步骤

  • 检查Redis中是否存在该 key。如果不存在,则初始化令牌桶,设置令牌数为maxTokens,并记录当前时间戳
  • Redis 中获取当前令牌数和上次请求的时间戳,计算自上次请求以来经过的时间,根据补充率计算新令牌数
  • 如果新令牌数大于 0,消耗一个令牌,并更新Redis中的令牌数和时间戳,如果没有足够的令牌,抛出异常,拒绝请求

代码实现

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
64
65
66
67
/**
* @author -侑枫
* @date 2024/7/30 23:11:12
*/
@Component
@Slf4j
@AllArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {

private final RedisUtil redisUtil;

@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
try {
// 查看是否存在于白名单,是的话直接放行
if (redisUtil.shaskey("whitelist", request.getRequestURI())) {
return true;
}
} catch (Exception e) {
/*
这个异常捕获 主要是为了检查Redis是否有问题
但不能将下面校验是否放行的断言包起来,否则异常抛不出去 = 白写
*/
log.error("Redis 接口节流异常", e);
// 如果 redis 处理出现问题(宕机什么的),应直接放行才不会影响到主业务。
return true;
}
// 获取令牌桶的参数
int maxTokens = redisUtil.hasKey("rateLimit") ? (int) redisUtil.get("rateLimit") : 10;
// 每秒补充的令牌数
int refillRate = redisUtil.hasKey("refillRate") ? (int) redisUtil.get("refillRate") : 1;
String ipAddress = request.getRemoteAddr();
String key = String.format("%s::%s", "rateLimit", ipAddress);

// 获取当前时间戳(秒)
long now = System.currentTimeMillis() / 1000;

// 初始化令牌桶
if (!redisUtil.hasKey(key)) {
redisUtil.hset(key, "tokens", String.valueOf(maxTokens));
redisUtil.hset(key, "timestamp", String.valueOf(now));
}

// 获取当前令牌数和上次请求的时间戳
String tokensStr = (String) redisUtil.hget(key, "tokens");
String lastRefillTimestampStr = (String) redisUtil.hget(key, "timestamp");

int tokens = tokensStr != null ? Integer.parseInt(tokensStr) : maxTokens;
long lastRefillTimestamp = lastRefillTimestampStr != null ? Long.parseLong(lastRefillTimestampStr) : now;

// 计算自上次请求后应补充的令牌数
long elapsedSeconds = now - lastRefillTimestamp;
int newTokens = (int) Math.min(maxTokens, tokens + (elapsedSeconds * refillRate));

ExceptionTypeEnum.Business.isTrue(newTokens > 0, "请求过于频繁,请稍后重试");

// 允许请求,消耗一个令牌
redisUtil.hset(key, "tokens", String.valueOf(newTokens - 1));
redisUtil.hset(key, "timestamp", String.valueOf(now));

// 设置Key的过期时间(设定为比桶最大容量时间稍长)
int expirationTime = maxTokens / refillRate + 1;
redisUtil.expire(key, expirationTime, TimeUnit.SECONDS);

return true;
}
}
令牌桶算法的实现思路如下:次请求时初始化令牌桶,包括设置令牌数量和记录上次请求的时间戳。每次请求时,根据当前时间和上次请求时间计算需要补充的令牌数。如果令牌数大于零,则允许请求并消耗一个令牌;否则,拒绝请求。最后,更新令牌桶的状态,并为 Redis 键设置过期时间。

漏桶(Leaky Bucket)

思路:通过维护一个漏桶,按固定速率处理请求。每个请求会加入到桶中,如果桶满则拒绝请求

实现步骤

  • 使用 Redis 的列表存储请求。
  • 定期通过后台任务按固定速率处理列表中的请求。
  • 每次请求时尝试加入到列表中,如果列表长度超过容量则拒绝请求。

代码实现

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
/**
* @author -侑枫
* @date 2024/7/30 23:11:12
*/
@Component
@Slf4j
@AllArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {

private final RedisUtil redisUtil;

@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
try {
// 查看是否存在于白名单,是的话直接放行
if (redisUtil.shaskey("whitelist", request.getRequestURI())) {
return true;
}
} catch (Exception e) {
/*
这个异常捕获 主要是为了检查Redis是否有问题
但不能将下面校验是否放行的断言包起来,否则异常抛不出去 = 白写
*/
log.error("Redis 接口节流异常", e);
// 如果 redis 处理出现问题(宕机什么的),应直接放行才不会影响到主业务。
return true;
}
// 获取最大请求数(桶的容量)
int bucketCapacity = redisUtil.hasKey("bucketCapacity") ?
(int) redisUtil.get("bucketCapacity") : 100;
// 获取请求流出速率(桶的漏水速度)
int leakRate = redisUtil.hasKey("leakRate") ?
(int) redisUtil.get("leakRate") : 1;

String ipAddress = request.getRemoteAddr();
String key = String.format("%s::%s", "rateLimit", ipAddress);

long now = System.currentTimeMillis();
long windowSizeInMillis = TimeUnit.SECONDS.toMillis(1);

// 清理超出窗口大小的旧请求记录
redisUtil.lremove(key, 0, String.valueOf(now - windowSizeInMillis));

// 获取当前桶中的请求数
long bucketSize = redisUtil.lgetListSize(key);

ExceptionTypeEnum.BUSINESS.isTrue(bucketSize < bucketCapacity, "请求过于频繁,请稍后重试");

// 添加当前请求时间戳
redisUtil.lset(key, String.valueOf(now));

// 设置key的过期时间,避免Redis中长期存有无效数据
redisUtil.expire(key, bucketCapacity / leakRate + 1, TimeUnit.SECONDS);

return true;
}
}
漏桶算法的实现思路如下:每次请求时,先移除超出时间窗口的旧请求,然后检查桶中当前的请求数是否超过了容量限制。如果没有超过,就将当前请求时间戳加入桶中;否则,拒绝请求。同时,设置Key的过期时间以清理Redis中的过期数据

使用 lua 脚本优化

使用上述方法虽然可以实现接口节流,但效率较低。因为每次 Java 应用与 Redis 通信时都会有时间开销,比如一个 get/set 操作可能需要 20 毫秒。如果需要执行接近 10 个这样的操作,总时间可能就会增加到 200 毫秒。为了提高效率,我们可以选择使用 Lua 脚本进行优化

补充 RedisUtil 工具类

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 执行 Redis 脚本
*
* @param redisScript Redis 脚本
* @param keys 脚本所需的键
* @param args 脚本所需的参数
* @param <T> 返回结果类型
* @return 脚本执行结果
*/
public <T> T executeScript(DefaultRedisScript<T> redisScript, Collection<String> keys, Object... args) {
return redisTemplate.execute(redisScript, (List<String>) keys, args);
}
将上述代码补充进 RedisUtil 工具类中

使用 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* @author -侑枫
* @date 2024/7/30 23:11:12
*/
@Component
@Slf4j
@AllArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {

private final RedisUtil redisUtil;

@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
String luaScript = "local DEFAULT_MAX_REQUESTS_PER_WINDOW = 10\n"
+ "local DEFAULT_EXPIRATION_TIME = 6\n"
+ "local DEFAULT_TIME_UNIT = \"SECONDS\"\n"
+ "local requestKey = KEYS[1]\n"
+ "local requestUri = ARGV[1]\n"
+ "local WHITELIST_KEY = \"whitelist\"\n"
+ "local MAX_REQUESTS_KEY = \"rateLimit\"\n"
+ "local TIME_UNIT_KEY = \"timeUnit\"\n"
+ "local EXPIRATION_TIME_KEY = \"expirationTime\"\n"
+ "local isWhitelisted = redis.call(\"SISMEMBER\", WHITELIST_KEY, requestUri)\n"
+ "if isWhitelisted == 1 then\n"
+ " return true\n"
+ "end\n"
+ "local maxRequestsPerWindow = tonumber(redis.call(\"GET\", MAX_REQUESTS_KEY) or DEFAULT_MAX_REQUESTS_PER_WINDOW)\n"
+ "local timeUnit = redis.call(\"GET\", TIME_UNIT_KEY) or DEFAULT_TIME_UNIT\n"
+ "local expirationTime = tonumber(redis.call(\"GET\", EXPIRATION_TIME_KEY) or DEFAULT_EXPIRATION_TIME)\n"
+ "local windowSizeInMillis\n"
+ "if timeUnit == \"SECONDS\" then\n"
+ " windowSizeInMillis = expirationTime * 1000\n"
+ "elseif timeUnit == \"MINUTES\" then\n"
+ " windowSizeInMillis = expirationTime * 60 * 1000\n"
+ "elseif timeUnit == \"HOURS\" then\n"
+ " windowSizeInMillis = expirationTime * 3600 * 1000\n"
+ "elseif timeUnit == \"DAYS\" then\n"
+ " windowSizeInMillis = expirationTime * 86400 * 1000\n"
+ "else\n"
+ " windowSizeInMillis = expirationTime * 1000\n"
+ "end\n"
+ "local now = tonumber(redis.call(\"TIME\")[1]) * 1000\n"
+ "redis.call(\"ZREMRANGEBYSCORE\", requestKey, 0, now - windowSizeInMillis)\n"
+ "redis.call(\"ZADD\", requestKey, now, now)\n"
+ "local requestCount = redis.call(\"ZCARD\", requestKey)\n"
+ "if requestCount > maxRequestsPerWindow then\n"
+ " return false\n"
+ "else\n"
+ " redis.call(\"PEXPIRE\", requestKey, windowSizeInMillis + 1000)\n"
+ " return true\n"
+ "end";

Boolean rateLimit;
try {
rateLimit = redisUtil.executeScript(new DefaultRedisScript<>(luaScript, Boolean.class), Collections.singletonList(String.format("%s::%s", "rate_limit", request.getRemoteAddr())), request.getRequestURI());
} catch (Exception e) {
// 如果 redis 处理出现问题(宕机什么的),应直接放行才不会影响到主业务。
log.error("redis执行脚本异常", e);
return true;
}

ExceptionTypeEnum.BUSINESS.isTrue(Boolean.TRUE.equals(rateLimit), "请求过于频繁,请稍后重试");
return true;
}
}
这样,我们可以减少 JavaRedis 之间的通信开销,从而提升性能

建议将 Lua 脚本代码提取到一个专门的文件夹中,以便于管理和维护。

创建 RateLimit.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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
-- KEYS[1]: 请求记录的 key
-- ARGV[1]: 请求的 URI

-- 默认值
local DEFAULT_MAX_REQUESTS_PER_WINDOW = 10
local DEFAULT_EXPIRATION_TIME = 6 -- 单位秒
local DEFAULT_TIME_UNIT = "SECONDS" -- 默认时间单位为秒

local requestKey = KEYS[1]
local requestUri = ARGV[1]

-- 白名单的 key
local WHITELIST_KEY = "whitelist"
-- 最大请求数的 key
local MAX_REQUESTS_KEY = "rateLimit"
-- 时间单位的 key
local TIME_UNIT_KEY = "timeUnit"
-- 滑动窗口时间长度的 key
local EXPIRATION_TIME_KEY = "expirationTime"

-- 检查是否在白名单中
local isWhitelisted = redis.call("SISMEMBER", WHITELIST_KEY, requestUri)
if isWhitelisted == 1 then
return true
end

-- 获取最大请求数
local maxRequestsPerWindow = tonumber(redis.call("GET", MAX_REQUESTS_KEY) or DEFAULT_MAX_REQUESTS_PER_WINDOW)

-- 获取滑动窗口的时间单位
local timeUnit = redis.call("GET", TIME_UNIT_KEY) or DEFAULT_TIME_UNIT
-- 获取滑动窗口的时间长度
local expirationTime = tonumber(redis.call("GET", EXPIRATION_TIME_KEY) or DEFAULT_EXPIRATION_TIME)

-- 转换时间单位为毫秒
local windowSizeInMillis
if timeUnit == "SECONDS" then
windowSizeInMillis = expirationTime * 1000
elseif timeUnit == "MINUTES" then
windowSizeInMillis = expirationTime * 60 * 1000
elseif timeUnit == "HOURS" then
windowSizeInMillis = expirationTime * 3600 * 1000
elseif timeUnit == "DAYS" then
windowSizeInMillis = expirationTime * 86400 * 1000
else
windowSizeInMillis = expirationTime * 1000 -- 默认单位为秒
end

-- 获取当前时间戳(毫秒)
local now = tonumber(redis.call("TIME")[1]) * 1000

-- 清理滑动窗口之外的过期请求记录
redis.call("ZREMRANGEBYSCORE", requestKey, 0, now - windowSizeInMillis)

-- 添加当前请求记录
redis.call("ZADD", requestKey, now, now)

-- 获取当前滑动窗口内的请求数
local requestCount = redis.call("ZCARD", requestKey)

-- 判断是否超过最大请求数
if requestCount > maxRequestsPerWindow then
return false
else
redis.call("PEXPIRE", requestKey, windowSizeInMillis + 1000)
return true
end

调整 RateLimitInterceptor

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
/**
* @author -侑枫
* @date 2024/7/30 23:11:12
*/
@Component
@Slf4j
@AllArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {

private final RedisUtil redisUtil;

@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("com/youfeng/blog/lua/RateLimit.lua"));
redisScript.setResultType(Boolean.class);
Boolean rateLimit;
try {
rateLimit = redisUtil.executeScript(new DefaultRedisScript<>(luaScript, Boolean.class), Collections.singletonList(String.format("%s::%s", "rate_limit", request.getRemoteAddr())), request.getRequestURI());
} catch (Exception e) {
// 如果 redis 处理出现问题(宕机什么的),应直接放行才不会影响到主业务。
log.error("redis执行脚本异常", e);
return true;
}
ExceptionTypeEnum.BUSINESS.isTrue(Boolean.TRUE.equals(rateLimit), "请求过于频繁,请稍后重试");
return true;
}
}

需要注意的是,如果将时间窗口边界作为参数传递给 Lua 脚本,可能会在初始阶段导致超频问题。原因大致如下:在大量请求初次涌入时,每个线程生成的边界时间可能非常接近。例如,A 的时间窗口是 09:00:00.000 ~ 09:00:01.000,而 B 的时间窗口是 09:00:00.100 ~ 09:00:01.100。如果 B 先从 Jedis 连接池中获取了 Redis 连接,并在 09:00:01.100 时更新了计数器,而 A 在判断时则可能会漏掉 B 的计数,导致 A 的请求也被错误地允许通过

使用 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
48
49
50
51
52
53
54
55
56
57
-- KEYS[1]: 计数器的key
-- ARGV[1]: 请求的URI

local key = KEYS[1]
local requestUri = ARGV[1]

-- 固定白名单的key
local WHITELIST_KEY = "whitelist"
-- 最大请求数的key
local MAX_REQUESTS_KEY = "rateLimit"
-- 时间单位的key
local TIME_UNIT_KEY = "timeUnit"
-- 滑动窗口时间长度的key
local EXPIRATION_TIME_KEY = "expirationTime"

-- 默认值
local defaultMaxRequestPer = 100
local defaultExpirationTime = 1
local defaultTimeUnit = "SECONDS"

-- 查看是否存在于白名单,是的话直接放行
if redis.call("SISMEMBER", WHITELIST_KEY, requestUri) == 1 then
return true
end

-- 获取最大请求数
local maxRequestPer = tonumber(redis.call("GET", MAX_REQUESTS_KEY) or defaultMaxRequestPer)

-- 获取过期时间和时间单位
local expirationTime = tonumber(redis.call("GET", EXPIRATION_TIME_KEY) or defaultExpirationTime)
local timeUnit = redis.call("GET", TIME_UNIT_KEY) or defaultTimeUnit

-- 获取当前已请求次数
local count = redis.call("INCR", key)

-- 如果是第一次请求,则设置过期时间
if count == 1 then
if timeUnit == "SECONDS" then
redis.call("EXPIRE", key, expirationTime)
elseif timeUnit == "MINUTES" then
redis.call("EXPIRE", key, expirationTime * 60)
elseif timeUnit == "HOURS" then
redis.call("EXPIRE", key, expirationTime * 3600)
elseif timeUnit == "DAYS" then
redis.call("EXPIRE", key, expirationTime * 86400)
else
-- 默认单位为秒
redis.call("EXPIRE", key, expirationTime)
end
end

-- 检查请求次数是否超限
if count > maxRequestPer then
return false
end

return true

使用 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
48
49
50
-- KEYS[1]: 请求记录的key
-- ARGV[1]: 请求的URI

local key = KEYS[1]
local requestUri = ARGV[1]

-- 固定白名单的key
local WHITELIST_KEY = "whitelist"

-- 默认值
local defaultMaxTokens = 10
local defaultRefillRate = 1

-- 获取当前时间戳(毫秒)
local now = tonumber(redis.call("TIME")[1]) * 1000

-- 查看是否存在于白名单,是的话直接放行
if redis.call("SISMEMBER", WHITELIST_KEY, requestUri) == 1 then
return true
end

-- 获取令牌桶的参数
local maxTokens = tonumber(redis.call("HGET", key, "maxTokens")) or defaultMaxTokens
local refillRate = tonumber(redis.call("HGET", key, "refillRate")) or defaultRefillRate

-- 获取当前令牌数和上次请求的时间戳
local tokensStr = redis.call("HGET", key, "tokens")
local lastRefillTimestampStr = redis.call("HGET", key, "timestamp")

local tokens = tokensStr and tonumber(tokensStr) or maxTokens
local lastRefillTimestamp = lastRefillTimestampStr and tonumber(lastRefillTimestampStr) or now

-- 计算自上次请求后应补充的令牌数
local elapsedSeconds = now - lastRefillTimestamp
local newTokens = math.min(maxTokens, tokens + (elapsedSeconds * refillRate))

-- 检查令牌是否足够
if newTokens <= 0 then
return false
end

-- 允许请求,消耗一个令牌
redis.call("HSET", key, "tokens", tostring(newTokens - 1))
redis.call("HSET", key, "timestamp", tostring(now))

-- 设置 Key 的过期时间(设定为比桶最大容量时间稍长)
local expirationTime = (maxTokens / refillRate) + 1
redis.call("EXPIRE", key, expirationTime)

return true

使用 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
48
49
50
51
-- KEYS[1]: 请求记录的 key
-- ARGV[1]: 请求的路由

local key = KEYS[1]
local requestUri = ARGV[1]

-- 默认白名单的 key
local WHITELIST_KEY = "whitelist"
-- 桶的容量的 key
local BUCKET_CAPACITY = "bucketCapacity"
-- 泄漏率
local LEAK_RATE = "leakRate"

-- 默认值
local defaultBucketCapacity = 100
local defaultLeakRate = 1
local windowSizeInMillis = 1000 -- 1秒

-- 获取当前时间戳(毫秒)
local now = tonumber(redis.call("TIME")[1]) * 1000

-- 检查白名单
if redis.call("SISMEMBER", WHITELIST_KEY, requestUri) == 1 then
return true
end

-- 获取桶的容量和漏水速度
local bucketCapacity = tonumber(redis.call("GET", BUCKET_CAPACITY)) or defaultBucketCapacity
local leakRate = tonumber(redis.call("GET", LEAK_RATE)) or defaultLeakRate

-- 清理超出窗口大小的旧请求记录
local minTimestamp = now - (windowSizeInMillis)
redis.call("LTRIM", key, 0, -1) -- 清空列表
redis.call("RPUSH", key, now) -- 添加当前请求时间戳

-- 从列表中删除时间戳小于最小时间戳的记录
redis.call("LREM", key, 0, minTimestamp)

-- 获取桶中的请求数
local bucketSize = redis.call("LLEN", key)

-- 检查桶是否超载
if bucketSize > bucketCapacity then
return false
end

-- 设置key的过期时间
local expirationTime = bucketCapacity / leakRate + 1
redis.call("EXPIRE", key, expirationTime)

return true

固定窗口、滑动窗口、令牌桶、漏桶算法的应用场景

固定窗口计数器

固定窗口计数器简单易实现,计算量较小,只需维护一个计数器,并通过简单的过期机制来重置计数。然而,在时间窗口的边界处,可能会出现请求突增的问题。例如,在窗口结束时段大量请求会被允许,从而导致瞬时流量过大。此外,固定窗口的时间精度较低,可能无法平滑处理请求流量。

固定窗口计数器适用于流量波动不大的系统,如简单的 API 接口限制。它可以用于对负载较低的服务进行基础的节流控制。

滑动窗口

滑动窗口算法通过精确计算时间窗口内的请求次数,能够平滑地控制流量,避免瞬时流量突增,并提供更高的时间精度。虽然滑动窗口能更有效地控制流量,但需要额外清理超出时间窗口的数据,这可能增加计算和存储开销。此外,滑动窗口的实现复杂度相对较高。

滑动窗口算法适合对流量控制要求较高的场景,如需要平滑处理请求流量的实时系统。它适用于用户请求有显著突发性和高波动的服务,如在线游戏、金融交易系统等。

令牌桶

令牌桶算法允许桶中的令牌积累,因此可以处理突发流量。通过设置令牌的添加速率和桶的容量,令牌桶算法能够平滑控制流量。尽管令牌桶算法灵活处理突发流量,但实现较为复杂,需要处理令牌的补充和消耗逻辑,同时可能需要额外的内存来存储令牌信息。

令牌桶算法适合需要灵活处理突发流量的应用,如 API 服务、下载管理、视频流等。此外,它也适用于高并发场景中,对请求速率有严格控制要求的服务

漏桶

漏桶算法以固定速率处理请求,从而平滑流量,避免短时间内的请求突增,保证请求处理的稳定性,并防止请求处理的不均匀。然而,漏桶算法对突发流量处理较差,如果请求超出桶的容量,可能会被拒绝。此外,漏桶算法的实现较为复杂,需要维护漏桶的状态,并实现漏水和请求处理逻辑。

漏桶算法适用于对处理速率有严格要求的场景,如流量控制系统和请求速率平滑的网络服务。它还可以用于实时数据处理、API 速率限制等需要稳定输出的应用

结语

在系统设计中,接口节流是确保服务稳定性和性能的关键手段。通过有效的节流策略,可以控制请求流量、平滑处理负载,并防止系统过载。本文详细探讨了如何利用 Redis 实现接口节流,包括介绍了固定窗口计数器、滑动窗口、令牌桶和漏桶四种常见节流算法的优劣及应用场景。

每种节流算法都有其独特的优势和适用场景。在实际应用中,选择合适的节流算法不仅可以有效地控制请求流量,还可以优化系统性能和用户体验。固定窗口计数器适合简单的流量控制,滑动窗口算法提供更平滑的控制,令牌桶算法灵活处理突发流量,而漏桶算法保证了稳定的处理速率。根据具体业务需求和系统特点,结合 Redis 的强大功能,能够实现最佳的节流效果。

事实上,很多开源的库已经帮我们实现了节流逻辑,例如 Bucket4j(一个 Java 的令牌桶算法实现库、Resilience4j(提供了令牌桶和滑动窗口的实现) 还有 Guava RateLimiter(GoogleGuava 库中的令牌桶算法实现),我们也可以直接通过这些库来帮助我们在 Java 应用中实现节流控制。

最后,希望通过本文的介绍,能够帮助读者更好地理解和实现接口节流策略,构建高效、稳定的系统架构。接口节流不仅是系统设计中的重要环节,更是提升用户体验和服务质量的关键一步。

  • 标题: 使用 Redis 实现接口节流
  • 作者: HYF
  • 创建于 : 2024-07-30 23:02:33
  • 更新于 : 2024-08-01 03:03:17
  • 链接: https://youfeng.ink/redis-rate-limiting-4f47278a353b/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。