查看: 500|回復: 0

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

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




圖片
比如在《Redis 深度歷險》分布式鎖小節,我們提到了 del_if_equals 偽指令,它可以將匹配 key 和刪除 key 合并在一起原子性執行,Redis 原生沒有提供這樣功能的指令,它可以使用 lua 腳本來完成。





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





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





上面的例子中只有 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 指令可以完成整數的自增操作,但是沒有提供自乘這樣的指令。





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





先將上面的腳本單行化,語句之間使用分號隔開





加載腳本





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






錯誤處理
上面的腳本參數要求傳入的附加參數必須是整數,如果沒有傳遞整數會怎樣呢?





可以看到客戶端輸出了服務器返回的通用錯誤消息,注意這是一個動態拋出的異常,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 函數調用中。





Redis 在 lua 腳本中除了提供了 redis.call 函數外,同樣也提供了 redis.pcall 函數。前者遇到錯誤向上拋出異常,后者會返回錯誤信息。使用時一定要注意 call 函數出錯時會中斷腳本的執行,為了保證腳本的原子性,要謹慎使用。
錯誤傳遞
redis.call 函數調用會產生錯誤,腳本遇到這種錯誤會返回怎樣的信息呢?我們再看個例子





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






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





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




看到這里細心的同學會注意到兩個疑點,第一個是 script kill 指令為什么執行了 2.58 秒,第二個是腳本都卡死了,Redis 哪里來的閑功夫接受 script kill 指令。如果你自己嘗試了在第二個窗口執行 redis-cli 去連接服務器,你還會發現第三個疑點,redis-cli 建立連接有點慢,大約頓了有 1 秒左右。
Script Kill 的原理
下面我就要開始揭秘 kill 的原理了,lua 腳本引擎功能太強大了,它提供了各式各樣的鉤子函數,它允許在內部虛擬機執行指令時運行鉤子代碼。比如每執行 N 條指令執行一次某個鉤子函數,Redis 正是使用了這個鉤子函數。










Redis 在鉤子函數里會忙里偷閑去處理客戶端的請求,并且只有在發現 lua 腳本執行超時之后才會去處理請求,這個超時時間默認是 5 秒。于是上面提出的三個疑點也就煙消云散了。
思考題
在延時隊列小節,我們使用 zrangebyscore 和 zdel 兩條指令來爭搶延時隊列中的任務,通過 zdel 的返回值來決定是哪個客戶端搶到了任務,這意味著那些沒有搶到任務的客戶端會有這樣一種感受 —— 到了嘴邊的肉(任務)最后還被別人搶走了,會很不爽。如果可以使用 lua 腳本來實現爭搶邏輯,將 zrangebyscore 和 zdel 指令原子性執行就不會存在這種問題,讀者可以嘗試一下。
歡迎工作一到五年的java工程師朋友們加入Java填坑之路:860113481
群內提供免費的Java架構學習資料(里面有高可用、高并發、高性能及分布式、Jvm性能調優、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!



回復

使用道具 舉報