学习

使用 Lua 实现原子性

Brian Sam-Bodden
作者
Brian Sam-Bodden, Redis 开发者倡导者

使用 Lua 提高原子性和性能#

改进我们实现的一种方法是将执行 INCREXPIRE 操作的责任从 incrAndExpireKey 方法移到 Lua 脚本中。

速率限制 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 下。现在,让我们分解一下

  1. 1.Redis 中的 Lua 脚本使用键 (KEYS[]) 和参数 (ARGV[]),在本例中,我们期望在 KEYS[1] 中有一个 key(Lua 数组从 1 开始编号)
  2. 2.我们通过对 GET 命令进行 call 来检索键在 requests 中的配额,如果键不存在,则返回 -1,并将值转换为数字。
  3. 3.配额作为第一个参数 (ARGV[1]) 传递并存储在 max_requests 中,以秒为单位的到期时间是第二个参数并存储在 expiry
  4. 4.如果语句检查请求是否是时间窗口中的第一个请求,或者请求数量是否未超过配额,在这种情况下,我们运行 INCR-EXPIRE 命令并返回 false(意味着我们没有进行速率限制并允许请求通过)。
  5. 5.如果他们超过了配额,那么我们通过返回 true 来进行速率限制

如果您想了解更多关于 Lua 的信息,请参阅 Programming in Lua

Spring Data Redis 中的 Redis Lua 脚本#

Spring Data Redis 通过类 RedisScript 支持 Lua 脚本。它处理序列化并智能地使用 Redis 脚本缓存。缓存使用 SCRIPT LOAD 命令填充。默认的 ScriptExecutor 使用 EVALSHA 使用脚本的 SHA1,如果脚本尚未加载到缓存中,则回退到 EVAL

让我们添加用注解修饰的方法 script() 从类路径加载我们的脚本

@Bean
public RedisScript<Boolean> script() {
  return RedisScript.of(new ClassPathResource("scripts/rateLimiter.lua"), Boolean.class);
}

修改过滤器以使用 Lua#

接下来,我们将修改过滤器以包含脚本以及配额;我们需要传递给脚本的值

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 方法以使用脚本。使用 RedisTemplateReactiveRedisTemplate 的 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));
}

让我们分解一下方法的添加部分

  1. 1. filter 方法使用模板 execute 方法传递脚本、键和参数。
  2. 2.我们期望一个 single 结果 (truefalse)。 single 方法接受一个默认值,如果我们得到一个空的结果,则返回该值。
  3. 3.最后,我们使用 flatMap 方法来获取值:
    • 如果它是 true,我们使用 HTTP 429 拒绝请求。
    • 如果它是 false,我们处理请求

应用过滤器#

让我们添加一个可配置的 @Value 注解的 Long 值到 FixedWindowRateLimiterApplication 来保存请求配额。

@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 进行测试 #

使用我们可靠的 curl 循环

for n in {1..22}; do echo $(curl -s -w " :: HTTP %{http_code}, %{size_download} bytes, %{time_total} s" -X GET https://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 https://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

如果我们在监视模式下运行 Redis,我们应该看到对 EVALSHA 的 Lua 调用,之后是对被拒绝请求的 GET 调用,以及相同操作加上对 INCREXPIRE 的调用(用于允许的请求)。

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 下。

最后更新于 2024 年 2 月 20 日