Redis 使用 Lua 指令碼替代 SETNX / DECR 保證原子性

loodeer發表於2020-01-05

背景

最近公司出了一起故障,問題程式碼如下:

    /**
     * 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 協議》,轉載必須註明作者和本文連結

相關文章