Redis學習-LUA腳本

  最近在作K線的項目中,須要計算商品的分時數據。爲了保證多臺機器對同一商品的計算的有序性,因此在Redis中進行計算,同時爲了保證在分時數據計算過程的原子性因此使用了LUA腳本,Redis內置了對LUA腳本的支持,而且在計算過程當中保證了腳本中執行的原子性。所以在開發過程當中對Redis對Lua的支持進行了學習。從 Redis 2.6.0 版本開始,經過內置的 Lua 解釋器,可使用EVAL命令對 Lua 腳本進行求值。如下將Redis對LUA的支持進行總結。redis

EVAL

  從Redis2.6.0版本開始,經過內置的Lua解釋器,可使用EVAL命令對Lua腳本進行求值。EVAL命令的格式以下:數據庫

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

  script參數是一段Lua腳本程序,它會被運行在Redis服務器上下文中,這段腳本沒必要(也不該該)定義爲一個Lua函數。numkeys參數用於指定鍵名參數的個數。鍵名參數 key [key ...] 從EVAL的第三個參數開始算起,表示在腳本中所用到的那些Redis鍵(key),這些鍵名參數能夠在 Lua中經過全局變量KEYS數組,用1爲基址的形式訪問( KEYS[1] , KEYS[2] ,以此類推)。在命令的最後,那些不是鍵名參數的附加參數 arg [arg ...] ,能夠在Lua中經過全局變量ARGV數組訪問,訪問的形式和KEYS變量相似( ARGV[1] 、 ARGV[2] ,諸如此類)。例如編程

> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

  其中 "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 是被求值的Lua腳本,數字2指定了鍵名參數的數量, key1和key2是鍵名參數,分別使用 KEYS[1] 和 KEYS[2] 訪問,而最後的 first 和 second 則是附加參數,能夠經過 ARGV[1] 和 ARGV[2] 訪問它們。在 Lua 腳本中,可使用兩個不一樣函數來執行Redis命令,它們分別是:json

redis.call()
redis.pcall()

  這兩個函數的惟一區別在於它們使用不一樣的方式處理執行命令所產生的錯誤。redis.call() 和 redis.pcall() 兩個函數的參數能夠是任何格式良好(well formed)的 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集羣能夠將你的請求發送到正確的集羣節點。服務器

  當Lua經過call()或 pcall()函數執行Redis命令的時候,命令的返回值會被轉換成Lua數據結構。一樣地,當Lua腳本在 Redis 內置的解釋器裏運行時Lua腳本的返回值也會被轉換成Redis協議(protocol),而後由EVAL將值返回給客戶端。Lua 類型和 Redis 類型之間存在着一一對應的轉換關係。如下列出的是詳細的轉換規則:數據結構

  • Redis integer reply -> Lua number / Redis 整數轉換成 Lua 數字
  • Redis bulk reply -> Lua string / Redis bulk 回覆轉換成 Lua 字符串
  • Redis multi bulk reply -> Lua table (may have other Redis data types nested) / Redis 多條 bulk 回覆轉換成 Lua 表,表內可能有其餘別的 Redis 數據類型
  • Redis status reply -> Lua table with a single ok field containing the status / Redis 狀態回覆轉換成 Lua 表,表內的 ok 域包含了狀態信息
  • Redis error reply -> Lua table with a single err field containing the error / Redis 錯誤回覆轉換成 Lua 表,表內的 err 域包含了錯誤信息
  • Redis Nil bulk reply and Nil multi bulk reply -> Lua false boolean type / Redis 的 Nil 回覆和 Nil 多條回覆轉換成 Lua 的布爾值 false
  • Lua boolean true -> Redis integer reply with value of 1 / Lua 布爾值 true 轉換成 Redis 整數回覆中的 1

如下是幾個類型轉換的例子:dom

> eval "return 10" 0
(integer) 10

> eval "return {1,2,{3,'Hello World!'}}" 0
1) (integer) 1
2) (integer) 2
3) 1) (integer) 3
2) "Hello World!"

> eval "return redis.call('get','foo')" 0
"bar"

  Redis使用單個 Lua 解釋器去運行全部腳本,而且, Redis 也保證腳本會以原子性(atomic)的方式執行:當某個腳本正在運行的時候,不會有其餘腳本或 Redis 命令被執行。這和使用 MULTI / EXEC 包圍的事務很相似。在其餘別的客戶端看來,腳本的效果(effect)要麼是不可見的(not visible),要麼就是已完成的(already completed)。另外一方面,這也意味着,執行一個運行緩慢的腳本並非一個好主意。寫一個跑得很快很順溜的腳本並不難,由於腳本的運行開銷(overhead)很是少,可是當你不得不使用一些跑得比較慢的腳本時,請當心,由於當這些蝸牛腳本在慢吞吞地運行的時候,其餘客戶端會由於服務器正忙而沒法執行命令。函數

   redis.call() 和 redis.pcall() 的惟一區別在於它們對錯誤處理的不一樣。當 redis.call() 在執行命令的過程當中發生錯誤時,腳本會中止執行,並返回一個腳本錯誤,錯誤的輸出信息會說明錯誤形成的緣由:

redis> lpush foo a
(integer) 1

redis> eval "return redis.call('get', 'foo')" 0
(error) ERR Error running script (call to f_282297a0228f48cd3fc6a55de6316f31422f5d17): ERR Operation against a key holding the wrong kind of value

  和 redis.call() 不一樣, redis.pcall() 出錯時並不引起(raise)錯誤,而是返回一個帶 err 域的 Lua 表(table),用於表示錯誤:

redis 127.0.0.1:6379> EVAL "return redis.pcall('get', 'foo')" 0
(error) ERR Operation against a key holding the wrong kind of value

  EVAL 命令要求你在每次執行腳本的時候都發送一次腳本主體(script body)。Redis 有一個內部的緩存機制,所以它不會每次都從新編譯腳本,不過在不少場合,付出無謂的帶寬來傳送腳本主體並非最佳選擇。爲了減小帶寬的消耗, Redis 實現了 EVALSHA 命令,它的做用和 EVAL 同樣,都用於對腳本求值,但它接受的第一個參數不是腳本,而是腳本的 SHA1 校驗和(sum)。EVALSHA 命令的表現以下:

  • 若是服務器還記得給定的 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](/commands/eval). 

  客戶端庫的底層實現能夠一直樂觀地使用 EVALSHA 來代替 EVAL ,並指望着要使用的腳本已經保存在服務器上了,只有當 NOSCRIPT 錯誤發生時,才使用 EVAL 命令從新發送腳本,這樣就能夠最大限度地節省帶寬。這也說明了執行 EVAL 命令時,使用正確的格式來傳遞鍵名參數和附加參數的重要性:由於若是將參數硬寫在腳本中,那麼每次當參數改變的時候,都要從新發送腳本,即便腳本的主體並無改變,相反,經過使用正確的格式來傳遞鍵名參數和附加參數,就能夠在腳本主體不變的狀況下,直接使用 EVALSHA 命令對腳本進行復用,免去了無謂的帶寬消耗。

  Redis 保證全部被運行過的腳本都會被永久保存在腳本緩存當中,這意味着,當 EVAL 命令在一個 Redis 實例上成功執行某個腳本以後,隨後針對這個腳本的全部 EVALSHA 命令都會成功執行。刷新腳本緩存的惟一辦法是顯式地調用 SCRIPT FLUSH 命令,這個命令會清空運行過的全部腳本的緩存。緩存能夠長時間儲存而不產生內存問題的緣由是,它們的體積很是小,並且數量也很是少,即便腳本在概念上相似於實現一個新命令,即便在一個大規模的程序裏有成百上千的腳本,即便這些腳本會常常修改,即使如此,儲存這些腳本的內存仍然是微不足道的。事實上,用戶會發現 Redis 不移除緩存中的腳本其實是一個好主意。好比說,對於一個和 Redis 保持持久化連接(的程序來講,它能夠確信,執行過一次的腳本會一直保留在內存當中,所以它能夠在流水線中使用 EVALSHA 命令而沒必要擔憂由於找不到所需的腳本而產生錯誤(稍候咱們會看到在流水線中執行腳本的相關問題)。Redis 提供瞭如下幾個 SCRIPT 命令,用於對腳本子系統(scripting subsystem)進行控制:

  • SCRIPT FLUSH :清除全部腳本緩存
  • SCRIPT EXISTS :根據給定的腳本校驗和,檢查指定的腳本是否存在於腳本緩存
  • SCRIPT LOAD :將一個腳本裝入腳本緩存,但並不當即運行它
  • SCRIPT KILL :殺死當前正在運行的腳本

  在編寫腳本方面,一個重要的要求就是,腳本應該被寫成純函數(pure function)。也就是說,腳本應該具備如下屬性:

  • 對於一樣的數據集輸入,給定相同的參數,腳本執行的Redis寫命令老是相同的。腳本執行的操做不能依賴於任何隱藏(非顯式)數據,不能依賴於腳本在執行過程當中、或腳本在不一樣執行時期之間可能變動的狀態,而且它也不能依賴於任何來自I/O設備的外部輸入。

  使用系統時間(system time)、調用像RANDOMKEY那樣的隨機命令、或者使用 Lua 的隨機數生成器,相似以上的這些操做,都會形成腳本的求值沒法每次都得出一樣的結果。爲了確保腳本符合上面所說的屬性, Redis作了如下工做:

  • Lua沒有訪問系統時間或者其餘內部狀態的命令
  • Redis會返回一個錯誤,阻止這樣的腳本運行: 這些腳本在執行隨機命令以後(好比RANDOMKEY 、 SRANDMEMBER或TIME等),還會執行能夠修改數據集的Redis命令。若是腳本只是執行只讀操做,那麼就沒有這一限制。注意,隨機命令並不必定就指那些帶RAND字眼的命令,任何帶有非肯定性的命令都會被認爲是隨機命令,好比TIME命令就是這方面的一個很好的例子。
  • 每當從Lua腳本中調用那些返回無序元素的命令時,執行命令所得的數據在返回給Lua以前會先執行一個靜默(slient)的字典序排序(lexicographical sorting)。舉個例子,由於 Redis的Set保存的是無序的元素,因此在Redis命令行客戶端中直接執行SMEMBERS ,返回的元素是無序的,可是,假如在腳本中執行 redis.call("smembers", KEYS[1]) ,那麼返回的老是排過序的元素。
  • 對Lua的僞隨機數生成函數math.random和math.randomseed進行修改,使得每次在運行新腳本的時候,老是擁有一樣的 seed 值。這意味着,每次運行腳本時,只要不使用 math.randomseed ,那麼 math.random產生的隨機數序列老是相同的。

  爲了防止沒必要要的數據泄漏進Lua環境, Redis腳本不容許建立全局變量。若是一個腳本須要在屢次執行之間維持某種狀態,它應該使用Redis key來進行狀態保存。企圖在腳本中訪問一個全局變量(不論這個變量是否存在)將引發腳本中止, EVAL命令會返回一個錯誤:

redis 127.0.0.1:6379> eval 'a=10' 0
(error) ERR Error running script (call to f_933044db579a2f8fd45d8065f04a8d0249383e57): user_script:1: Script attempted to create global variable 'a'

  Lua的debug工具,或者其餘設施,好比打印(alter用於實現全局保護的meta table ,均可以用於實現全局變量保護。實現全局變量保護並不難,不過有時候仍是會不當心而爲之。一旦用戶在腳本中混入了 Lua 全局狀態,那麼 AOF持久化和複製(replication)都會沒法保證,因此,請不要使用全局變量。避免引入全局變量的一個訣竅是:將腳本中用到的全部變量都使用 local 關鍵字定義爲局部變量。

  Redis內置的Lua解釋器加載瞭如下Lua庫:base、table、string、math、debug、cjson、cmsgpack。其中cjson庫可讓Lua以很是快的速度處理JSON數據,除此以外,其餘別的都是Lua的標準庫。每一個Redis實例都保證會加載上面列舉的庫,從而確保每一個 Redis 腳本的運行環境都是相同的。

  在Lua腳本中,能夠經過調用redis.log函數來寫Redis日誌(log):redis.log(loglevel, message)其中, message 參數是一個字符串,而 loglevel 參數能夠是如下任意一個值:

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

  上面的這些等級(level)和標準 Redis 日誌的等級相對應。對於腳本散發(emit)的日誌,只有那些和當前 Redis 實例所設置的日誌等級相同或更高級的日誌纔會被散發。如下是一個日誌示例:

redis.log(redis.LOG_WARNING, "Something is wrong with this script.")

   執行上面的函數會產生這樣的信息:

[32343] 22 Mar 15:21:39 # Something is wrong with this script.

  腳本應該僅僅用於傳遞參數和對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 ,它經過中止服務器來阻止當前數據集寫入磁盤

  在流水線請求的上下文中使用EVALSHA命令時,要特別當心,由於在流水線中,必須保證命令的執行順序。一旦在流水線中由於EVALSHA命令而發生NOSCRIPT錯誤,那麼這個流水線就再也沒有辦法從新執行了,不然的話,命令的執行順序就會被打亂。爲了防止出現以上所說的問題,客戶端庫實現應該實施如下的其中一項措施:

  • 老是在流水線中使用EVAL命令
  • 檢查流水線中要用到的全部命令,找到其中的 EVAL命令,並使用 SCRIPT EXISTS 命令檢查要用到的腳本是否是全都已經保存在緩存裏面了。若是所需的所有腳本均可以在緩存裏找到,那麼就能夠放心地將全部 EVAL 命令改爲 EVALSHA 命令,不然的話,就要在流水線的頂端(top)將缺乏的腳本用 SCRIPT LOAD 命令加上去。

EVALSHA

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

  根據給定的sha1校驗碼,對緩存在服務器中的腳本進行求值。將腳本緩存到服務器的操做能夠經過 SCRIPT LOAD 命令進行。這個命令的其餘地方,好比參數的傳入方式,都和 EVAL 命令同樣。

redis> SCRIPT LOAD "return 'hello moto'"
"232fd51614574cf0867b83d384a5e898cfd24e5a"
redis> EVALSHA "232fd51614574cf0867b83d384a5e898cfd24e5a" 0
"hello moto"

SCRIPT EXISTS

SCRIPT EXISTS sha1 [sha1 ...]

  給定一個或多個腳本的SHA1校驗和,返回一個包含0和1的列表,表示校驗和所指定的腳本是否已經被保存在緩存當中。返回值:

  •  一個列表,包含0和1 ,前者表示腳本不存在於緩存,後者表示腳本已經在緩存裏面了。
  •  列表中的元素和給定的SHA1校驗和保持對應關係,好比列表的第三個元素的值就表示第三個SHA1校驗和所指定的腳本在緩存中的狀態。
redis> SCRIPT LOAD "return 'hello moto'"    # 載入一個腳本
"232fd51614574cf0867b83d384a5e898cfd24e5a"

redis> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a
1) (integer) 1

redis> SCRIPT FLUSH     # 清空緩存
OK

redis> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a
1) (integer) 0

SCRIPT KILL

  殺死當前正在運行的Lua腳本,當且僅當這個腳本沒有執行過任何寫操做時,這個命令才生效。這個命令主要用於終止運行時間過長的腳本,好比一個由於BUG而發生無限loop的腳本,諸如此類。SCRIPT KILL執行以後,當前正在運行的腳本會被殺死,執行這個腳本的客戶端會從EVAL命令的阻塞當中退出,並收到一個錯誤做爲返回值。另外一方面,假如當前正在運行的腳本已經執行過寫操做,那麼即便執行 SCRIPT KILL ,也沒法將它殺死,由於這是違反 Lua 腳本的原子性執行原則的。在這種狀況下,惟一可行的辦法是使用 SHUTDOWN NOSAVE 命令,經過中止整個 Redis 進程來中止腳本的運行,並防止不完整(half-written)的信息被寫入數據庫中。

相關文章
相關標籤/搜索