Redis學習手冊13—Lua腳本

Redis對Lua腳本的支持是從Redis 2.6.0版開始引入的,它可以讓用戶在Redis服務器內置的Lua解釋器中執行指定的Lua腳本。被執行的Lua腳本可以直接調用 Redis命令,並使用Lua語言及其內置的函數庫處理命令結果。

Lua腳本給Redis帶來的變化

引入Lua腳本特性,爲Redis帶來了如下直觀的變化:

  • 可以使用Lua腳本來很方便的擴展Redis服務器的功能。
  • Redis服務器以原子方式執行Lua腳本,在執行完整個Lua腳本及其包含的Redis命令之前,Redis服務器不會執行其客戶端發送的命令或腳本,因此被執行的Lua腳本天生就具有原子性。
  • 雖然使用流水線加上事務同樣可以達到一次執行多條Redis命令的目的,但Redis提供的Lua腳本緩存特性能夠更爲有效的減少帶寬佔用。
  • Redis在Lua環境中內置了一些非常有用的包,通過這些包,用戶可以直接在服務器端對數據進行處理,然後把它們存儲到數據庫中,這可以有效的減少不必要的網絡傳輸。

EVAL:執行腳本

使用EVAL命令可以執行給定的Lua腳本:

EVAL script numkeys key [key ... ] arg [arg ... ]

其中:

  • script參數用於傳遞腳本本身,因爲 Redis 目前內置的是Lua 5.1版本的解釋器,所以用戶在腳本中也只能使用Lua 5.1版本的語法。
  • numkeys參數用於指定腳本需要處理的鍵數量,而之後的任意多個 key 參數則用於指定被處理的鍵。通過key傳遞的鍵可以在腳本中通過KEYS數組進行訪問。根據 Lua的慣例,KYES數組的索引將從1開始。
  • 任意多個arg參數用於指定傳遞給腳本的附加參數,這些參數可以在腳本中使用ARGV數組進行訪問,同樣的索引從 1 開始。
127.0.0.1:6379> EVAL "return 'hello world'" 0
"hello world"

使用腳本執行Redis命令

Lua腳本的強大之處在於它可以讓用戶直接在腳本中執行Redis命令,這一點可以通過在腳本中調用 redis.call() 函數或者 redis.pcall() 函數來完成:

redis.call(command, ...)
redis.pcall(command, ...)

這兩個函數接受的第一個參數都是被執行的Redis命令的名字,而後面跟着的則是任意多個命令參數。在Lua腳本中執行Redis命令所使用的格式與在redis-cli客戶端中執行Redis命令的格式完全一樣。

127.0.0.1:6379> eval "return redis.call('SET',KEYS[1],ARGV[1])" 1 "message" "hello world"
OK
127.0.0.1:6379> eval "return redis.call('ZADD',KEYS[1],ARGV[1],ARGV[2])" 1 "fruit-price" "8.5" "apple"
(integer) 1
127.0.0.1:6379> zrange "fruit-price" 0 -1 WITHSCORES
1) "apple"
2) "8.5"

redis.call() 函數和 redis.pcall() 函數都可以用於執行Redis命令,它們之間唯一的區別是處理錯誤的方式:

  • redis.call() 函數在執行命令出錯時會引發一個Lua錯誤,迫使 EVAL 命令向調用者返回一個錯誤;
  • redis.pcall() 函數則會將錯誤包裹起來,並返回一個表示錯誤的Lua表格;

值轉換

EVAL命令出現之前,Redis服務器中只有一個環境,那就是Redis命令執行器所在的環境。但是,隨着 EVAL命令以及 Lua 解釋器的出現,使得Redis服務器中同時存在了兩種不同的環境。因爲這兩種環境使用的是不同的輸入和輸出,所以在這兩種環境之間傳遞值將引發相應的轉換操作:

  • 當Lua腳本通過 redis.call() 函數或者 redis.pcall() 函數執行Redis命令時,傳入的Lua值將被轉換成 Redis協議值;
  • redis.call() 函數或者 redis.pcall() 函數執行完Redis命令時,命令返回的Redis協議值將被轉換成 Lua 值;
  • 當Lua腳本執行完畢並向 EVAL 命令的調用者返回結果時,Lua值將被轉換成Redis協議值。

下表列出了Redis協議值轉換成Lua值的規則:
在這裏插入圖片描述
下表是Lua值轉換成Redis協議值的規則:

在這裏插入圖片描述
如上面表格中的規則所言,因爲帶有小數部分的Lua數字將被轉換爲Redis整數回覆:

127.0.0.1:6379> eval "return 3.14" 0
(integer) 3

所以如果你想要向Redis返回一個小數,那麼可以先使用Lua內置的 tostring() 函數將它轉換成字符串,然後再返回:

127.0.0.1:6379> eval "return tostring(3.14)" 0
"3.14"

全局變量保護

爲了防止預定義的Lua環境被污染,Redis只允許用戶在Lua腳本中創建局部變量,而不允許創建全局變量,嘗試在腳本中創建全局變量將引發錯誤:

127.0.0.1:6379> eval "local database='redis'; return database" 0
"redis"
127.0.0.1:6379> eval "number=10" 0
(error) ERR Error running script (call to f_a2754fa2d614ad76ecfd143acc06993bedf1f691): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'number'

在腳本中切換數據庫

與普通的Redis客戶端一樣,Lua腳本也允許用戶執行 SELECT 命令來切換數據庫,但需要注意的是,不同版本的Redis在腳本中執行 SELECT 命令的效果不同:

  • 在Redis 2.8.12之前,用戶在腳本中切換數據庫之後,客戶端使用的數據庫也會進行相應的切換。
  • 在Redis 2.8.12以及以後的版本中,腳本執行 SELECT 命令只會對腳本自身產生影響,客戶端的當前數據庫不會發生改變。

腳本的原子性

Redis的Lua腳本與Redis的事務一樣,都是以原子方式執行的:在Redis服務器開始執行 EVAL 命令之後,直到 EVAL 命令執行完畢並向調用者返回結果之前,Redis服務器只會執行 EVAL 命令給定的腳本及其包含的Redis命令調用,至於其他的客戶端發送的命令請求則會被阻塞,直到 EVAL 命令執行完畢爲止。

基於上述原因,用戶在使用Lua腳本的時候,必須儘可能的保證腳本能夠高效,快速的執行,從而避免因爲獨佔服務器導致阻塞其他客戶端。

命令行方式執行腳本

除了可以在redis-cli客戶端中使用 EVAL 命令執行腳本之外,還可以通過 redis-cli客戶端的 eval 選項,以命令行的方式執行腳本文件。

如下test.lua腳本文件:

redis.call("SET", KEYS[1], ARGV[1])
return redis.call("GET", KEYS[1])

在命令行中執行如下命令:

redis-cli --eval test.lua  'msg' , 'hello'
"hello"

注意:使用命令行執行腳本文件時,鍵名與參數之間的逗號前後必須要有空格間隔,如果缺少空格將引發錯誤

複雜度:EVAL命令的複雜度由被執行的腳本決定
版本要求:EVAL命令從Redis 2.6.0版本開始可用。

SCRIPT LOAD 和 EVALSHA

在定義腳本後,程序通常會重複的執行腳本。如果客戶端每次執行腳本都需要將相同的腳本重新發送一次,顯然會浪費網絡帶寬。爲了解決上述問題,Redis提供了Lua腳本緩存功能,這一功能允許用戶將給定的Lua腳本緩存在服務器中,然後根據Lua腳本的SHA1校驗和直接調用腳本,從而避免了需要重複發送相同腳本的麻煩。

SCRIPT LOAD script

SCRIPT LOAD命令可以將用戶發送的腳本緩存在服務器中,並返回腳本的SHA1校驗和。

127.0.0.1:6379> script load "return 'hello world'"
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"

然後就可以通過EVALSHA命令來執行被緩存的腳本:

EVALSHA sha1 numkeys key [key ... ] arg [arg ... ]

除了第一個參數是腳本 SHA1校驗和之外,其他的參數和EVAL命令的一樣。

其實在使用EVAL命令執行腳本的時候,也會把腳本給緩存起來,只不過EVAL並不返回腳本的SHA1校驗和,因此需要自己根據腳本生成SHA1校驗和。

腳本管理

除了SCRIPT LOAD命令之外,Redis還提供了SCRIPT EXISTSSCRIPT FLUSHSCRIPT KILL 這3個命令來管理腳本。

