一網(wǎng)打盡Redis Lua腳本并發(fā)原子組合操作

1. 前言
Redis 提供了豐富的命令來(lái)供我們使用以實(shí)現(xiàn)一些計(jì)算。Redis 的單個(gè)命令都是原子性的,有時(shí)候我們希望能夠組合多個(gè) Redis 命令,并讓這個(gè)組合也能夠原子性的執(zhí)行,甚至可以重復(fù)使用。Redis 開發(fā)者意識(shí)到這種場(chǎng)景還是很普遍的,就在 2.6 版本中引入了一個(gè)特性來(lái)解決這個(gè)問題,這就是 Redis 執(zhí)行 Lua 腳本。
2. Lua
Lua 也算一門古老的語(yǔ)言了,玩魔獸世界的玩家應(yīng)該對(duì)它不陌生,WOW 的插件就是用 Lua 腳本編寫的。在高并發(fā)的網(wǎng)絡(luò)游戲中 Lua 大放異彩被廣泛使用。
Lua 廣泛作為其它語(yǔ)言的嵌入腳本,尤其是 C/C++,語(yǔ)法簡(jiǎn)單,小巧,源碼一共才 200 多 K,這可能也是 Redis 官方選擇它的原因。
另一款明星軟件 Nginx 也支持 Lua,利用 Lua 也可以實(shí)現(xiàn)很多有用的功能。
3. Lua 并不難
Redis 官方指南也指出不要在 Lua 腳本中編寫過(guò)于復(fù)雜的邏輯。
為了實(shí)現(xiàn)一個(gè)功能就要學(xué)習(xí)一門語(yǔ)言,這看起來(lái)就讓人有打退堂鼓的感覺。其實(shí) Lua 并不難學(xué),而且作為本文的場(chǎng)景來(lái)說(shuō)我們不需要去學(xué)習(xí) Lua 的完全特性,要在 Redis 中輕量級(jí)使用 Lua 語(yǔ)言。這對(duì)掌握了 Java 這種重量級(jí)語(yǔ)言的你來(lái)說(shuō)根本不算難事。這里胖哥只對(duì) Redis 中的涉及到的基本語(yǔ)法說(shuō)一說(shuō)。
Lua 的簡(jiǎn)單語(yǔ)法
Lua 在 Redis 腳本中我個(gè)人建議只需要使用下面這幾種類型:
nil空boolean布爾值number數(shù)字string字符串table表
聲明類型
聲明類型非常簡(jiǎn)單,不用攜帶類型。
---?全局變量
name?=?'felord.cn'
---?局部變量
local?age?=?18
Redis 腳本在實(shí)踐中不要使用全局變量,局部變量效率更高。
table 類型
前面四種非常好理解,第五種table需要簡(jiǎn)單說(shuō)一下,它既是數(shù)組又類似 Java 中的HashMap(字典),它是 Lua 中僅有的數(shù)據(jù)結(jié)構(gòu)。
數(shù)組不分具體類型,演示如下
Lua?5.1.5??Copyright?(C)?1994-2012?Lua.org,?PUC-Rio
>?arr_table?=?{'felord.cn','Felordcn',1}
>?print(arr_table[1])
felord.cn
>?print(arr_table[3])
1
>?print(#arr_table)
3
作為字典:
Lua?5.1.5??Copyright?(C)?1994-2012?Lua.org,?PUC-Rio
>?arr_table?=?{name?=?'felord.cn',?age?=?18}
>?print(arr_table['name'])
felord.cn
>?print(arr_table.name)
felord.cn
>?print(arr_table[1])
nil
>?print(arr_table['age'])
18
>?print(#arr_table)
0
混合模式:
Lua?5.1.5??Copyright?(C)?1994-2012?Lua.org,?PUC-Rio
>?arr_table?=?{'felord.cn','Felordcn',1,age?=?18,nil}
>?print(arr_table[1])
felord.cn
>?print(arr_table[4])
nil
>?print(arr_table['age'])
18
>?print(#arr_table)
3
?
#取 table 的長(zhǎng)度不一定精準(zhǔn),慎用。同時(shí)在 Redis 腳本中避免使用混合模式的 table,同時(shí)元素應(yīng)該避免包含空值nil。在不確定元素的情況下應(yīng)該使用循環(huán)來(lái)計(jì)算真實(shí)的長(zhǎng)度。
判斷
判斷非常簡(jiǎn)單,格式為:
local?a?=?10
if?a?10??then
?print('a小于10')
elseif?a?20?then
?print('a小于20,大于等于10')
else
?print('a大于等于20')
end
數(shù)組循環(huán)
local?arr?=?{1,2,name='felord.cn'}
for?i,?v?in?ipairs(arr)?do
????print('i?=?'..i)
????print('v?=?'..?v)
end
print('-------------------')
for?i,?v?in?pairs(arr)?do
????print('p?i?=?'..i)
????print('p?v?=?'..?v)
end
打印結(jié)果:
i?=?1
v?=?1
i?=?2
v?=?2
-----------------------
p?i?=?1
p?v?=?1
p?i?=?2
p?v?=?2
p?i?=?name
p?v?=?felord.cn
返回值
像 Python 一樣,Lua 也可以返回多個(gè)返回值。不過(guò)在 Redis 的 Lua 腳本中不建議使用此特性,如果有此需求請(qǐng)封裝為數(shù)組結(jié)構(gòu)。在 Spring Data Redis 中支持腳本的返回值規(guī)則可以從這里分析:
public?static?ReturnType?fromJavaType(@Nullable?Class>?javaType)?{
???if?(javaType?==?null)?{
??????return?ReturnType.STATUS;
???}
???if?(javaType.isAssignableFrom(List.class))?{
??????return?ReturnType.MULTI;
???}
???if?(javaType.isAssignableFrom(Boolean.class))?{
??????return?ReturnType.BOOLEAN;
???}
???if?(javaType.isAssignableFrom(Long.class))?{
??????return?ReturnType.INTEGER;
???}
???return?ReturnType.VALUE;
}
胖哥在實(shí)踐中會(huì)使用 List、Boolean、Long三種,避免出現(xiàn)幺蛾子。
到此為止 Redis Lua 腳本所需要知識(shí)點(diǎn)就完了,其它的函數(shù)、協(xié)程等特性也不應(yīng)該在 Redis Lua 腳本中出現(xiàn),用到內(nèi)置函數(shù)的話搜索查詢一下就行了。
在接觸一門新的技術(shù)時(shí)先要中規(guī)中矩的使用,如果你想玩花活就意味著更高的學(xué)習(xí)成本。
4. Redis 中的 Lua
接下來(lái)就是 Redis Lua 腳本的實(shí)際操作了。
EVAL 命令
Redis 中使用EVAL命令來(lái)直接執(zhí)行指定的 Lua 腳本。
EVAL?luascript?numkeys?key?[key?...]?arg?[arg?...]
EVAL命令的關(guān)鍵字。luascriptLua 腳本。numkeys指定的 Lua 腳本需要處理鍵的數(shù)量,其實(shí)就是key數(shù)組的長(zhǎng)度。key傳遞給 Lua 腳本零到多個(gè)鍵,空格隔開,在 Lua 腳本中通過(guò)KEYS[INDEX]來(lái)獲取對(duì)應(yīng)的值,其中1 <= INDEX <= numkeys。arg是傳遞給腳本的零到多個(gè)附加參數(shù),空格隔開,在 Lua 腳本中通過(guò)ARGV[INDEX]來(lái)獲取對(duì)應(yīng)的值,其中1 <= INDEX <= numkeys。
接下來(lái)我簡(jiǎn)單來(lái)演示獲取鍵hello的值得簡(jiǎn)單腳本:
127.0.0.1:6379>?set?hello?world
OK
127.0.0.1:6379>?get?hello
"world"
127.0.0.1:6379>?EVAL?"return?redis.call('GET',KEYS[1])"?1?hello
"world"
127.0.0.1:6379>?EVAL?"return?redis.call('GET','hello')"
(error)?ERR?wrong?number?of?arguments?for?'eval'?command
127.0.0.1:6379>?EVAL?"return?redis.call('GET','hello')"?0
"world"
從上面的演示代碼中發(fā)現(xiàn),KEYS[1]可以直接替換為hello,但是 Redis 官方文檔指出這種是不建議的,目的是在命令執(zhí)行前會(huì)對(duì)命令進(jìn)行分析,以確保 Redis Cluster 可以將命令轉(zhuǎn)發(fā)到適當(dāng)?shù)募汗?jié)點(diǎn)。
numkeys無(wú)論什么情況下都是必須的命令參數(shù)。
call 函數(shù)和 pcall 函數(shù)
在上面的例子中我們通過(guò)redis.call()來(lái)執(zhí)行了一個(gè)SET命令,其實(shí)我們也可以替換為redis.pcall()。它們唯一的區(qū)別就在于處理錯(cuò)誤的方式,前者執(zhí)行命令錯(cuò)誤時(shí)會(huì)向調(diào)用者直接返回一個(gè)錯(cuò)誤;而后者則會(huì)將錯(cuò)誤包裝為一個(gè)我們上面講的table表格:
127.0.0.1:6379>?EVAL?"return?redis.call('no_command')"?0
(error)?ERR?Error?running?script?(call?to?f_1e6efd00ab50dd564a9f13e5775e27b966c2141e):?@user_script:1:?@user_script:?1:?Unknown?Redis?command?called?from?Lua?script
127.0.0.1:6379>?EVAL?"return?redis.pcall('no_command')"?0
(error)?@user_script:?1:?Unknown?Redis?command?called?from?Lua?script
這就像 Java 遇到一個(gè)異常,前者會(huì)直接拋出一個(gè)異常;后者會(huì)把異常處理成 JSON 返回。
值轉(zhuǎn)換
由于在 Redis 中存在 Redis 和 Lua 兩種不同的運(yùn)行環(huán)境,在 Redis 和 Lua 互相傳遞數(shù)據(jù)時(shí)必然發(fā)生對(duì)應(yīng)的轉(zhuǎn)換操作,這種轉(zhuǎn)換操作是我們?cè)趯?shí)踐中不能忽略的。例如如果 Lua 腳本向 Redis 返回小數(shù),那么會(huì)損失小數(shù)精度;如果轉(zhuǎn)換為字符串則是安全的。
127.0.0.1:6379>?EVAL?"return?3.14"?0
(integer)?3
127.0.0.1:6379>?EVAL?"return?tostring(3.14)"?0
"3.14"
根據(jù)胖哥經(jīng)驗(yàn)傳遞字符串、整數(shù)是安全的,其它需要你去仔細(xì)查看官方文檔并進(jìn)行實(shí)際驗(yàn)證。
原子執(zhí)行
Lua 腳本在 Redis 中是以原子方式執(zhí)行的,在 Redis 服務(wù)器執(zhí)行EVAL命令時(shí),在命令執(zhí)行完畢并向調(diào)用者返回結(jié)果之前,只會(huì)執(zhí)行當(dāng)前命令指定的 Lua 腳本包含的所有邏輯,其它客戶端發(fā)送的命令將被阻塞,直到EVAL命令執(zhí)行完畢為止。因此 LUA 腳本不宜編寫一些過(guò)于復(fù)雜了邏輯,必須盡量保證 Lua 腳本的效率,否則會(huì)影響其它客戶端。
腳本管理
SCRIPT LOAD
加載腳本到緩存以達(dá)到重復(fù)使用,避免多次加載浪費(fèi)帶寬,每一個(gè)腳本都會(huì)通過(guò) SHA 校驗(yàn)返回唯一字符串標(biāo)識(shí)。需要配合EVALSHA命令來(lái)執(zhí)行緩存后的腳本。
127.0.0.1:6379>?SCRIPT?LOAD?"return?'hello'"
"1b936e3fe509bcbc9cd0664897bbe8fd0cac101b"
127.0.0.1:6379>?EVALSHA?1b936e3fe509bcbc9cd0664897bbe8fd0cac101b?0
"hello"
SCRIPT FLUSH
既然有緩存就有清除緩存,但是遺憾的是并沒有根據(jù) SHA 來(lái)刪除腳本緩存,而是清除所有的腳本緩存,所以在生產(chǎn)中一般不會(huì)再生產(chǎn)過(guò)程中使用該命令。
SCRIPT EXISTS
以 SHA 標(biāo)識(shí)為參數(shù)檢查一個(gè)或者多個(gè)緩存是否存在。
127.0.0.1:6379>?SCRIPT?EXISTS?1b936e3fe509bcbc9cd0664897bbe8fd0cac101b??1b936e3fe509bcbc9cd0664897bbe8fd0cac1012
1)?(integer)?1
2)?(integer)?0
SCRIPT KILL
終止正在執(zhí)行的腳本。但是為了數(shù)據(jù)的完整性此命令并不能保證一定能終止成功。如果當(dāng)一個(gè)腳本執(zhí)行了一部分寫的邏輯而需要被終止時(shí),該命令是不湊效的。需要執(zhí)行SHUTDOWN nosave在不對(duì)數(shù)據(jù)執(zhí)行持久化的情況下終止服務(wù)器來(lái)完成終止腳本。
其它一些要點(diǎn)
了解了上面這些知識(shí)基本上可以滿足開發(fā)一些簡(jiǎn)單的 Lua 腳本了。但是實(shí)際開發(fā)中還是有一些要點(diǎn)的。
務(wù)必對(duì) Lua 腳本進(jìn)行全面測(cè)試以保證其邏輯的健壯性,當(dāng) Lua 腳本遇到異常時(shí),已經(jīng)執(zhí)行過(guò)的邏輯是不會(huì)回滾的。 盡量不使用 Lua 提供的具有隨機(jī)性的函數(shù),參見相關(guān)官方文檔。 在 Lua 腳本中不要編寫 function函數(shù),整個(gè)腳本作為一個(gè)函數(shù)的函數(shù)體。在腳本編寫中聲明的變量全部使用 local關(guān)鍵字。在集群中使用 Lua 腳本要確保邏輯中所有的 key分到相同機(jī)器,也就是同一個(gè)插槽(slot)中,可采用Redis Hash Tag技術(shù)。再次重申 Lua 腳本一定不要包含過(guò)于耗時(shí)、過(guò)于復(fù)雜的邏輯。
5. 總結(jié)
本文對(duì) Redis Lua 腳本的場(chǎng)景以及編寫 Redis Lua 腳本所需要的 Lua 編程語(yǔ)法進(jìn)行了詳細(xì)的講解和演示,也對(duì) Redis Lua 腳本在實(shí)際開發(fā)中需要注意的一些要點(diǎn)進(jìn)行了分享。希望能夠幫助你掌握此技術(shù)

