# Lua 腳本
[TOC=2,3]
Lua 腳本功能是 Reids 2.6 版本的最大亮點,通過內嵌對 Lua 環境的支持,Redis 解決了長久以來不能高效地處理 CAS (check-and-set)命令的缺點,并且可以通過組合使用多個命令,輕松實現以前很難實現或者不能高效實現的模式。
本章先介紹 Lua 環境的初始化步驟,然后對 Lua 腳本的安全性問題、以及解決這些問題的方法進行說明,最后對執行 Lua 腳本的兩個命令 —— [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 和 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 的實現原理進行介紹。
### 初始化 Lua 環境
在初始化 Redis 服務器時,對 Lua 環境的初始化也會一并進行。
為了讓 Lua 環境符合 Redis 腳本功能的需求,Redis 對 Lua 環境進行了一系列的修改,包括添加函數庫、更換隨機函數、保護全局變量,等等。
整個初始化 Lua 環境的步驟如下:
1. 調用 [lua_open](http://www.lua.org/pil/24.1.html) 函數,創建一個新的 Lua 環境。
1. 載入指定的 Lua 函數庫,包括:
- 基礎庫(base lib)。
- 表格庫(table lib)。
- 字符串庫(string lib)。
- 數學庫(math lib)。
- 調試庫(debug lib)。
- 用于處理 JSON 對象的 `cjson` 庫。
- 在 Lua 值和 C 結構(struct)之間進行轉換的 `struct` 庫([http://www.inf.puc-rio.br/~roberto/struct/](http://www.inf.puc-rio.br/~roberto/struct/))。
- 處理 MessagePack 數據的 `cmsgpack` 庫([https://github.com/antirez/lua-cmsgpack](https://github.com/antirez/lua-cmsgpack))。
1. 屏蔽一些可能對 Lua 環境產生安全問題的函數,比如 [loadfile](http://pgl.yoyo.org/luai/i/loadfile) 。
1. 創建一個 Redis 字典,保存 Lua 腳本,并在復制(replication)腳本時使用。字典的鍵為 SHA1 校驗和,字典的值為 Lua 腳本。
1. 創建一個 `redis` 全局表格到 Lua 環境,表格中包含了各種對 Redis 進行操作的函數,包括:
- 用于執行 Redis 命令的 `redis.call` 和 `redis.pcall` 函數。
- 用于發送日志(log)的 `redis.log` 函數,以及相應的日志級別(level):
- `redis.LOG_DEBUG`
- `redis.LOG_VERBOSE`
- `redis.LOG_NOTICE`
- `redis.LOG_WARNING`
- 用于計算 SHA1 校驗和的 `redis.sha1hex` 函數。
- 用于返回錯誤信息的 `redis.error_reply` 函數和 `redis.status_reply` 函數。
1. 用 Redis 自己定義的隨機生成函數,替換 `math` 表原有的 `math.random` 函數和 `math.randomseed` 函數,新的函數具有這樣的性質:每次執行 Lua 腳本時,除非顯式地調用 `math.randomseed` ,否則 `math.random` 生成的偽隨機數序列總是相同的。
1. 創建一個對 Redis 多批量回復(multi bulk reply)進行排序的輔助函數。
1. 對 Lua 環境中的全局變量進行保護,以免被傳入的腳本修改。
1. 因為 Redis 命令必須通過客戶端來執行,所以需要在服務器狀態中創建一個無網絡連接的偽客戶端(fake client),專門用于執行 Lua 腳本中包含的 Redis 命令:當 Lua 腳本需要執行 Redis 命令時,它通過偽客戶端來向服務器發送命令請求,服務器在執行完命令之后,將結果返回給偽客戶端,而偽客戶端又轉而將命令結果返回給 Lua 腳本。
1. 將 Lua 環境的指針記錄到 Redis 服務器的全局狀態中,等候 Redis 的調用。
以上就是 Redis 初始化 Lua 環境的整個過程,當這些步驟都執行完之后,Redis 就可以使用 Lua 環境來處理腳本了。
嚴格來說,步驟 1 至 8 才是初始化 Lua 環境的操作,而步驟 9 和 10 則是將 Lua 環境關聯到服務器的操作,為了按順序觀察整個初始化過程,我們將兩種操作放在了一起。
另外,步驟 6 用于創建無副作用的腳本,而步驟 7 則用于去除部分 Redis 命令中的不確定性(non deterministic),關于這兩點,請看下面一節關于腳本安全性的討論。
### 腳本的安全性
當將 Lua 腳本復制到附屬節點,或者將 Lua 腳本寫入 AOF 文件時,Redis 需要解決這樣一個問題:如果一段 Lua 腳本帶有隨機性質或副作用,那么當這段腳本在附屬節點運行時,或者從 AOF 文件載入重新運行時,它得到的結果可能和之前運行的結果完全不同。
考慮以下一段代碼,其中的 `get_random_number()` 帶有隨機性質,我們在服務器 SERVER 中執行這段代碼,并將隨機數的結果保存到鍵 `number` 上:
~~~
# 虛構例子,不會真的出現在腳本環境中
redis> EVAL "return redis.call('set', KEYS[1], get_random_number())" 1 number
OK
redis> GET number
"10086"
~~~
現在,假如 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 的代碼被復制到了附屬節點 SLAVE ,因為 `get_random_number()` 的隨機性質,它有很大可能會生成一個和 `10086` 完全不同的值,比如 `65535` :
~~~
# 虛構例子,不會真的出現在腳本環境中
redis> EVAL "return redis.call('set', KEYS[1], get_random_number())" 1 number
OK
redis> GET number
"65535"
~~~
可以看到,帶有隨機性的寫入腳本產生了一個嚴重的問題:它破壞了服務器和附屬節點數據之間的一致性。
當從 AOF 文件中載入帶有隨機性質的寫入腳本時,也會發生同樣的問題。
Note
只有在帶有隨機性的腳本進行寫入時,隨機性才是有害的。
如果一個腳本只是執行只讀操作,那么隨機性是無害的。
比如說,如果腳本只是單純地執行 `RANDOMKEY` 命令,那么它是無害的;但如果在執行 `RANDOMKEY` 之后,基于 `RANDOMKEY` 的結果進行寫入操作,那么這個腳本就是有害的。
和隨機性質類似,如果一個腳本的執行對任何副作用產生了依賴,那么這個腳本每次執行所產生的結果都可能會不一樣。
為了解決這個問題,Redis 對 Lua 環境所能執行的腳本做了一個嚴格的限制 ——所有腳本都必須是無副作用的純函數(pure function)。
為此,Redis 對 Lua 環境做了一些列相應的措施:
- 不提供訪問系統狀態狀態的庫(比如系統時間庫)。
- 禁止使用 [loadfile](http://pgl.yoyo.org/luai/i/loadfile) 函數。
- 如果腳本在執行帶有隨機性質的命令(比如 [RANDOMKEY](http://redis.readthedocs.org/en/latest/key/randomkey.html#randomkey "(in Redis 命令參考 v2.8)") ),或者帶有副作用的命令(比如 [TIME](http://redis.readthedocs.org/en/latest/server/time.html#time "(in Redis 命令參考 v2.8)") )之后,試圖執行一個寫入命令(比如 [SET](http://redis.readthedocs.org/en/latest/string/set.html#set "(in Redis 命令參考 v2.8)") ),那么 Redis 將阻止這個腳本繼續運行,并返回一個錯誤。
- 如果腳本執行了帶有隨機性質的讀命令(比如 [SMEMBERS](http://redis.readthedocs.org/en/latest/set/smembers.html#smembers "(in Redis 命令參考 v2.8)") ),那么在腳本的輸出返回給 Redis 之前,會先被執行一個自動的[字典序排序](http://en.wikipedia.org/wiki/Lexicographical_order) ,從而確保輸出結果是有序的。
- 用 Redis 自己定義的隨機生成函數,替換 Lua 環境中 `math` 表原有的 [math.random](http://pgl.yoyo.org/luai/i/math.random) 函數和 [math.randomseed](http://pgl.yoyo.org/luai/i/math.randomseed) 函數,新的函數具有這樣的性質:每次執行 Lua 腳本時,除非顯式地調用 `math.randomseed` ,否則 `math.random` 生成的偽隨機數序列總是相同的。
經過這一系列的調整之后,Redis 可以保證被執行的腳本:
1. 無副作用。
1. 沒有有害的隨機性。
1. 對于同樣的輸入參數和數據集,總是產生相同的寫入命令。
### 腳本的執行
在腳本環境的初始化工作完成以后,Redis 就可以通過 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 命令或 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 命令執行 Lua 腳本了。
其中,[EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 直接對輸入的腳本代碼體(body)進行求值:
~~~
redis> EVAL "return 'hello world'" 0
"hello world"
~~~
而 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 則要求輸入某個腳本的 SHA1 校驗和,這個校驗和所對應的腳本必須至少被 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 執行過一次:
~~~
redis> EVAL "return 'hello world'" 0
"hello world"
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0 // 上一個腳本的校驗和
"hello world"
~~~
或者曾經使用 [SCRIPT LOAD](http://redis.readthedocs.org/en/latest/script/script_load.html#script-load "(in Redis 命令參考 v2.8)") 載入過這個腳本:
~~~
redis> SCRIPT LOAD "return 'dlrow olleh'"
"d569c48906b1f4fca0469ba4eee89149b5148092"
redis> EVALSHA d569c48906b1f4fca0469ba4eee89149b5148092 0
"dlrow olleh"
~~~
因為 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 是基于 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 構建的,所以下文先用一節講解 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 的實現,之后再講解 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 的實現。
### EVAL 命令的實現
[EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 命令的執行可以分為以下步驟:
1. 為輸入腳本定義一個 Lua 函數。
1. 執行這個 Lua 函數。
以下兩個小節分別介紹這兩個步驟。
### 定義 Lua 函數
所有被 Redis 執行的 Lua 腳本,在 Lua 環境中都會有一個和該腳本相對應的無參數函數:當調用 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 命令執行腳本時,程序第一步要完成的工作就是為傳入的腳本創建一個相應的 Lua 函數。
舉個例子,當執行命令 `EVAL "return 'hello world'" 0` 時,Lua 會為腳本 `"return 'hello world'"` 創建以下函數:
~~~
function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91()
return 'hello world'
end
~~~
其中,函數名以 `f_` 為前綴,后跟腳本的 SHA1 校驗和(一個 40 個字符長的字符串)拼接而成。而函數體(body)則是用戶輸入的腳本。
以函數為單位保存 Lua 腳本有以下好處:
- 執行腳本的步驟非常簡單,只要調用和腳本相對應的函數即可。
- Lua 環境可以保持清潔,已有的腳本和新加入的腳本不會互相干擾,也可以將重置 Lua 環境和調用 Lua GC 的次數降到最低。
- 如果某個腳本所對應的函數在 Lua 環境中被定義過至少一次,那么只要記得這個腳本的 SHA1 校驗和,就可以直接執行該腳本 —— 這是實現 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 命令的基礎,稍后在介紹 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 的時候就會說到這一點。
在為腳本創建函數前,程序會先用函數名檢查 Lua 環境,只有在函數定義未存在時,程序才創建函數。重復定義函數一般并沒有什么副作用,這算是一個小優化。
另外,如果定義的函數在編譯過程中出錯(比如,腳本的代碼語法有錯),那么程序向用戶返回一個腳本錯誤,不再執行后面的步驟。
### 執行 Lua 函數
在定義好 Lua 函數之后,程序就可以通過運行這個函數來達到運行輸入腳本的目的了。
不過,在此之前,為了確保腳本的正確和安全執行,還需要執行一些設置鉤子、傳入參數之類的操作,整個執行函數的過程如下:
1. 將 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 命令中輸入的 `KEYS` 參數和 `ARGV` 參數以全局數組的方式傳入到 Lua 環境中。
1. 設置偽客戶端的目標數據庫為調用者客戶端的目標數據庫: `fake_client->db = caller_client->db` ,確保腳本中執行的 Redis 命令訪問的是正確的數據庫。
1. 為 Lua 環境裝載超時鉤子,保證在腳本執行出現超時時可以殺死腳本,或者停止 Redis 服務器。
1. 執行腳本對應的 Lua 函數。
1. 如果被執行的 Lua 腳本中帶有 `SELECT` 命令,那么在腳本執行完畢之后,偽客戶端中的數據庫可能已經有所改變,所以需要對調用者客戶端的目標數據庫進行更新: `caller_client->db = fake_client->db` 。
1. 執行清理操作:清除鉤子;清除指向調用者客戶端的指針;等等。
1. 將 Lua 函數執行所得的結果轉換成 Redis 回復,然后傳給調用者客戶端。
1. 對 Lua 環境進行一次單步的漸進式 GC 。
以下是執行 `EVAL "return 'hello world'" 0` 的過程中,調用者客戶端(caller)、Redis 服務器和 Lua 環境之間的數據流表示圖:
~~~
發送命令請求
EVAL "return 'hello world'" 0
Caller ----------------------------------------> Redis
為腳本 "return 'hello world'"
創建 Lua 函數
Redis ----------------------------------------> Lua
綁定超時處理鉤子
Redis ----------------------------------------> Lua
執行腳本函數
Redis ----------------------------------------> Lua
返回函數執行結果(一個 Lua 值)
Redis <---------------------------------------- Lua
將 Lua 值轉換為 Redis 回復
并將結果返回給客戶端
Caller <---------------------------------------- Redis
~~~
上面這個圖可以作為所有 Lua 腳本的基本執行流程圖,不過它展示的 Lua 腳本中不帶有 Redis 命令調用:當 Lua 腳本里本身有調用 Redis 命令時(執行 `redis.call` 或者 `redis.pcall` ),Redis 和 Lua 腳本之間的數據交互會更復雜一些。
舉個例子,以下是執行命令 `EVAL "return redis.call('DBSIZE')" 0` 時,調用者客戶端(caller)、偽客戶端(fake client)、Redis 服務器和 Lua 環境之間的數據流表示圖:
~~~
發送命令請求
EVAL "return redis.call('DBSIZE')" 0
Caller ------------------------------------------> Redis
為腳本 "return redis.call('DBSIZE')"
創建 Lua 函數
Redis ------------------------------------------> Lua
綁定超時處理鉤子
Redis ------------------------------------------> Lua
執行腳本函數
Redis ------------------------------------------> Lua
執行 redis.call('DBSIZE')
Fake Client <------------------------------------- Lua
偽客戶端向服務器發送
DBSIZE 命令請求
Fake Client -------------------------------------> Redis
服務器將 DBSIZE 的結果
(Redis 回復)返回給偽客戶端
Fake Client <------------------------------------- Redis
將命令回復轉換為 Lua 值
并返回給 Lua 環境
Fake Client -------------------------------------> Lua
返回函數執行結果(一個 Lua 值)
Redis <------------------------------------------ Lua
將 Lua 值轉換為 Redis 回復
并將該回復返回給客戶端
Caller <------------------------------------------ Redis
~~~
因為 `EVAL "return redis.call('DBSIZE')"` 只是簡單地調用了一次 `DBSIZE` 命令,所以 Lua 和偽客戶端只進行了一趟交互,當腳本中的 `redis.call` 或者 `redis.pcall` 次數增多時,Lua 和偽客戶端的交互趟數也會相應地增多,不過總體的交互方法和上圖展示的一樣。
### EVALSHA 命令的實現
前面介紹 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 命令的實現時說過,每個被執行過的 Lua 腳本,在 Lua 環境中都有一個和它相對應的函數,函數的名字由 `f_` 前綴加上 40 個字符長的 SHA1 校驗和構成:比如 `f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91` 。
只要腳本所對應的函數曾經在 Lua 里面定義過,那么即使用戶不知道腳本的內容本身,也可以直接通過腳本的 SHA1 校驗和來調用腳本所對應的函數,從而達到執行腳本的目的 ——這就是 [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 命令的實現原理。
可以用偽代碼來描述這一原理:
~~~
def EVALSHA(sha1):
# 拼接出 Lua 函數名字
func_name = "f_" + sha1
# 查看該函數是否已經在 Lua 中定義
if function_defined_in_lua(func_name):
# 如果已經定義過的話,執行函數
return exec_lua_function(func_name)
else:
# 沒有找到和輸入 SHA1 值相對應的函數則返回一個腳本未找到錯誤
return script_error("SCRIPT NOT FOUND")
~~~
除了執行 [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 命令之外,[SCRIPT LOAD](http://redis.readthedocs.org/en/latest/script/script_load.html#script-load "(in Redis 命令參考 v2.8)") 命令也可以為腳本在 Lua 環境中創建函數:
~~~
redis> SCRIPT LOAD "return 'hello world'"
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
"hello world"
~~~
[SCRIPT LOAD](http://redis.readthedocs.org/en/latest/script/script_load.html#script-load "(in Redis 命令參考 v2.8)") 執行的操作和前面《[定義 Lua 函數](#)》小節描述的一樣。
### 小結
- 初始化 Lua 腳本環境需要一系列步驟,其中最重要的包括:
- 創建 Lua 環境。
- 載入 Lua 庫,比如字符串庫、數學庫、表格庫,等等。
- 創建 `redis` 全局表格,包含各種對 Redis 進行操作的函數,比如 `redis.call` 和 `redis.log` ,等等。
- 創建一個無網絡連接的偽客戶端,專門用于執行 Lua 腳本中的 Redis 命令。
- Reids 通過一系列措施保證被執行的 Lua 腳本無副作用,也沒有有害的寫隨機性:對于同樣的輸入參數和數據集,總是產生相同的寫入命令。
- [EVAL](http://redis.readthedocs.org/en/latest/script/eval.html#eval "(in Redis 命令參考 v2.8)") 命令為輸入腳本定義一個 Lua 函數,然后通過執行這個函數來執行腳本。
- [EVALSHA](http://redis.readthedocs.org/en/latest/script/evalsha.html#evalsha "(in Redis 命令參考 v2.8)") 通過構建函數名,直接調用 Lua 中已定義的函數,從而執行相應的腳本。