SCRIPT EXISTS:檢查腳本是否已被緩存

SCRIPT EXISTS命令接受一個或多個 SHA1校驗和作爲參數,檢查這些校驗和對應的腳本是否已經被緩存到了服務器中:

SCRIPT EXISTS sha1 [sha1 ... ]

如果某個校驗和對應的腳本已經緩存到了服務器中,則返回 1,否則返回 0

SCRIPT FLUSH:移除所有已經緩存的腳本

執行SCRIPT FLUSH命令,可以移除所有已經緩存在服務器中的腳本:

SCRIPT FLUSH

成功移除所有緩存腳本之後,返回 0

SCRIPT KILL:強制停止正在運行的腳本

因爲Lua腳本在執行的時候會獨佔Redis服務器,所以如果Lua腳本運行的時間太長,又或者因爲編程錯誤導致腳本無法退出,那麼就會導致其他客戶端一直無法執行命令。因此Redis提供了 SCRIPT KILL命令,可以用來強制停止正在運行的腳本:

SCRIPT KILL

用戶在執行SCRIPT KILL命令之後,服務器可能會有以下反應:

  • 如果正在運行的Lua腳本尚未執行過任何寫命令,那麼服務器將終止該腳本,然後回到正常狀態,繼續處理客戶端的命令請求;
  • 如果正在運行的Lua腳本已經執行過寫命令,並且因爲該腳本尚未執行完畢,所以它寫入的數據可能並不完整或者錯誤的,爲了防止這些髒數據被保存到數據庫中,服務器是不會直接終止腳本並回到正常狀態的。在這種情況下,用戶只能使用 SHUTDOWN nosave 命令,在不執行持久化操作的情況下關閉服務器,然後重啓服務器讓它回到正常狀態。

內置函數庫

Redis在Lua環境中內置了一些函數庫,用戶可以通過這些函數庫對Redis服務器進行操作,或者對給定的數據進行處理,這些函數庫分別是:

  • base包
  • table包
  • string包
  • math包
  • redis包
  • bit包
  • struct包
  • cjson包
  • cmsgpack包

其中basetablestring以及math包均爲Lua標準庫;redis包爲調用Redis功能的專用包,而 bitsrtuctcjson以及cmsgpack包則是從外部引入的數據處理包。

redis包

除了前面介紹過的 redis.call() 函數和**redis.pcall()**函數之外,redis包還包含了以下函數:

  • redis.log()
  • redis.sha1hex()
  • redis.error_reply()
  • redis.status_reply()
  • redis.breakpoint()
  • redis.debug()
  • redis.replicate_commands()
  • redis.set_repl()

redis.log()函數

**redis.log()**函數用於在腳本中向Redis服務器寫入日誌,它接受一個日誌等級和一條消息作爲參數:

redis.log(loglevel, message)

其中loglevel的值可以是以下4個日誌等級中的一個,這些日誌等級與Redis服務器本身的日誌等級完全一致:

  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE
  • redis.LOG_WARING

當給定的日誌等級超過或等同於Redis服務器當前設置的日誌等級時,Redis服務器就會把給定的消息寫入日誌中。

redis.sha1hex()函數

**redis.sha1hex()**函數可以計算出給定字符串的SHA1校驗和:

redis.sha1hex(string)

127.0.0.1:6379> eval "return redis.sha1hex('show me your sha1')" 0
"e00ecdbe6ea77b31972c28dccad6aceba9822a12"

redis.error_reply()函數和redis.status_reply()函數

**redis.error_reply()函數和redis.status_reply()**是兩個輔助函數,分別用於返回Redis的錯誤回覆以及狀態回覆:

redis.error_reply(error_message)
redis.status_reply(status_message)

redis.error_reply() 函數會返回一個包含 err 字段的Lua表格,而 err 字段的值則是給定的錯誤消息;同樣的,redis.status_reply() 函數將返回一個只包含 ok 字段的Lua表格,而 ok 字段的值則是給定的狀態消息。

127.0.0.1:6379> eval "return redis.error_reply('something wrong')" 0
(error) something wrong
127.0.0.1:6379> eval "return redis.status_reply('all is well')" 0
all is well

bit包

