深入分析 Redis Lua 指令碼執行原理

老錢發表於2018-10-23

Redis 提供了非常豐富的指令集,但是使用者依然不滿足,希望可以自定義擴充若干指令來完成一些特定領域的問題。Redis 為這樣的使用者場景提供了 lua 指令碼支援,使用者可以向伺服器傳送 lua 指令碼來執行自定義動作,獲取指令碼的響應資料。Redis 伺服器會單執行緒原子性執行 lua 指令碼,保證 lua 指令碼在處理的過程中不會被任意其它請求打斷。

深入分析 Redis Lua 指令碼執行原理

比如在分散式鎖小節,我們提到了 del_if_equals 偽指令,它可以將匹配 key 和刪除 key 合併在一起原子性執行,Redis 原生沒有提供這樣功能的指令,它可以使用 lua 指令碼來完成。

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
複製程式碼

那上面這個指令碼如何執行呢?使用 EVAL 指令

127.0.0.1:6379> set foo bar
OK
127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 foo bar
(integer) 1
127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 foo bar
(integer) 0
複製程式碼

EVAL 指令的第一個引數是指令碼內容字串,上面的例子我們將 lua 指令碼壓縮成一行以單引號圍起來是為了方便命令列執行。然後是 key 的數量以及每個 key 串,最後是一系列附加引數字串。附加引數的數量不需要和 key 保持一致,可以完全沒有附加引數。

EVAL SCRIPT KEY_NUM KEY1 KEY2 ... KEYN ARG1 ARG2 ....
複製程式碼

上面的例子中只有 1 個 key,它就是 foo,緊接著 bar 是唯一的附加引數。在 lua 指令碼中,陣列下標是從 1 開始,所以通過 KEYS[1] 就可以得到 第一個 key,通過 ARGV[1] 就可以得到第一個附加引數。redis.call 函式可以讓我們呼叫 Redis 的原生指令,上面的程式碼分別呼叫了 get 指令和 del 指令。return 返回的結果將會返回給客戶端。

SCRIPT LOAD 和 EVALSHA 指令

在上面的例子中,指令碼的內容很短。如果指令碼的內容很長,而且客戶端需要頻繁執行,那麼每次都需要傳遞冗長的指令碼內容勢必比較浪費網路流量。所以 Redis 還提供了 SCRIPT LOAD 和 EVALSHA 指令來解決這個問題。

深入分析 Redis Lua 指令碼執行原理
SCRIPT LOAD 指令用於將客戶端提供的 lua 指令碼傳遞到伺服器而不執行,但是會得到指令碼的唯一 ID,這個唯一 ID 是用來唯一標識伺服器快取的這段 lua 指令碼,它是由 Redis 使用 sha1 演算法揉捏指令碼內容而得到的一個很長的字串。有了這個唯一 ID,後面客戶端就可以通過 EVALSHA 指令反覆執行這個指令碼了。 我們知道 Redis 有 incrby 指令可以完成整數的自增操作,但是沒有提供自乘這樣的指令。

incrby key value  ==> $key = $key + value
mulby key value ==> $key = $key * value
複製程式碼

下面我們使用 SCRIPT LOAD 和 EVALSHA 指令來完成自乘運算。

local curVal = redis.call("get", KEYS[1])
if curVal == false then
  curVal = 0
else
  curVal = tonumber(curVal)
end
curVal = curVal * tonumber(ARGV[1])
redis.call("set", KEYS[1], curVal)
return curVal
複製程式碼

先將上面的指令碼單行化,語句之間使用分號隔開

local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal
複製程式碼

載入指令碼

127.0.0.1:6379> script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'
"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"
複製程式碼

命令列輸出了很長的字串,它就是指令碼的唯一標識,下面我們使用這個唯一標識來執行指令

127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 notexistskey 5
(integer) 0
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 notexistskey 5
(integer) 0
127.0.0.1:6379> set foo 1
OK
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo 5
(integer) 5
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo 5
(integer) 25
複製程式碼

錯誤處理

上面的指令碼引數要求傳入的附加引數必須是整數,如果沒有傳遞整數會怎樣呢?

127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 foo bar
(error) ERR Error running script (call to f_be4f93d8a5379e5e5b768a74e77c8a4eb0434441): @user_script:1: user_script:1: attempt to perform arithmetic on a nil value
複製程式碼

可以看到客戶端輸出了伺服器返回的通用錯誤訊息,注意這是一個動態丟擲的異常,Redis 會保護主執行緒不會因為指令碼的錯誤而導致伺服器崩潰,近似於在指令碼的外圍有一個很大的 try catch 語句包裹。在 lua 指令碼執行的過程中遇到了錯誤,同 redis 的事務一樣,那些通過 redis.call 函式已經執行過的指令對伺服器狀態產生影響是無法撤銷的,在編寫 lua 程式碼時一定要小心,避免沒有考慮到的判斷條件導致指令碼沒有完全執行。

深入分析 Redis Lua 指令碼執行原理
如果讀者對 lua 語言有所瞭解就知道 lua 原生沒有提供 try catch 語句,那上面提到的異常包裹語句究竟是用什麼來實現的呢?lua 的替代方案是內建了 pcall(f) 函式呼叫。pcall 的意思是 protected call,它會讓 f 函式執行在保護模式下,f 如果出現了錯誤,pcall 呼叫會返回 false 和錯誤資訊。而普通的 call(f) 呼叫在遇到錯誤時只會向上丟擲異常。在 Redis 的原始碼中可以看到 lua 指令碼的執行被包裹在 pcall 函式呼叫中。

