内容

大家好,我是《江湖X》、《汉家江湖》的服务器主程、运维、客服和背锅临时工,今天给大家分享一下,这两年我们使用Redis的一点浅薄的心得体会,如果能给大家带来一点点帮助,就心满意足了。

(感谢阿里云)

总(fei)结(hua)

Redis是一款强大的工具,优点是响应微秒级,有广播/订阅功能,支持事务(非集群),支持队列、哈希表和哈希集合,缺点是需要手动管理索引,事务不可回滚,原生集群不支持事务等高级功能。

我们在《汉家江湖》服务器组中,“大规模”使用了Redis(几十个阿里云实例,相对我们的体量来说,挺大规模了),主要有如下四类用途:

  1. 永久存储

  2. 数据缓存

  3. 实时消息通知

  4. 消息队列

最后一节,附上简单的Redis事务代码。

永久存储

因为Redis支持持久化(rdb和aof),所以在《江湖X》内测的时候,事情还是比较简单的,我们把所有数据全部丢在Redis,每天日活500,怎么弄都行。(《江湖X》是《汉家江湖》的前作,技术路线一脉相承)。并且,我们也没有使用阿里云的Redis,而是直接在实例ECS上自己搭建的(说搭建有点抬举自己,下载双击bat文件就好了)。

这个时候,我们主要使用了Redis的哈希表和哈希集合。《汉家江湖》客户端提交到服务器的玩家数据,是几千张哈希表,我们直接全部原样存进了Redis,然后把一个玩家的所有哈希表的key名,存进一个玩家的哈希集合,作为索引。如下图所示,这是一个玩家的索引集合,IT=Index Table。

image.png

大家可以看到,玩家“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快爆炸这个问题,当时有两个思路:

  1. 买买买:8核16G配SSD,不行就上16核32G,最后实在不行就滚服。(我们绝不滚服!)

  2. 底层重构:引入mysql或者mongodb做存储,redis做缓存。

贫穷限制了我们的想象力,高配实在是买不起买不起,对我们创业者来说简直是抢劫。

但是,贫穷激发了我们的行动力和创造力,不就是重构么,搞!

经过调研,我们排除了mongodb,决定采用mysql来做持久化。(这里安利一下ServiceStack的Ormlite和Redis,非常好用)玩家数据的流程图如下:

image.png

经过一个月有条不紊(都是反话)的开发,我们在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,还只是了解了一点皮毛,欢迎大家指出文中的任何问题,谢谢!



源文:https://zhuanlan.zhihu.com/p/34672726