1. 程式人生 > >高性能偽事務之Lua in Redis

高性能偽事務之Lua in Redis

this 生成器 whole 是否 creat tex pad 不必要 ner

EVAL簡介

Redis2.6加入了對Lua腳本的支持。Lua腳本可以被用來擴展Redis的功能,並提供更好的性能。

在《Redis拾遺》中曾經引用了《Redis in Action》中的一套悲觀鎖的實現,使用Lua腳本實現同樣的功能,性能提高1倍以上。在另一個自動補全的例子中,使用Lua腳本比WATH/MULTI/EXEC快了20倍。

EVAL 和 EVALSHA 命令是從 Redis 2.6.0 版本開始的,使用內置的 Lua 解釋器,可以對 Lua 腳本進行求值。

EVAL的第一個參數是一段 Lua 5.1 腳本程序。 這段Lua腳本不需要(也不應該)定義函數。它運行在 Redis 服務器中。

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"

註:返回結果是Redis multi bulk replies的Lua數組,這是一個Redis的返回類型,您的客戶端庫可能會將他們轉換成數組類型。

這是從一個Lua腳本中使用兩個不同的Lua函數來調用Redis的命令的例子:

redis.call()
redis.pcall()

redis.call() 與 redis.pcall()很類似, 他們唯一的區別是當redis命令執行結果返回錯誤時, redis.call()將返回給調用者一個錯誤,而redis.pcall()會將捕獲的錯誤以Lua表的形式返回

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 集群的工作還在進行當中,但是腳本功能被設計成可以與集群功能保持兼容。)不過,這條規矩並不是強制性的, 從而使得用戶有機會濫用(abuse) Redis 單實例配置(single instance configuration),代價是這樣寫出的腳本不能被 Redis 集群所兼容。

Lua 腳本能返回一個值,這個值能按照一組轉換規則從Lua轉換成redis的返回類型。

Lua 數據類型和 Redis 數據類型之間轉換

當 Lua 通過 call() 或 pcall() 函數執行 Redis 命令的時候,命令的返回值會被轉換成 Lua 數據結構。 同樣地,當 Lua 腳本在 Redis 內置的解釋器裏運行時,Lua 腳本的返回值也會被轉換成 Redis 協議(protocol),然後由 EVAL 將值返回給客戶端。

數據類型之間的轉換遵循這樣一個設計原則:如果將一個 Redis 值轉換成 Lua 值,之後再將轉換所得的 Lua 值轉換回 Redis 值,那麽這個轉換所得的 Redis 值應該和最初時的 Redis 值一樣。

換句話說, Lua 類型和 Redis 類型之間存在著一一對應的轉換關系。

Redis 到 Lua 的轉換表。

  • 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 到 Redis 的轉換表。

  • Lua number -> Redis integer reply (the number is converted into an integer) / Lua 數字轉換成 Redis 整數
  • Lua string -> Redis bulk reply / Lua 字符串轉換成 Redis bulk 回復
  • Lua table (array) -> Redis multi bulk reply (truncated to the first nil inside the Lua array if any) / Lua 表(數組)轉換成 Redis 多條 bulk 回復
  • Lua table with a single ok field -> Redis status reply / 一個帶單個 ok 域的 Lua 表,轉換成 Redis 狀態回復
  • Lua table with a single err field -> Redis error reply / 一個帶單個 err 域的 Lua 表,轉換成 Redis 錯誤回復
  • Lua boolean false -> Redis Nil bulk reply. / Lua 的布爾值 false 轉換成 Redis 的 Nil bulk 回復

從 Lua 轉換到 Redis 有一條額外的規則,這條規則沒有和它對應的從 Redis 轉換到 Lua 的規則:

  • Lua boolean true -> Redis integer reply with value of 1. / Lua 布爾值 true 轉換成 Redis 整數回復中的 1

還有下面兩點需要重點註意:

  • lua中整數和浮點數之間沒有什麽區別。因此,我們始終Lua的數字轉換成整數的回復,這樣將舍去小數部分。如果你想從Lua返回一個浮點數,你應該將它作為一個字符串(見比如ZSCORE命令)。
  • There is no simple way to have nils inside Lua arrays, this is a result of Lua table semantics, so when Redis converts a Lua array into Redis protocol the conversion is stopped if a nil is encountered.

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

> 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"

