【Redis】Redis中使用Lua脚本
Lua是一種輕量小巧的腳本語言,用標準C語言編寫并以源代碼形式開放,其設計目的是為了嵌入應用程序中,從而為應用程序提供靈活的擴展和定制功能。
Lua具體語法參考:https://www.runoob.com/lua/lua-tutorial.html
腳本的原子性
Redis使用單個Lua解釋器去運行所有腳本,并且Redis也保證腳本會以原子性(atomic)的方式執行:當某個腳本正在運行的時候,不會有其他腳本或 Redis命令被執行。這和使用MULTI/EXEC包圍的事務很類似。
在其他別的客戶端看來,腳本的效果要么是不可見的,要么就是已完成的。
另一方面,這也意味著,執行一個運行緩慢的腳本并不是一個好主意。寫一個跑得很快很順溜的腳本并不難,因為腳本的運行開銷非常少,但是當你不得不使用一些跑得比較慢的腳本時,請小心,因為當這些蝸牛腳本在慢吞吞地運行的時候,其他客戶端會因為服務器正忙而無法執行命令。
eval命令的使用
eval和evalsha命令是從Redis2.6.0版本開始引入的,使用內置的Lua解釋器,可以對Lua腳本進行求值。
eval命令的說明:
> help evalEVAL script numkeys key [key ...] arg [arg ...]summary: Execute a Lua script server sidesince: 2.6.0group: scripting參數說明:
- script:一段Lua腳本程序,這段Lua腳本不需要也不應該定義函數,它運行在Redis服務器中。
- numkeys:鍵名參數的個數。
- key[]: 鍵名參數,表示在腳本中所用到的那些Redis鍵(key),這些鍵名參數可以在Lua中通過全局變量KEYS數組,用1為基址的形式訪問(KEYS[1]、KEYS[2],以此類推)。
- arg[]:不是鍵名參數的附加參數,可以在Lua中通過全局變量ARGV數組訪問,訪問的形式和KEYS變量類似(ARGV[1]、ARGV[2],諸如此類)。
舉例說明:
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 a b c d 1) "a" 2) "b" 3) "c" 4) "d"返回結果是Redis multi bulk replies的Lua數組,這是一個Redis的返回類型,其他客戶端庫(如JAVA客戶端)可能會將他們轉換成數組類型。
Lua中執行redis命令
在Lua中,可以通過內置的函數redis.call()和redis.pcall()來執行redis命令。
redis.call()和redis.pcall()兩個函數的參數可以是任意的Redis命令:
> eval "return redis.call('set','foo','bar')" 0 OK需要注意的是,上面這段腳本的確實現了將鍵foo的值設為bar的目的,但是,它違反了EVAL命令的語義,因為腳本里使用的所有鍵都應該由KEYS數組來傳遞,就像這樣:
> eval "return redis.call('set',KEYS[1],'bar')" 1 foo OK要求使用正確的形式來傳遞鍵(key)是有原因的,因為不僅僅是EVAL這個命令,所有的Redis命令,在執行之前都會被分析,借此來確定命令會對哪些鍵進行操作。
因此,對于EVAL命令來說,必須使用正確的形式來傳遞鍵,才能確保分析工作正確地執行。除此之外,使用正確的形式來傳遞鍵還有很多其他好處,它的一個特別重要的用途就是確保Redis集群可以將你的請求發送到正確的集群節點。
redis.call()與redis.pcall()很類似,他們唯一的區別是當redis命令執行結果返回錯誤時,redis.call()將返回給調用者一個錯誤,而redis.pcall()會將捕獲的錯誤以Lua表的形式返回。
下面的例子演示了redis.call()與redis.pcall()的區別:
> eval "return redis.call('set1',KEYS[1],'bar')" 1 foo (error) ERR Error running script (call to f_d968406ee98123006fa91fd2ee764d4f7f859dd7): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script> eval "return redis.pcall('set1',KEYS[1],'bar')" 1 foo (error) @user_script: 1: Unknown Redis command called from Lua script> eval "return type(redis.call('set1',KEYS[1],'bar'))" 1 foo (error) ERR Error running script (call to f_c62b83c8313fd8f2557865e37d2bb5133f1789af): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script> eval "return type(redis.pcall('set1',KEYS[1],'bar'))" 1 foo "table"Lua數據類型和Redis數據類型之間轉換
當Lua通過call()或pcall()函數執行Redis命令的時候,命令的返回值會被轉換成Lua數據結構。
同樣地,當Lua腳本在Redis內置的解釋器里運行時,Lua腳本的返回值也會被轉換成Redis協議(protocol),然后由EVAL將值返回給客戶端。
數據類型之間的轉換遵循這樣一個設計原則:如果將一個Redis值轉換成Lua值,之后再將轉換所得的Lua值轉換回Redis值,那么這個轉換所得的Redis 值應該和最初時的Redis值一樣。
換句話說,Lua類型和Redis類型之間存在著一一對應的轉換關系。
| Redis integer reply | Lua number |
| Redis bulk reply | Lua string |
| Redis multi bulk reply | Lua table (may have other Redis data types nested) |
| Redis status reply | Lua table with a single ok field containing the status |
| Redis error reply | Lua table with a single err field containing the error |
| Redis Nil bulk reply and Nil multi bulk reply | Lua false boolean type |
從Lua轉換到Redis有一條額外的規則,這條規則沒有和它對應的從Redis轉換到Lua的規則:
- Lua boolean true -> Redis integer reply with value of 1. / Lua 布爾值 true 轉換成 Redis 整數回復中的 1
Lua中整數和浮點數之間沒有什么區別。因此,我們始終將Lua的數字轉換成整數的回復,這樣將舍去小數部分。如果你想從Lua返回一個浮點數,你應該將它作為一個字符串,比如ZSCORE命令。
以下是幾個類型轉換的例子:
> eval "return 10" 0 (integer) 10> eval "return {1,2,{3,'Hello World!'}}" 0 1) (integer) 1 2) (integer) 2 3) 1) (integer) 32) "Hello World!"> eval "return redis.call('get','foo')" 0 "bar"最后一個例子展示如果是Lua直接命令調用它是如何可以從redis.call()或redis.pcall()接收到準確的返回值。
下面的例子我們可以看到浮點數和nil將怎么樣處理:
> eval "return {1,2,3.3333,'foo',nil,'bar'}" 0 1) (integer) 1 2) (integer) 2 3) (integer) 3 4) "foo"正如你看到的3.333被轉換成了3,并且nil后面的字符串bar沒有被返回回來。
可以使用tostring()函數將數字轉字符串:
> eval "return tostring(3.3333)" 0 "3.3333"有兩個輔助函數從Lua返回Redis的類型:
- redis.error_reply(error_string):returns an error reply. This function simply returns the single field table with the err field set to the specified string for you.
- redis.status_reply(status_string):returns a status reply. This function simply returns the single field table with the ok field set to the specified string for you.
使用redis.error_reply()函數與直接返回一個table效果一樣:
> eval "return {err='My Error'}" 0 (error) My Error> eval "return redis.error_reply('My Error')" 0 (error) My ErrorEVALSHA
EVAL命令要求你在每次執行腳本的時候都發送一次腳本主體(script body)。Redis有一個內部的緩存機制,因此它不會每次都重新編譯腳本,不過在很多場合,付出無謂的帶寬來傳送腳本主體并不是最佳選擇。
為了減少帶寬的消耗,Redis實現了EVALSHA命令,它的作用和EVAL一樣,都用于對腳本求值,但它接受的第一個參數不是腳本,而是腳本的SHA1校驗和(sum)。
如果服務器還記得給定的SHA1校驗和所指定的腳本,那么執行這個腳本,如果服務器不記得給定的SHA1校驗和所指定的腳本,那么它返回一個特殊的錯誤,提醒用戶使用EVAL代替EVALSHA。
以下是示例:
> set foo bar OK> eval "return redis.call('get','foo')" 0 "bar"> evalsha 6b1bf486c81ceb7edf3c093f4c48582e38c0e791 0 "bar"> evalsha ffffffffffffffffffffffffffffffffffffffff 0 (error) NOSCRIPT No matching script. Please use EVAL.客戶端庫的底層實現可以一直樂觀地使用EVALSHA來代替EVAL,并期望著要使用的腳本已經保存在服務器上了,只有當NOSCRIPT錯誤發生時,才使用 EVAL命令重新發送腳本,這樣就可以最大限度地節省帶寬。
這也說明了執行EVAL命令時,使用正確的格式來傳遞鍵名參數和附加參數的重要性:因為如果將參數硬寫在腳本中,那么每次當參數改變的時候,都要重新發送腳本,即使腳本的主體并沒有改變,相反,通過使用正確的格式來傳遞鍵名參數和附加參數,就可以在腳本主體不變的情況下,直接使用EVALSHA 命令對腳本進行復用,免去了無謂的帶寬消耗。
腳本緩存
Redis保證所有被運行過的腳本都會被永久保存在腳本緩存當中,這意味著,當EVAL命令在一個Redis實例上成功執行某個腳本之后,隨后針對這個腳本的所有EVALSHA命令都會成功執行。
刷新腳本緩存的唯一辦法是顯式地調用SCRIPT FLUSH命令,這個命令會清空運行過的所有腳本的緩存。通常只有在云計算環境中,Redis實例被改作其他客戶或者別的應用程序的實例時,才會執行這個命令。
緩存可以長時間儲存而不產生內存問題的原因是,它們的體積非常小,而且數量也非常少,即使腳本在概念上類似于實現一個新命令,即使在一個大規模的程序里有成百上千的腳本,即使這些腳本會經常修改,即便如此,儲存這些腳本的內存仍然是微不足道的。
事實上,用戶會發現Redis不移除緩存中的腳本實際上是一個好主意。比如說,對于一個和Redis保持持久化鏈接(persistent connection)的程序來說,它可以確信,執行過一次的腳本會一直保留在內存當中,因此它可以在流水線中使用EVALSHA命令而不必擔心因為找不到所需的腳本而產生錯誤。
Redis提供了以下幾個SCRIPT命令,用于對腳本子系統(scripting subsystem)進行控制:
- SCRIPT FLUSH:清除所有腳本緩存
- SCRIPT EXISTS:根據給定的腳本校驗和,檢查指定的腳本是否存在于腳本緩存
- SCRIPT LOAD:將一個腳本裝入腳本緩存,但并不立即運行它
- SCRIPT KILL:殺死當前正在運行的腳本
可用庫
Redis Lua解釋器可用加載以下Lua庫:
- base lib.
- table lib.
- string lib.
- math lib.
- debug lib.
- struct lib.
- cjson lib.
- cmsgpack lib.
- bitop lib.
- redis.sha1hex function.
每一個Redis實例都擁有以上的所有類庫,以確保您使用腳本的環境都是一樣的。
struct,CJSON和cmsgpack都是外部庫,所有其他庫都是標準Lua庫。
CJSON庫為Lua提供極快的JSON處理:
> eval 'return cjson.encode({["foo"]= "bar"})' 0 "{\"foo\":\"bar\"}"> eval 'return cjson.decode(ARGV[1])["foo"]' 0 "{\"foo\":\"bar\"}" "bar"> eval "local table = {} table['foo']='bar' table['hello']='world' return cjson.encode(table)" 0 "{\"hello\":\"world\",\"foo\":\"bar\"}"沙箱(sandbox)和最大執行時間
腳本應該僅僅用于傳遞參數和對Redis數據進行處理,它不應該嘗試去訪問外部系統(比如文件系統),或者執行任何系統調用。
除此之外,腳本還有一個最大執行時間限制,它的默認值是5秒鐘,一般正常運作的腳本通常可以在幾分之幾毫秒之內完成,花不了那么多時間,這個限制主要是為了防止因編程錯誤而造成的無限循環而設置的。
最大執行時間的長短由lua-time-limit選項來控制(以毫秒為單位),可以通過編輯redis.conf文件或者使用CONFIG GET和CONFIG SET命令來修改它。
當一個腳本達到最大執行時間的時候,它并不會自動被Redis結束,因為Redis必須保證腳本執行的原子性,而中途停止腳本的運行意味著可能會留下未處理完的數據在數據集(data set)里面。
因此,當腳本運行的時間超過最大執行時間后,以下動作會被執行:
- Redis記錄一個腳本正在超時運行
- Redis開始重新接受其他客戶端的命令請求,但是只有SCRIPT KILL和SHUTDOWN NOSAVE兩個命令會被處理,對于其他命令請求,Redis服務器只是簡單地返回BUSY錯誤。
- 可以使用SCRIPT KILL命令將一個僅執行只讀命令的腳本殺死,因為只讀命令并不修改數據,因此殺死這個腳本并不破壞數據的完整性
- 如果腳本已經執行過寫命令,那么唯一允許執行的操作就是SHUTDOWN NOSAVE,它通過停止服務器來阻止當前數據集寫入磁盤
流水線(pipeline)上下文(context)中的EVALSHA
在流水線請求的上下文中使用EVALSHA命令時,要特別小心,因為在流水線中,必須保證命令的執行順序。
一旦在流水線中因為EVALSHA命令而發生NOSCRIPT錯誤,那么這個流水線就再也沒有辦法重新執行了,否則的話,命令的執行順序就會被打亂。
為了防止出現以上所說的問題,客戶端庫實現應該實施以下的其中一項措施:
- 總是在流水線中使用EVAL命令
- 檢查流水線中要用到的所有命令,找到其中的EVAL命令,并使用SCRIPT EXISTS命令檢查要用到的腳本是不是全都已經保存在緩存里面了。如果所需的全部腳本都可以在緩存里找到,那么就可以放心地將所有EVAL命令改成EVALSHA命令,否則的話,就要在流水線的頂端(top)將缺少的腳本用SCRIPT LOAD 命令加上去。
總結
以上是生活随笔為你收集整理的【Redis】Redis中使用Lua脚本的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: [react] React中验证prop
- 下一篇: 再谈Redis应用场景