楔子
這次我們來說一下如何在Redis中嵌入Lua腳本,Lua和Python一樣,是一門腳本語言。只不過Lua解釋器非常的精簡,所以它不具備像Python一樣獨立開發大型應用程序的能力,它的目的就是為別的語言提供擴展功能的。一般都會嵌入到C++中,我們知道C++在編譯的時候是比較耗時的,而我們每做一次修改都要重新編譯,這是讓人有點難以接受的,所以這個時候就可以把那些非性能核心的代碼交給Lua去做。
當然Lua也是可以嵌入在Python中的,Python有一個第三方模塊叫lupa,完全實現了Lua解釋器的功能。所以你使用Python的lupa模塊話,甚至都不需要安裝Lua環境就可以執行,我們舉個栗子:
import lupa
# 調用lupa.LuaRuntime實例化一個Lua解釋器(運行時)
Lua = lupa.LuaRuntime()
# 隨便寫一些Lua代碼
Lua_code = """
function (a, b)
if a >= b then return a + b, a - b
else return a + b, b - a
end
end
"""
print(Lua.eval(Lua_code)) # <Lua function at 0x000001CC73B4C4E0>
print(Lua.eval(Lua_code)(11, 22)) # (33, 11)
print(Lua.eval(Lua_code)(22, 8)) # (30, 14)
我們甚至可以在Lua.eval中寫Python的語法,主要原因就在於這里不是通過Lua解釋器調用、再返回結果給Python,而是這個lupa模塊已經完全實現了Lua解釋器的功能,在支持Lua語法 的同時,還對Python多了一些照顧。
關於Lua的語法,這里不再贅述了,可以網上搜索,這門語言非常簡單,基本上一天入門足矣,當然我在其它系列中也介紹過,可以去找一找。
而最關鍵的是Redis中也可以嵌入Lua腳本,同樣可以為Redis提供擴展功能,比如我們上一篇介紹的分布式鎖,就可以是使用Lua來實現,我們后面會說,目前先來看看Redis中如何引入Lua腳本吧。
Redis中引入Lua腳本
Redis中引入Lua腳本還有一個好處,那就是執行Lua腳本的時候是原子性的。我們知道Redis不支持事務回滾,中間一個命令出錯,那么后面的命令依舊可以執行,當然這也是和Redis的定位有關系,人家設計的時候就是這么設計的。
但如果我們引入的是Lua腳本,那么就可以保證整體事務性,要么都成功要么都失敗。
下面我們介紹Redis中如何執行Lua語言,首先Redis可以執行字符串形式的Lua代碼,也可以將Lua代碼寫在文件里讓Redis執行。
eval
命令:eval script numkeys key[key···] arg[arg···]
127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 name age hanser 28
1) "name"
2) "age"
3) "hanser"
4) "28"
127.0.0.1:6379>
script:Lua腳本,直接寫上一個返回值即可,顯然這返回的是Lua中的表。
numkeys:keys參數的個數。
key、arg:分別對應鍵和值,鍵可以通過KEYS[索引]獲取,值可以通過ARGV[索引]獲取,注意:Lua中的索引是從1開始的,不是從0開始。
127.0.0.1:6379> # 顯然腳本中只有兩個key,但是我們卻說有3個,那么前兩個正常返回
127.0.0.1:6379> # 但是第3個是ARGV[1],不是KEYS因此返回失敗,最終只保留了一個值
127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 3 name age hanser 28
1) "name"
2) "age"
3) "28"
127.0.0.1:6379> # 同樣的道理
127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 4 name age hanser 28
1) "name"
2) "age"
所以一定要保證KEYS的個數正確。
但是這樣是不是相當於設置了鍵值對呢?
127.0.0.1:6379> eval "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 name age hanser 28
1) "name"
2) "age"
3) "hanser"
4) "28"
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get age
(nil)
127.0.0.1:6379>
但是顯然結果讓我們失望了,Redis只是以批量回復的形式返回了Lua數組,這是Redis返回的一種類型,如果是Python操作的話,那么會得到一個list,舉栗說明:
import redis
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")
res = client.eval("return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}", 2,
"name", "hanser", "age", 28)
print(res) # ['name', 'hanser', 'age', '28']
我們看到這個eval貌似沒什么用啊,單獨使用感覺確實沒啥用,但是里面的KEYS、ARGV、numkeys是我們接下來所需要的,因為eval一旦搭配redis.call或者redis.pcall就有用了。
redis.call和redis.pcall
如果我們希望通過Lua腳本的方式,給Redis設置鍵值對的話,那么可以使用redis.call或者redis.pcall
127.0.0.1:6379> get name # 此時不存在name
(nil)
127.0.0.1:6379> # 調用redis.call進行設置,將命令的各個部分按照redis要求的順序傳遞即可
127.0.0.1:6379> # 這里沒有KEYS,所以numkeys是0
127.0.0.1:6379> eval "return redis.call('set', 'name', 'yousa')" 0
OK
127.0.0.1:6379> get name # 再次獲取,發現name被設置了
"yousa"
127.0.0.1:6379>
127.0.0.1:6379> get age # 此時不存在age
(nil)
127.0.0.1:6379> eval "return redis.pcall('set', 'age', 18)" 0 # 調用pcall設置,調用方式和call一樣
OK
127.0.0.1:6379> get age # age被設置
"18"
127.0.0.1:6379>
既然redis.call和redis.pcall都可以設置值,那么這兩者有什么區別呢?答案是區別只有一個:
如果Redis命令調用發生了錯誤,redis.call將拋出一個Lua類型的錯誤,再強制eval命令把錯誤返回給命令的調用者,而redis.pcall將捕獲錯誤並返回表示錯誤的Lua表的類型
這里的參數我們可不可以通過上面的KEYS和ARGV傳遞呢?顯然是可以的。
# 顯然我們指定了一個key,三個ARG
# 所以name yousa ex 30會按照順序傳遞給KEYS[1], ARGV[1], ARGV[2], ARGV[3]
127.0.0.1:6379> eval "return redis.call('set', KEYS[1], ARGV[1], ARGV[2], ARGV[3])" 1 name yousa ex 30
OK
127.0.0.1:6379> get name # 成功獲取
"yousa"
127.0.0.1:6379> ttl name # 查看過期時間
(integer) 23
127.0.0.1:6379>
127.0.0.1:6379> # 還可以設置多個值
127.0.0.1:6379> eval "return redis.call('mset', KEYS[1], KEYS[2], ARGV[1], ARGV[2])" 2 name hanser age 28
OK
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get age
"28"
127.0.0.1:6379>
如果是設置多個key的話,那么numkeys的數量一定要指定正確,並且KEYS在前、ARGV在后,索引各自從1開始。對於這里的KEYS[1]就會和ARGV[1]組合,KEYS[2]和ARGV[2]組合。如果我們這樣寫會怎么樣:
127.0.0.1:6379> eval "return redis.call('mset', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 name hanser age 28
OK
127.0.0.1:6379> get name
"age"
127.0.0.1:6379> get age
(nil)
127.0.0.1:6379> get hanser
"28"
127.0.0.1:6379>
我們看到,本來是希望將:name和hanser組合起來,age和28組合起來的,結果變成了name和組合、hanser和28組合了,顯然這不是我們期望的。
看一下如何使用Python進行調用。
import redis
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")
client.eval("return redis.call('set', 'age', 18)", 0)
res = client.eval("return redis.call('incrby', KEYS[1], ARGV[1])", 1, "age", 10)
# 得到的是incrby命令的返回值,當然使用get age也是一眼給的
print(res) # 28
evalsha
這個命令和eval類似,只不過它需要搭配script load來使用。
# 這個不會立刻執行,而是會返回一個哈希值
127.0.0.1:6379> script load "return redis.call('set', 'name', 'yousa')"
"1737531390c4e2ba0f7a42bc644b531e962cf235"
127.0.0.1:6379> get name # 此時為空
(nil)
127.0.0.1:6379> evalsha "1737531390c4e2ba0f7a42bc644b531e962cf235" 0 # 對哈希值使用evalsha即可
OK
127.0.0.1:6379> get name
"yousa"
127.0.0.1:6379>
eval和evalsha的語法是一樣的,只不過eval后面的字符串是具體的Lua代碼,evalsha后面是Lua代碼使用script load得到的哈希值。
import redis
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")
hash_value = client.script_load("return redis.call('set', KEYS[1], ARGV[1])")
client.evalsha(hash_value, 1, "name", "神楽めあ")
print(client.get("name")) # 神楽めあ
Lua和Redis數據類型之間的轉換
當使用redis.call和redis.pcall調用Redis命令時,Redis命令的返回值會轉換為Lua的數據類型,然后再eval的時候,再將Lua返回的數據類型轉換為Redis支持的協議。
數據類型之間的轉換原則是:如果將Redis類型轉換為Lua類型,然后將結果轉換回Redis類型,則結果與初始值相同。換句話說,Lua和Redis類型之間存在一對一的轉換。
127.0.0.1:6379> eval "return 10" 0
(integer) 10
127.0.0.1:6379> eval "return 'mea'" 0
"mea"
127.0.0.1:6379> eval "return {1, 2, 3, 'xxx'}" 0
1) (integer) 1
2) (integer) 2
3) (integer) 3
4) "xxx"
127.0.0.1:6379>
但是有兩點需要注意:
1. 在Lua中,整數和浮點數都屬於number類型,因此我們總是將Lua數字轉換為整數返回。因此如果有浮點數的話,會刪除數字的小數部分。所以如果你想從Lua腳本中返回一個浮點數,你應該像字符串一樣返回它,就像Redis自己做的那樣,比如zset。
2. 在Lua的表中盡量不要出現nil,因為Lua的表中一旦出現nil,會出現異常不到的結果,這是由Lua的表的語義決定的。如果出現nil,Redis的轉換會中止。
127.0.0.1:6379> eval "return 10.5" 0 # 10.5被強制截斷了
(integer) 10
127.0.0.1:6379> eval "return '10.5'" 0
"10.5"
127.0.0.1:6379>
127.0.0.1:6379> eval "return {1, 2, 3, nil, 4, 5}" 0 # 出現了nil,轉換中止
1) (integer) 1
2) (integer) 2
3) (integer) 3
127.0.0.1:6379> # 當然你可以把eval "lua_code"想象成直接執行Redis命令,如果Redis命令是有返回值的,那么eval "lua_code"也會直接返回
127.0.0.1:6379> eval "return redis.call('set', 'a', 2)" 0
OK
127.0.0.1:6379> eval "return redis.call('get', 'a')" 0
"2"
127.0.0.1:6379>
redis.error_reply和redis.status_reply
這兩個老鐵是做什么的,我們來看一下。
127.0.0.1:6379> set name # 設置失敗返回一個error
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> # 這種方式也是返回一個error,內容就是我們這里傳遞的內容
127.0.0.1:6379> eval "return redis.error_reply('error occurred')" 0
(error) error occurred
127.0.0.1:6379> # 等價於下面這種方式
127.0.0.1:6379> eval "return {err='error occurred'}" 0
(error) error occurred
127.0.0.1:6379> # 如果不是err,那么就不會設置異常了
127.0.0.1:6379> eval "return {err1='error occurred'}" 0
(empty array)
127.0.0.1:6379> # redis.status_reply就是設置一個狀態,返回的就是其本身內容
127.0.0.1:6379> eval "return redis.status_reply('abc')" 0
abc
127.0.0.1:6379>
我們使用Python來操作一下。
import redis
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")
print(client.eval("return redis.status_reply('abc')", 0)) # abc
# 直接拋異常了,程序終止
client.eval("return redis.error_reply('abc')", 0)
"""
Traceback (most recent call last):
File "D:/satori/1.py", line 7, in <module>
client.eval("return redis.error_reply('abc')", 0)
File "C:\python38\lib\site-packages\redis\client.py", line 2817, in eval
return self.execute_command('EVAL', script, numkeys, *keys_and_args)
File "C:\python38\lib\site-packages\redis\client.py", line 839, in execute_command
return self.parse_response(conn, command_name, **options)
File "C:\python38\lib\site-packages\redis\client.py", line 853, in parse_response
response = connection.read_response()
File "C:\python38\lib\site-packages\redis\connection.py", line 718, in read_response
raise response
redis.exceptions.ResponseError: abc
"""
腳本的原子性
我們知道Redis可以執行Lua腳本,因為Redis源碼里面包含了Lua解釋器的源代碼
所以Redis會使用相同的Lua解釋器來運行所有命令。另外,Redis保證以原子方式執行腳本:執行腳本時不會執行其他腳本或Redis命令。與 MULTI/EXEC 事務的概念相似。從所有其他客戶端的角度來看,腳本要不已經執行完成,要不根本不執行。
因此運行一個緩慢的Lua腳本是一個非常愚蠢的做法,其實創建能夠快速執行的腳本並不難,因為腳本開銷很低,而且Lua中也引入了JIT(即時編譯)
功能。所以如果執行了運行緩慢的Lua腳本,由於其原子性,導致其他客戶端的命令都是得不到執行的,這並不是我們想要的結果,因此要注意這一點。
此外,Redis對Lua腳本的執行時間也有一個限制,最長不能超過5s,可以通過配置文件redis.conf中的
lua-time-limit
進行設置,默認是5000,單位是毫秒。
那么此時,我們就可以實現上一篇博客中說的分布式鎖了。
import time
import threading
import redis
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")
# 隨便起一個ID,如果返回的值是設置的ID,那么刪除,否則進行設置
# 我們看到可以寫多行的Lua腳本,而且里面出現了return,但是我們並沒有放到函數中
# 這是因為Redis會自動幫我們創建一個函數,函數體就是我們這里的代碼,當然上面例子中出現的return也是一樣的道理。
lua_code = """
if redis.call('get', 'lock') == ARGV[1] then
return redis.call('del', 'lock')
else
return redis.call('set', 'lock', ARGV[1])
end
"""
def func1():
for _ in range(3):
client.eval(lua_code, 0, "線程1")
print("線程1獲取了鎖,開始執行任務")
time.sleep(3)
def func2():
for _ in range(3):
client.eval(lua_code, 0, "線程2")
print("線程2獲取了鎖,開始執行任務")
time.sleep(3)
t1 = threading.Thread(target=func1)
t2 = threading.Thread(target=func2)
t1.start()
t2.start()
t1.join()
t2.join()
"""
線程2獲取了鎖,開始執行任務
線程1獲取了鎖,開始執行任務
線程1獲取了鎖,開始執行任務
線程2獲取了鎖,開始執行任務
線程2獲取了鎖,開始執行任務
線程1獲取了鎖,開始執行任務
"""
script命令
Redis提供了一個可用於控制腳本子系統的SCRIPT命令。 SCRIPT目前接受以下幾種不同的命令:
script load
這個我們之前就說過了,它是根據Lua腳本得到一個哈希值,但是里面的命令不會立刻執行。
127.0.0.1:6379> script load "return redis.call('set', 'foo', 'bar')"
"a0c38691e9fffe4563723c32ba77a34398e090e6"
127.0.0.1:6379>
script exists
判斷哈希值是否存在,就是有沒有通過script load得到這樣的哈希值。
127.0.0.1:6379> script load "return redis.call('set', 'foo', 'bar')"
"a0c38691e9fffe4563723c32ba77a34398e090e6"
127.0.0.1:6379> # 顯然存在,返回1
127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e6"
1) (integer) 1
127.0.0.1:6379> # 將哈希值的最后一位給改掉,發現返回0,不存在。
127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e5"
1) (integer) 0
127.0.0.1:6379>
script flush
強制Redis刷新腳本緩存,將加載腳本得到的哈希值清空,我們舉個栗子:
127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e6"
1) (integer) 1
127.0.0.1:6379> script flush # 清空之后 就不存在了
OK
127.0.0.1:6379> script exists "a0c38691e9fffe4563723c32ba77a34398e090e6"
1) (integer) 0
127.0.0.1:6379>
script kill
當腳本的執行時間達到配置的腳本最大執行時間時,此命令是中斷長時間運行的腳本的唯一方法。 script kill命令只能用於在執行期間沒有修改數據集的腳本(因為停止只讀腳本不會違反腳本引擎的所保證的原子性)。
全局變量保護
Redis腳本不允許創建全局變量,以避免用戶的狀態數據和Lua全局變量之間造成混亂。
127.0.0.1:6379> eval "a = 10" 0
(error) ERR Error running script (call to f_d1c61e47e71a9af32fe0564b32c2bd85e845c304):
@enable_strict_lua:8: user_script:1: Script attempted to create global variable 'a'
127.0.0.1:6379> # 告訴我們腳本試圖創建一個全局變量
127.0.0.1:6379> # 創建一個局部變量是可以的
127.0.0.1:6379> eval "local a = 10" 0
(nil)
可用的庫
Lua語言中提供了一些庫,在Lua5.3中是直接內嵌在解釋器里面的,當然在Redis中也是可以直接用的,那么都可以使用哪些庫呢?
可使用的庫:base、table、string、math、struct、cjson、cmsgpack、bitop、redis.sha1hex、redis.breakpoint、redis.debug等等
我們舉個栗子:
127.0.0.1:6379> eval "return math.sin(math.pi / 2)" 0
(integer) 1
127.0.0.1:6379> eval "return cjson.encode({['foo']= 'bar'})" 0
"{\"foo\":\"bar\"}"
127.0.0.1:6379> eval 'return cmsgpack.pack({"foo", "bar", "baz"})' 0
"\x93\xa3foo\xa3bar\xa3baz"
127.0.0.1:6379>
使用腳本寫Redis日志
可以在Lua腳本中寫入Redis日志文件:redis.log(日志級別, 日志信息)
日志級別可以是以下幾種:
redis.LOG_DEBUG
redis.LOG_VERBOSE
redis.LOG_NOTICE
redis.LOG_WARNING
127.0.0.1:6379> eval "return redis.log(redis.LOG_DEBUG, 'test input')" 0
(nil)
沙箱和最大執行時間
Redis對Lua腳本做了一個限制,所有腳本都必須是無副作用的純函數(pure function)。比如:生成隨機數,因為是隨機的,所以在master生成的隨機數和在slave節點生成隨機數是不一樣的,這樣就破壞了主從節點的一致性。
那么Redis都對Lua腳本做了哪些限制呢?
1. 不允許訪問系統狀態狀態的庫(比如系統時間庫)
2. 禁止使用 loadfile 函數
3. 如果腳本在執行帶有隨機性質的命令(比如 RANDOMKEY ),或者帶有副作用的命令(比如 TIME )之后,試圖執行一個寫入命令(比如 SET ),那么 Redis 將阻止這個腳本繼續運行,並返回一個錯誤。
4. 如果腳本執行了帶有隨機性質的讀命令(比如 SMEMBERS ),那么在腳本的輸出返回給 Redis 之前,會先被執行一個自動的字典序排序,從而確保輸出結果是有序的。
5. 用Redis自己定義的隨機生成函數,替換Lua環境中 math表原有的 math.random 函數和 math.randomseed 函數,新的函數具有這樣的性質:每次執行 Lua 腳本時,除非顯式地調用math.randomseed,否則 math.random生成的偽隨機數序列總是相同的。
此外,lua腳本也受最大執行時間(默認為5秒)的限制。這個默認的超時時間可以說有點長,因為腳本運行很快,執行時間通常在毫秒以下,之所以有這個限制主要是為了處理在開發過程中產生的意外死循環。
可以通過redis.conf配置文件或者使用 config set 命令修改lua腳本以毫秒級精度執行的最長時間。修改的配置參數就是 lua-time-limit,比如:
127.0.0.1:6379> config get lua-time-limit
1) "lua-time-limit"
2) "5000"
127.0.0.1:6379> config set lua-time-limit 1000 # 表示Lua腳本的最長執行時間不能超過1s
OK
127.0.0.1:6379>
但如果腳本真的執行超時了,那么Redis並不會自動終止它的執行,因為這違反了Redis和腳本引擎之間的合約,Redis要保證腳本執行是原子性的。出於這個原因,當腳本執行超時時,會發生以下情況:
1. Redis日志記錄腳本運行時間過長。
2. 此時如果又有客戶端再次向Redis服務器端發送了命令,服務器端則會向所有發送命令的客戶端回復BUSY錯誤。在這種狀態下唯一允許的命令是SCRIPT KILL和SHUTDOWN NOSAVE。
3. 可以使用SCRIPT KILL命令終止一個只執行只讀命令的腳本。這不會違反腳本語義,因為腳本不會將數據寫入數據集。
4. 如果腳本已經執行了寫入命令,則唯一允許的命令將是 SHUTDOWN NOSAVE,它會在不保存磁盤上當前數據集(基本上服務器已中止)的情況下停止服務器。
小結
在Redis中嵌入Lua腳本是很有用的,只是Lua語言用的人不是很多,所以這個功能也很少被使用。但是實際上,對Lua語言的要求並不高,從目前來看,貌似沒有用到關於Lua語言的太多語法,最復雜也就是出現了一個if else語句罷了。
Lua腳本執行的很快,即使你嵌入了一個上千行的Lua腳本,只要代碼正確,執行時間也會很短,只不過此時就需要你了解一下Lua的語法了。而Lua語言也非常簡單,那么精簡的一個語言能難哪里去,基本上一天之內就可以入門,如果想成為一個Redis高手的話,那么還是建議了解一下Lua語言。