玩轉Redis-Lua腳本入門到實戰-樹形結構存儲及查詢

《玩轉Redis》系列文章 by zxiaofan主要講述Redis的基礎及中高級應用,穿插企業實戰案例。本文是《玩轉Redis》系列第【16】篇,最新系列文章請前往 公衆號 “zxiaofan”(點我點我)查看,或 百度搜索 “玩轉Redis zxiaofan”(點我點我)即可。

本文關鍵字:玩轉Redis、Lua腳本入門到實戰、調試Lua腳本、樹形結構、樹形結構的存儲方案“鄰接表”、“路徑枚舉”查找部門的所有上級部門

往期精選《玩轉Redis-幹掉釘子戶-沒有設置過期時間的key》

大綱

  • 樹形結構的常見場景及解決方案
    • 樹形結構的常見場景
    • 樹形結構的解決方案:“鄰接表”、“路徑枚舉”
  • Redis下樹形結構的存儲
  • Redis中如何使用Lua腳本
    • Redis支持的Lua腳本命令簡述
    • Redis支持的Lua腳本命令詳解
    • Redis如何調試Lua腳本
  • Lua腳本實戰Redis樹形結構
  • Redis中使用Lua腳本的注意事項

前言:

在toB公司負責中臺業務,衆多企業的部門關係是樹形結構,前段時間有業務訴求是“在大數據量下高效查詢指定部門的所有上級部門,企業的部門樹形關係可能隨時變更”。在MySQL的基礎上遂想到了利用Redis緩存樹形結構並實現高效查詢。

1、樹形結構的常見場景及解決方案

1.1、樹形結構的常見場景

生活中我們有很多樹形結構的數據場景,比如:

  • 國家行政區域編碼;
  • 企業組織架構;

國家行政區域編碼 企業組織架構

樹形結構數據的特徵是 有明顯的所屬關係,比如 “行政區域編碼”示例中“朝陽區”屬於“北京市”,“組織架構”示例中“社交產品部”屬於“產品中心”。

1.2、樹形結構的解決方案

1.2.1、鄰接表

業界最常使用的方案恐怕就是“鄰接表”了,簡而言之,“鄰接表”的每條數據都存儲了“上級數據ID”。

數據ID 數據名稱 上級數據ID
cpzx 產品中心 總公司ID
sjcpb 社交產品部 cpzx
bgcpb 辦公產品部 cpzx
bgcpyz 辦公產品一組 bgcpb
bgcpez 辦公產品二組 bgcpb

“鄰接表”的優點:

  • 添加數據高效;
  • 修改數據的上級高效;
  • 刪除葉子節點數據高效;

“鄰接表”的缺點:

  • 刪除中間節點需移動其子節點;
  • 查詢節點的所有葉子節點、所有父節點複雜;
    • 這裏指MySQL,Oracle是支持遞歸查詢的;

1.2.2、路徑枚舉

另一個比較常用的方案就是“路徑枚舉”了,其核心思想是,每條數據都有字段存儲了其所有的上級信息。

數據ID 數據名稱 路徑
1 中國 1
11 北京市 1,11
110105 朝陽區 1,11,110105
51 四川省 1,51
5101 成都市 1,51,5101
510104 錦江區 1,51,5101,510104

“路徑枚舉”的優點:

  • 查詢節點的所有父節點高效;
    • select 路徑 where 數據ID = '節點ID';
  • 查詢節點的所有子節點高效;
    • select 數據ID where 路徑 like '1,51%';

“路徑枚舉”的缺點:

  • 依賴複雜邏輯維護路徑;
  • 路徑長度可能失控;
  • 非葉子節點刪除或變更上級節點時,所有子節點都將變動;

除了“鄰接表”、“路徑枚舉”,還有存儲子孫節點範圍的“嵌套集”、維護獨立數據表存儲所有子孫節點關係的“閉包表”等方案用於存儲樹形結構數據。由於不是本文重點,此處不再贅述。

在實際的生產方案中,我們也不用拘泥於以上某個方案,適當的將方案整合使用,往往事半功倍。比如我們的生產系統,就有將“鄰接表”、“路徑枚舉”方案混合使用的場景,綜合性能也相當出色。

