Redis入門(6) - Lua腳本

  • Lua基本語法
  • 表類型
  • 函數
  • Redis執行腳本
  • KEYS與ARGV
  • 沙盒與隨機數
  • 腳本相關命令
  • 原子性和執行時間

Lua是一種高效的輕量級腳本語言,能夠方便地嵌入到其他語言中使用。在Redis中,藉助Lua腳本可以自定義擴展命令。

Lua基本語法

數據類型

  • 空(nil),沒有賦值的變量或表的字段值都是nil
  • 布爾(boolean)
  • 數字(number),整數或浮點數
  • 字符串(string),字符串可以用單引號或雙引號表示,可以包含轉義字符如\n \r等
  • 表(table),表類型是Lua語言中唯一的數據結構,既可以當數組又可以當字典,十分靈活
  • 函數(function),函數在Lua中是一等值(first-class-value),可以存儲在變量中、作爲函數的參數或返回結果。

變量

Lua的變量分爲全局變量和局部變量,全局變量無需聲明就可以直接使用,默認值是nil。
全局變量:

a=1 -- 爲全局變量a賦值
print(b) -- 無需聲明即可使用,默認值是nil

局部變量:

local c -- 聲明一個局部變量c,默認值是nil
local d=1 -- 聲明一個局部變量d並賦值爲1
local e,f -- 可以同時聲明多個局部變量

但在Redis中,爲了防止腳本之間相互影響,只允許使用局部變量。

賦值

Lua支持多重賦值,如:

local a,b=1,2 --a的值是1,b的值是2
local c,d=1,2,3 --c的值是1,d的值是2,3被捨棄了
local e,f =1 --e的值是1,f的值是nil

操作符

  1. 數學操作符,包括常見的+ - * \ %(取模) -(一元操作符,取負)和冪運算符號^。

  2. 比較操作符,包括== ~=(不等於) > < >= <=。
    比較操作符不會對兩邊的操作數進行自動類型轉換:

pring(1=='1') --結果爲false
print({'a'}=={'a'}) -false,表類型比較的是二者的引用
  1. 邏輯操作符
    包括下面三個:
    not,根據操作數的真和假相應地返回false和true;
    and,a and b中如果a是真則返回b,否則返回a;
    or,a or b中,如果a是真則返回a,否則返回b。
    這些根據操作符短路的原理可以推斷出。
print(1 and 5)  --5
print(1 or 5)  --1
print(not 0)  --false
print('' or 1)  --''

