Redis 使用 Lua 脚本替代 SETNX / DECR 保证原子性

背景

最近公司出了一起故障,问题代码如下:

    /**
     * TRUE: 触发限流,FALSE:未触发限流
     */
    public function acquire() {
        try {
            $redisHandler = $this->redisInstance->getHandler();
            $redisHandler->set($this->rateLimitKey, $this->tokenNum, ['nx', 'ex' => $this->expireTime]);
            $leftTokenNum = $redisHandler->decr($this->rateLimitKey);
            if ($leftTokenNum < 0) {
                return TRUE;
            }
            return FALSE;
        } catch (\Exception $e) {
            return FALSE;
        }
    }

作者的目的是针对爆款商品的购买,使用 redis 来起到一个限流的作用,1 秒钟只允许 1 人购买。

结果上线过后不久,运营就反馈线上出故障了,该爆款商品所有人都不能购买了。

分析

上面代码的思路很简单:通过 $redis->set('key', '1', ['nx', 'ex'=>1]); 命令,设置值为 1 过期时间为 1 秒的计数器,基于该计数器的扣减来达到 1 秒钟放行 1 个请求的目的。

测试

我们简化一下上面的代码,

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = 'test_redis_key';
$redis->set($key, '1', ['nx', 'ex' => 1]);
$left = $redis->decr($key);

if ($left < 0) {
  // 这里通过状态码来更方便的观察
  header('Is-Limited:1', true, 500);
} else {
  header('Is-Limited:0', true, 200);
}

简化后使用 siege 模拟 100 个用户并发压测一下。
Redis 执行 Lua 脚本替代 SETNX / DECR 保证原子性
非常稳啊,1 秒钟通过 1 个请求。
我们的开发同学也就是经过了上述测试才放心把代码发上线的,咋一上线就炸了呢?

原因

我们来看下面一段操作,

[root@e98dffb83384 src]# ./redis-cli
127.0.0.1:6379> SETNX k 1
(integer) 1
127.0.0.1:6379> EXPIRE k 10 # 为了方便演示,这里设置 10 秒过期时间
(integer) 1
127.0.0.1:6379> DECR k # 在过期时间内,第一次扣减成 0
(integer) 0
127.0.0.1:6379> DECR k # 继续扣减成 -1
(integer) -1
127.0.0.1:6379> DECR k # 继续扣减成 -2
(integer) -2
127.0.0.1:6379> TTL k # k 还有 2 秒过期
(integer) 2
127.0.0.1:6379> DECR k # 继续扣减成 -3
(integer) -3
127.0.0.1:6379> TTL k # 距离设置过期时间 10 秒之后,k 已经过期
(integer) -2
127.0.0.1:6379> DECR k # 这时候再扣减发现 k 的值被扣减成 -1 
(integer) -1
127.0.0.1:6379> DECR k # 继续扣减成 -2
(integer) -2
127.0.0.1:6379> TTL k # 查看 k 过期时间是永不过期
(integer) -1
127.0.0.1:6379> SETNX k 3 # 再设置是不成功的
(integer) 0
127.0.0.1:6379> DECR k # 继续扣减成 -3
(integer) -3

在 Redis key 未过期之前,DECR 命令都是正常扣减的。一旦 key 过期了,再执行 DECR 命令,会发现 key 的值和过期时间都变为 -1 了。

Redis 官网对 DECR 命令介绍里有这么一段:

Decrements the number stored at key by one. If the key does not exist, it is set to 0 before performing the operation.

对于出问题的代码,

$redisHandler->set($this->rateLimitKey, $this->tokenNum, ['nx', 'ex' => $this->expireTime]);
$leftTokenNum = $redisHandler->decr($this->rateLimitKey);

假设在第一句 SETNX 之后第二句 DECR 之前,key 过期了,再执行 DECR 就会先生成一个永不过期值为 0 的 key。

之后所有请求的 SETNX 都是 fasle,一直会基于这个永不过期的 key 进行递减,所有的 $leftTokenNum 都小于 0,因此导致所有请求被限流。

问题复现

自测时为啥发现不了问题?因为自测时设置的过期时间是 1 秒,导致 key 在两步之间过期出现的概率很小。我们只要将过期时间调的足够小,很容易复现问题。

把过期时间改为 5 毫秒,

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = 'test_redis_key';
$redis->set($key, '3', ['nx', 'px' => 5]); // key 设置成 5 毫秒过期
$left = $redis->decr($key);