bit 包可以對Lua腳本中的數字執行二進制按位操作,這個包從Redis 2.8.18版本開始可用。bit 包提供了將數字轉換爲十六進制字符串的 tohex() 函數,以及對給定數字執行 按位反按位或按位並 以及 按位異或bnot()bor()band()bxor() 等函數。

bit.tohex(x [, n])
bit.bnot(x)
bit.bor(x1 [, x2 ...])
bit.band(x1 [, x2 ... ])
bit.bxor(x1 [, x2 ... ])

127.0.0.1:6379> eval "return bit.tohex(65535)" 0
"0000ffff"
127.0.0.1:6379> eval "return bit.tohex(65535,4)" 0
"ffff"
127.0.0.1:6379> eval "return bit.tohex(bit.bnot(0xFFFF))" 0
"ffff0000"
127.0.0.1:6379> eval "return bit.tohex(bit.bor(0xF00F,0x0F00))" 0
"0000ff0f"

struct包

struct包提供了能夠在Lua值以及C結構之間進行轉換的基本設施,這個包提供了 pack()unpack()size() 這3個函數:

struct.pack(fmt, v1, v2, ...)
struct.unpack(fmt, s, [i])
struct.size(fmt)

其中 struct.pack() 用於將給定的一個或多個Lua值打包爲一個類結構字符串,struct.unpack() 用於從給定的類結構字符串中解包出多個Lua值,而 struct.size() 函數則用於計算按照給定格式進行打包需要耗費的字節數量。

-- 打包一個浮點數、一個無符號長整數以及一個11字節長的字符串
127.0.0.1:6379> eval "return struct.pack('fLc11',3.14,10086,'hello world')" 0
"\xc3\xf5H@f'\x00\x00\x00\x00\x00\x00hello world"

-- 計算打包耗費的字節數
127.0.0.1:6379> eval "return struct.size('fLc11')" 0
(integer) 23

-- 對給定的類結構字符串進行解包
127.0.0.1:6379> eval "return {struct.unpack('fLc11',ARGV[1])}" 0 "\xc3\xf5H@f'\x00\x00\x00\x00\x00\x00hello world"
1) (integer) 3
2) (integer) 10086
3) "hello world"
4) (integer) 24

cjson包

cjson包能夠爲Lua腳本提供快速的JSON編碼和解碼操作,這個包中最常用的就是將Lua值編碼爲JSON數據的編碼函數 encode(),以及將JSON數據解碼爲Lua值的解碼函數 decode()

cjson.encode(value)
cjson.decode(json_text)

127.0.0.1:6379> eval "return cjson.encode({true,128,'hello world'})" 0
"[true,128,\"hello world\"]"
127.0.0.1:6379> eval "return cjson.decode(ARGV[1])" 0 "[true,128,\"hello world\"]"
1) (integer) 1
2) (integer) 128
3) "hello world"

cmsgpack函數

cmsgpack 函數能夠爲Lua腳本提供快速的 MessagePack 打包和解包操作,這個包中最常用的就是打包函數 pack() 以及解包函數 unpack(),前者可以將給定的任意多個Lua值打包爲msgpack包,而後者則可以將給定的 msgpack 包解包爲任意多個Lua值:

cmsgpack.pack(arg1, arg2, arg3 ... )
cmsgpack.unpack(msgpack)

127.0.0.1:6379> eval "return cmsgpack.pack({true,128,'hello world'})" 0
"\x93\xc3\xcc\x80\xabhello world"
127.0.0.1:6379> eval "return cmsgpack.unpack(ARGV[1])" 0 "\x93\xc3\xcc\x80\xabhello world"
1) (integer) 1
2) (integer) 128
3) "hello world"

腳本調試

在早期支持的Lua腳本功能的Redis版本中,爲了對腳本進行調試,通常需要重複執行同一個腳本多次,並通過查看返回值的方式驗證計算結果,這給腳本調試帶來了很大的麻煩。爲了解決這個問題,Redis從3.2版本開始引入了新的Lua調試器,這個調試器被稱爲 Redis Lua調試器,簡稱 LDB, 用戶可以通過 LDB 實現單步調試添加斷點返回日誌打印調用鏈重載腳本 等多種功能。

redis-cli --ldb --eval demo.lua "msg" , "hello world"

