這篇文章給大家分享的是有關(guān)redis中Lua腳本有什么用的內(nèi)容。小編覺得挺實用的,因此分享給大家做個參考,一起跟隨小編過來看看吧。
創(chuàng)新互聯(lián)公司主要從事做網(wǎng)站、成都網(wǎng)站建設(shè)、網(wǎng)頁設(shè)計、企業(yè)做網(wǎng)站、公司建網(wǎng)站等業(yè)務(wù)。立足成都服務(wù)崇州,10多年網(wǎng)站建設(shè)經(jīng)驗,價格優(yōu)惠、服務(wù)專業(yè),歡迎來電咨詢建站服務(wù):13518219792
redis lua 腳本相關(guān)命令
這一小節(jié)的內(nèi)容是基本命令,可粗略閱讀后跳過,等使用的時候再回來查詢
redis 自 2.6.0 加入了 lua 腳本相關(guān)的命令,EVAL
、EVALSHA
、SCRIPT EXISTS
、SCRIPT FLUSH
、SCRIPT KILL
、SCRIPT LOAD
,自 3.2.0 加入了 lua 腳本的調(diào)試功能和命令SCRIPT DEBUG
。這里對命令做下簡單的介紹。
EVAL
執(zhí)行一段lua腳本,每次都需要將完整的lua腳本傳遞給redis服務(wù)器。
SCRIPT LOAD
將一段lua腳本緩存到redis中并返回一個tag串,并不會執(zhí)行。
EVALSHA
執(zhí)行一個腳本,不過傳入?yún)?shù)是「2」中返回的tag,節(jié)省網(wǎng)絡(luò)帶寬
。SCRIPT EXISTS
判斷「2」返回的tag串是否存在服務(wù)器中。
SCRIPT FLUSH
清除服務(wù)器上的所有緩存的腳本。
SCRIPT KILL
殺死正在運行的腳本。
SCRIPT DEBUG
設(shè)置調(diào)試模式,可設(shè)置同步、異步、關(guān)閉,同步會阻塞所有請求。
生產(chǎn)環(huán)境中,推薦使用EVALSHA
,相較于EVAL
的每次發(fā)送腳本主體、浪費帶寬,會更高效。這里要注意SCRIPT KILL
,殺死正在運行腳本的時候,如果腳本執(zhí)行過寫操作了,這里會殺死失敗,因為這違反了 redis lua 腳本的原子性。調(diào)試盡量放在測試環(huán)境完成之后再發(fā)布到生產(chǎn)環(huán)境,在生產(chǎn)環(huán)境調(diào)試千萬不要使用同步模式,原因下文會詳細討論。
Redis 中 lua 腳本的書寫和調(diào)試
redis lua 腳本是對其現(xiàn)有命令的擴充,單個命令不能完成、需要多個命令,但又要保證原子性的動作可以用腳本來實現(xiàn)。腳本中的邏輯一般比較簡單,不要加入太復(fù)雜的東西,因為 redis 是單線程的,當(dāng)腳本執(zhí)行的時候,其他命令、腳本需要等待直到當(dāng)前腳本執(zhí)行完成。因此,對 lua 的語法也不需完全了解,了解基本的使用就足夠了,這里對 lua 語法不做過多介紹,會穿插到腳本示例里面。
一個秒殺搶購示例
假設(shè)有一個秒殺活動,商品庫存 100,每個用戶 uid 只能搶購一次。設(shè)計搶購流程如下:
先通過 uid 判斷是否已經(jīng)搶過,已經(jīng)搶過返回0
結(jié)束。
判斷商品剩余庫存是否大于0,是的話進入「3」,否的話返回0
結(jié)束。
將用戶 uid 加入已購用戶set中。
物品數(shù)量減一,返回成功1
結(jié)束。
local goodsSurplus local flag -- 判斷用戶是否已搶過 local buyMembersKey = tostring(KEYS[1]) local memberUid = tonumber(ARGV[1]) local goodsSurplusKey = tostring(KEYS[2]) local hasBuy = redis.call("sIsMember", buyMembersKey, memberUid) -- 已經(jīng)搶購過,返回0 if hasBuy ~= 0 then return 0 end -- 準(zhǔn)備搶購 goodsSurplus = redis.call("GET", goodsSurplusKey) if goodsSurplus == false then return 0 end -- 沒有剩余可搶購物品 goodsSurplus = tonumber(goodsSurplus) if goodsSurplus <= 0 then return 0 end flag = redis.call("SADD", buyMembersKey, memberUid) flag = redis.call("DECR", goodsSurplusKey) return 1
即使不了解 lua,相信你也可以將上面的腳本看個一二,其中--
開始的是單行注釋。local
用來聲明局部變量,redis lua 腳本中的所有變量都應(yīng)該聲明為local xxx
,避免在持久化、復(fù)制的時候產(chǎn)生各種問題。KEYS
和ARGV
是兩個全局變量,就像 PHP 中的$argc
、$argv
一樣,腳本執(zhí)行時傳入的參數(shù)會寫入這兩個變量,供我們在腳本中使用。redis.call
用來執(zhí)行 redis 現(xiàn)有命令,傳參跟 redis 命令行執(zhí)行時傳入?yún)?shù)順序一致。
另外 redis lua 腳本中用到 lua table 的地方還比較多,這里要注意,lua 腳本中的 table 下標(biāo)是從 1 開始的,比如KEYS
、ARGV
,這里跟其他語言不一樣,需要注意。
對于主要使用 PHP 這種弱類型語言開發(fā)同學(xué)來說,一定要注意變量的類型,不同類型比較的時候可能會出現(xiàn)類似attempt to compare string with number
的提示,這個時候使用 lua 的tonumber
將字符串轉(zhuǎn)換為數(shù)字在進行比較即可。比如我們使用GET
去獲取一個值,然后跟 0 比較大小,就需要將獲取出來的字符串轉(zhuǎn)換為數(shù)字。
在調(diào)試之前呢,我們先看看效果,將上面的代碼保存到 lua 文件中/path/to/buy.lua
,然后運行redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984
即可執(zhí)行腳本,執(zhí)行之后返回-1
,因為我們未設(shè)置商品數(shù)量,set goodsSurplus 5
之后再次執(zhí)行,效果如下:
? ~ redis-cli set goodsSurplus 5 OK ? ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984 (integer) 1 ? ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984 (integer) 0 ? ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742983 (integer) 1 ? ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742982 (integer) 1 ? ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742981 (integer) 1 ? ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742980 (integer) -1 ? ~ redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus , 58247 (integer) -1
在命令行運行腳本的時候,腳本后面?zhèn)魅氲氖菂?shù),通過 ,
分隔為兩組,前面是鍵,后面是值,這兩組分別寫入KEYS
和ARGV
。分隔符一定要看清楚了,逗號前后都有空格,漏掉空格會讓腳本解析傳入?yún)?shù)異常。
debug 調(diào)試
上一小節(jié),我們寫了很長一段 redis lua 腳本,怎么調(diào)試呢,有沒有像 GDB 那樣的調(diào)試工具呢,答案是肯定的。redis 從 v3.2.0 開始支持 lua debugger,可以加斷點、print 變量信息、展示正在執(zhí)行的代碼......我們結(jié)合上一小節(jié)的腳本,來詳細說說 redis 中 lua 腳本的調(diào)試。
如何進入調(diào)試模式
執(zhí)行redis-cli --ldb --eval /path/to/buy.lua hadBuyUids goodsSurplus , 5824742984
,進入調(diào)試模式,比之前執(zhí)行的時候多了參數(shù)--ldb
,這個參數(shù)是開啟 lua dubegger 的意思,這個模式下 redis 會 fork 一個進程進入隔離環(huán)境,不會影響 redis 正常提供服務(wù),但調(diào)試期間,原始 redis 執(zhí)行命令、腳本的結(jié)果也不會體現(xiàn)到 fork 之后的隔離環(huán)境之中。因此呢,還有另外一種調(diào)試模式--ldb-sync-mode
,也就是前面提到的同步模式,這個模式下,會阻塞 redis 上所有的命令、腳本,直到腳本退出,完全模擬了正式環(huán)境使用時候的情況,使用的時候務(wù)必注意這點。
調(diào)試命令詳解
這一小節(jié)的內(nèi)容是調(diào)試時候的詳細命令,可以粗略閱讀后跳過,等使用的時候再回來查詢
幫助信息
[h]elp
調(diào)試模式下,輸入h
或者help
展示調(diào)試模式下的全部可用指令。
流程相關(guān)
[s]tep 、 [n]ext 、 [c]continue
執(zhí)行當(dāng)前行代碼,并停留在下一行,如下所示
* Stopped at 4, stop reason = step over -> 4 local buyMembersKey = tostring(KEYS[1]) lua debugger> n * Stopped at 5, stop reason = step over -> 5 local memberUid = tonumber(ARGV[1]) lua debugger> n * Stopped at 6, stop reason = step over -> 6 local goodsSurplusKey = tostring(KEYS[2]) lua debugger> s * Stopped at 7, stop reason = step over -> 7 local hasBuy = redis.call("sIsMember", buyMembersKey, memberUid)
continue
從當(dāng)前行開始執(zhí)行代碼直到結(jié)束或者碰到斷點。
展示相關(guān)
[l]list 、 [l]list [line] 、 [l]list [line] [ctx] 、 [w]hole
展示當(dāng)前行附近的代碼,[line]
是重新指定中心行,[ctx]
是指定展示中心行周圍幾行代碼。[w]hole
是展示所有行代碼
打印相關(guān)
[p]rint 、 [p]rint
打印當(dāng)前所有局部變量,是打印指定變量,如下所示:
lua debugger> printgoodsSurplus = nil flag = nil buyMembersKey = "hadBuyUids" memberUid = 58247 lua debugger> print buyMembersKey "hadBuyUids"
斷點相關(guān)
[b]reak 、 [b]reak、 [b]reak - 、 [b]reak 0
展示斷點、像指定行添加斷點、刪除指定行的斷點、刪除所有斷點
其他命令
[r]edis、 [m]axlen [len] 、 [a]bort 、 [e]eval 、 [t]race
在調(diào)試其中執(zhí)行 redis 命令
設(shè)置展示內(nèi)容的最大長度,0表示不限制
退出調(diào)試模式,同步模式下(設(shè)置了參數(shù)--ldb-sync-mode)修改會保留。
執(zhí)行一行 lua 代碼。
展示執(zhí)行棧。
詳細說下
[m]axlen [len]
命令,如下代碼:
local myTable = {}
local count = 0
while count < 1000 do
myTable[count] = count
count = count + 1
end
return 1
在最后一行打印斷點,執(zhí)行
print
可以看到,輸出了一長串內(nèi)容,我們執(zhí)行maxlen 10
之后,再次執(zhí)行print
可以看到打印的內(nèi)容變少了,設(shè)置為maxlen 0
之后,再次執(zhí)行可以看到所有的內(nèi)容全部展示了。
詳細說下
[t]race
命令,代碼如下:
local function func1(num)
num = num + 1
return num
end
local function func2(num)
num = func1(num)
num = num + 1
return num
end
func2(123)
執(zhí)行
b 2
在 func1 中打斷點,然后執(zhí)行c
,斷點地方停頓,再次執(zhí)行t
,可以到如下信息:
lua debugger> t
In func1:
->#3 return num
From func2:
7 num = func1(num)
From top level:
12 func2(123)
請求限流
至此,算是對 redis lua 腳本有了基本的認識,基本語法、調(diào)試也做了了解,接下來就實現(xiàn)一個請求限流器。流程和代碼如下:
--[[
傳入?yún)?shù):
業(yè)務(wù)標(biāo)識
ip
限制時間
限制時間內(nèi)的訪問次數(shù)
]]--
local busIdentify = tostring(KEYS[1])
local ip = tostring(KEYS[2])
local expireSeconds = tonumber(ARGV[1])
local limitTimes = tonumber(ARGV[2])
local identify = busIdentify .. "_" .. ip
local times = redis.call("GET", identify)
--[[
獲取已經(jīng)記錄的時間
獲取到繼續(xù)判斷是否超過限制
超過限制返回0
否則加1,返回1
]]--
if times ~= false then
times = tonumber(times)
if times >= limitTimes then
return 0
else
redis.call("INCR", identify)
return 1
end
end
-- 不存在的話,設(shè)置為1并設(shè)置過期時間
local flag = redis.call("SETEX", identify, expireSeconds, 1)
return 1
將上面的 lua 腳本保存到
/path/to/limit.lua
,執(zhí)行redis-cli --eval /path/to/limit.lua limit_vgroup 192.168.1.19 , 10 3
,表示 limit_vgroup 這個業(yè)務(wù),192.168.1.1 這個 ip 每 10 秒鐘限制訪問三次。
好了,至此,一個請求限流功能就完成了,連續(xù)執(zhí)行三次之后上面的程序會返回 0,過 10 秒鐘在執(zhí)行,又可以返回 1,這樣便達到了限流的目的。
有同學(xué)可能會說了,這個請求限流功能還有值得優(yōu)化的地方,如果連續(xù)的兩個計數(shù)周期,第一個周期的最后請求 3 次,接著馬上到第二個周期了,又可以請求了,這個地方如何優(yōu)化呢,我們接著往下看。
請求限流優(yōu)化
上面的計數(shù)器法簡單粗暴,但是存在臨界點的問題。為了解決這個問題,引入類似滑動窗口的概念,讓統(tǒng)計次數(shù)的周期是連續(xù)的,可以很好的解決臨界點的問題,滑動窗口原理如下圖所示:
建立一個 redis list 結(jié)構(gòu),其長度等價于訪問次數(shù),每次請求時,判斷 list 結(jié)構(gòu)長度是否超過限制次數(shù),未超過的話,直接加到隊首返回成功,否則,判斷隊尾一條數(shù)據(jù)是否已經(jīng)超過限制時間,未超過直接返回失敗,超過刪除隊尾元素,將此次請求時間插入隊首,返回成功。
local busIdentify = tostring(KEYS[1])
local ip = tostring(KEYS[2])
local expireSeconds = tonumber(ARGV[1])
local limitTimes = tonumber(ARGV[2])
-- 傳入額外參數(shù),請求時間戳
local timestamp = tonumber(ARGV[3])
local lastTimestamp
local identify = busIdentify .. "_" .. ip
local times = redis.call("LLEN", identify)
if times < limitTimes then
redis.call("RPUSH", identify, timestamp)
return 1
end
lastTimestamp = redis.call("LRANGE", identify, 0, 0)
lastTimestamp = tonumber(lastTimestamp[1])
if lastTimestamp + expireSeconds >= timestamp then
return 0
end
redis.call("LPOP", identify)
redis.call("RPUSH", identify, timestamp)
return 1
上面的 lua 腳本保存到
/path/to/limit_fun.lua
,執(zhí)行redis-cli --eval /path/to/limit_fun.lua limit_vgroup 192.168.1.19 , 10 3 1548660999
即可。
最開始,我想著把時間戳計算
redis.call("TIME")
也放入 redis lua 腳本中,后來發(fā)現(xiàn)使用的時候 redis 會報錯,這是因為 redis 默認情況復(fù)制 lua 腳本到備機和持久化中,如果腳本是一個非純函數(shù)(pure function),備庫中執(zhí)行的時候或者宕機恢復(fù)的時候可能產(chǎn)生不一致的情況,這里可以類比 MySQL 中基于 SQL 語句的復(fù)制模式。redis 在 3.2 版本中加入了redis.replicate_commands
函數(shù)來解決這個問題,在腳本第一行執(zhí)行這個函數(shù),redis 會將修改數(shù)據(jù)的命令收集起來,然后用MULTI/EXEC
包裹起來,這種方式稱為script effects replication,這個類似于 mysql 中的基于行的復(fù)制模式,將非純函數(shù)的值計算出來,用來持久化和主從復(fù)制。我們這里將變動參數(shù)提到調(diào)用方這里,調(diào)用者傳入時間戳來解決這個問題。
另外,redis 從版本 5 開始,默認支持script effects replication,不需要在第一行調(diào)用開啟函數(shù)了。如果是耗時計算,這樣當(dāng)然很好,同步、恢復(fù)的時候只需要計算一次后邊就不用計算了,但是如果是一個循環(huán)生成的數(shù)據(jù),可能在同步的時候會浪費更多的帶寬,沒有腳本來的更直接,但這種情況應(yīng)該比較少。
至此,腳本優(yōu)化完成了,但我又想到一個問題,我們的環(huán)境是單機環(huán)境,如果是分布式環(huán)境的話,腳本怎么執(zhí)行、何處理呢,接下來一節(jié),我們來討論下這個問題。
集群環(huán)境中 lua 處理
redis 集群中,會將鍵分配的不同的槽位上,然后分配到對應(yīng)的機器上,當(dāng)操作的鍵為一個的時候,自然沒問題,但如果操作的鍵為多個的時候,集群如何知道這個操作落到那個機器呢?比如簡單的
mget
命令,mget test1 test2 test3
,還有我們上面執(zhí)行腳本時候傳入多個參數(shù),帶著這個問題我們繼續(xù)。
首先用 docker 啟動一個 redis 集群,
docker pull grokzen/redis-cluster
,拉取這個鏡像,然后執(zhí)行docker run -p 7000:7000 -p 7001:7001 -p 7002:7002 -p 7003:7003 -p 7004:7004 -p 7005:7005 --name redis-cluster-script -e "IP=0.0.0.0" grokzen/redis-cluster
啟動這個容器,這個容器啟動了一個 redis 集群,3 主 3 從。
我們從任意一個節(jié)點進入集群,比如
redis-cli -c -p 7003
,進入后執(zhí)行cluster nodes
可以看到集群的信息,我們鏈接的是從庫,執(zhí)行set lua fun
,有同學(xué)可能會問了,從庫也可以執(zhí)行寫嗎,沒問題的,集群會計算出 lua 這個鍵屬于哪個槽位,然后定向到對應(yīng)的主庫。
執(zhí)行
mset lua fascinating redis powerful
,可以看到集群反回了錯誤信息,告訴我們本次請求的鍵沒有落到同一個槽位上
(error) CROSSSLOT Keys in request don't hash to the same slot
同樣,還是上面的 lua 腳本,我們加上集群端口號,執(zhí)行
redis-cli -p 7000 --eval /tmp/limit_fun.lua limit_vgroup 192.168.1.19 , 10 3 1548660999
,一樣返回上面的錯誤。
針對這個問題,redis官方為我們提供了
hash tag
這個方法來解決,什么意思呢,我們?nèi)℃I中的一段來計算 hash,計算落入那個槽中,這樣同一個功能不同的 key 就可以落入同一個槽位了,hash tag 是通過{}
這對括號括起來的字符串,比如上面的,我們改為mset lua{yes} fascinating redis{yes} powerful
,就可以執(zhí)行成功了,我這里 mset 這個操作落到了 7002 端口的機器。
同理,我們對傳入腳本的鍵名做 hash tag 處理就可以了,這里要注意不僅傳入鍵名要有相同的 hash tag,里面實際操作的 key 也要有相同的 hash tag,不然會報錯
Lua script attempted to access a non local key in a cluster node
,什么意思呢,就拿我們上面的例子來說,執(zhí)行的時候如下所示,可以看到 ,
前面的兩個鍵都加了 hash tag —— yes,這樣沒問題,因為腳本里面只是用了一個拼接的 key —— limit_vgroup{yes}_192.168.1.19{yes}
。
redis-cli -c -p 7000 --eval /tmp/limit_fun.lua limit_vgroup{yes} 192.168.1.19{yes} , 10 3 1548660999
如果我們在腳本里面加上
redis.call("GET", "yesyes")
(別讓這個鍵跟我們拼接的鍵落在一個solt),可以看到就報了上面的錯誤,所以在執(zhí)行腳本的時候,只要傳入?yún)?shù)鍵、腳本里面執(zhí)行 redis 命令時候的鍵有相同的 hash tag 即可。
另外,這里有個 hash tag 規(guī)則:
鍵中包含{
字符;建中包含{
字符,并在{
字符右邊;并且{
,}
之間有至少一個字符,之間的字符就用來做鍵的 hash tag。
所以,鍵
limit_vgroup{yes}_192.168.1.19{yes}
的 hash tag 是 yes
。foo{}{bar}
鍵的 hash tag就是它本身。foo{{bar}}
鍵的 hash tag 是 {bar
。
使用 golang 連接使用 redis
這里我們使用 golang 實例展示下,通過
ForEachMaster
將 lua 腳本緩存到集群中的每個 node,并保存返回的 sha 值,以后通過 evalsha 去執(zhí)行代碼。
package main
import (
"github.com/go-redis/redis"
"fmt"
)
func createScript() *redis.Script {
script := redis.NewScript(`
local busIdentify = tostring(KEYS[1])
local ip = tostring(KEYS[2])
local expireSeconds = tonumber(ARGV[1])
local limitTimes = tonumber(ARGV[2])
-- 傳入額外參數(shù),請求時間戳
local timestamp = tonumber(ARGV[3])
local lastTimestamp
local identify = busIdentify .. "_" .. ip
local times = redis.call("LLEN", identify)
if times < limitTimes then
redis.call("RPUSH", identify, timestamp)
return 1
end
lastTimestamp = redis.call("LRANGE", identify, 0, 0)
lastTimestamp = tonumber(lastTimestamp[1])
if lastTimestamp + expireSeconds >= timestamp then
return 0
end
redis.call("LPOP", identify)
redis.call("RPUSH", identify, timestamp)
return 1
`)
return script
}
func scriptCacheToCluster(c *redis.ClusterClient) string {
script := createScript()
var ret string
c.ForEachMaster(func(m *redis.Client) error {
if result, err := script.Load(m).Result(); err != nil {
panic("緩存腳本到主節(jié)點失敗")
} else {
ret = result
}
return nil
})
return ret
}
func main() {
redisdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
":7000",
":7001",
":7002",
":7003",
":7004",
":7005",
},
})
// 將腳本緩存到所有節(jié)點,執(zhí)行一次拿到結(jié)果即可
sha := scriptCacheToCluster(redisdb)
// 執(zhí)行緩存腳本
ret := redisdb.EvalSha(sha, []string{
"limit_vgroup{yes}",
"192.168.1.19{yes}",
}, 10, 3,1548660999)
if result, err := ret.Result(); err != nil {
fmt.Println("發(fā)生異常,返回值:", err.Error())
} else {
fmt.Println("返回值:", result)
}
// 示例錯誤情況,sha 值不存在
ret1 := redisdb.EvalSha(sha + "error", []string{
"limit_vgroup{yes}",
"192.168.1.19{yes}",
}, 10, 3,1548660999)
if result, err := ret1.Result(); err != nil {
fmt.Println("發(fā)生異常,返回值:", err.Error())
} else {
fmt.Println("返回值:", result)
}
}
執(zhí)行上面的代碼,返回值如下:
返回值: 0
發(fā)生異常,返回值: NOSCRIPT No matching script. Please use EVAL.
感謝各位的閱讀!關(guān)于“Redis中Lua腳本有什么用”這篇文章就分享到這里了,希望以上內(nèi)容可以對大家有一定的幫助,讓大家可以學(xué)到更多知識,如果覺得文章不錯,可以把它分享出去讓更多的人看到吧!
標(biāo)題名稱:Redis中Lua腳本有什么用
標(biāo)題URL:http://weahome.cn/article/poojdd.html
其他資訊