我使用 Redis 来管理 API 上的速率限制,并用于SETEX每小时自动重置速率限制。
我发现 Redis 无法清除某些密钥并报告其TTLat -1。下面是一个 redis-cli 会话示例,使用占位符 IP 地址演示了这一点:
> GET allowance:127.0.0.1
> 0
> TTL allowance:127.0.0.1
-1
> GET allowance:127.0.0.1
0
Run Code Online (Sandbox Code Playgroud)
请注意,尽管其 TTL 为负数,但当我删除此键时,Redis 不会清除该键GET。
我试图重现这种状态但不能。
> SETEX doomedkey -1 hello
(error) ERR invalid expire time in SETEX
> SETEX doomedkey 0 hello
(error) ERR invalid expire time in SETEX
> SETEX doomedkey 5 hello
OK
> TTL doomedkey
4
> GET doomedkey
hello
(... wait 5 seconds)
> TTL doomedkey
-2
> GET doomedkey
(nil)
Run Code Online (Sandbox Code Playgroud)
这是一些不幸的竞争条件导致 Redis 无法使这些密钥过期吗?在数万名已成功过期的人中,只有约10人仍滞留在该-1州。
我在用redis_version:2.8.9。
我遇到了同样的问题,仅使用 Redis 2.8.24,但也使用它进行 API 速率限制。
我怀疑你正在像这样进行速率限制(仅使用 Ruby 代码作为示例):
def consume_rate_limit
# Fetch the current limit for a given account or user
rate_limit = Redis.get('available_limit:account_id')
# It can be nil if not already initialized or if TTL has expired
if rate_limit == nil
# So let's just initialize it to the initial limit
# Let's use a window of 10,000 requests, resetting every hour
rate_limit = 10000
Redis.setex('available_limit:account_id', 3600, rate_limit - 1)
else
# If the key already exists, just decrement the limit
Redis.decr('available_limit:account_id')
end
# Return true if we are OK or false the limit has been reached
return (rate_limit > 0)
end
Run Code Online (Sandbox Code Playgroud)
好吧,我正在使用这种方法,发现“get”和“decr”调用之间存在并发问题,这导致了您所描述的确切问题。
当速率限制密钥的 TTL 在“get”调用之后但在“decr”调用之前到期时,就会出现此问题。会发生什么:
首先,“get”调用将返回当前限制。假设它返回 500。然后在几毫秒内,该密钥的 TTL 到期,因此它在 Redis 中不再存在。因此代码继续运行并到达“decr”调用。该错误也在这里出现:
decr文档指出(我的重点):
将键中存储的数字减一。如果该键不存在,则在执行操作之前将其设置为0。(...)
由于密钥已被删除(因为已过期),因此“decr”指令会将密钥初始化为零,然后将其递减,这就是密钥值为-1的原因。并且密钥将在没有 TTL 的情况下创建,因此发出 aTTL key_name也会发出 -1。
解决方案可能是使用 MULTI 和 EXEC 命令将所有代码包装在事务块内。但是,这可能会很慢,因为它需要多次往返 Redis 服务器。
我使用的解决方案是编写一个 Lua 脚本并使用 EVAL 命令运行它。它的优点是原子性(这意味着没有并发问题),并且到 Redis 服务器只有一个 RTT。
local expire_time = ARGV[1]
local initial_rate_limit = ARGV[2]
local rate_limit = redis.call('get', KEYS[1])
-- rate_limit will be false when the key does not exist.
-- That's because redis converts Nil to false in Lua scripts.
if rate_limit == false then
rate_limit = initial_rate_limit
redis.call('setex', KEYS[1], initial_rate_limit, rate_limit - 1)
else
redis.call('decr', KEYS[1])
end
return rate_limit
Run Code Online (Sandbox Code Playgroud)
要使用它,我们可以将consume_rate_limit函数重写为:
def consume_rate_limit
script = <<-LUA
... that script above, omitting it here not to bloat things ...
LUA
rate_limit = Redis.eval(script, keys: ['available_limit:account_id'], argv: [3600, 10000]).to_i
return (rate_limit > 0)
end
Run Code Online (Sandbox Code Playgroud)