游戏数据零点更新问题
具体请参考原文链接
接上一篇【游戏架构】游戏部署分库分服的重要性 中提到游戏数据处理的一个难点,就是零点数据重置,容易出现数据不同步或者延迟的情况。分库分服只能降低数据库的压力,尽量避免出现数据库压力爆表的情况,但这不是解决游戏数据零点重置问题的根本办法。
按照常规设计思路,一个数据需要零点进行重置,我们通常会写一个零点定时器,到了每天零点,去重置玩家的数据,比较挫一点的写法是,定时器触发后,以玩家为单位进行更新,循环所有玩家,先把玩家内存的数据重置,然后把玩家的数据更新到数据库。
这种重置模式,在玩家数量少的时候没什么感觉,数量多了,比如几千个玩家就要重复几千个同样的数据库请求,会发现效率十分低下,这个时间的数据库压力很大。
怎么办?只能写一些批量重置的SQL语句,但是批量重置的写法破坏了玩家面向对象的特性,而且这样的写法其实十分丑陋,但是丑陋归丑陋,胜在实用。采用批量重置的办法确实可以有效减轻数据库请求次数,降低数据库零点压力。
但是,批量操作虽然好,却会有其他一些多线程问题。举个例子,零点的时候触发了数据重置定时器,定时器是一个单独的线程处理的,此时内存数据的重置是即时的,并通知了客户端,但这时候数据库批量重置没那么快,还没重置完(假设需要10秒钟),然后玩家在0点0分05秒的时候进行了操作,并通过游戏主线程对玩家数据进行了数据库更新,这个更新很快成功了。最后发现,这次操作被被姗姗来迟的批量重置语句覆盖了,这时候就形成了内存数据和数据库数据的不一致。
内存数据已经有新的数据,而数据库是0,在某些极端情况下,比如这个玩家又恰好下线或者因为某种方式重新更新了数据库数据,他就会出现零点被重置两次的情况,可以获得两次收益。这个案例确实存在,虽然出现的几率很低很低,但是确实出现过,而且之前排查问题的时候感觉毫无头绪。
后来我发现以个人为单位进行数据重置也会存在这个问题,这其实根本原因是一个多线程问题,只要你是多线程去处理玩家数据,就存在出现这种问题的可能性。
俗话说,解决多线程问题的办法就是用单线程,哈哈哈。
开个玩笑,不过我们确实采用了一种更加折中的办法,个人数据在个人登录的时候进行重置,这样玩家数据就在当前线程完成了重置,每日零点只对小部分还在线的玩家进行重置。玩家数据里存了一个最近一次重置的时间,登录的时候根据重置时间来判断个人数据是否需要进行重置,从而分散玩家重置的过程,让零点的压力减轻,这套方案我沿用了两个游戏,确实很有成效,但很遗憾的是,它依然没有解决在线玩家零点重置的时候这个问题。
于是,我开始思考,怎么样才能真正解决游戏里的零点重置问题。
在解决玩家数据拓展性时,之前的文章【游戏架构】如何解决玩家数据的拓展性 里有提到,我对玩家的每日数据写了这样一个k-v结构,来解决数据拓展性问题。针对每个玩家数据,这个结构的主键是roleId-dataType。
data class MemRoleDaily(
var recordId: Int = 0,
var roleId: Int = 0,
var dataType: Int = 0, // 数据类型
var dataValue: Int = 0 // 数据值
)
写完之后我就在考虑这个结构的重置问题,绕来绕去,还是绕不开之前的问题。于是我就思考这个数据的真实目的,它就是个每日数据,每日数据必然是只针对某一天生效,那么我是不是可以加入一个日期标签,让这个数据变成某一天的专属数据。
这个思路让我豁然开朗,于是我把结构调整为如下
data class MemRoleDaily(
var recordId: Int = 0,
var roleId: Int = 0,
var date: Int = 0, // 数据所属日期
var dataType: Int = 0, // 数据类型
var dataValue: Int = 0 // 数据值
)
这时候,这个玩家数据的主键变成roleId-date-dataType,日期成为了每日数据中关键的一个标记,所有数据都围绕这个标记展开。这个date,我写了一个实时获取函数,来获取当时当下最新的日期标记。
fun date(): Int {
val c = Calendar.getInstance()
return c.get(Calendar.YEAR) * 10000 + (c.get(Calendar.MONTH) + 1) * 100 + c.get(Calendar.DATE)
}
然后就是修改对外的获取和设置数据接口,这两个接口可以保证你无论何时调用,都是当天最新的数据,并可以准确更新到数据库。在零点到来的一刹那,数据已经完成了自动切换,不,它其实根本不存在切换,因为有就是有,没有就是没有。
fun getValue(dataType: Int): Int {
val date = date()
val memRoleDaily = dataSet.find { it.date == date && it.dataType == dataType }
return memRoleDaily?.dataValue ?: 0
}
fun setValue(dataType: Int, value: Int) {
if (value < 0) {
return
}
val date = date()
val memRoleDaily = dataSet.find { it.date == date && it.dataType == dataType }
if (memRoleDaily == null) {
create(date, dataType, value)
} else {
memRoleDaily.dataValue = value
update(memRoleDaily)
}
}
最核心的一点是,这个结构无须在零点去清理数据库。你可以在任意一个你想清理的时间去批量清理老旧数据,同时你可以清理也可以不清理,这都不会对你现在的数据准确性造成影响。这个结构甚至都不用清理内存数据,依然能保证你每天的数据都是最新的。
当然,必要的数据清理还是要做的,必要的数据库清理也是要做的,但是这些很容易实现。我自己其实也没想到,解决数据零点依赖的根本办法竟然是消灭零点更新,一个看起来简单至极的数据结构,却有化腐朽为神奇的感觉。而且,从每日数据可以延伸到很多其他数据,比如每周数据,每月数据,每年数据等等,这个结构都会让你的系统更加强壮。
写了一堆废话,谢谢你看完了,哈哈哈。有时候大道至简,返璞归真,写代码也一样,怎么让代码拓展性更强,更加健壮,更加稳定或许是一个优秀码农必备的技能。下一期和大家讲讲怎么让活动结构变得通用起来。