> 不管黑貓白貓,抓到耗子就是好貓。

2、Redis下樹形結構的存儲

在闡述存儲方案前,我們先詳細梳理下現有的業務場景,技術都是爲業務服務的

toB系統,系統中有衆多企業(company1 ~ company666),每個企業都有自己的部門樹。示例:某公司 A0 下有 B1 ~ B50 這 50 個一級部門;每個一級部門 下 又有若干 個二級部門(比如 B1 下 有 CB1-1 ~ CB1-30 這 30 個二級部門,B3 下 有 CB3-1 ~ CB3-40 這 40 個二級部門);同理,每個二級部門下又有若干個三級部門。對於大企業而言,部門達到幾千上萬。此外需要注意的是,企業的部門信息是可能隨時變動的。而現在我們的訴求是:查詢 第N級某個部門的所有上級部門信息。

企業組織架構樹

在MySQL場景下,我們可以“鄰接表”或者“路徑枚舉”方案,甚至於像上面提及的需要做方案混合。對於訴求“查詢節點的所有父節點”,通過先前的方案分析,“路徑枚舉”是較優的方案。但如果當QPS很高,需要進一步提升性能呢,除了提升DB的性能響應外,我們是否還有其他的出路?

高性能的Redis進入了我們的視野,那麼如何使用Redis完成樹型結構的存儲呢?

此處我們使用的Redis數據結構是 Hash,Redis的key爲企業ID(depttree:企業ID),field 爲 部門ID,field 對應的value是 該部門ID對應的上級部門ID。示例如下:

Redis下部門樹的存儲

業務邏輯:

  • 查詢所有父部門時,先從緩存中查詢,緩存缺失時從DB查詢並更新到Redis;
  • 部門關係變更時,則刪除Redis緩存;
  • 部門刪除時,則刪除Redis緩存;
  • Redis中的數據存儲採用的是“鄰接表”的方式;
  • 由於任意部門的父部門都可能變動,Redis中的數據存儲不採用“路徑枚舉”方案;

需要注意的是:

  • 更新Redis時採用批量更新提升性能,HMSET key field value [field value …];
  • 實際生產中,我們採用的是二級緩存,方案更復雜,此處不展開;
HMSET depttree:企業001 B1 A0 B2 A0 B3 A0 CB1-1 B1

3、Redis中如何使用Lua腳本

在上一節中部門關係數據已經存到Redis了,從hash的結構看,無法一次性查詢指定部門的所有上級部門,所以我們需要使用到 Lua 腳本。正式實戰之前,我們先學習下Redis中如何使用 Lua 腳本。

Redis 2.6.0 版本開始支持 Lua 腳本。Redis中使用 Lua 腳本應直接提供程序體,不需要也不能定義一個函數。下面我們來開始Redis Lua腳本的入門吧。

3.1、Redis支持的Lua腳本命令簡述

命令 功能 參數
EVAL 執行Lua腳本 EVAL script numkeys key [key ...] arg [arg ...]
SCRIPT LOAD 將腳本內容導入Redis的腳本緩存 SCRIPT LOAD script
EVALSHA 通過導入腳本的SHA1摘要值執行腳本 EVALSHA sha1 numkeys key [key ...] arg [arg ...]
SCRIPT EXISTS 判斷指定SHA1摘要值的腳本是否存在 SCRIPT EXISTS sha1 [sha1 ...]
SCRIPT FLUSH 清空所有的Lua腳本緩存 SCRIPT FLUSH
SCRIPT KILL 殺死正在執行的沒有寫操作的Lua腳本 SCRIPT KILL
SCRIPT DEBUG 設置Lua腳本debug模式 SCRIPT DEBUG YES

3.2、Redis支持的Lua腳本命令詳解

