Redis 執(zhí)行 Lua 腳本替代 SETNX DECR 保證原子性

背景

最近公司出了一起故障妒潭,問題代碼如下:

    /**
     * TRUE: 觸發(fā)限流,F(xiàn)ALSE:未觸發(fā)限流
     */
    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 人購買搜骡。

結(jié)果上線過后不久拂盯,運營就反饋線上出故障了,該爆款商品所有人都不能購買了记靡。

分析

上面代碼的思路很簡單:通過 $redis->set('key', '1', ['nx', 'ex'=>1]); 命令谈竿,設(shè)置值為 1 過期時間為 1 秒的計數(shù)器团驱,基于該計數(shù)器的扣減來達到 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) {
  // 這里通過狀態(tài)碼來更方便的觀察
  header('Is-Limited:1', true, 500);
} else {
  header('Is-Limited:0', true, 200);
}

簡化后使用 siege 模擬 100 個用戶并發(fā)壓測一下嚎花。

1.gif

非常穩(wěn)啊,1 秒鐘通過 1 個請求呀洲。
我們的開發(fā)同學(xué)也就是經(jīng)過了上述測試才放心把代碼發(fā)上線的紊选,咋一上線就炸了呢?

原因

我們來看下面一段操作道逗,

[root@e98dffb83384 src]# ./redis-cli
127.0.0.1:6379> SETNX k 1
(integer) 1
127.0.0.1:6379> EXPIRE k 10 # 為了方便演示兵罢,這里設(shè)置 10 秒過期時間
(integer) 1
127.0.0.1:6379> DECR k # 在過期時間內(nèi),第一次扣減成 0
(integer) 0
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -1
(integer) -1
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -2
(integer) -2
127.0.0.1:6379> TTL k # k 還有 2 秒過期
(integer) 2
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -3
(integer) -3
127.0.0.1:6379> TTL k # 距離設(shè)置過期時間 10 秒之后滓窍,k 已經(jīng)過期
(integer) -2
127.0.0.1:6379> DECR k # 這時候再扣減發(fā)現(xiàn) k 的值被扣減成 -1 
(integer) -1
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -2
(integer) -2
127.0.0.1:6379> TTL k # 查看 k 過期時間是永不過期
(integer) -1
127.0.0.1:6379> SETNX k 3 # 再設(shè)置是不成功的
(integer) 0
127.0.0.1:6379> DECR k # 繼續(xù)扣減成 -3
(integer) -3

在 Redis key 未過期之前卖词,DECR 命令都是正常扣減的吏夯。一旦 key 過期了此蜈,再執(zhí)行 DECR 命令,會發(fā)現(xiàn) key 的值和過期時間都變?yōu)?-1 了噪生。

Redis 官網(wǎng)對 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);

假設(shè)在第一句 SETNX 之后第二句 DECR 之前,key 過期了杠园,再執(zhí)行 DECR 就會先生成一個永不過期值為 0 的 key顾瞪。

之后所有請求的 SETNX 都是 fasle,一直會基于這個永不過期的 key 進行遞減抛蚁,所有的 $leftTokenNum 都小于 0陈醒,因此導(dǎo)致所有請求被限流。

問題復(fù)現(xiàn)

自測時為啥發(fā)現(xiàn)不了問題瞧甩?因為自測時設(shè)置的過期時間是 1 秒钉跷,導(dǎo)致 key 在兩步之間過期出現(xiàn)的概率很小。我們只要將過期時間調(diào)的足夠小肚逸,很容易復(fù)現(xiàn)問題爷辙。

把過期時間改為 5 毫秒,

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

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

if ($left < 0) {
  // 這里通過狀態(tài)碼來更方便的觀察
  header('Is-Limited:1', true, 500);
} else {
  header('Is-Limited:0', true, 200);
}

依然使用 siege 壓測:

2.gif

由于設(shè)置的 5 毫秒放行一個請求朦促,因此前半部分基本上都是通過的請求膝晾,偶爾有幾個限流的,這是正常的务冕。
但是沒過多久血当,所有請求都被限流了,也就復(fù)現(xiàn)了線上的故障。

解決方案

如何改進代碼來正確的實現(xiàn)限流呢臊旭?

Redis 的 EVAL 命令 執(zhí)行 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 之間會出現(xiàn)過期的尷尬情況。

        $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 壓測离熏,

3.gif

持續(xù)壓了 10 多分鐘也沒出現(xiàn)之前問題佳谦,問題得以解決。

總結(jié)

  • Redis 中 DECR 一個不存在的 key 會先把 key 值設(shè)置為 0 , TTL 設(shè)置為 -1(永不過期)滋戳,再進行減 1 操作钻蔑。
  • 使用 SETNX 配合 DECR 實現(xiàn)限流,會出現(xiàn) key 永不過期情況胧瓜。過期時間比較小或者高并發(fā)情況下矢棚,發(fā)生概率更高。
  • 在 Redis 中執(zhí)行 Lua 腳本是原子操作府喳。
  • 可以通過 Redis + Lua 實現(xiàn)高并發(fā)下的限流蒲肋。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市钝满,隨后出現(xiàn)的幾起案子兜粘,更是在濱河造成了極大的恐慌,老刑警劉巖弯蚜,帶你破解...
    沈念sama閱讀 218,386評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件孔轴,死亡現(xiàn)場離奇詭異,居然都是意外死亡碎捺,警方通過查閱死者的電腦和手機路鹰,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,142評論 3 394
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來收厨,“玉大人晋柱,你說我怎么就攤上這事∷腥” “怎么了雁竞?”我有些...
    開封第一講書人閱讀 164,704評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長拧额。 經(jīng)常有香客問我碑诉,道長,這世上最難降的妖魔是什么侥锦? 我笑而不...
    開封第一講書人閱讀 58,702評論 1 294
  • 正文 為了忘掉前任进栽,我火速辦了婚禮,結(jié)果婚禮上恭垦,老公的妹妹穿的比我還像新娘快毛。我一直安慰自己盲厌,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,716評論 6 392
  • 文/花漫 我一把揭開白布祸泪。 她就那樣靜靜地躺著,像睡著了一般建芙。 火紅的嫁衣襯著肌膚如雪没隘。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,573評論 1 305
  • 那天禁荸,我揣著相機與錄音右蒲,去河邊找鬼。 笑死赶熟,一個胖子當(dāng)著我的面吹牛瑰妄,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播映砖,決...
    沈念sama閱讀 40,314評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼间坐,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了邑退?” 一聲冷哼從身側(cè)響起竹宋,我...
    開封第一講書人閱讀 39,230評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎地技,沒想到半個月后蜈七,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,680評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡莫矗,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,873評論 3 336
  • 正文 我和宋清朗相戀三年飒硅,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片作谚。...
    茶點故事閱讀 39,991評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡三娩,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出食磕,到底是詐尸還是另有隱情尽棕,我是刑警寧澤,帶...
    沈念sama閱讀 35,706評論 5 346
  • 正文 年R本政府宣布彬伦,位于F島的核電站滔悉,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏单绑。R本人自食惡果不足惜回官,卻給世界環(huán)境...
    茶點故事閱讀 41,329評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望搂橙。 院中可真熱鬧歉提,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,910評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至侄泽,卻和暖如春礁芦,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背悼尾。 一陣腳步聲響...
    開封第一講書人閱讀 33,038評論 1 270
  • 我被黑心中介騙來泰國打工柿扣, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人闺魏。 一個月前我還...
    沈念sama閱讀 48,158評論 3 370
  • 正文 我出身青樓未状,卻偏偏與公主長得像,于是被迫代替她去往敵國和親析桥。 傳聞我的和親對象是個殘疾皇子司草,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,941評論 2 355

推薦閱讀更多精彩內(nèi)容

  • 1.1 資料 ,最好的入門小冊子泡仗,可以先于一切文檔之前看翻伺,免費。 作者Antirez的博客沮焕,Antirez維護的R...
    JefferyLcm閱讀 17,056評論 1 51
  • Lua腳本 Redis的單一指令都是原子的吨岭,可以有效保證執(zhí)行結(jié)果要么成功要么失敗峦树;當(dāng)用戶要執(zhí)行多條數(shù)據(jù)時辣辫,一方面每...
    李小磊_0867閱讀 3,519評論 3 5
  • 在互聯(lián)網(wǎng)企業(yè)中,限購的做法魁巩,多種多樣急灭,有的別出心裁,有的因循守舊谷遂,但是種種做法皆想達到的目的葬馋,無外乎幾種,商品賣的...
    java菜閱讀 601評論 0 5
  • redis是一個以key-value存儲的非關(guān)系型數(shù)據(jù)庫肾扰。有五種數(shù)據(jù)類型畴嘶,string、hashes集晚、list窗悯、s...
    林ze宏閱讀 994評論 0 0
  • NOSQL類型簡介鍵值對:會使用到一個哈希表,表中有一個特定的鍵和一個指針指向特定的數(shù)據(jù)偷拔,如redis蒋院,volde...
    MicoCube閱讀 3,981評論 2 27