一網(wǎng)打盡Redis Lua腳本并發(fā)原子組合操作
1. 前言
Redis 是高性能的 KV 內存數(shù)據(jù)庫,除了做緩存中間件的基本作用外還有很多用途。Redis 提供了豐富的命令來供我們使用以實現(xiàn)一些計算。Redis 的單個命令都是原子性的,有時候我們希望能夠組合多個 Redis 命令,并讓這個組合也能夠原子性的執(zhí)行,甚至可以重復使用。Redis 開發(fā)者意識到這種場景還是很普遍的,就在 2.6 版本中引入了一個特性來解決這個問題,這就是 Redis 執(zhí)行 Lua 腳本。
2. Lua
Lua 也算一門古老的語言了,玩魔獸世界的玩家應該對它不陌生,WOW 的插件就是用 Lua 腳本編寫的。在高并發(fā)的網(wǎng)絡游戲中 Lua 大放異彩被廣泛使用。
Lua 廣泛作為其它語言的嵌入腳本,尤其是 C/C++,語法簡單,小巧,源碼一共才 200 多 K,這可能也是 Redis 官方選擇它的原因。
另一款明星軟件 Nginx 也支持 Lua,利用 Lua 也可以實現(xiàn)很多有用的功能。
3. Lua 并不難
Redis 官方指南也指出不要在 Lua 腳本中編寫過于復雜的邏輯。
為了實現(xiàn)一個功能就要學習一門語言,這看起來就讓人有打退堂鼓的感覺。其實 Lua 并不難學,而且作為本文的場景來說我們不需要去學習 Lua 的完全特性,要在 Redis 中輕量級使用 Lua 語言。這對掌握了 Java 這種重量級語言的你來說根本不算難事。這里胖哥只對 Redis 中的涉及到的基本語法說一說。
Lua 的簡單語法
Lua 在 Redis 腳本中我個人建議只需要使用下面這幾種類型:
- nil 空
- boolean 布爾值
- number 數(shù)字
- string 字符串
- table 表
聲明類型
聲明類型非常簡單,不用攜帶類型。
- --- 全局變量
- name = 'felord.cn'
- --- 局部變量
- local age = 18
Redis 腳本在實踐中不要使用全局變量,局部變量效率更高。
table 類型
前面四種非常好理解,第五種table需要簡單說一下,它既是數(shù)組又類似 Java 中的HashMap(字典),它是 Lua 中僅有的數(shù)據(jù)結構。
數(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 的長度不一定精準,慎用。同時在 Redis 腳本中避免使用混合模式的 table,同時元素應該避免包含空值nil。在不確定元素的情況下應該使用循環(huá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
打印結果:
- 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 也可以返回多個返回值。不過在 Redis 的 Lua 腳本中不建議使用此特性,如果有此需求請封裝為數(shù)組結構。在 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;
- }
胖哥在實踐中會使用 List、Boolean、Long三種,避免出現(xiàn)幺蛾子。
到此為止 Redis Lua 腳本所需要知識點就完了,其它的函數(shù)、協(xié)程等特性也不應該在 Redis Lua 腳本中出現(xiàn),用到內置函數(shù)的話搜索查詢一下就行了。
在接觸一門新的技術時先要中規(guī)中矩的使用,如果你想玩花活就意味著更高的學習成本。
4. Redis 中的 Lua
接下來就是 Redis Lua 腳本的實際操作了。
EVAL 命令
Redis 中使用EVAL命令來直接執(zhí)行指定的 Lua 腳本。
- EVAL luascript numkeys key [key ...] arg [arg ...]
- EVAL 命令的關鍵字。
- luascript Lua 腳本。
- numkeys 指定的 Lua 腳本需要處理鍵的數(shù)量,其實就是 key數(shù)組的長度。
- key 傳遞給 Lua 腳本零到多個鍵,空格隔開,在 Lua 腳本中通過 KEYS[INDEX]來獲取對應的值,其中1 <= INDEX <= numkeys。
- arg是傳遞給腳本的零到多個附加參數(shù),空格隔開,在 Lua 腳本中通過ARGV[INDEX]來獲取對應的值,其中1 <= INDEX <= numkeys。
接下來我簡單來演示獲取鍵hello的值得簡單腳本:
- 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í)行前會對命令進行分析,以確保 Redis Cluster 可以將命令轉發(fā)到適當?shù)募汗?jié)點。
numkeys無論什么情況下都是必須的命令參數(shù)。
call 函數(shù)和 pcall 函數(shù)
在上面的例子中我們通過redis.call()來執(zhí)行了一個SET命令,其實我們也可以替換為redis.pcall()。它們唯一的區(qū)別就在于處理錯誤的方式,前者執(zhí)行命令錯誤時會向調用者直接返回一個錯誤;而后者則會將錯誤包裝為一個我們上面講的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 遇到一個異常,前者會直接拋出一個異常;后者會把異常處理成 JSON 返回。
值轉換
由于在 Redis 中存在 Redis 和 Lua 兩種不同的運行環(huán)境,在 Redis 和 Lua 互相傳遞數(shù)據(jù)時必然發(fā)生對應的轉換操作,這種轉換操作是我們在實踐中不能忽略的。例如如果 Lua 腳本向 Redis 返回小數(shù),那么會損失小數(shù)精度;如果轉換為字符串則是安全的。
- 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)驗傳遞字符串、整數(shù)是安全的,其它需要你去仔細查看官方文檔并進行實際驗證。
原子執(zhí)行
Lua 腳本在 Redis 中是以原子方式執(zhí)行的,在 Redis 服務器執(zhí)行EVAL命令時,在命令執(zhí)行完畢并向調用者返回結果之前,只會執(zhí)行當前命令指定的 Lua 腳本包含的所有邏輯,其它客戶端發(fā)送的命令將被阻塞,直到EVAL命令執(zhí)行完畢為止。因此 LUA 腳本不宜編寫一些過于復雜了邏輯,必須盡量保證 Lua 腳本的效率,否則會影響其它客戶端。
腳本管理
SCRIPT LOAD
加載腳本到緩存以達到重復使用,避免多次加載浪費帶寬,每一個腳本都會通過 SHA 校驗返回唯一字符串標識。需要配合EVALSHA命令來執(zhí)行緩存后的腳本。
- 127.0.0.1:6379> SCRIPT LOAD "return 'hello'"
- "1b936e3fe509bcbc9cd0664897bbe8fd0cac101b"
- 127.0.0.1:6379> EVALSHA 1b936e3fe509bcbc9cd0664897bbe8fd0cac101b 0
- "hello"
SCRIPT FLUSH
既然有緩存就有清除緩存,但是遺憾的是并沒有根據(jù) SHA 來刪除腳本緩存,而是清除所有的腳本緩存,所以在生產(chǎn)中一般不會再生產(chǎn)過程中使用該命令。
SCRIPT EXISTS
以 SHA 標識為參數(shù)檢查一個或者多個緩存是否存在。
- 127.0.0.1:6379> SCRIPT EXISTS 1b936e3fe509bcbc9cd0664897bbe8fd0cac101b 1b936e3fe509bcbc9cd0664897bbe8fd0cac1012
- 1) (integer) 1
- 2) (integer) 0
SCRIPT KILL
終止正在執(zhí)行的腳本。但是為了數(shù)據(jù)的完整性此命令并不能保證一定能終止成功。如果當一個腳本執(zhí)行了一部分寫的邏輯而需要被終止時,該命令是不湊效的。需要執(zhí)行SHUTDOWN nosave在不對數(shù)據(jù)執(zhí)行持久化的情況下終止服務器來完成終止腳本。
其它一些要點
了解了上面這些知識基本上可以滿足開發(fā)一些簡單的 Lua 腳本了。但是實際開發(fā)中還是有一些要點的。
- 務必對 Lua 腳本進行全面測試以保證其邏輯的健壯性,當 Lua 腳本遇到異常時,已經(jīng)執(zhí)行過的邏輯是不會回滾的。
- 盡量不使用 Lua 提供的具有隨機性的函數(shù),參見相關官方文檔。
- 在 Lua 腳本中不要編寫function函數(shù),整個腳本作為一個函數(shù)的函數(shù)體。
- 在腳本編寫中聲明的變量全部使用local關鍵字。
- 在集群中使用 Lua 腳本要確保邏輯中所有的key分到相同機器,也就是同一個插槽(slot)中,可采用Redis Hash Tag技術。
- 再次重申 Lua 腳本一定不要包含過于耗時、過于復雜的邏輯。
5. 總結
本文對 Redis Lua 腳本的場景以及編寫 Redis Lua 腳本所需要的 Lua 編程語法進行了詳細的講解和演示,也對 Redis Lua 腳本在實際開發(fā)中需要注意的一些要點進行了分享。希望能夠幫助你掌握此技術。今天的分享就到這里,下次我將分享如何在實際 Redis 開發(fā)中使用 Lua 腳本,所以這一篇一定要進行掌握。
本文轉載自微信公眾號「碼農(nóng)小胖哥」,可以通過以下二維碼關注。轉載本文請聯(lián)系碼農(nóng)小胖哥公眾號。