Redis 事务不解决 SETNX DECR 过期问题
前言
在上一篇文章 Redis 使用 Lua 脚本替代 SETNX / DECR 保证原子性 中,我描述了最近使用 Redis 使用 SETNX / DECR 做限流时出现的一个问题,并给出了在 Redis 中使用 LUA 脚本的解决方案。
本文接着前文,没看过的同学可以先看一下前文。
评论区 [@xxx](https://learnku.com/users/11510) 同学提到是否能用 Redis 事务来解决问题。
当时我是这么回复的,
我的想法是 Redis 执行 Lua 脚本具有原子性可以解决 SETNX / DECR 之间存在 key 过期的问题,Redis 事务同样具有原子性,自然也可以达到同样的效果。
结果在实验的过程中,啪啪啪打脸了。因此有了这篇文章。
Redis 事务方案并不奏效
使用 Redis 事务代码如下,
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = 'test_redis_key';
$redis->watch($key);
$redis->multi();
$redis->set($key, '1', ['nx', 'px' => 5]); // key 设置成 5 毫秒过期
$redis->decr($key);
$ret = $redis->exec();
// $ret = FALSE if test_redis_key has been modified between the call to WATCH and the call to EXEC.
if ($ret === false) {
header('Is-Limited:1', true, 500);
} else {
list(, $left) = $ret;
if ($left < 0) {
header('Is-Limited:1', true, 500);
} else {
header('Is-Limited:0', true, 200);
}
}
用 siege 压测,前面几分钟一直正常,然后我挂着 siege 去上了个厕所,结果回来时发现还是出现了之前的问题。
在某一个时刻之后,所有的请求都被限流了(key 永不过期了)。
由于要跑很久才能复现,gif 没法录,这里就不贴 gif 了,贴一个全部被限流的图。
实践是检验真理的唯一标准,因此先给结论:不能用 Redis 事务来解决该问题。
事务和 LUA 脚本的差异
分析原因之前,我们先来看两段代码。
代码一:Redis 事务
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// test_redis_key 是当前不存在的一个 key
$key = 'test_redis_key';
$redis->watch($key);
$redis->multi();
$redis->set($key, '2', ['nx', 'px' => 1]); // 初始值为2,过期时间为 1 毫秒
// 这个循环可以理解为 sleep 大于 1 毫秒
for($i = 1;$i<1000;$i++) {
$redis->get('xxxx');
}
$redis->decr($key);
$redis->ttl($key);
$ret = $redis->exec();
// 事务中最后一个操作的结果,也就是 test_redis_key 的 ttl 值
$ttl = array_pop($ret);
// 事务中倒数第二个操作的结果,也就是 decr 的结果
$current = array_pop($ret);
var_dump($ttl, $current); // 结果是 int(-1) int(-1)
这段代码模拟的一个现象是,在事务中 SETNX 和 DECR 两个命令之间,超过了 key 的过期时间。
最后打印的结果表明,跟我们上一篇文章中对一个已过期的 key 执行了 DECR 一样,结果是 -1,key 的过期时间也是 -1(永不过期)。
代码二:Redis 使用EVAL 执行 LUA 脚本
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// test_redis_key1 也是当前不存在的一个 key
$key = 'test_redis_key1';
// lua 里面的一段循环可以理解为 sleep 大于 1 毫秒
$script = <<<LUA
local interval_milliseconds = tonumber(ARGV[1])
redis.call('set', KEYS[1], 2)
redis.call('pexpire', KEYS[1], interval_milliseconds)
for i = 1000000000,1,-1
do
end
redis.call('decr', KEYS[1])
local current = redis.call('get', KEYS[1])
local ttl = redis.call('ttl', KEYS[1])
return {current,ttl}
LUA;
$redis->script('load', $script);
$ret = $redis->eval($script, [$key, 1], 1); // 1 毫秒过期
var_dump($ret); // 结果是 array(2) { [0] => string(1) "1" [1] => int(0) }
这段代码模拟的一个现象是,在 LUA 脚本中 SETNX 和 DECR 两个命令之间,超过了 key 的过期时间。
最后打印的结果表明,执行 DECR 时,key 并没有过期。结果是 1(2 - 1), ttl 是 0(不像代码一是 -1)。
分析
代码一的现象就表明,当我们使用 Redis 事务来解决该问题时,肯定会翻车。
即便是在事务中,也会出现 SETNX 命令判断到 key 还未过期,但是在执行 DECR 的时候过期了,导致 key 永不过期,后续的所有请求都被限流。
Redis 官网上是这么介绍事务的
事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
MULTI 命令用于开启一个事务,它总是返回
OK
。 MULTI 执行之后, 客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中, 当 EXEC命令被调用时, 所有队列中的命令才会被执行。
事务起到的作用其实只是帮我们隔离了其他客户端的命令,在 Redis 使用 Lua 脚本替代 SETNX / DECR 保证原子性 前文里描述的问题其实是在一个客户端内发生的(一个客户端的前后两句命令执行之间 key 过期了)。
在事务内,两条命令依然是顺序执行的,依然会出现两条命令之间 key 过期的情况。
因此使用事务并不能解决我们的问题。
至于,为啥使用 EVAL
命令执行 LUA 脚本,不会出现两条命令之间 key 过期的情况,本质原因我还没弄明白……看看春节里能不能翻翻书籍,搞明白这个它。
有没有了解的同学,给科普一下…… 感谢!
总结
- Redis 事务具备隔离性,顺序执行事务中的所有命令。
- 使用 Redis 事务不能避免 SETNX / DESC 之间 key 过期。
题外话:Redis 事务有没有原子性?
拿最熟悉的转账例子,A 要给 B 转账 100 元。
A 账户扣减 100 元,B 账户增加 100 元。
这两个操作要么全部成功,要么全部不成功。这就叫原子操作。
对于事务而言,事务中的命令要么完整的被执行,要么完全不执行(回滚掉算不执行)。这种特性就叫原子性。
在 Redis 事务中,如果入队的命令中,有某一条执行失败了,后续的其他命令依然会正常执行,并没有回滚机制。
因此,Redis 事务并不具备原子性。
参考资料
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: