詳解Redis中Lua指令碼的應用和實踐
引言
前段時間組內有個投票的產品,上線前考慮欠缺,導致被刷票嚴重。後來,通過研究,發現可以通過 redis lua 指令碼實現限流,這裡將 redis lua 指令碼相關的知識分享出來,講的不到位的地方還望斧正。
redis lua 指令碼相關命令
這一小節的內容是基本命令,可粗略閱讀後跳過,等使用的時候再回來查詢
redis 自 2.6.0 加入了 lua 指令碼相關的命令,EVAL
、EVALSHA
、SCRIPT EXISTS
、SCRIPT FLUSH
、SCRIPT KILL
、SCRIPT LOAD
,自 3.2.0 加入了 lua 指令碼的除錯功能和命令SCRIPT DEBUG
。這裡對命令做下簡單的介紹。
EVAL
執行一段lua指令碼,每次都需要將完整的lua指令碼傳遞給redis伺服器。SCRIPT LOAD
將一段lua指令碼快取到redis中並返回一個tag串,並不會執行。EVALSHA
執行一個指令碼,不過傳入引數是「2」中返回的tag,節省網路頻寬- 。
SCRIPT EXISTS
判斷「2」返回的tag串是否存在伺服器中。 SCRIPT FLUSH
清除伺服器上的所有快取的指令碼。SCRIPT KILL
殺死正在執行的指令碼。SCRIPT DEBUG
設定除錯模式,可設定同步、非同步、關閉,同步會阻塞所有請求。
生產環境中,推薦使用EVALSHA
,相較於EVAL
SCRIPT KILL
,殺死正在執行指令碼的時候,如果指令碼執行過寫操作了,這裡會殺死失敗,因為這違反了 redis lua 指令碼的原子性。除錯儘量放在測試環境完成之後再發布到生產環境,在生產環境除錯千萬不要使用同步模式,原因下文會詳細討論。
Redis 中 lua 指令碼的書寫和除錯
redis lua 指令碼是對其現有命令的擴充,單個命令不能完成、需要多個命令,但又要保證原子性的動作可以用指令碼來實現。指令碼中的邏輯一般比較簡單,不要加入太複雜的東西,因為 redis 是單執行緒的,當指令碼執行的時候,其他命令、指令碼需要等待直到當前指令碼執行完成。因此,對 lua 的語法也不需完全瞭解,瞭解基本的使用就足夠了,這裡對 lua 語法不做過多介紹,會穿插到指令碼示例裡面。
一個秒殺搶購示例
假設有一個秒殺活動,商品庫存 100,每個使用者 uid 只能搶購一次。設計搶購流程如下:
- 先通過 uid 判斷是否已經搶過,已經搶過返回
0
結束。 - 判斷商品剩餘庫存是否大於0,是的話進入「3」,否的話返回
0
結束。 - 將使用者 uid 加入已購使用者set中。
- 物品數量減一,返回成功
1
結束。
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) -- 已經搶購過,返回0 if hasBuy ~= 0 then return 0 end -- 準備搶購 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",memberUid) flag = redis.call("DECR",goodsSurplusKey) return 1
即使不瞭解 lua,相信你也可以將上面的指令碼看個一二,其中--
開始的是單行註釋。local
用來宣告區域性變數,redis lua 指令碼中的所有變數都應該宣告為local xxx
,避免在持久化、複製的時候產生各種問題。KEYS
和ARGV
是兩個全域性變數,就像 PHP 中的$argc
、$argv
一樣,指令碼執行時傳入的引數會寫入這兩個變數,供我們在指令碼中使用。redis.call
用來執行 redis 現有命令,傳參跟 redis 命令列執行時傳入引數順序一致。
另外 redis lua 指令碼中用到 lua table 的地方還比較多,這裡要注意,lua 指令碼中的 table 下標是從 1 開始的,比如KEYS
、ARGV
,這裡跟其他語言不一樣,需要注意。
對於主要使用 PHP 這種弱型別語言開發同學來說,一定要注意變數的型別,不同型別比較的時候可能會出現類似attempt to compare string with number
的提示,這個時候使用 lua 的tonumber
將字串轉換為數字在進行比較即可。比如我們使用GET
去獲取一個值,然後跟 0 比較大小,就需要將獲取出來的字串轉換為數字。
在除錯之前呢,我們先看看效果,將上面的程式碼儲存到 lua 檔案中/path/to/buy.lua
,然後執行redis-cli --eval /path/to/buy.lua hadBuyUids goodsSurplus,5824742984
即可執行指令碼,執行之後返回-1
,因為我們未設定商品數量,set goodsSurplus 5
之後再次執行,效果如下:
➜ ~ 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
在命令列執行指令碼的時候,指令碼後面傳入的是引數,通過,
分隔為兩組,前面是鍵,後面是值,這兩組分別寫入KEYS
和ARGV
。分隔符一定要看清楚了,逗號前後都有空格,漏掉空格會讓指令碼解析傳入引數異常。
debug 除錯
上一小節,我們寫了很長一段 redis lua 指令碼,怎麼除錯呢,有沒有像 GDB 那樣的除錯工具呢,答案是肯定的。redis 從 v3.2.0 開始支援 lua debugger,可以加斷點、print 變數資訊、展示正在執行的程式碼......我們結合上一小節的指令碼,來詳細說說 redis 中 lua 指令碼的除錯。
如何進入除錯模式
執行redis-cli --ldb --eval /path/to/buy.lua hadBuyUids goodsSurplus,5824742984
,進入除錯模式,比之前執行的時候多了引數--ldb
,這個引數是開啟 lua dubegger 的意思,這個模式下 redis 會 fork 一個程序進入隔離環境,不會影響 redis 正常提供服務,但除錯期間,原始 redis 執行命令、指令碼的結果也不會體現到 fork 之後的隔離環境之中。因此呢,還有另外一種除錯模式--ldb-sync-mode
,也就是前面提到的同步模式,這個模式下,會阻塞 redis 上所有的命令、指令碼,直到指令碼退出,完全模擬了正式環境使用時候的情況,使用的時候務必注意這點。
除錯命令詳解
這一小節的內容是除錯時候的詳細命令,可以粗略閱讀後跳過,等使用的時候再回來查詢
幫助資訊
[h]elp
除錯模式下,輸入h
或者help
展示除錯模式下的全部可用指令。
流程相關
[s]tep 、 [n]ext 、 [c]continue
執行當前行程式碼,並停留在下一行,如下所示
* 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",memberUid)
continue
從當前行開始執行程式碼直到結束或者碰到斷點。
展示相關
[l]list 、 [l]list [line] 、 [l]list [line] [ctx] 、 [w]hole
展示當前行附近的程式碼,[line]
是重新指定中心行,[ctx]
是指定展示中心行周圍幾行程式碼。[w]hole
是展示所有行程式碼
列印相關
[p]rint 、 [p]rint <var>
列印當前所有區域性變數,<var>
是列印指定變數,如下所示:
lua debugger> print <value> goodsSurplus = nil <value> flag = nil <value> buyMembersKey = "hadBuyUids" <value> memberUid = 58247 lua debugger> print buyMembersKey <value> "hadBuyUids"
斷點相關
[b]reak 、 [b]reak <line> 、 [b]reak -<line> 、 [b]reak 0
展示斷點、像指定行新增斷點、刪除指定行的斷點、刪除所有斷點
其他命令
[r]edis <cmd> 、 [m]axlen [len] 、 [a]bort 、 [e]eval <code> 、 [t]race
- 在除錯其中執行 redis 命令
- 設定展示內容的最大長度,0表示不限制
- 退出除錯模式,同步模式下(設定了引數--ldb-sync-mode)修改會保留。
- 執行一行 lua 程式碼。
- 展示執行棧。
詳細說下[m]axlen [len]
命令,如下程式碼:
local myTable = {} local count = 0 while count < 1000 do myTable[count] = count count = count + 1 end return 1
在最後一行列印斷點,執行print
可以看到,輸出了一長串內容,我們執行maxlen 10
之後,再次執行print
可以看到列印的內容變少了,設定為maxlen 0
之後,再次執行可以看到所有的內容全部展示了。
詳細說下[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)
執行b 2
在 func1 中打斷點,然後執行c
,斷點地方停頓,再次執行t
,可以到如下資訊:
lua debugger> t In func1: ->#3 return num From func2: 7 num = func1(num) From top level: 12 func2(123)
請求限流
至此,算是對 redis lua 指令碼有了基本的認識,基本語法、除錯也做了瞭解,接下來就實現一個請求限流器。流程和程式碼如下:
--[[ 傳入引數: 業務標識 ip 限制時間 限制時間內的訪問次數 ]]-- 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) --[[ 獲取已經記錄的時間 獲取到繼續判斷是否超過限制 超過限制返回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 -- 不存在的話,設定為1並設定過期時間 local flag = redis.call("SETEX",identify,expireSeconds,1) return 1
將上面的 lua 指令碼儲存到/path/to/limit.lua
,執行redis-cli --eval /path/to/limit.lua limit_vgroup 192.168.1.19,10 3
,表示 limit_vgroup 這個業務,192.168.1.1 這個 ip 每 10 秒鐘限制訪問三次。
好了,至此,一個請求限流功能就完成了,連續執行三次之後上面的程式會返回 0,過 10 秒鐘在執行,又可以返回 1,這樣便達到了限流的目的。
有同學可能會說了,這個請求限流功能還有值得優化的地方,如果連續的兩個計數週期,第一個週期的最後請求 3 次,接著馬上到第二個週期了,又可以請求了,這個地方如何優化呢,我們接著往下看。
請求限流優化
上面的計數器法簡單粗暴,但是存在臨界點的問題。為了解決這個問題,引入類似滑動視窗的概念,讓統計次數的週期是連續的,可以很好的解決臨界點的問題,滑動視窗原理如下圖所示:
建立一個 redis list 結構,其長度等價於訪問次數,每次請求時,判斷 list 結構長度是否超過限制次數,未超過的話,直接加到隊首返回成功,否則,判斷隊尾一條資料是否已經超過限制時間,未超過直接返回失敗,超過刪除隊尾元素,將此次請求時間插入隊首,返回成功。
local busIdentify = tostring(KEYS[1]) local ip = tostring(KEYS[2]) local expireSeconds = tonumber(ARGV[1]) local limitTimes = tonumber(ARGV[2]) -- 傳入額外引數,請求時間戳 local timestamp = tonumber(ARGV[3]) local lastTimestamp local identify = busIdentify .. "_" .. ip local times = redis.call("LLEN",identify) if times < limitTimes then redis.call("RPUSH",timestamp) return 1 end lastTimestamp = redis.call("LRANGE",0) lastTimestamp = tonumber(lastTimestamp[1]) if lastTimestamp + expireSeconds >= timestamp then return 0 end redis.call("LPOP",identify) redis.call("RPUSH",timestamp) return 1
上面的 lua 指令碼儲存到/path/to/limit_fun.lua
,執行redis-cli --eval /path/to/limit_fun.lua limit_vgroup 192.168.1.19,10 3 1548660999
即可。
最開始,我想著把時間戳計算redis.call("TIME")
也放入 redis lua 指令碼中,後來發現使用的時候 redis 會報錯,這是因為 redis 預設情況複製 lua 指令碼到備機和持久化中,如果指令碼是一個非純函式(pure function),備庫中執行的時候或者宕機恢復的時候可能產生不一致的情況,這裡可以類比 mysql 中基於 SQL 語句的複製模式。redis 在 3.2 版本中加入了redis.replicate_commands
函式來解決這個問題,在指令碼第一行執行這個函式,redis 會將修改資料的命令收集起來,然後用MULTI/EXEC
包裹起來,這種方式稱為script effects replication,這個類似於 mysql 中的基於行的複製模式,將非純函式的值計算出來,用來持久化和主從複製。我們這裡將變動引數提到呼叫方這裡,呼叫者傳入時間戳來解決這個問題。
另外,redis 從版本 5 開始,預設支援script effects replication,不需要在第一行呼叫開啟函數了。如果是耗時計算,這樣當然很好,同步、恢復的時候只需要計算一次後邊就不用計算了,但是如果是一個迴圈生成的資料,可能在同步的時候會浪費更多的頻寬,沒有指令碼來的更直接,但這種情況應該比較少。
至此,指令碼優化完成了,但我又想到一個問題,我們的環境是單機環境,如果是分散式環境的話,指令碼怎麼執行、何處理呢,接下來一節,我們來討論下這個問題。
叢集環境中 lua 處理
redis 叢集中,會將鍵分配的不同的槽位上,然後分配到對應的機器上,當操作的鍵為一個的時候,自然沒問題,但如果操作的鍵為多個的時候,叢集如何知道這個操作落到那個機器呢?比如簡單的mget
命令,mget test1 test2 test3
,還有我們上面執行指令碼時候傳入多個引數,帶著這個問題我們繼續。
首先用 docker 啟動一個 redis 叢集,docker pull grokzen/redis-cluster
,拉取這個映象,然後執行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 從。
我們從任意一個節點進入叢集,比如redis-cli -c -p 7003
,進入後執行cluster nodes
可以看到叢集的資訊,我們連結的是從庫,執行set lua fun
,有同學可能會問了,從庫也可以執行寫嗎,沒問題的,叢集會計算出 lua 這個鍵屬於哪個槽位,然後定向到對應的主庫。
執行mset lua fascinating redis powerful
,可以看到叢集反回了錯誤資訊,告訴我們本次請求的鍵沒有落到同一個槽位上
(error) CROSSSLOT Keys in request don't hash to the same slot
同樣,還是上面的 lua 指令碼,我們加上叢集埠號,執行redis-cli -p 7000 --eval /tmp/limit_fun.lua limit_vgroup 192.168.1.19,10 3 1548660999
,一樣返回上面的錯誤。
針對這個問題,redis官方為我們提供了hash tag
這個方法來解決,什麼意思呢,我們取鍵中的一段來計算 hash,計算落入那個槽中,這樣同一個功能不同的 key 就可以落入同一個槽位了,hash tag 是通過{}
這對括號括起來的字串,比如上面的,我們改為mset lua{yes} fascinating redis{yes} powerful
,就可以執行成功了,我這裡 mset 這個操作落到了 7002 埠的機器。
同理,我們對傳入指令碼的鍵名做 hash tag 處理就可以了,這裡要注意不僅傳入鍵名要有相同的 hash tag,裡面實際操作的 key 也要有相同的 hash tag,不然會報錯Lua script attempted to access a non local key in a cluster node
,什麼意思呢,就拿我們上面的例子來說,執行的時候如下所示,可以看到,
前面的兩個鍵都加了 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),可以看到就報了上面的錯誤,所以在執行指令碼的時候,只要傳入引數鍵、腳本里面執行 redis 命令時候的鍵有相同的 hash tag 即可。
另外,這裡有個 hash tag 規則:
鍵中包含{
字元;建中包含{
字元,並在{
字元右邊;並且{
,}
之間有至少一個字元,之間的字元就用來做鍵的 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 去執行程式碼。
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]) -- 傳入額外引數,請求時間戳 local timestamp = tonumber(ARGV[3]) local lastTimestamp local identify = busIdentify .. "_" .. ip local times = redis.call("LLEN",identify) if times < limitTimes then redis.call("RPUSH",timestamp) return 1 end lastTimestamp = redis.call("LRANGE",0) lastTimestamp = tonumber(lastTimestamp[1]) if lastTimestamp + expireSeconds >= timestamp then return 0 end redis.call("LPOP",identify) redis.call("RPUSH",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("快取指令碼到主節點失敗") } else { ret = result } return nil }) return ret } func main() { redisdb := redis.NewClusterClient(&redis.ClusterOptions{ Addrs: []string{ ":7000",":7001",":7002",":7003",":7004",":7005",},}) // 將指令碼快取到所有節點,執行一次拿到結果即可 sha := scriptCacheToCluster(redisdb) // 執行快取指令碼 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("發生異常,返回值:",err.Error()) } else { fmt.Println("返回值:",result) } // 示例錯誤情況,sha 值不存在 ret1 := redisdb.EvalSha(sha + "error",err := ret1.Result(); err != nil { fmt.Println("發生異常,返回值:",result) } }
執行上面的程式碼,返回值如下:
返回值: 0
發生異常,返回值: NOSCRIPT No matching script. Please use EVAL.
好了,目前為止,相信你對 redis lua 指令碼已經有了很好的瞭解,可以實現一些自己想要的功能了,感謝大家的閱讀。希望對大家的學習有所幫助,也希望大家多多支援我們。