Redis 定長隊列的探索和實踐
vivo 互聯網服務器團隊 - Wang Zhi
一、業務背景
從技術的角度來說,技術方案的選型都是受限於實際的業務場景,都以解決實際業務場景爲目標。
在我們的實際業務場景中,需要以遊戲的維度收集和上報行爲數據,考慮數據的量級,執行盡最大努力交付且允許數據的部分丟棄。
數據上報支持遊戲的維度的批量上報,支持同一款遊戲 128 個行爲進行批量上報。
數據上報需要時效控制,上報的數據必須是上報時刻的前 3 分鐘的數據。
整體數據的業務形態如下圖所示:
二、技術選型
從業務的角度來說包含數據的收集和數據的上報,我們把數據的收集比作生產者,數據的上報比作消費者,是一個典型的生產消費模型。
生產消費模型在 JVM 進程內部通過隊列 + 鎖或者無鎖的 Disruptor 來實現,在跨進程場景下通過 MQ(RocketMQ/kafka)進行處理解耦。
但是細化到具體業務場景來看,消息的消費有諸多限制,包括:遊戲維度的批量行爲上報,行爲上報的時效限制,細化到各個技術方案選型進行對比。
方案一
使用 RocketMQ 或者 Kafaka 等消息隊列來存儲上報的消息,但是消費側需要考慮在業務進程中按照遊戲維度進行聚合,其中技術細節涉及按照遊戲維度進行拆分,在滿足消息時效性和批量性的前提下觸發上報。在這種方案下消息中間件扮演的角色本質上消息的中轉站,沒有解決任何業務場景中提及的遊戲維度拆分、批量性和時效性。
方案二
在方案一的基礎上,尋求一種技術方案來解決遊戲維度的消息分組、批量消費 、時效性。通過 Redis 的 list 結構來實現隊列(進一步要求實現定長隊列)來解決遊戲維度的消息分組;通過 Redis 的 list 支持的 Lrange 來實現批量消費;通過業務側的多線程來解決時效問題,針對高頻的遊戲使用單獨的線程池進行處理,上述兩個手段能夠保證消費速度大於生產速度。
方案對比
對比兩種方案後決定使用 Redis 的實現了一個僞消息中間件:
-
通過 List 對象實現定長隊列來保存遊戲維度的行爲消息(以遊戲作爲 key 的 List 對象來保存用戶行爲);
-
通過 List 來保存所有的存在行爲數據的遊戲列表;
-
通過 Set 來進行去重判斷來保證 2 中的 List 對象的唯一性。
整體的技術方案如下圖所示:
生產過程
步驟一: 遊戲維度的某行爲數據 PUSH 到遊戲維度的隊列當中。
步驟二: 判斷遊戲是否在遊戲的集合 Set 中,如果在就直接返回,如果不在進行步驟三。
步驟三: 往遊戲列表中 PUSH 遊戲。
消費過程
步驟一: 從遊戲對象的列表中循環取出一款遊戲。
步驟二: 通過步驟一獲取的遊戲對象去該遊戲對象的行爲數據隊列中批量獲取數據處理。
三、技術原理
在 Redis 的支持命令中,在 List 和 Set 的基礎命令,結合 Lua 腳本來實現整個技術方案。
消息數據層面,通過單獨的 List 循環維護待消費的遊戲維度的數據,每個遊戲維度使用定長的 List 來保存消息。
消息生產過程中,通過結合 List 的 llen+lpop+rpush 來實現遊戲維度的定長隊列,保證隊列的長度可控。
消息消費過程中,通過結合 List 的 lrange+ltrim 來實現遊戲維度的消息的批量消費。
在整個執行的複雜度層面,需要保證時間複雜度在 0(N) 常量維度,保證時間可控。
3.1 Lua 腳本
EVAL script numkeys key [key ...] arg [arg ...]
時間複雜度:取決於腳本本身的執行的時間複雜度。
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
Redis uses the same Lua interpreter to run all the commands.
Also Redis guarantees that a script is executed in an atomic way:
no other script or Redis command will be executed while a script is being executed.
This semantic is similar to the one of MULTI / EXEC.
From the point of view of all the other clients the effects of a script are either still not visible or already completed.
Redis 採用相同的 Lua 解釋器去運行所有命令,我們可以保證,腳本的執行是原子性的。作用就類似於加了 MULTI/EXEC。
-
Lua 腳本內多個命令以原子性的方式執行,保證了命令執行的線程安全。
-
Lua 腳本結合 List 命令實現定長隊列,實現批量消費。
-
Lua 腳本僅支持單個 key 的操作,不支持多 key 的操作。
3.2 List 對象
LLEN key
計算List的長度
時間複雜度:O(1)。
LPOP key [count]
從List的左側移除元素
時間複雜度:O(N),N爲移除元素的個數。
RPUSH key element [element ...]
從List的右側保存元素
時間複雜度:O(N),N爲保存元素的個數。
-
List 的基礎命令包括計算 List 的長度,移除數據,添加數據,整體命令的複雜度都在 O(N) 的常量時間。
-
整合上述三個命令,我們能保證實現固定長度的隊列,通過判斷隊列長度是否達到定長結合新增隊列元素和移除隊列元素來完成。
LRANGE key start end
時間複雜度:O(S+N), S爲偏移量start, N爲指定區間內元素的數量。
下標(index)參數 start 和 stop 都以 0 爲底,也就是說,以 0 表示列表的第一個元素,以 1 表示列表的第二個元素,以此類推。
你也可以使用負數下標,以 -1 表示列表的最後一個元素, -2 表示列表的倒數第二個元素,以此類推。
LTRIM key start stop
時間複雜度:O(N) where N is the number of elements to be removed by the operation.
修剪(trim)一個已存在的 list,這樣 list 就會只包含指定範圍的指定元素。
-
List 的基礎命令包括批量返回數據和裁剪數據,整體命令的複雜度都在 O(N) 的常量時間。
-
整合上述兩個命令,我們能夠批量消費數據並移除隊列數據,通過 LRANGE 批量返回數據並通過 LTRIM 保留剩餘數據。
3.3 Set 對象
SADD key member [member ...]
往Set集合添加數據。
時間複雜度:O(1)。
SISMEMBER key member
判斷Set集合是否存在元素。
時間複雜度:O(1)。
- 通過 Set 集合來保證數據的唯一性,且時間複雜度可控。
四、技術應用
4.1 生產消息
定義LUA腳本
CACHE_NPPA_EVENT_LUA =
"local retVal = 0 " +
"local key = KEYS[1] " +
"local num = tonumber(ARGV[1]) " +
"local val = ARGV[2] " +
"local expire = tonumber(ARGV[3]) " +
"if (redis.call('llen', key) < num) then redis.call('rpush', key, val) " +
"else redis.call('lpop', key) redis.call('rpush', key, val) retVal = 1 end " +
"redis.call('expire', key, expire) return retVal";
執行LUA腳本
String data = JSON.toJSONString(nppaBehavior);
Long retVal = (Long)jedisClusterTemplate.eval(CACHE_NPPA_EVENT_LUA, 1, NPPA_PREFIX + nppaBehavior.getGamePackage(), String.valueOf(MAX_GAME_EVENT_PER_GAME), data, String.valueOf(NPPA_TTL_MINUTE * 60));
執行效果
實現固長隊列的數據存儲並設置過期時間
-
通過整合 llen+rpush+lpop 三個命令實現定長隊列。
-
通過 lua 腳本保證上述命令的原子性執行。
- 整體的執行流程如上圖所示,核心理念通過 lua 腳本的原子性保證了隊列長度計算(llen)、隊列數據移除(lpop)、隊列數據保存(rpush) 的原子性執行。
4.2 消費消息
定義LUA腳本
QUERY_NPPA_EVENT_LUA =
"local data = {} " +
"local key = KEYS[1] " +
"local num = tonumber(ARGV[1]) " +
"data = redis.call('lrange', key, 0, num) redis.call('ltrim', key, num+1, -1) return data";
執行LUA腳本
Integer batchSize = NppaConfigUtils.getInteger("nppa.report.batch.size", 1);
Object result = jedisClusterTemplate.eval(QUERY_NPPA_EVENT_LUA, 1,NPPA_PREFIX + gamePackage, String.valueOf(batchSize));
執行效果
取固定數量的對象,然後保留隊列的剩餘的消息對象。
-
通過整合 lrange+ltrim 兩個命令實現消息的批量消費。
-
通過 lua 腳本保證上述命令的原子性執行。
-
整體的執行流程如上圖所示,核心理念通過 lua 腳本的原子性保證了數據獲取(Lrange)和數據裁剪(Ltrim) 的原子性執行。
-
整體的消費流程選擇 pull 模式,通過多線程循環輪詢可消費的隊列進行消費。與藉助於 redis 的 pub/sub 的通知機制實現消費流程的 push 模式相比,pull 模式成本更低效果更佳。
4.3 注意事項
-
Redis 集羣模式下,執行 Lua 腳本建議傳單 key,多 key 會報重定向錯誤。
-
在不同的 Redis 版本下,Lua 腳本針對 null 的返回值處理不同,參考官方文檔。
-
消費者的消費過程中通過循環遍歷遊戲列表,然後根據遊戲去獲取對應的消息對象,但是不同的遊戲對應的熱度不同,所以在消費端我們通過配置的方式爲熱門遊戲單獨開啓消費線程進行消費,相當於針對不同遊戲配置不同優先級的消費者。
五、線上效果
-
生產和消費的 QPS 約爲 1w qps 左右,整體上報 QPS 通過批量上報後會遠低於生產的消息生產和消費的 QPS。
-
整體數據的使用遊戲包名作爲 key 進行存儲,性能上不存在熱點的問題。
六、適用場景
在描述完方案的原理和實現細節之後,進一步對適用的業務場景進行下總結。整體方案是基於 redis 的基本數據結構構建一個僞消息隊列,用以解決消息的單個生產批量消費的場景,通過多 key 形式實現消息隊列的多 Topic 模式,重要的是能夠藉助於 redis 的原生能力在 O(N) 的時間複雜度完成批量消費。另外該方案也可以降級作爲實現先進先出定長的日誌隊列。
七、總結
本文主要探索在特定業務場景下通過 Redis 的原生命令實現類 MQ 的功能,創新式的通過 Lua 腳本組合 Redis 的 List 的基礎命令,實現了消息的分組,消息的定長隊列,消息的批量消費功能;整體解決方案在線上環境落地並平穩運行,爲特定場景提供了一種通用的解決方案。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/QUwGm8ggCXYgAakicEvX1g