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