Lua 腳本功能是 Reids 2.6 版本的最大亮點,通過內(nèi)嵌對 Lua 環(huán)境的支持,Redis 解決了長久以來不能高效地處理 CAS (check-and-set)命令的缺點,并且可以通過組合使用多個命令,輕松實現(xiàn)以前很難實現(xiàn)或者不能高效實現(xiàn)的模式。
本章先介紹 Lua 環(huán)境的初始化步驟,然后對 Lua 腳本的安全性問題、以及解決這些問題的方法進行說明,最后對執(zhí)行 Lua 腳本的兩個命令 —— EVAL 和 EVALSHA 的實現(xiàn)原理進行介紹。
在初始化 Redis 服務(wù)器時,對 Lua 環(huán)境的初始化也會一并進行。
為了讓 Lua 環(huán)境符合 Redis 腳本功能的需求,Redis 對 Lua 環(huán)境進行了一系列的修改,包括添加函數(shù)庫、更換隨機函數(shù)、保護全局變量,等等。
整個初始化 Lua 環(huán)境的步驟如下:
cjson
庫。struct
庫(http://www.inf.puc-rio.br/~roberto/struct/)。cmsgpack
庫(https://github.com/antirez/lua-cmsgpack)。redis
全局表格到 Lua 環(huán)境,表格中包含了各種對 Redis 進行操作的函數(shù),包括:redis.call
和 redis.pcall
函數(shù)。用于發(fā)送日志(log)的 redis.log
函數(shù),以及相應(yīng)的日志級別(level):
redis.LOG_DEBUG
redis.LOG_VERBOSE
redis.LOG_NOTICE
redis.LOG_WARNING
redis.sha1hex
函數(shù)。redis.error_reply
函數(shù)和 redis.status_reply
函數(shù)。math
表原有的 math.random
函數(shù)和 math.randomseed
函數(shù),新的函數(shù)具有這樣的性質(zhì):每次執(zhí)行 Lua 腳本時,除非顯式地調(diào)用 math.randomseed
,否則 math.random
生成的偽隨機數(shù)序列總是相同的。以上就是 Redis 初始化 Lua 環(huán)境的整個過程,當(dāng)這些步驟都執(zhí)行完之后,Redis 就可以使用 Lua 環(huán)境來處理腳本了。
嚴格來說,步驟 1 至 8 才是初始化 Lua 環(huán)境的操作,而步驟 9 和 10 則是將 Lua 環(huán)境關(guān)聯(lián)到服務(wù)器的操作,為了按順序觀察整個初始化過程,我們將兩種操作放在了一起。
另外,步驟 6 用于創(chuàng)建無副作用的腳本,而步驟 7 則用于去除部分 Redis 命令中的不確定性(non deterministic),關(guān)于這兩點,請看下面一節(jié)關(guān)于腳本安全性的討論。
當(dāng)將 Lua 腳本復(fù)制到附屬節(jié)點,或者將 Lua 腳本寫入 AOF 文件時,Redis 需要解決這樣一個問題:如果一段 Lua 腳本帶有隨機性質(zhì)或副作用,那么當(dāng)這段腳本在附屬節(jié)點運行時,或者從 AOF 文件載入重新運行時,它得到的結(jié)果可能和之前運行的結(jié)果完全不同。
考慮以下一段代碼,其中的 get_random_number()
帶有隨機性質(zhì),我們在服務(wù)器 SERVER 中執(zhí)行這段代碼,并將隨機數(shù)的結(jié)果保存到鍵 number
上:
# 虛構(gòu)例子,不會真的出現(xiàn)在腳本環(huán)境中
redis> EVAL "return redis.call('set', KEYS[1], get_random_number())" 1 number
OK
redis> GET number
"10086"
現(xiàn)在,假如 EVAL 的代碼被復(fù)制到了附屬節(jié)點 SLAVE ,因為 get_random_number()
的隨機性質(zhì),它有很大可能會生成一個和 10086
完全不同的值,比如 65535
:
# 虛構(gòu)例子,不會真的出現(xiàn)在腳本環(huán)境中
redis> EVAL "return redis.call('set', KEYS[1], get_random_number())" 1 number
OK
redis> GET number
"65535"
可以看到,帶有隨機性的寫入腳本產(chǎn)生了一個嚴重的問題:它破壞了服務(wù)器和附屬節(jié)點數(shù)據(jù)之間的一致性。
當(dāng)從 AOF 文件中載入帶有隨機性質(zhì)的寫入腳本時,也會發(fā)生同樣的問題。
Note
只有在帶有隨機性的腳本進行寫入時,隨機性才是有害的。
如果一個腳本只是執(zhí)行只讀操作,那么隨機性是無害的。
比如說,如果腳本只是單純地執(zhí)行 RANDOMKEY
命令,那么它是無害的;但如果在執(zhí)行 RANDOMKEY
之后,基于 RANDOMKEY
的結(jié)果進行寫入操作,那么這個腳本就是有害的。
和隨機性質(zhì)類似,如果一個腳本的執(zhí)行對任何副作用產(chǎn)生了依賴,那么這個腳本每次執(zhí)行所產(chǎn)生的結(jié)果都可能會不一樣。
為了解決這個問題,Redis 對 Lua 環(huán)境所能執(zhí)行的腳本做了一個嚴格的限制 ——所有腳本都必須是無副作用的純函數(shù)(pure function)。
為此,Redis 對 Lua 環(huán)境做了一些列相應(yīng)的措施:
math
表原有的 math.random 函數(shù)和 math.randomseed 函數(shù),新的函數(shù)具有這樣的性質(zhì):每次執(zhí)行 Lua 腳本時,除非顯式地調(diào)用 math.randomseed
,否則 math.random
生成的偽隨機數(shù)序列總是相同的。經(jīng)過這一系列的調(diào)整之后,Redis 可以保證被執(zhí)行的腳本:
在腳本環(huán)境的初始化工作完成以后,Redis 就可以通過 EVAL 命令或 EVALSHA 命令執(zhí)行 Lua 腳本了。
其中,EVAL 直接對輸入的腳本代碼體(body)進行求值:
redis> EVAL "return 'hello world'" 0
"hello world"
而 EVALSHA 則要求輸入某個腳本的 SHA1 校驗和,這個校驗和所對應(yīng)的腳本必須至少被 EVAL 執(zhí)行過一次:
redis> EVAL "return 'hello world'" 0
"hello world"
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0 // 上一個腳本的校驗和
"hello world"
或者曾經(jīng)使用 SCRIPT LOAD 載入過這個腳本:
redis> SCRIPT LOAD "return 'dlrow olleh'"
"d569c48906b1f4fca0469ba4eee89149b5148092"
redis> EVALSHA d569c48906b1f4fca0469ba4eee89149b5148092 0
"dlrow olleh"
因為 EVALSHA 是基于 EVAL 構(gòu)建的,所以下文先用一節(jié)講解 EVAL 的實現(xiàn),之后再講解 EVALSHA 的實現(xiàn)。
EVAL 命令的執(zhí)行可以分為以下步驟:
以下兩個小節(jié)分別介紹這兩個步驟。
所有被 Redis 執(zhí)行的 Lua 腳本,在 Lua 環(huán)境中都會有一個和該腳本相對應(yīng)的無參數(shù)函數(shù):當(dāng)調(diào)用 EVAL 命令執(zhí)行腳本時,程序第一步要完成的工作就是為傳入的腳本創(chuàng)建一個相應(yīng)的 Lua 函數(shù)。
舉個例子,當(dāng)執(zhí)行命令 EVAL "return 'hello world'" 0
時,Lua 會為腳本 "return 'hello world'"
創(chuàng)建以下函數(shù):
function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91()
return 'hello world'
end
其中,函數(shù)名以 f_
為前綴,后跟腳本的 SHA1 校驗和(一個 40 個字符長的字符串)拼接而成。而函數(shù)體(body)則是用戶輸入的腳本。
以函數(shù)為單位保存 Lua 腳本有以下好處:
在為腳本創(chuàng)建函數(shù)前,程序會先用函數(shù)名檢查 Lua 環(huán)境,只有在函數(shù)定義未存在時,程序才創(chuàng)建函數(shù)。重復(fù)定義函數(shù)一般并沒有什么副作用,這算是一個小優(yōu)化。
另外,如果定義的函數(shù)在編譯過程中出錯(比如,腳本的代碼語法有錯),那么程序向用戶返回一個腳本錯誤,不再執(zhí)行后面的步驟。
在定義好 Lua 函數(shù)之后,程序就可以通過運行這個函數(shù)來達到運行輸入腳本的目的了。
不過,在此之前,為了確保腳本的正確和安全執(zhí)行,還需要執(zhí)行一些設(shè)置鉤子、傳入?yún)?shù)之類的操作,整個執(zhí)行函數(shù)的過程如下:
KEYS
參數(shù)和 ARGV
參數(shù)以全局數(shù)組的方式傳入到 Lua 環(huán)境中。fake_client->db = caller_client->db
,確保腳本中執(zhí)行的 Redis 命令訪問的是正確的數(shù)據(jù)庫。SELECT
命令,那么在腳本執(zhí)行完畢之后,偽客戶端中的數(shù)據(jù)庫可能已經(jīng)有所改變,所以需要對調(diào)用者客戶端的目標數(shù)據(jù)庫進行更新: caller_client->db = fake_client->db
。以下是執(zhí)行 EVAL "return 'hello world'" 0
的過程中,調(diào)用者客戶端(caller)、Redis 服務(wù)器和 Lua 環(huán)境之間的數(shù)據(jù)流表示圖:
發(fā)送命令請求
EVAL "return 'hello world'" 0
Caller ----------------------------------------> Redis
為腳本 "return 'hello world'"
創(chuàng)建 Lua 函數(shù)
Redis ----------------------------------------> Lua
綁定超時處理鉤子
Redis ----------------------------------------> Lua
執(zhí)行腳本函數(shù)
Redis ----------------------------------------> Lua
返回函數(shù)執(zhí)行結(jié)果(一個 Lua 值)
Redis <---------------------------------------- Lua
將 Lua 值轉(zhuǎn)換為 Redis 回復(fù)
并將結(jié)果返回給客戶端
Caller <---------------------------------------- Redis
上面這個圖可以作為所有 Lua 腳本的基本執(zhí)行流程圖,不過它展示的 Lua 腳本中不帶有 Redis 命令調(diào)用:當(dāng) Lua 腳本里本身有調(diào)用 Redis 命令時(執(zhí)行 redis.call
或者 redis.pcall
),Redis 和 Lua 腳本之間的數(shù)據(jù)交互會更復(fù)雜一些。
舉個例子,以下是執(zhí)行命令 EVAL "return redis.call('DBSIZE')" 0
時,調(diào)用者客戶端(caller)、偽客戶端(fake client)、Redis 服務(wù)器和 Lua 環(huán)境之間的數(shù)據(jù)流表示圖:
發(fā)送命令請求
EVAL "return redis.call('DBSIZE')" 0
Caller ------------------------------------------> Redis
為腳本 "return redis.call('DBSIZE')"
創(chuàng)建 Lua 函數(shù)
Redis ------------------------------------------> Lua
綁定超時處理鉤子
Redis ------------------------------------------> Lua
執(zhí)行腳本函數(shù)
Redis ------------------------------------------> Lua
執(zhí)行 redis.call('DBSIZE')
Fake Client <------------------------------------- Lua
偽客戶端向服務(wù)器發(fā)送
DBSIZE 命令請求
Fake Client -------------------------------------> Redis
服務(wù)器將 DBSIZE 的結(jié)果
(Redis 回復(fù))返回給偽客戶端
Fake Client <------------------------------------- Redis
將命令回復(fù)轉(zhuǎn)換為 Lua 值
并返回給 Lua 環(huán)境
Fake Client -------------------------------------> Lua
返回函數(shù)執(zhí)行結(jié)果(一個 Lua 值)
Redis <------------------------------------------ Lua
將 Lua 值轉(zhuǎn)換為 Redis 回復(fù)
并將該回復(fù)返回給客戶端
Caller <------------------------------------------ Redis
因為 EVAL "return redis.call('DBSIZE')"
只是簡單地調(diào)用了一次 DBSIZE
命令,所以 Lua 和偽客戶端只進行了一趟交互,當(dāng)腳本中的 redis.call
或者 redis.pcall
次數(shù)增多時,Lua 和偽客戶端的交互趟數(shù)也會相應(yīng)地增多,不過總體的交互方法和上圖展示的一樣。
前面介紹 EVAL 命令的實現(xiàn)時說過,每個被執(zhí)行過的 Lua 腳本,在 Lua 環(huán)境中都有一個和它相對應(yīng)的函數(shù),函數(shù)的名字由 f_
前綴加上 40 個字符長的 SHA1 校驗和構(gòu)成:比如 f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91
。
只要腳本所對應(yīng)的函數(shù)曾經(jīng)在 Lua 里面定義過,那么即使用戶不知道腳本的內(nèi)容本身,也可以直接通過腳本的 SHA1 校驗和來調(diào)用腳本所對應(yīng)的函數(shù),從而達到執(zhí)行腳本的目的 ——這就是 EVALSHA 命令的實現(xiàn)原理。
可以用偽代碼來描述這一原理:
def EVALSHA(sha1):
# 拼接出 Lua 函數(shù)名字
func_name = "f_" + sha1
# 查看該函數(shù)是否已經(jīng)在 Lua 中定義
if function_defined_in_lua(func_name):
# 如果已經(jīng)定義過的話,執(zhí)行函數(shù)
return exec_lua_function(func_name)
else:
# 沒有找到和輸入 SHA1 值相對應(yīng)的函數(shù)則返回一個腳本未找到錯誤
return script_error("SCRIPT NOT FOUND")
除了執(zhí)行 EVAL 命令之外,SCRIPT LOAD 命令也可以為腳本在 Lua 環(huán)境中創(chuàng)建函數(shù):
redis> SCRIPT LOAD "return 'hello world'"
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
"hello world"
SCRIPT LOAD 執(zhí)行的操作和前面《定義 Lua 函數(shù)》小節(jié)描述的一樣。
初始化 Lua 腳本環(huán)境需要一系列步驟,其中最重要的包括:
redis
全局表格,包含各種對 Redis 進行操作的函數(shù),比如 redis.call
和 redis.log
,等等。創(chuàng)建一個無網(wǎng)絡(luò)連接的偽客戶端,專門用于執(zhí)行 Lua 腳本中的 Redis 命令。
更多建議: