Redis在遊戲伺服器中的簡單應用
大家好,我是《江湖X》、《漢家江湖》的伺服器主程、運維、客服和背鍋臨時工,今天給大家分享一下,這兩年我們使用Redis的一點淺薄的心得體會,如果能給大家帶來一點點幫助,就心滿意足了。
(感謝阿里雲)
總(fei)結(hua)
Redis是一款強大的工具,優點是響應微秒級,有廣播/訂閱功能,支持事務(非集群),支持隊列、哈希表和哈希集合,缺點是需要手動管理索引,事務不可回滾,原生集群不支持事務等高級功能。
我們在《漢家江湖》伺服器組中,「大規模」使用了Redis(幾十個阿里雲實例,相對我們的體量來說,挺大規模了),主要有如下四類用途:
- 永久存儲
- 數據緩存
- 實時消息通知
- 消息隊列
最後一節,附上簡單的Redis事務代碼。
永久存儲
因為Redis支持持久化(rdb和aof),所以在《江湖X》內測的時候,事情還是比較簡單的,我們把所有數據全部丟在Redis,每天日活500,怎麼弄都行。(《江湖X》是《漢家江湖》的前作,技術路線一脈相承)。並且,我們也沒有使用阿里雲的Redis,而是直接在實例ECS上自己搭建的(說搭建有點抬舉自己,下載雙擊bat文件就好了)。
這個時候,我們主要使用了Redis的哈希表和哈希集合。《漢家江湖》客戶端提交到伺服器的玩家數據,是幾千張哈希表,我們直接全部原樣存進了Redis,然後把一個玩家的所有哈希表的key名,存進一個玩家的哈希集合,作為索引。如下圖所示,這是一個玩家的索引集合,IT=Index Table。
大家可以看到,玩家「xh937789#1」的這個索引集合,有1502個值,對應了這個玩家1502個哈希表。而且這還不是一個高級玩家,高級玩家的哈希表,一般在2500到3000左右。這樣,《江湖X》7月上線之後,美妙的事情發生了:阿里雲ECS的硬碟扛不住了。(說實話,阿里雲的硬碟性能是真不行)
等等,Redis不是內存程序么?關硬碟一毛錢關係啊?這是因為,拿redis當永久存儲,一定要面臨的問題就是持久化,不然你出個惡性bug,連伺服器回檔都做不到,那就gg思密達了。(科普:rdb是把Redis裡面的所有數據,全量拷貝壓縮存儲放進硬碟;aof類似於mysql的bin日誌,保證可以通過aof文件回溯任一狀態)
一開始我們天真的開啟了rdb和aof兩種持久化方式,rdb一小時複製一次全量到硬碟,aof採用每秒一次強制寫,這樣可以保證回檔精度為1秒(回檔精度,咳咳)。上線之後第一天,核心玩家瘋狂湧入,我默默地把aof的強制寫頻率設置為操作系統的30秒,這樣回檔精度就是30秒了。
由於Redis是存著所有玩家數據的,所以隨著用戶的增多,rdb開銷直線上升;隨著日活的增加,aof的開銷也日日見漲。上線之後第四天,我默默地關閉了aof功能,這樣回檔精度就是一小時了(rdb一小時一次)。
所以,當時《江湖X》的備份機制就是,每小時有一份全量拷貝,存在ECS的硬碟里。過了大概半個月,每小時的拷貝文件突破了1G的大小;過了大概一個月,突破2G大小,這樣一天就是24G,並且還隨著時間的增加不斷變大,Redis佔用的實時內存也突破6G。
當時覺得,再這麼下去,伺服器要爆炸,我們遊戲隨時要嗝屁了。
緩存數據
《江湖X》上線一個月之後,我們必須解決Redis快爆炸這個問題,當時有兩個思路:
- 買買買:8核16G配SSD,不行就上16核32G,最後實在不行就滾服。(我們絕不滾服!)
- 底層重構:引入mysql或者mongodb做存儲,redis做緩存。
貧窮限制了我們的想像力,高配實在是買不起買不起,對我們創業者來說簡直是搶劫。
但是,貧窮激發了我們的行動力和創造力,不就是重構么,搞!
經過調研,我們排除了mongodb,決定採用mysql來做持久化。(這裡安利一下ServiceStack的Ormlite和Redis,非常好用)玩家數據的流程圖如下:
經過一個月有條不紊(都是反話)的開發,我們在2016年9月12號上線了新的伺服器,並且引入了阿里雲的雙可用區備份的Mysql,Redis還是自己搭建在實例上;通過以上方案,回檔精度為5分鐘,萬一Redis出問題,也可以在Mysql裡面找到5分鐘之前的數據;Mysql通過阿里雲來保證雙實例熱備,整個伺服器穩定性相較之前的裸奔狀態,提升了無數個數量級。實例上的Redis,是關閉了rdb和aof持久化的,所以對硬碟,沒有任何負載。
這之後,《漢家江湖》絕大多數數據,都在Mysql上做持久化,Redis只做緩存(包括好友系統、社交系統等)。
少部分有時間限制的數據,我們還是直接用Redis做存儲,例如每天自動刷新的一些數據(日常任務,日常活動),每周舉行的一些活動(周末玩法等)。
實時消息通知
Redis的消息訂閱功能,非常強大,這裡安利一下阿里雲的256MB的Redis,一個月25塊錢,簡直是實時消息的利器。《漢家江湖》的公共聊天、幫會聊天、私聊、系統消息、GM命令、諸多玩法實時消息隊列,都是基於Redis的消息訂閱來做的。(私聊後續還需要改進)
這裡沒什麼多說的,直接上代碼比較實在。基於ServiceStack的Redis,實現了一個簡單的消息訂閱框架。注意,只能實時通知,離線掉線就會收不到。
public class RedisMsg{ //private readonly Dictionary<string, Thread> _subThreads = new Dictionary<string, Thread>(); private readonly Dictionary<string, IRedisPubSubServer> _subServers = new Dictionary<string, IRedisPubSubServer>(); protected RedisManagerPool RedisPool; //初始化 public RedisMsg(string redisStr) { RedisPool = new RedisManagerPool(redisStr); } //初始化 public RedisMsg(RedisManagerPool redisPool) { RedisPool = redisPool; } //發布消息 public void Pub(string channel, string msg) { lock (this) { using (var pub = RedisPool.GetClient()) { pub.PublishMessage(channel, msg); } } } //訂閱 public bool Sub(string channel, Action<string> callback) { if (_subServers.ContainsKey(channel)) { return false; } var redisPubSub = new RedisPubSubServer(RedisPool, channel) { OnMessage = (recieveChannel, msg) => { callback(msg); } }.Start(); _subServers.Add(channel, redisPubSub); return true; } //取消訂閱 public void UnSub(string channel) { if (!_subServers.ContainsKey(channel)) return; var server = _subServers[channel]; server.Stop(); _subServers.Remove(channel); }}
消息隊列
《漢家江湖》有一個「論劍」玩法,在一個小時內,會有幾萬場玩家對戰,每一場對戰,都是在伺服器進行計算,然後將計算結果下發。這就需要引入一個消息隊列,保證每一條消息必定被且只被消費一次,並且與消費者離線/在線狀態無關。
一開始我們使用了阿里雲的消息隊列MQ,這是一個支持」億/秒級別「的消息隊列,優點很明顯,成熟穩定,巨量支撐;缺點就是,坑很多,因為太複雜了,你永遠不知道在哪裡會出問題,而且百萬千萬級別的消息,丟了一點,延遲一點問題不大,但是在」論劍「這種玩家實時對戰的時候,出一次問題,就是遊戲體驗的毀滅性打擊。
經過連續兩個月晚上9點到10點的蹲點,在阿里雲售後的幫助下,我們解決了一大堆MQ的問題。
在那之後,我們終於可以長舒一口氣了,因為,我們決定放棄阿里的MQ。
不是它不好,而是我們的需求不滿足它的設計目的。我們需要的就是實時性、必定到達和冪等,量級在「千/秒」。所以我們用了Redis的List用來做簡單的消息隊列,完美滿足我們的需求,至今半年沒有出過任何問題。
發布任務代碼:
public int InitTask(string uid, string teamInfoA, string teamInfoB){ if (!_IsInitialedDispatcher) return 0; using (var db = _mysql.Open()) { var task = new ComputeTask { UID = uid, Status = (int)ComputeTaskStatus.Initial, CreateAt = DateTime.Now, TeamInfoA = teamInfoA, TeamInfoB = teamInfoB, UpdateAt = DateTime.Now }; var id = (int)db.Insert(task, true); using (var redis = _redis.GetClient()) { redis.EnqueueItemOnList(QueueListKey, id.ToString()); } return id; }}
消費任務代碼:
public class RedisTaskQueue{ private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); public int ThreadNum = 0; public int MaxThreadNum = 12; //0為停止,1為開始 private int _status = 0; private RedisManagerPool _redis; private Thread _loopThread; private string _topic; private Action<int> _taskHandle; public string QueueListKey => $"Queue:{_topic}"; public void Init(string topic, int threadNums, string redisStr, Action<int> taskHandle) { Output($"begin init, topic:{topic}, num:{threadNums}, redis:{redisStr}"); _topic = topic; MaxThreadNum = threadNums; _taskHandle = taskHandle; _redis = new RedisManagerPool(redisStr); Output($"call the start function"); Start(); } public void Stop() { Interlocked.CompareExchange(ref _status, 0, 1); } public void Start() { Interlocked.CompareExchange(ref _status, 1, 0); _loopThread = new Thread(DoLoop); _loopThread.Start(); } public void DoLoop() { using (var redis = _redis.GetClient()) { Output($"開始一次循環"); while (Interlocked.CompareExchange(ref _status, -1, -1) == 1) { if (Interlocked.CompareExchange(ref ThreadNum, -1, -1) >= MaxThreadNum) { Output($"超過最大線程數,休息一秒"); Thread.Sleep(1000); } else { var idStr = redis.BlockingDequeueItemFromList(QueueListKey, TimeSpan.FromSeconds(1)); if (!string.IsNullOrEmpty(idStr)) { Thread.Sleep(200); var taskId = Convert.ToInt32(idStr); Output($"開始計算任務{taskId}"); Task.Run(() => { Interlocked.Increment(ref ThreadNum); _taskHandle(taskId); Interlocked.Decrement(ref ThreadNum); }); } } } _logger.Info($"循環結束"); } } private static void Output(string msg) { _logger.Info(msg); }}
通過Watch實現事務
Redis的響應是微秒級的,一秒可以處理百萬級別的指令。但是,天有不測風雲,人有旦夕禍福,總有些人運氣特別好,能遇到這種百萬分之一的概率。
《漢家江湖》中的幫會Boss戰,一般有幾十名玩家參加。Boss血量共享,每次玩家提交傷害,都需要修改同一個當前血量欄位,這個欄位放在redis中。然後,某一天,神奇的事情發生了,某幫會有一名玩家,明明打了Boss,最後沒有收到獎勵。通過排查日誌,他提交傷害的時候,與另外一名玩家衝突了,導致Redis事務失敗(論日誌的重要性)。下面是改進的Redis事務代碼,通過「watch」關鍵的key值,可以重啟事務,來提升事務的成功率。
var bFlag = true;while (bFlag){ var bKill = false; redis.Watch(infoHKey); redis.Watch(scoreZKey); var currentHp = Convert.ToInt32(redis.GetValueFromHash(infoHKey, "CurrentHp")); if (currentHp <= 0) return false; if (damage >= currentHp) { damage = currentHp; bKill = true; } using (var trans = redis.CreateTransaction()) { var damage1 = damage; trans.QueueCommand(r => r.IncrementItemInSortedSet(scoreZKey, userKey, damage1)); trans.QueueCommand(r => r.IncrementValueInHash(infoHKey, "CurrentHp", -damage1)); trans.QueueCommand(r => r.AddItemToSortedSet(timeZKey, userKey, now)); if (trans.Commit()) bFlag = false; } if (bKill && !bFlag) SendKillMessage(bossId, bhId); //向redis發送消息,管理服來結算}
對於Redis,還只是了解了一點皮毛,歡迎大家指出文中的任何問題,謝謝!
推薦閱讀:
※口碑vs利益:獨立遊戲路在何方
※去月球:結果往往不比過程更重要
※為什麼在中國遊戲市場的今天,我們還要去做獨立遊戲?
※【工具】遊戲製作記錄
※我們做了一款遊戲,希望能關注我們,感謝!