3.2.1、EVAL

  • 參數
    • EVAL script numkeys key [key ...] arg [arg ...]
  • 功能
    • 執行Lua腳本
  • 可用版本
    • 2.6.0
  • 時間複雜度
    • 取決於執行的腳本;
  • 參數說明
    • script:腳本內容;
    • numkeys:key的個數;
    • [key ...]:key值,個數必須和numkeys匹配;
    • [arg ...]:附加參數,[key ...]後的均爲附加參數,個數不固定;
  • 返回值
    • 腳本執行結果;
  • 備註
    • Lua 腳本中通過 KEYS[1]、KEYS[2]、ARGV[1] 獲取傳入的參數;
127.0.0.1:6379> eval "return redis.call('set','公衆號','zxiaofan')" 0
OK

127.0.0.1:6379> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK

3.2.2、SCRIPT LOAD

  • 參數
    • SCRIPT LOAD script
  • 功能
    • 將腳本內容導入Redis的腳本緩存;
  • 可用版本
    • 2.6.0
  • 時間複雜度
    • O(N),N取決於腳本字節長度;
  • 參數說明
    • script:腳本內容;
  • 返回值
    • 導入腳本的SHA1摘要值;
  • 備註
    • 同一個腳本連續導入返回的SHA1摘要值相同;
    • 導入的腳本將用於存放在Redis的腳本緩存裏,除非使用 FLUSH 命令刪除;
    • 使用redis-cli交互時,我們可以使用 "$(cat ./luascript/xxx.lua)" 的方式導入指定路徑下的lua腳本;
    • 如果已經進入Redis交互shell,則不能使用此方式了;
127.0.0.1:6379> SCRIPT LOAD "return 1"
"e0e1f9fabfc9d4800c877a703b823ac0578ff8db"

// redis-cli 導入指定路徑下的lua腳本,公衆號 zxiaofan

./redis-cli -a password -p 6379 script load "$(cat ./luascript/xxx.lua)"
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
"8144f1139110991cf3f085b70f807a4ef261b727"


3.2.3、EVALSHA

  • 參數
    • EVALSHA sha1 numkeys key [key ...] arg [arg ...]
  • 功能
    • 通過導入腳本的SHA1摘要值執行腳本;
  • 可用版本
    • 2.6.0
  • 時間複雜度
    • 取決於執行的腳本;
  • 參數說明
    • sha1:導入腳本的 sha1 摘要值;
    • numkeys:key的個數;
    • [key ...]:key值,個數必須和numkeys匹配;
    • [arg ...]:附加參數,[key ...]後的均爲附加參數,個數不固定;
  • 返回值
    • 腳本執行結果;
127.0.0.1:6379> evalsha e0e1f9fabfc9d4800c877a703b823ac0578ff8db 0
"1"

3.2.4、SCRIPT EXISTS

  • 參數
    • SCRIPT EXISTS sha1 [sha1 ...]
  • 功能
    • 判斷指定 SHA1 摘要值的腳本是否存在
  • 可用版本
    • 2.6.0
  • 時間複雜度
    • O(N),N取決於 SHA1 摘要值數量,單個判斷的時間複雜度是O(1);
  • 參數說明
    • [sha1 ...]:SHA1 摘要值列表;
  • 返回值
    • 1:腳本存在;
    • 0:腳本不存在;
127.0.0.1:6379> SCRIPT EXISTS e0e1f9fabfc9d4800c877a703b823ac0578ff8db e0e1f9fabfc9d4800c877a703b823ac0578ff8db2
1) (integer) 1
2) (integer) 0

3.2.5、SCRIPT FLUSH

  • 參數
    • SCRIPT FLUSH
  • 功能
    • 清空所有的Lua腳本緩存;
  • 可用版本
    • 2.6.0
  • 時間複雜度
    • O(N),N是Redis 腳本緩存中腳本的個數;
  • 參數說明
    • 無參數;
  • 返回值
    • OK:清除成功;
  • 備註
    • FLUSH命令無差別攻擊,不能清除指定的腳本
127.0.0.1:6379> SCRIPT FLUSH
OK

3.2.6、SCRIPT KILL

  • 參數
    • SCRIPT KILL
  • 功能
    • 殺死正在執行的沒有寫操作的Lua腳本;
  • 可用版本
    • 2.6.0
  • 時間複雜度
    • O(1)
  • 參數說明
    • 無參數;
  • 備註
    • KILL命令無差別攻擊,不能殺死指定的腳本
