Lua 腳本

2018-02-24 15:46 更新

Lua 腳本

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è)命令 —— EVALEVALSHA 的實(shí)現(xiàn)原理進(jìn)行介紹。

初始化 Lua 環(huá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)境的步驟如下:

  1. 調(diào)用 lua_open 函數(shù),創(chuàng)建一個(gè)新的 Lua 環(huán)境。
  2. 載入指定的 Lua 函數(shù)庫(kù),包括:
  • 基礎(chǔ)庫(kù)(base lib)。
  • 表格庫(kù)(table lib)。
  • 字符串庫(kù)(string lib)。
  • 數(shù)學(xué)庫(kù)(math lib)。
  • 調(diào)試庫(kù)(debug lib)。
  • 用于處理 JSON 對(duì)象的 cjson 庫(kù)。
  • 在 Lua 值和 C 結(jié)構(gòu)(struct)之間進(jìn)行轉(zhuǎn)換的 struct 庫(kù)(http://www.inf.puc-rio.br/~roberto/struct/)。
  • 處理 MessagePack 數(shù)據(jù)的 cmsgpack 庫(kù)(https://github.com/antirez/lua-cmsgpack)。
  1. 屏蔽一些可能對(duì) Lua 環(huán)境產(chǎn)生安全問(wèn)題的函數(shù),比如 loadfile
  2. 創(chuàng)建一個(gè) Redis 字典,保存 Lua 腳本,并在復(fù)制(replication)腳本時(shí)使用。字典的鍵為 SHA1 校驗(yàn)和,字典的值為 Lua 腳本。
  3. 創(chuàng)建一個(gè) redis 全局表格到 Lua 環(huán)境,表格中包含了各種對(duì) Redis 進(jìn)行操作的函數(shù),包括:
  • 用于執(zhí)行 Redis 命令的 redis.callredis.pcall 函數(shù)。
  • 用于發(fā)送日志(log)的 redis.log 函數(shù),以及相應(yīng)的日志級(jí)別(level):

  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE
  • redis.LOG_WARNING

  • 用于計(jì)算 SHA1 校驗(yàn)和的 redis.sha1hex 函數(shù)。
  • 用于返回錯(cuò)誤信息的 redis.error_reply 函數(shù)和 redis.status_reply 函數(shù)。
  1. 用 Redis 自己定義的隨機(jī)生成函數(shù),替換 math 表原有的 math.random 函數(shù)和 math.randomseed 函數(shù),新的函數(shù)具有這樣的性質(zhì):每次執(zhí)行 Lua 腳本時(shí),除非顯式地調(diào)用 math.randomseed ,否則 math.random 生成的偽隨機(jī)數(shù)序列總是相同的。
  2. 創(chuàng)建一個(gè)對(duì) Redis 多批量回復(fù)(multi bulk reply)進(jìn)行排序的輔助函數(shù)。
  3. 對(duì) Lua 環(huán)境中的全局變量進(jìn)行保護(hù),以免被傳入的腳本修改。
  4. 因?yàn)?Redis 命令必須通過(guò)客戶端來(lái)執(zhí)行,所以需要在服務(wù)器狀態(tài)中創(chuàng)建一個(gè)無(wú)網(wǎng)絡(luò)連接的偽客戶端(fake client),專門(mén)用于執(zhí)行 Lua 腳本中包含的 Redis 命令:當(dāng) Lua 腳本需要執(zhí)行 Redis 命令時(shí),它通過(guò)偽客戶端來(lái)向服務(wù)器發(fā)送命令請(qǐng)求,服務(wù)器在執(zhí)行完命令之后,將結(jié)果返回給偽客戶端,而偽客戶端又轉(zhuǎn)而將命令結(jié)果返回給 Lua 腳本。
  5. 將 Lua 環(huán)境的指針記錄到 Redis 服務(wù)器的全局狀態(tài)中,等候 Redis 的調(diào)用。

以上就是 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)的措施:

  • 不提供訪問(wèn)系統(tǒng)狀態(tài)狀態(tài)的庫(kù)(比如系統(tǒng)時(shí)間庫(kù))。
  • 禁止使用 loadfile 函數(shù)。
  • 如果腳本在執(zhí)行帶有隨機(jī)性質(zhì)的命令(比如 RANDOMKEY ),或者帶有副作用的命令(比如 TIME )之后,試圖執(zhí)行一個(gè)寫(xiě)入命令(比如 SET ),那么 Redis 將阻止這個(gè)腳本繼續(xù)運(yùn)行,并返回一個(gè)錯(cuò)誤。
  • 如果腳本執(zhí)行了帶有隨機(jī)性質(zhì)的讀命令(比如 SMEMBERS ),那么在腳本的輸出返回給 Redis 之前,會(huì)先被執(zhí)行一個(gè)自動(dòng)的字典序排序 ,從而確保輸出結(jié)果是有序的。
  • 用 Redis 自己定義的隨機(jī)生成函數(shù),替換 Lua 環(huán)境中 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í)行的腳本:

  1. 無(wú)副作用。
  2. 沒(méi)有有害的隨機(jī)性。
  3. 對(duì)于同樣的輸入?yún)?shù)和數(shù)據(jù)集,總是產(chǎn)生相同的寫(xiě)入命令。