最後一個例子展示如果是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沒有被返回回來。

  • 返回redis類型的輔助函數

有兩個輔助函數從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.

There is no difference between using the helper functions or directly returning the table with the specified format, so the following two forms are equivalent:

return {err="My Error"}
return redis.error_reply("My Error")

腳本的原子性

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

錯誤處理

前面的命令介紹部分說過, redis.call() 和 redis.pcall() 的唯一區別在於它們對錯誤處理的不同。

當 redis.call() 在執行命令的過程中發生錯誤時,腳本會停止執行,並返回一個腳本錯誤,錯誤的輸出信息會說明錯誤造成的原因:

> del foo
(integer) 1
> lpush foo a
(integer) 1
> eval "return redis.call(‘get‘,‘foo‘)" 0
(error) ERR Error running script (call to f_6b1bf486c81ceb7edf3c093f4c48582e38c0e791): 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

帶寬和 EVALSHA

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 不移除緩存中的腳本實際上是一個好主意。比如說,對於一個和 Redis 保持持久化鏈接(persistent connection)的程序來說,它可以確信,執行過一次的腳本會一直保留在內存當中,因此它可以在流水線中使用 EVALSHA 命令而不必擔心因為找不到所需的腳本而產生錯誤(稍候我們會看到在流水線中執行腳本的相關問題)。

SCRIPT 命令

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 產生的隨機數序列總是相同的。

盡管有那麽多的限制,但用戶還是可以用一個簡單的技巧寫出帶隨機行為的腳本(如果他們需要的話)。

假設現在我們要編寫一個 Redis 腳本,這個腳本從列表中彈出 N 個隨機數。一個 Ruby 寫的例子如下:

require ‘rubygems‘
require ‘redis‘

r = Redis.new

RandomPushScript = <<EOF
    local i = tonumber(ARGV[1])
    local res
    while (i > 0) do
        res = redis.call(‘lpush‘,KEYS[1],math.random())
        i = i-1
    end
    return res
EOF

r.del(:mylist)
puts r.eval(RandomPushScript,[:mylist],[10,rand(2**32)])

這個程序每次運行都會生成帶有以下元素的列表:

> lrange mylist 0 -1
1) "0.74509509873814"
2) "0.87390407681181"
3) "0.36876626981831"
4) "0.6921941534114"
5) "0.7857992587545"
6) "0.57730350670279"
7) "0.87046522734243"
8) "0.09637165539729"
9) "0.74990198051087"
10) "0.17082803611217"

上面的 Ruby 程序每次都只生成同樣的列表,用途並不是太大。那麽,該怎樣修改這個腳本,使得它仍然是一個純函數(符合 Redis 的要求),但是每次調用都可以產生不同的隨機元素呢?

一個簡單的辦法是,為腳本添加一個額外的參數,讓這個參數作為 Lua 的隨機數生成器的 seed 值,這樣的話,只要給腳本傳入不同的 seed ,腳本就會生成不同的列表元素。

以下是修改後的腳本:

RandomPushScript = <<EOF
    local i = tonumber(ARGV[1])
    local res
    math.randomseed(tonumber(ARGV[2]))
    while (i > 0) do
        res = redis.call(‘lpush‘,KEYS[1],math.random())
        i = i-1
    end
    return res
EOF

r.del(:mylist)
puts r.eval(RandomPushScript,1,:mylist,10,rand(2**32))

盡管對於同樣的 seed ,上面的腳本產生的列表元素是一樣的(因為它是一個純函數),但是只要每次在執行腳本的時候傳入不同的 seed ,我們就可以得到帶有不同隨機元素的列表。

Seed 會在復制(replication link)和寫 AOF 文件時作為一個參數來傳播,保證在載入 AOF 文件或附屬節點(slave)處理腳本時, seed 仍然可以及時得到更新。

註意,Redis 實現保證 math.random 和 math.randomseed 的輸出和運行 Redis 的系統架構無關,無論是 32 位還是 64 位系統,無論是小端(little endian)還是大端(big endian)系統,這兩個函數的輸出總是相同的。

全局變量保護

為了防止不必要的數據泄漏進 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 關鍵字定義為局部變量。

使用選擇內部腳本

在正常的客戶端連接裏面可以調用SELECT選擇內部的Lua腳本,但是Redis 2.8.11和Redis 2.8.12在行為上有一個微妙的變化。在2.8.12之前,會將腳本傳送到調用腳本的當前數據庫。從2.8.12開始Lua腳本只影響腳本本身的執行,但不修改當前客戶端調用腳本時選定的數據庫。