127.0.0.1:6379> SCRIPT KILL
(error) NOTBUSY No scripts in execution right now.

3.2.7、SCRIPT DEBUG

  • 參數
    • SCRIPT DEBUG YES|SYNC|NO
  • 功能
    • 設置Lua腳本debug模式
  • 可用版本
    • 3.2.0.
  • 時間複雜度
    • O(1)
  • 參數說明
    • YES:設置debug模式爲異步模式,調試腳本不阻塞,所有的改變在對話關閉後將回滾;
    • SYNC:設置debug模式爲同步模式,調試腳本將阻塞,所有的改變將保存到Redis;
    • NO:禁用腳本調試模式;
  • 返回值
    • OK
  • 備註
    • 沒有方式可以查看當前的腳本調試模式;
// 指定debug模式調試腳本;

./redis-cli --ldb-sync-mode --eval /tmp/script.lua

3.3、Redis如何調試Lua腳本

Redis中包含一個完整的 Lua調試器(Lua debugger),代號 LDB。

  • 調試命令 s:單步執行;
  • 調試命令 p:打印所有變量值;
  • Lua 腳本中加入 redis.debug() 用於調試時打印信息到控制檯;
  • Lua 腳本加入redis.breakpoint(),當下一行有斷點時暫停;

其他詳細調試命令說明見下文的註釋:

[redis@redis redis]$./redis-cli --ldb --eval /tmp/script.lua mykey key1 arg1 arg2

Lua debugging session started, please use:
quit    -- End the session.
restart -- Restart the script in debug mode again.
help    -- Show Lua script debugging commands.

// 輸入 help 可 查看調試命令,用的最多的應該是 s(單步執行),公衆號 zxiaofan

lua debugger> help
Redis Lua debugger help:
[h]elp               Show this help.
[s]tep               Run current line and stop again. 單步執行;
[n]ext               Alias for step. 單步執行,同s;
[c]continue          Run till next breakpoint. 執行到下一個斷點;
[l]list              List source code around current line. 展示當前行源碼;
[l]list [line]       List source code around [line].
                     line = 0 means: current position.展示指定行源碼;
[l]list [line] [ctx] In this form [ctx] specifies how many lines
                     to show before/after [line]. 從第[line]行開始,展示[ctx]行源碼;
[w]hole              List all source code. Alias for 'list 1 1000000'. 展示所有源碼;
[p]rint              Show all the local variables.打印所有變量值;
[p]rint <var>        Show the value of the specified variable.
                     Can also show global vars KEYS and ARGV.打印指定變量值;
[b]reak              Show all breakpoints.展示所有斷點;
[b]reak <line>       Add a breakpoint to the specified line.在指定行添加一個斷點;
[b]reak -<line>      Remove breakpoint from the specified line.移除指定行斷點;
[b]reak 0            Remove all breakpoints.移除所有斷點;
[t]race              Show a backtrace.查看當前執行棧;
[e]eval <code>       Execute some Lua code (in a different callframe). 獨立的棧中執行Lua代碼;
[r]edis <cmd>        Execute a Redis command.執行一個Redis指令; 
[m]axlen [len]       Trim logged Redis replies and Lua var dumps to len.
                     Specifying zero as <len> means unlimited.
[a]bort              Stop the execution of the script. In sync
                     mode dataset changes will be retained. 終止腳本執行,sync模式下寫入的數據將被保留;

Debugger functions you can call from Lua scripts:
redis.debug()        Produce logs in the debugger console.
redis.breakpoint()   Stop execution like if there was a breakpoint in the
                     next line of code.

4、Lua腳本實戰Redis樹形結構

前文“Redis下樹形結構的存儲”已講述Redis下樹形結果數據的存儲,此處主要講解 如何 通過 Lua 腳本 快速查詢指定節點的所有上級節點;