// 編譯期
int luaCreateFunction(client *c, lua_State *lua, char *funcname, robj *body) {
  ...
  if (lua_pcall(lua,0,0,0)) {
    addReplyErrorFormat(c,"Error running script (new function): %s\n",
            lua_tostring(lua,-1));
    lua_pop(lua,1);
    return C_ERR;
  }
  ...
}

// 執行期
void evalGenericCommand(client *c, int evalsha) {
  ...
  err = lua_pcall(lua,0,1,-2);
  ...
}
複製程式碼

Redis 在 lua 指令碼中除了提供了 redis.call 函式外,同樣也提供了 redis.pcall 函式。前者遇到錯誤向上丟擲異常,後者會返回錯誤資訊。使用時一定要注意 call 函式出錯時會中斷指令碼的執行,為了保證指令碼的原子性,要謹慎使用。

錯誤傳遞

redis.call 函式呼叫會產生錯誤,指令碼遇到這種錯誤會返回怎樣的資訊呢?我們再看個例子

127.0.0.1:6379> hset foo x 1 y 2
(integer) 2
127.0.0.1:6379> eval 'return redis.call("incr", "foo")' 0
(error) ERR Error running script (call to f_8727c9c34a61783916ca488b366c475cb3a446cc): @user_script:1: WRONGTYPE Operation against a key holding the wrong kind of value
複製程式碼

客戶端輸出的依然是一個通用的錯誤訊息,而不是 incr 呼叫本應該返回的 WRONGTYPE 型別的錯誤訊息。Redis 內部在處理 redis.call 遇到錯誤時是向上丟擲異常,外圍的使用者看不見的 pcall呼叫捕獲到指令碼異常時會向客戶端回覆通用的錯誤資訊。如果我們將上面的 call 改成 pcall,結果就會不一樣,它可以將內部指令返回的特定錯誤向上傳遞。

127.0.0.1:6379> eval 'return redis.pcall("incr", "foo")' 0
(error) WRONGTYPE Operation against a key holding the wrong kind of value
複製程式碼

指令碼死迴圈怎麼辦?

Redis 的指令執行是個單執行緒,這個單執行緒還要執行來自客戶端的 lua 指令碼。如果 lua 指令碼中來一個死迴圈,是不是 Redis 就完蛋了?Redis 為了解決這個問題,它提供了 script kill 指令用於動態殺死一個執行時間超時的 lua 指令碼。不過 script kill 的執行有一個重要的前提,那就是當前正在執行的指令碼沒有對 Redis 的內部資料狀態進行修改,因為 Redis 不允許 script kill 破壞指令碼執行的原子性。比如指令碼內部使用了 redis.call("set", key, value) 修改了內部的資料,那麼 script kill 執行時伺服器會返回錯誤。下面我們來嘗試以下 script kill 指令。

127.0.0.1:6379> eval 'while(true) do print("hello") end' 0
複製程式碼

eval 指令執行後,可以明顯看出來 redis 卡死了,死活沒有任何響應,如果去觀察 Redis 伺服器日誌可以看到日誌在瘋狂輸出 hello 字串。這時候就必須重新開啟一個 redis-cli 來執行 script kill 指令。

127.0.0.1:6379> script kill
OK
(2.58s)
複製程式碼

再回過頭看 eval 指令的輸出

127.0.0.1:6379> eval 'while(true) do print("hello") end' 0
(error) ERR Error running script (call to f_d395649372f578b1a0d3a1dc1b2389717cadf403): @user_script:1: Script killed by user with SCRIPT KILL...
(6.99s)
複製程式碼

看到這裡細心的同學會注意到兩個疑點,第一個是 script kill 指令為什麼執行了 2.58 秒,第二個是指令碼都卡死了,Redis 哪裡來的閒功夫接受 script kill 指令。如果你自己嘗試了在第二個視窗執行 redis-cli 去連線伺服器,你還會發現第三個疑點,redis-cli 建立連線有點慢,大約頓了有 1 秒左右。

Script Kill 的原理

下面我就要開始揭祕 kill 的原理了,lua 指令碼引擎功能太強大了,它提供了各式各樣的鉤子函式,它允許在內部虛擬機器執行指令時執行鉤子程式碼。比如每執行 N 條指令執行一次某個鉤子函式,Redis 正是使用了這個鉤子函式。

深入分析 Redis Lua 指令碼執行原理

void evalGenericCommand(client *c, int evalsha) {
  ...
  // lua引擎每執行10w條指令,執行一次鉤子函式 luaMaskCountHook
  lua_sethook(lua,luaMaskCountHook,LUA_MASKCOUNT,100000);
  ...
}
複製程式碼

Redis 在鉤子函式裡會忙裡偷閒去處理客戶端的請求,並且只有在發現 lua 指令碼執行超時之後才會去處理請求,這個超時時間預設是 5 秒。於是上面提出的三個疑點也就煙消雲散了。

思考題

在延時佇列小節,我們使用 zrangebyscore 和 zdel 兩條指令來爭搶延時佇列中的任務,通過 zdel 的返回值來決定是哪個客戶端搶到了任務,這意味著那些沒有搶到任務的客戶端會有這樣一種感受 —— 到了嘴邊的肉(任務)最後還被別人搶走了,會很不爽。如果可以使用 lua 指令碼來實現爭搶邏輯,將 zrangebyscore 和 zdel 指令原子性執行就不會存在這種問題,讀者可以嘗試一下。

注:如果讀者不熟悉 lua,建議先學習 lua 語言,lua 語言簡單易學,但是也不是幾分鐘就可以學會的事,需要再來一本小冊的內容。本小冊專注 Redis,所以就不開大篇內容來細講 lua 語言了,有需要的朋友可以搜尋相關線上教程。

深入分析 Redis Lua 指令碼執行原理

閱讀更多深度技術文章,掃一掃上面的二維碼關注微信公眾號「碼洞」

相關文章