慾求不滿之 Redis Lua 腳本的執行原理

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

圖片

比如在《Redis 深度歷險》分佈式鎖小節,我們提到了 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 指令來解決這個問題。

圖片

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 代碼時一定要小心,避免沒有考慮到的判斷條件導致腳本沒有完全執行。

圖片

如果讀者對 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 正是使用了這個鉤子函數。

圖片

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 語言了,有需要的朋友可以搜索相關在線教程。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章