腳本入參總共有4個:

  • rediskey :Redis的key,key的數據結構是Hash;
  • currentDeptNo :待查詢的指定部門;
  • utilDeptNo :查詢上級部門的終止節點,查到此部門爲止;
  • maxGetTimes:迭代查詢最大次數,避免髒數據形成死循環;
    • 最大值 100,若傳入的 maxGetTimes 超過 100 將強制賦值爲 100;
    • 因爲實際的部門層級沒有超過100的;
// 數據初始化,1 的上級是 2, 2 的上級是 3 , 3 的上級是 4;公衆號 zxiaofan

127.0.0.1:6378&gt; HMSET depttree:001 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10
OK

// 執行腳本
// 查找 節點1 的所有上級,直到查到節點 3 爲止,最多查詢 66 次。

[redis@redis redis]$ ./openredis.sh 6378 "--eval luascript/lua_getAllSupDept.lua depttree:001 1 3 66"
"1,2,3"

lua_getAllSupDept.lua 腳本已在github開源,持續完善更新:https://github.com/zxiaofan/OpenSource_Study/blob/master/redis_scripts/lua_getAllSupDept.lua

在Lua腳本中,Redis 返回的結果爲 (nil) 時,其真實的數據類型爲 boolean,所以數據不存在 的判斷方式是 if(tempDept == false )。

-- Redis Lua腳本:查詢指定部門的所有上級部門,公衆號 zxiaofan
-- 腳本保存爲 lua_getAllSupDept.lua;

--[[
	luaScriptName: getAllSupDept;
	function: get all Super Dept of currentDeptNo;
	auther: zxiaofan.com;
param:
	rediskey: the key of redis, the data structures is Hashes;
	currentDeptNo: the current DeptNo;
	utilDeptNo: query super dept until the utilDeptNo;
	maxGetTimes: the max times of query to prevent dead loop.
result:
	a. the whole super detp data;
	b. the super detp data but not until specified dept(utilDeptNo);
	c. return currentDeptNo when no data;
	d. return error "error: the times of query exceeded the maxGetTimes!";
	--]]

local rediskey = KEYS[1];
local currentDeptNo = KEYS[2];
local utilDeptNo = KEYS[3];
local maxGetTimes = tonumber(KEYS[4]);

-- redis.debug("rediskey: %q",rediskey);
-- redis.debug("currentDeptNo: %q",currentDeptNo);
-- redis.debug("utilDeptNo: %q",utilDeptNo);
-- redis.debug("maxGetTimes: %q",maxGetTimes);


	if(currentDeptNo == utilDeptNo) then
		return currentDeptNo;
	end

	if(maxGetTimes &gt; 100) then
		maxGetTimes = 100;
	end

	local time = 1;

	local result = currentDeptNo;
	local tempDept = currentDeptNo;

	while(tempDept ~= utilDeptNo)
	do
		if(time &gt; maxGetTimes) then
			return "error: the times of query exceeded the maxGetTimes!";
		end

		tempDept = redis.call('hget',rediskey , tempDept);
		-- redis.debug("tempDept: %q",tempDept);

		if(tempDept == false or tempDept == "NULL") then
			return result;
		end

		result = result .. "," .. tempDept;
		time = time + 1 ;
	end

	return result;

5、Redis中使用Lua腳本的注意事項

  • 腳本是一個程序體,不能定義函數;
  • 腳本執行是原子性的,腳本執行時不會有其他腳本或Redis命令執行;
    • 避免使用 Lua 慢腳本;
  • Lua腳本中的變量必須是 局部變量
  • Lua腳本可通過 redis.conf 中的 lua-time-limit 設置最大運行時間
    • lua-time-limit:默認5000,即5秒;
    • 腳本運行超過時間限制後,Redis 將接收其他指令,但由於要保證腳本的原子性,腳本不會被終止,其他指令將返回“BUSY”錯誤
    • 可通過 “SCRIPT KILL” 殺掉正在運行的 Lua腳本;
    • “SCRIPT KILL”將不能殺死 正在運行的包含修改操作的腳本,此時需要執行“SHUTDOWN NOSAVE” 命令來強行終止 Redis;
  • Lua腳本執行完畢後,命令才追加寫入到AOF中;
  • 腳本內容提前導入Redis中,再利用 EVALSHA sha1 執行 Lua 腳本可提升性能,如果腳本較大,還可以節省帶寬

