Redis 事務不解決 SETNX DECR 過期問題

loodeer發表於2020-01-23

前言

在上一篇文章 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 協議》,轉載必須註明作者和本文連結

相關文章