Redis 事务不解决 SETNX DECR 过期问题

前言

在上一篇文章 Redis 使用 Lua 脚本替代 SETNX / DECR 保证原子性 中,我描述了最近使用 Redis 使用 SETNX / DECR 做限流时出现的一个问题,并给出了在 Redis 中使用 LUA 脚本的解决方案。

本文接着前文,没看过的同学可以先看一下前文

评论区 [@xxx](https://learnku.com/users/11510) 同学提到是否能用 Redis 事务来解决问题。

当时我是这么回复的,

image-20200122225445475

我的想法是 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 了,贴一个全部被限流的图。

image-20200122230656478

实践是检验真理的唯一标准,因此先给结论:不能用 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 命令用于开启一个事务,它总是返回 OKMULTI 执行之后, 客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中, 当 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 协议》,转载必须注明作者和本文链接
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 2

没错,Redis 原生事务是会把同一时间内的其他请求进行阻隔的,所以不能直接用原生事务,而且 lua 的使用对于这个问题来说只能算是优化不算解决。在请求并发量级更高的时候还是会产生这个阻隔的问题。

4年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!