從補丁級發布的語義變化是必要的,因為舊的行為與Redis復制層固有的不相容是錯誤的原因。

可用庫

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庫。

struct 庫

struct 是一個Lua裝箱/拆箱的庫

Valid formats:
> - big endian
< - little endian
![num] - alignment
x - pading
b/B - signed/unsigned byte
h/H - signed/unsigned short
l/L - signed/unsigned long
T   - size_t
i/In - signed/unsigned integer with size `n‘ (default is size of int)
cn - sequence of `n‘ chars (from/to a string); when packing, n==0 means
     the whole string; when unpacking, n==0 means use the previous
     read number as the string length
s - zero-terminated string
f - float
d - double
‘ ‘ - ignored

例子:

127.0.0.1:6379> eval ‘return struct.pack("HH", 1, 2)‘ 0
"\x01\x00\x02\x00"
127.0.0.1:6379> eval ‘return {struct.unpack("HH", ARGV[1])}‘ 0 "\x01\x00\x02\x00"
1) (integer) 1
2) (integer) 2
3) (integer) 5
127.0.0.1:6379> eval ‘return struct.size("HH")‘ 0
(integer) 4

CJSON 庫

CJSON 庫為Lua提供極快的JSON處理

例子:

redis 127.0.0.1:6379> eval ‘return cjson.encode({["foo"]= "bar"})‘ 0
"{\"foo\":\"bar\"}"
redis 127.0.0.1:6379> eval ‘return cjson.decode(ARGV[1])["foo"]‘ 0 "{\"foo\":\"bar\"}"
"bar"

cmsgpack 庫

cmsgpack 庫為Lua提供了簡單、快速的MessagePack操縱

例子:

127.0.0.1:6379> eval ‘return cmsgpack.pack({"foo", "bar", "baz"})‘ 0
"\x93\xa3foo\xa3bar\xa3baz"
127.0.0.1:6379> eval ‘return cmsgpack.unpack(ARGV[1])‘ 0 "\x93\xa3foo\xa3bar\xa3baz"
1) "foo"
2) "bar"
3) "baz"

bitop 庫

bitop庫為Lua的位運算模塊增加了按位操作數。 它是Redis 2.8.18開始加入的。

例子:

127.0.0.1:6379> eval ‘return bit.tobit(1)‘ 0
(integer) 1
127.0.0.1:6379> eval ‘return bit.bor(1,2,4,8,16,32,64,128)‘ 0
(integer) 255
127.0.0.1:6379> eval ‘return bit.tohex(422342)‘ 0
"000671c6"

它支持幾個其他功能: bit.tobit, bit.tohex, bit.bnot, bit.band, bit.bor, bit.bxor, bit.lshift, bit.rshift, bit.arshift, bit.rol, bit.ror, bit.bswap. 所有可用的功能請參考Lua BitOp documentation

redis.sha1hex

對字符串執行SHA1算法

例子:

127.0.0.1:6379> eval ‘return redis.sha1hex(ARGV[1])‘ 0 "foo"
"0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33"

使用腳本散發 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.