如上述命令就可以對腳本 demo.lua 開啓調試模式。然後可以使用 step命令或者 next命令進行單步調試,使用 print 命令來查看程序當前已有的局部變量以及它們的值。

調試命令

除了 next 命令和 print 命令外,Lua腳本調試器還提供了很多不同的調試命令,如下表所示:

在這裏插入圖片描述

斷點

可以使用 break 命令給腳本添加斷點,然後使用 continue 命令執行代碼,直到遇到下一個斷點爲止。

例如如下腳本代碼:

1 redis.call("echo", "line 1")
2 redis.call("echo", "line 2")
3 redis.call("echo", "line 3")
4 redis.call("echo", "line 4")
5 redis.call("echo", "line 5")

我們可以通過執行命令 break 3 5,分別在腳本的第3行和第5行添加斷點:

lua debugger> break 3 5

動態斷點

除了可以使用 break 命令添加斷點之外,Redis還允許用戶在腳本中通過調用 redis.breakpoint() 函數來添加動態斷點,當調試器執行至 redis.breakpoint() 調用所在行時,調試器就會暫停執行過程並等待用戶的指示。

if condition == true then
	redis.breakpoint()
	...
end

輸出調試日誌

使用 redis.debug() 函數能夠直接把給定的值輸出到調試客戶端,使得用戶可以方便的得知給定變量或者表達式的值。

執行指定的代碼或命令

Lua調試器提供了 evalredis 這兩個調試命令,用戶可以使用前者來執行指定的Lua代碼,並使用後者來執行指定的Redis命令,也可以通過這兩個命令來快速地驗證一些想法以及結果。

lua debugger> eval redis.sha1hex('hello world')

當在調試過程中需要知道某個鍵的值時,可以使用 redis 命令:

lua debugger> redis GET msg
<redis> GET msg
<redis> "hello world"

顯示調用鏈

trace調試命令可以用來打印出腳本的調用鏈信息,這些信息在研究腳本的調用路徑時非常有用。

重載腳本

restart 是一個非常重要的調試器命令,它可以讓調試客戶端重新載入被調試的腳本,並開啓一個新的調試會話。

調試模式

Redis的Lua調試器支持兩種不同的調試模式,一種是異步調試,另外一種是同步調試。當用戶以 ldb 選項啓動調試會話時,Redis服務器將以異步方式調試腳本:

redis-cli --ldb --eval script.lua

運行在異步調試模式下的Redis服務器會爲每個調試器會話分別創建新的子進程,並將其用作調試進程:

  • 因爲Redis服務器可以創建出任意多的子進程作爲調試進程,所以異步調試允許多個調試會話同時存在。
  • 因爲異步調試是在子進程而不是服務器進程上進行,它不會阻塞服務器進程,所以異步調試的過程中,其他客戶端可以繼續訪問Redis服務器。
  • 因爲異步調試期間執行的所有Lua代碼以及Redis命令都是在子進程中進行的,所以在調試完成後,調試期間產生的所有數據修改也會隨着子進程的終結而消失,它們不會對Redis服務器的數據庫產生任何影響。

當用戶以 ldb-sync-model 選項啓動調試會話時,Redis服務器將以同步的方式調試腳本:

redis-cli --ldb-sync-model --eval script.lua

在同步調試模式下:

  • 因爲同步調試不會創建任何子進程,而是直接使用服務器進程作爲調試進程,所以同一時間只能有一個調試會話存在。
  • 因爲同步調試直接在服務器進程上進行,它需要獨佔服務器,因此在調試期間,其他客戶端的訪問都會被阻塞。
  • 因爲在同步調試期間,所有Lua代碼以及Redis命令都是在服務器進程上執行的,所以調試期間產生的數據修改將保留在服務器的數據庫中。

終止調試會話

在調試Lua腳本時,有3種方式可以終止調試會話:

  • 當腳本執行完畢時,調試會話自動終止。
  • 當用戶在調試器中按下 Ctrl + C 鍵時,調試器將執行完整個腳本後終止調試會話。
  • 當用戶在調試器中執行 abort 命令時,調試器不再執行任何代碼,直接終止調試會話。

上一篇:Redis學習手冊12—流水線與事務

下一篇:Redis學習手冊14—持久化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章