只要操作數不是nil或false,邏輯操作符就認爲操作數是真,否則是假。而且即使是0或空字符串也被當作真,所以上面的代碼中print(not 0)的結果爲false,print(’’ or 1)的結果爲’’。

  1. 連接操作符
    Lua中的連接操作符爲’…’,用來連接兩個字符串。

  2. 取長度操作符

 print(#'hello')  --5

if語句

Lua中if語句的格式爲

if condition then
    ...
else if condition then
    ...
else
    ...
end

由於Lua中只有nil和false才認爲是假,這裏也需要注意避坑,比如Redis中EXISTS命令返回1和0分別表示存在或不存在,類似下面的寫法if條件將始終爲true:

if redis.call('EXISTS','key1') then
    ...

所以需要寫成:

if redis.call('EXISTS','key1')==1 then
    ...

循環語句

Lua中的循環語句有四種形式:

while condition do
    ...
end
repeat
    ...
until condition
for i=初值, 終值, 步長 do
    ...
end

其中步長爲1時可以省略。

for 變量1,變量2,...,變量N in 迭代器 do
    ...
end

表類型

表是Lua中唯一的數據結構,可以理解爲關聯數組,除nil之外的任何類型的值都可以作爲表的索引。

表的定義和賦值

-- 表的定義
a={} --將變量a賦值爲一個空表
-- 表的賦值
a['field']='value' --將field字段賦值爲value
print(a.field) --a['field']可以簡化爲a.field

-- 定義的同時賦值
b={
    name='bom',
    age=7
}
-- 取值
print(b['age'])
print(b.age)

當索引爲整數的時候表和傳統的數組一樣,但需要注意的是Lua的索引是從1開始的。

a={}
a[1]='bob'
a[2]='daffy'

上面的定義和賦值的過程可以直接簡化爲:

a={'bob','daffy'}

取值:

print(a[1])

表的遍歷

之前介紹的這種類型的for循環可以用於表的遍歷:

for 變量1,變量2,...,變量N in 迭代器 do
    ...
end
a={'bob','daffy'}

for index,value in ipairs(a) do
    print(index) 
    print(value) 
end

ipairs用於數組的遍歷,index和value分別爲元素的索引和值,變量名不是必須爲index和value,可以自定義。
或者:

for i=1, #a do
    print(i)
    print(a[i])
end

通過#a可以去到數組a的長度。

對於非數組的遍歷,可以使用pairs

b={
    name='bom',
    age=7
}

for key,value in pairs(b) do
    print(key) 
    print(value) 
end

變量名不是必須爲key和value,可以自定義。

函數

函數的定義爲:

function(參數列表)
    ...
end

實際使用中可以將其賦值給一個局部變量,如:

local square=function(num)
    return num * num
end

還可以簡化爲:

local function square(num)
    return num * num
end

如果實參的個數小於形參的個數,則沒有匹配到的形參的值爲nil;如果實參的個數大於形參的個數,則多出的實參會被忽略。如果希望參數可變,可以用…表示形參。

在腳本中調用Redis命令

在腳本中使用redis.call可以調用Redis命令

redis.call('SET','foo','bar')

redis.call的返回值就是Redis命令的執行結果。針對Redis的不同返回類型,redis.call會將其轉換爲對應的Lua的數據類型,兩者的對應關係爲:

Redis返回類型 Lua數據類型
整數回覆 數字類型
字符串回覆 字符串類型
多行字符串回覆 表類型(數組形式)
狀態回覆 表類型(只有一個ok字段存儲狀態信息)
錯誤回覆 表類型(只有一個err字段存儲錯誤信息)

Redis的nil回覆會被轉換爲false。

Lua腳本執行完畢後可以通過return將結果返回給Redis客戶端,這是又會將Lua的數據類型轉換爲Redis的返回類型,過程與上面的表格相反。

redis.pcall函數與redis.call的功能相同,但redis.pcall在執行出錯時會記錄錯誤並繼續執行,而redis.call則會中斷執行。

Redis執行腳本

EVAL

在Redis客戶端通過EVAL命令可以調用腳本,其格式爲:

EVAL 腳本內容 key參數的數量 [key...] [arg...]

例如用腳本來設置鍵的值,就是這樣的:

EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 1 foo bar

通過key和arg這兩類參數向腳本傳遞數據,它們的值可以在腳本中分別使用KEYS和ARGV兩個表類型的全局變量訪問。key參數的數量是必須指定的,沒有key參數時必須設爲0,EVAL會依據這個數值將傳入的參數分別存入KEYS和ARGV兩個表類型的全局變量。

EVALSHA

如果腳本比較長,每次調用腳本都將整個腳本傳給Redis會佔用較多的帶寬。而使用EVALSHA命令可以腳本內容的SHA1摘要來執行腳本,該命令的用法和EVAL一樣,只不過是將腳本內容替換成腳本內容的SHA1摘要。Redis在執行EVAL命令時會計算腳本的SHA1摘要並記錄在腳本緩存中,執行EVALSHA命令時Redis會根據提供的摘要從腳本緩存中查找對應的腳本內容,如果找到了則執行腳本,否則會返回錯誤:“NOSCRIPT No matching script. Please use EVAL.”。

具體使用時,可以先計算腳本的SHA1摘要,並用EVALSHA命令執行腳本,如果返回NOSCRIPT錯誤,就用EVAL重新執行腳本。

KEYS與ARGV

前面提到過向腳本傳遞的參數分爲KEYS和ARGV兩類,前者表示要操作的鍵名,後者表示非鍵名參數。但這一要求並不輸強制的,比如設置鍵值的腳本:

EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 1 foo bar

也可以寫成:

EVAL "return redis.call('SET',ARGV[1],ARGV[2])" 0 foo bar

雖然規則不是強制的,但不遵守這樣的規則可能會爲後續帶來不必要的麻煩。比如Redis 3.0之後支持集羣功能,開啓集羣后會將鍵發佈到不同的節點上,所以在腳本執行前就需要知道腳本會操作哪些鍵以便找到對應的節點,而如果腳本中的鍵名沒有使用KEYS參數傳遞則無法兼容集羣。

沙盒與隨機數

Redis限制腳本只能在沙盒中運行,只允許腳本對Redis的數據進行處理,而禁止使用Lua標準庫中與文件或系統調用相關的函數,Redis還通過禁用腳本的全局變量的方式保證每個腳本都是相對隔離、不會互相干擾的。

使用沙盒一方面可保證服務器的安全性,還可確保可以重現(腳本執行的結果只和腳本本身以及傳遞的參數有關)。

Redis還替換了math.random和math.randomseed函數,使得每次執行腳本時生成的隨機數列都相同。如果希望獲得不同的隨機數序列,可以採用提前生成隨機數並通過參數傳遞給腳本,或者提前生成隨機數種子的方式。

集合類型和散列類型的字段是無序的,所以SMEMBERS和HKEYS命令原本會返回隨機結果,但在腳本中調用這些命令時,Redis會對結果按照字典順序排序。

對於會產生隨機結果但無法排序的命令,比如SPOP,SRANDMEMBER, RANDOMKEY, TIME,Redis會在這類命令執行後將該腳本狀態標記爲lua_random_dirty,此後只允許調用只讀命令,不允許修改數據庫的值,否則會返回錯誤:“Write commands not allowed after non deterministic commands.”

腳本相關命令

SCRIPT LOAD

EVAL命令會執行腳本,並將腳本計算SHA1、加入到腳本緩存中,如果只是希望緩存腳本而不執行,就可以使用SCRIPT LOAD,返回值是腳本的SHA1結果:

> SCRIPT LOAD "return redis.call('SET',KEYS[1],ARGV[1])"
"cf63a54c34e159e75e5a3fe4794bb2ea636ee005"

SCRIPT EXISTS

通過SHA1查詢某個腳本是否被緩存,可以查詢多個SHA1。參數必須是完整的SHA1,而不能像docker只輸前幾位。返回結果1表示存在。

SCRIPT FLUSH

Redis將腳本加入到緩存後會永久保留,如果要清空緩存可以使用SCRIPT FLUSH。

SCRIPT KILL

用於終止正在執行的腳本

原子性和執行時間

Redis的腳本執行是原子的,腳本執行期間其他命令不會被執行,必須等待上一個腳本執行完成。

但爲了防止某個腳本執行時間過長導致Redis無法提供服務(比如陷入死循環),Redis提供了lua-time-limit參數限制腳本的最長運行時間,默認爲5秒鐘。當腳本運行時間超過這一限制後,Redis將開始接受其他命令,但爲了確保腳本的原子性,新的腳本仍然不會執行,而是會返回“BUSY”錯誤。

可以打開兩個redis-cli實例A和B來驗證,首先在A執行一個死循環腳本:

EVAL "while true do end" 0

這時在實例B執行GET key1會返回:
(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

如果按照錯誤提示,在B執行SCRIPT KILL,這時在實例A的腳本會被終止,並返回:
(error) ERR Error running script (call to f_694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7): @user_script:1: Script killed by user with SCRIPT KILL…

但如果A已經對Redis的數據做了修改,則SCRIPT KILL無法將其終止,A執行:

EVAL "redis.call('SET','foo','bar') while true do end" 0

如果在B嘗試KILL腳本,會返回錯誤:
(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.

這時就只能通過SHUTDOWN NOSAVE命令強行終止Redis。SHUTDOWN NOSAVE與SHUTDOWN命令的區別在於,SHUTDOWN NOSAVE將不會進行持久化操作,所有發生在上一次快照後的數據庫修改都會丟失!

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