改进我们实现的一种方法是将执行 INCR
和 EXPIRE
操作的职责从 incrAndExpireKey
方法移到 Lua 脚本中。
Redis 能够在服务器端执行 Lua 脚本。Lua 脚本以原子方式执行,即在脚本运行时不会有其他脚本或命令运行,这为我们提供了与 MULTI
/ EXEC
相同的事务语义。
下面是一个简单的 Lua 脚本,用于封装速率限制逻辑。如果请求应被拒绝,脚本返回 true
,否则返回 false
-- rateLimiter.lua
local key = KEYS[1]
local requests = tonumber(redis.call('GET', key) or '-1')
local max_requests = tonumber(ARGV[1])
local expiry = tonumber(ARGV[2])
if (requests == -1) or (requests < max_requests) then
redis.call('INCR', key)
redis.call('EXPIRE', key, expiry)
return false
else
return true
end
将脚本放在 src/main/resources/scripts
下。现在,我们来分解它
KEYS[]
) 和参数 (ARGV[]
),在我们的例子中,我们期望 KEYS[1]
中有一个 key
(Lua 数组是基于 1 的)call
调用 GET
命令检索 requests
中键的配额,如果键不存在则返回 -1
,并将值转换为数字。ARGV[1]
) 传递并存储在 max_requests
中,以秒为单位的过期时间是第二个参数并存储在 expiry
中if
语句检查请求是否是时间窗口内的第一个请求,或者请求数量是否未超过配额,如果是,则运行 INCR
-EXPIRE
命令并返回 false
(表示我们未进行速率限制并允许请求通过)。true
进行速率限制如果您想了解更多关于 Lua 的信息,请参阅《Lua 编程》。
@Bean
public RedisScript<Boolean> script() {
return RedisScript.of(new ClassPathResource("scripts/rateLimiter.lua"), Boolean.class);
}
接下来,我们将修改过滤器以包含脚本和配额;这是我们需要传递给脚本的值
class RateLimiterHandlerFilterFunction implements HandlerFilterFunction<ServerResponse, ServerResponse> {
private ReactiveRedisTemplate<String, Long> redisTemplate;
private RedisScript<Boolean> script;
private Long maxRequestPerMinute;
public RateLimiterHandlerFilterFunction(ReactiveRedisTemplate<String, Long> redisTemplate,
RedisScript<Boolean> script, Long maxRequestPerMinute) {
this.redisTemplate = redisTemplate;
this.script = script;
this.maxRequestPerMinute = maxRequestPerMinute;
}
现在我们可以修改 filter
方法来使用脚本。脚本通过 RedisTemplate
或 ReactiveRedisTemplate
的 execute 方法运行。execute
方法使用可配置的 ScriptExecutor
/ReactiveScriptExecutor
,该执行器继承模板的键值序列化设置来运行脚本
@Override
public Mono<ServerResponse> filter(ServerRequest request, HandlerFunction<ServerResponse> next) {
int currentMinute = LocalTime.now().getMinute();
String key = String.format("rl_%s:%s", requestAddress(request.remoteAddress()), currentMinute);
return redisTemplate //
.execute(script, List.of(key), List.of(maxRequestPerMinute, 59)) //
.single(false) //
.flatMap(value -> value ? //
ServerResponse.status(TOO_MANY_REQUESTS).build() : //
next.handle(request));
}
让我们分解方法添加内容
filter
方法使用模板的 execute
方法,传递脚本、键和参数。single
结果(true
或 false
)。single
方法接受一个默认值,以便在获得空结果时返回。flatMap
方法获取值:true
,我们将以 HTTP 429 拒绝请求。false
,我们将处理请求让我们向 FixedWindowRateLimiterApplication
添加一个带有 @Value
注解的可配置 Long
值,用于保存请求配额。
@Value("${MAX_REQUESTS_PER_MINUTE}")
Long maxRequestPerMinute;
在我们的 application.properties
中,我们将最大值设置为每分钟 20 个请求
MAX_REQUESTS_PER_MINUTE=20
要调用过滤器,我们使用新修改的构造函数,传递模板、脚本和 maxRequestPerMinute
值
@Bean
RouterFunction<ServerResponse> routes() {
return route() //
.GET("/api/ping", r -> ok() //
.contentType(TEXT_PLAIN) //
.body(BodyInserters.fromValue("PONG")) //
).filter(new RateLimiterHandlerFilterFunction(redisTemplate, script(), maxRequestPerMinute)).build();
}
使用我们可靠的 curl 循环
for n in {1..22}; do echo $(curl -s -w " :: HTTP %{http_code}, %{size_download} bytes, %{time_total} s" -X GET http://localhost:8080/api/ping); sleep 0.5; done
您应该会看到第 21 个请求被拒绝
➜ for n in {1..22}; do echo $(curl -s -w " :: HTTP %{http_code}, %{size_download} bytes, %{time_total} s" -X GET http://localhost:8080/api/ping); sleep 0.5; done
PONG :: HTTP 200, 4 bytes, 0.173759 s
PONG :: HTTP 200, 4 bytes, 0.008903 s
PONG :: HTTP 200, 4 bytes, 0.008796 s
PONG :: HTTP 200, 4 bytes, 0.009625 s
PONG :: HTTP 200, 4 bytes, 0.007604 s
PONG :: HTTP 200, 4 bytes, 0.008052 s
PONG :: HTTP 200, 4 bytes, 0.011364 s
PONG :: HTTP 200, 4 bytes, 0.012158 s
PONG :: HTTP 200, 4 bytes, 0.010415 s
PONG :: HTTP 200, 4 bytes, 0.010373 s
PONG :: HTTP 200, 4 bytes, 0.010009 s
PONG :: HTTP 200, 4 bytes, 0.006587 s
PONG :: HTTP 200, 4 bytes, 0.006807 s
PONG :: HTTP 200, 4 bytes, 0.006970 s
PONG :: HTTP 200, 4 bytes, 0.007948 s
PONG :: HTTP 200, 4 bytes, 0.007949 s
PONG :: HTTP 200, 4 bytes, 0.006606 s
PONG :: HTTP 200, 4 bytes, 0.006336 s
PONG :: HTTP 200, 4 bytes, 0.007855 s
PONG :: HTTP 200, 4 bytes, 0.006515 s
:: HTTP 429, 0 bytes, 0.006633 s
:: HTTP 429, 0 bytes, 0.008264 s
如果在 monitor 模式下运行 Redis,我们应该会看到对 EVALSHA
的 Lua 调用,接着是针对被拒绝请求的 GET
调用,以及针对允许请求的 INCR
和 EXPIRE
调用。
1630342834.878972 [0 172.17.0.1:65008] "EVALSHA" "16832548450a4b1c5e23ffab55bddefe972fecd2" "1" "rl_localhost:0" "20" "59"
1630342834.879044 [0 lua] "GET" "rl_localhost:0"
1630342834.879091 [0 lua] "INCR" "rl_localhost:0"
1630342834.879141 [0 lua] "EXPIRE" "rl_localhost:0" "59"
1630342835.401937 [0 172.17.0.1:65008] "EVALSHA" "16832548450a4b1c5e23ffab55bddefe972fecd2" "1" "rl_localhost:0" "20" "59"
1630342835.402009 [0 lua] "GET" "rl_localhost:0"
此实现的完整代码位于 with_lua
分支下。