if ($left < 0) {
  // 这里通过状态码来更方便的观察
  header('Is-Limited:1', true, 500);
} else {
  header('Is-Limited:0', true, 200);
}

依然使用 siege 压测:
Redis 执行 Lua 脚本替代 SETNX / DECR 保证原子性
由于设置的 5 毫秒放行一个请求,因此前半部分基本上都是通过的请求,偶尔有几个限流的,这是正常的。
但是没过多久,所有请求都被限流了,也就复现了线上的故障。

解决方案

如何改进代码来正确的实现限流呢?

Redis 的 EVAL 命令 执行 Lua 脚本时可以保证原子性。

Atomicity of scripts
Redis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed.

EVAL 命令的格式为:

EVAL script numkeys key [key ...] arg [arg ...]

例子:

> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

我们可以借助 Lua 脚本来避免 SETNXDECR 之间会出现过期的尴尬情况。

        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);

        $key = 'test_redis_key1';

        $script = <<<LUA
local max = tonumber(ARGV[1])
local interval_milliseconds = tonumber(ARGV[2])
local current = tonumber(redis.call('get', KEYS[1]) or 0)

if (current + 1 > max) then
    return true
else
    redis.call('incrby', KEYS[1], 1)
    if (current == 0) then
        redis.call('pexpire', KEYS[1], interval_milliseconds)
    end
    return false
end
LUA;

        $redis->script('load', $script);
        $isLimited = $redis->eval($script, [$key, 1, 5], 1); // key 5 毫秒过期

        if ($isLimited) {
            header('Is-Limited:1', true, 500);
        } else {
            header('Is-Limited:0', true, 200);
        }

依然使用 siege 压测,
Redis 执行 Lua 脚本替代 SETNX / DECR 保证原子性
持续压了 10 多分钟也没出现之前问题,问题得以解决。

总结

  • Redis 中 DECR 一个不存在的 key 会先把 key 值设置为 0 , TTL 设置为 -1(永不过期),再进行减 1 操作。
  • 使用 SETNX 配合 DECR 实现限流,会出现 key 永不过期情况。过期时间比较小或者高并发情况下,发生概率更高。
  • 在 Redis 中执行 Lua 脚本是原子操作。
  • 可以通过 Redis + Lua 实现高并发下的限流。
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 8个月前 自动加精
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 14

6 啊 老哥! 学到了 解决问题的思路

8个月前 评论

@johnlui 竟然得到了大佬的称赞,受宠若惊!很早就关注了大佬的博客,文章质量非常高 :+1: :+1:

8个月前 评论

@Enzo_Lwb 思路比结论更重要 :smile:

8个月前 评论

总结:在 Redis key 未过期之前,DECR 命令都是正常扣减的。一旦 key 过期了,再执行 DECR 命令,会发现 key 的值和过期时间都变为 -1 了。 :+1:

8个月前 评论

学到了学到了

8个月前 评论
j475523225

mark一下

8个月前 评论
Krisji

所以lua 还是有必要学下 哈哈

8个月前 评论

直接进行del是不是也可以解决

8个月前 评论
loodeer (楼主) 8个月前

set和decr之前判断key是否存在,可不可以解决问题?

8个月前 评论
quincyzhang 8个月前
loodeer (楼主) 8个月前

把这段 LUA 脚本换成同步锁是不是也可以同样的效果?

8个月前 评论
loodeer (楼主) 8个月前
quincyzhang (作者) 8个月前
loodeer (楼主) 8个月前

是否可以先EXISTS key再DECR 或者说 KEY小于X秒且VALUE还存在时延长X秒

7个月前 评论
loodeer (楼主) 7个月前

能否用redis的事务来实现?

7个月前 评论
loodeer (楼主) 7个月前
loodeer (楼主) 7个月前

我觉得你这个解决和lua没多大关系,主要还是转换了不同的逻辑方法。把递减变成了递增。使用lua使得在读取key到判断的时候,不在允许有其他去读取,否则可能同时读取出4来,那么判断是都通过。还有这个也没法保证任何1秒5次呀,比如在1秒内的后面快过期时(如后半秒)处理了5次,那么在下一次1秒的刚开始(如前半秒)处理5次,那就有1秒10次的情况了。不知道自己理解的有没有对?

1个月前 评论
loodeer (楼主) 1个月前

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!