腳本的執(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 命令的實(shí)現(xiàn)

EVAL 命令的執(zhí)行可以分為以下步驟:

  1. 為輸入腳本定義一個(gè) Lua 函數(shù)。
  2. 執(zhí)行這個(gè) Lua 函數(shù)。

以下兩個(gè)小節(jié)分別介紹這兩個(gè)步驟。

定義 Lua 函數(shù)

所有被 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 腳本有以下好處:

  • 執(zhí)行腳本的步驟非常簡(jiǎn)單,只要調(diào)用和腳本相對(duì)應(yīng)的函數(shù)即可。
  • Lua 環(huán)境可以保持清潔,已有的腳本和新加入的腳本不會(huì)互相干擾,也可以將重置 Lua 環(huán)境和調(diào)用 Lua GC 的次數(shù)降到最低。
  • 如果某個(gè)腳本所對(duì)應(yīng)的函數(shù)在 Lua 環(huán)境中被定義過(guò)至少一次,那么只要記得這個(gè)腳本的 SHA1 校驗(yàn)和,就可以直接執(zhí)行該腳本 —— 這是實(shí)現(xiàn) EVALSHA 命令的基礎(chǔ),稍后在介紹 EVALSHA 的時(shí)候就會(huì)說(shuō)到這一點(diǎn)。

在為腳本創(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í)行后面的步驟。

執(zhí)行 Lua 函數(shù)

在定義好 Lua 函數(shù)之后,程序就可以通過(guò)運(yùn)行這個(gè)函數(shù)來(lái)達(dá)到運(yùn)行輸入腳本的目的了。

不過(guò),在此之前,為了確保腳本的正確和安全執(zhí)行,還需要執(zhí)行一些設(shè)置鉤子、傳入?yún)?shù)之類的操作,整個(gè)執(zhí)行函數(shù)的過(guò)程如下:

  1. EVAL 命令中輸入的 KEYS 參數(shù)和 ARGV 參數(shù)以全局?jǐn)?shù)組的方式傳入到 Lua 環(huán)境中。
  2. 設(shè)置偽客戶端的目標(biāo)數(shù)據(jù)庫(kù)為調(diào)用者客戶端的目標(biāo)數(shù)據(jù)庫(kù): fake_client->db = caller_client->db ,確保腳本中執(zhí)行的 Redis 命令訪問(wèn)的是正確的數(shù)據(jù)庫(kù)。
  3. 為 Lua 環(huán)境裝載超時(shí)鉤子,保證在腳本執(zhí)行出現(xiàn)超時(shí)時(shí)可以殺死腳本,或者停止 Redis 服務(wù)器。
  4. 執(zhí)行腳本對(duì)應(yīng)的 Lua 函數(shù)。
  5. 如果被執(zhí)行的 Lua 腳本中帶有 SELECT 命令,那么在腳本執(zhí)行完畢之后,偽客戶端中的數(shù)據(jù)庫(kù)可能已經(jīng)有所改變,所以需要對(duì)調(diào)用者客戶端的目標(biāo)數(shù)據(jù)庫(kù)進(jìn)行更新: caller_client->db = fake_client->db
  6. 執(zhí)行清理操作:清除鉤子;清除指向調(diào)用者客戶端的指針;等等。
  7. 將 Lua 函數(shù)執(zhí)行所得的結(jié)果轉(zhuǎn)換成 Redis 回復(fù),然后傳給調(diào)用者客戶端。
  8. 對(duì) Lua 環(huán)境進(jìn)行一次單步的漸進(jìn)式 GC 。

以下是執(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ò)總體的交互方法和上圖展示的一樣。

EVALSHA 命令的實(shí)現(xiàn)

前面介紹 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é)描述的一樣。

小結(jié)

  • 初始化 Lua 腳本環(huán)境需要一系列步驟,其中最重要的包括:

  • 創(chuàng)建 Lua 環(huán)境。
  • 載入 Lua 庫(kù),比如字符串庫(kù)、數(shù)學(xué)庫(kù)、表格庫(kù),等等。
  • 創(chuàng)建 redis 全局表格,包含各種對(duì) Redis 進(jìn)行操作的函數(shù),比如 redis.callredis.log ,等等。
  • 創(chuàng)建一個(gè)無(wú)網(wǎng)絡(luò)連接的偽客戶端,專門(mén)用于執(zhí)行 Lua 腳本中的 Redis 命令。

  • Reids 通過(guò)一系列措施保證被執(zhí)行的 Lua 腳本無(wú)副作用,也沒(méi)有有害的寫(xiě)隨機(jī)性:對(duì)于同樣的輸入?yún)?shù)和數(shù)據(jù)集,總是產(chǎn)生相同的寫(xiě)入命令。
  • EVAL 命令為輸入腳本定義一個(gè) Lua 函數(shù),然后通過(guò)執(zhí)行這個(gè)函數(shù)來(lái)執(zhí)行腳本。
  • EVALSHA 通過(guò)構(gòu)建函數(shù)名,直接調(diào)用 Lua 中已定義的函數(shù),從而執(zhí)行相應(yīng)的腳本。
以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)