Redis學習-LUA腳本


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

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命令,它們分別是:

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

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

> 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)的信息被寫入數據庫中。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM