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),
四、技術應用
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 = https://www.cnblogs.com/vivotech/p/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 = https://www.cnblogs.com/vivotech/p/{}" +
"local key = KEYS[1] " +
"local num = tonumber(ARGV[1]) " +
"data = https://www.cnblogs.com/vivotech/p/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的基礎命令,實作了訊息的分組,訊息的定長佇列,訊息的批量消費功能;整體解決方案在線上環境落地并平穩運行,為特定場景提供了一種通用的解決方案,
分享 vivo 互聯網技術干貨與沙龍活動,推薦最新行業動態與熱門會議,轉載請註明出處,本文鏈接:https://www.uj5u.com/shujuku/501217.html
標籤:其它