沙箱(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中使用EVAL命令來運行Lua腳本。其參數分三個部分,分別為Lua腳本、操作的鍵的個數與鍵值、其他參數。例如:

1
> eval "return redis.call(‘set‘,KEYS[1],ARGV[1])" 1 foo bar

上邊的命令相當於運行set foo bar。在參數中指定鍵值並不是必須的,但是在集群環境中,Redis通過分析參數中的鍵來確定腳本需要運行在哪些節點上。

在Lua腳本中調用Redis命令有兩種方式,一種是如上邊例子中的redis.call,另一種是redis.pcall。兩者的區別是,當發生異常時,call會拋出異常終止程序,並返回錯誤信息。而pcall則會捕獲異常並返回一個使用Lua Table表示的錯誤信息,但腳本會繼續運行。在下邊的例子中,將set誤寫為secall拋出異常,而pcall會捕獲異常並繼續執行。

1
2
> eval "redis.call(‘se‘, ‘foo‘, ‘bar‘);return 1" 0
(error) ERR Error running script (call to f_d6ca96827cc8fb5e8cdeacf0ccabcee83fb23513): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script
1
2
> eval "redis.pcall(‘se‘, ‘foo‘, ‘bar‘);return 1" 0
(integer) 1

Redis中的Lua環境載入的庫有:basetablestringmathstructcjsoncmsgpackbitop

數據類型轉換

在Lua腳本中使用callpcall調用Redis命令時,就需要將Lua的數據類型轉成Redis的數據類型,同時Redis調用的返回值又需要轉回到Lua的數據類型。下邊兩張表是他們互相轉換的規則:

Redis類型到Lua類型的轉換表:

RedisLua
integer reply number
bulk reply string
multi bulk reply table (may have other Redis data types nested)
status reply table with a single ok field containing the status
error reply table with a single err field containing the error
Nil bulk reply and Nil multi bulk reply false boolean type

Lua類型到Redis類型轉換表:

LuaRedis
number integer reply (the number is converted into an integer)
string bulk reply
table (array) multi bulk reply (truncated to the first nil inside the Lua array if any)
table with a single ok field status reply
table with a single err field error reply
boolean false Nil bulk reply
boolean true integer reply with value of 1

另外兩條重要的規則:

  • Lua只有一個number表示數字類型,不區分整型與浮點型,在將Lua的number轉換成Redis類型時,小數部分會被忽略。所以,如果需要返回浮點型的數值,需要轉成Lua的string類型返回。
  • Lua的數組中基本上不會出現nils,所以在將Lua數組轉到Redis類型時,當遇到nil,轉換即停止。

在下邊的例子中可以看到,Lua的table類型被轉成了Redis的multi bulk reply,並且浮點數3.3333的小數位被省略了,同時在第一個nil處停止了轉換:

1
2
3
4
5
> eval "return {1,2,3.3333,‘foo‘,nil,‘bar‘}" 0
1) (integer) 1
2) (integer) 2
3) (integer) 3
4) "foo"

Redis提供了幫助生成狀態與錯誤值的方法,分別為redis.status_replyredis.error_reply。腳本return {err="My Error"}return redis.error_reply("My Error")的結果是相同的。

原子性

Redis一次只運行一個命令,Lua腳本的運行與其他的Redis命令相同,都是原子操作。在Lua腳本運行的過程中,不會有其他命令運行,因此數據也不會被其他操作修改和讀取。這和前邊《Redis拾遺》中提到的,實現事務的MULTI/EXEC操作很像。所以,Lua腳本可以用來實現事物,它也是官方推薦的實現事物的方式,因為在復雜情境下,完全在服務端運行的Lua腳本的性能要優於需要多次網絡交互的MULTI/EXEC操作。

在Lua腳本運行期間,Redis不能處理其他請求。所以,確保腳本輕量和快速運行非常重要。如果一個腳本運行的時間過長,就會超時,Redis默認的腳本運行超時是5秒鐘,可以使用配置文件中的lua-time-limit進行調整。

如果腳本運行超時了,Redis並不是簡單的殺死腳本,並繼續提供服務,這樣違反其原子性。超時後,Redis會記錄超時的日誌,並開始接受新的請求,但是對SCRIPT KILLSHUTDOWN NOSAVE之外的命令都只返回BUSY的錯誤。如果運行的腳本只是讀取數據,還沒有寫入數據,這時就可以用SCRIPT KILL將其殺死,否則只能使用SHUTDOWN NOSAVE關閉服務器並放棄之前一段時間的更改,保證數據的一致性。

調試

Redis 3.2版加入Lua腳本的調試器。Lua調試器運行在服務器上,可以在客戶端使用Redis Cli進行遠程調試。默認情況下,調試會話不會阻塞服務器的正常運行,並且在同一個服務器上可以打開多個調試會話,數據在調試會話結束後會回滾。同時也提供了同步的調試會話,會阻塞服務器,並且不會回滾數據。調試器支持步近、斷點、獲取Lua變量值、跟蹤Redis命令調用、無限循環與超時運行檢測等功能。

使用ldb參數打開調試器:

1
redis-cli --ldb --eval ./script.lua key1 key2 , arg1 arg2

參考

  • 《Redis in Action》- Josiah Carlson
  • Redis Lua Scripting
  • Debugging Lua scripts
  • Programming in Lua: 19.1 Array Size
  • Spring Data Redis Document: 5.12. Redis Scripting

高性能偽事務之Lua in Redis