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