> SHUTDOWN NOSAVE 和 SHUTDOWN 的區別在於 SHUTDOWN NOSAVE 不會進行持久化操作,也就是說在上一次快照後的數據修改都將丟失。

// 此腳本可被 SCRIPT KILL 殺死

eval 'while(true) do print("1") end' 0

// 此腳本 不可被 SCRIPT KILL 殺死

eval "redis.call('set','公衆號','zxiaofan') while true do end" 0

// Lua腳本執行超時後,日誌裏將有警告

Lua slow script detected: still in execution after 5000 milliseconds. You can try killing the script using the SCRIPT KILL command. Script SHA1 is: 3245e4edc1a1e2a9bac3c52e99466f9ccabf65c0

// jedis 提示 Lua 腳本超時信息;

redis.clients.jedis.exceptions.JedisDataException: BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

Redis集羣模式下Lua腳本注意事項:

127.0.0.1:6379&gt; eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 公衆號 zxiaofan
(error) CROSSSLOT Keys in request don't hash to the same slot

Redis Cluster 操作多key時,要求命令中的所有key都屬於一個slot,否則會拋出異常“CROSSSLOT Keys in request don't hash to the same slot”。

通過查看jedis的 getSlot 源碼,我們可以發現,如果 key 包含 {},則會使用第一個 {} 中的字符串作爲 hash key,所以集羣模式下我們可以將 Redis key 的相同內容 使用 {} 包裝起來

所以將上面報錯的語句的 key1 key2 修改爲 {key}1 {key}2 即可正常運行。

127.0.0.1:6379&gt; eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 {key}1 {key}2 公衆號 zxiaofan
1) "{key}1"
2) "{key}2"
3) "公衆號"
4) "zxiaofan"

需要注意的是,{} 模式 雖然能將 不同的 key hash 到相同 solt,但數據量過大時,極易造成 數據傾斜,從而影響系統的穩定性。所以使用前請充分分析評估數據,按需靈活處理。

// 集羣模式getSlot操作;公衆號 zxiaofan;
// 源碼來自:https://github.com/redis/jedis/blob/6ed1441ca4c5de7e66648edfeafa16854707482c/src/main/java/redis/clients/jedis/util/JedisClusterCRC16.java

public static int getSlot(byte[] key) {
    if (key == null) {
      throw new JedisClusterOperationException("Slot calculation of null is impossible");
    }

    int s = -1;
    int e = -1;
    boolean sFound = false;
    for (int i = 0; i &lt; key.length; i++) {
      if (key[i] == '{' &amp;&amp; !sFound) {
        s = i;
        sFound = true;
      }
      if (key[i] == '}' &amp;&amp; sFound) {
        e = i;
        break;
      }
    }
    if (s &gt; -1 &amp;&amp; e &gt; -1 &amp;&amp; e != s + 1) {
      return getCRC16(key, s + 1, e) &amp; (16384 - 1);
    }
    return getCRC16(key) &amp; (16384 - 1);
  }

後記

Lua 腳本法力無邊,但也需慎之又慎,避免超時阻塞,避免數據傾斜

生產環境使用 Lua 腳本時,必須有嚴格的檢查評審機制

參考文檔:
https://redis.io/commands/eval;
https://redis.io/topics/ldb;

【玩轉Redis系列文章 近期精選 公衆號@zxiaofan】

《玩轉Redis-幹掉釘子戶-沒有設置過期時間的key》

《玩轉Redis-8種數據淘汰策略及近似LRU、LFU原理》

《玩轉Redis-生產環境如何導入、導出及刪除大量數據》

《玩轉Redis-刪除了兩百萬key,爲什麼內存依舊未釋放?》

《玩轉Redis-HyperLogLog原理探索》


公衆號搜索zxiaofan查閱最新系列文章。
Life is all about choices!
將來的你一定會感激現在拼命的自己!
CSDN】【GitHub】【OSCHINA】【掘金】【語雀】【微信公衆號(點擊關注)


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章