谈谈GS对象的落地

现在的游戏服务器通常使用NoSQL作为DB以满足Model设计上的灵活性,特别是即将到来的MongoDB 4.0将提供多文档事务支持,这意味着,SQL转向NoSQL的最后障碍已经被消除。

理想的MongoDB使用场景是将单个对象映射为单个文档,比如玩家数据,公会数据等,但一方面MongoDB对单文档大小硬限制为16M,另一方面,我们需要根据对象更新频度,固有特性等进一步优化,提高DB性能。这里简单谈谈那些GS对象落地的方方面面。

一. 存储方案

1. 单文档

最简单的模型,将对象放在一个文档中,需要能够预估对象空间占用总量,比如玩家数据,单服内所有已经使用的玩家名字等。

2. 分组保存

将N个小对象分为M个文档保存,可以按照逻辑或者底层统一划分,比如简单按照对象ID%M得到,这种方案主要用于减少文档数量(文档数量对MongoDB序列化时间的影响是比较可观的)。分组保存主要有两种更新方式:基于$set/$unset的增量式更新(GroupSet),以及基于文档的覆写更新(GroupWrite)。

3. GridFS

MongoDB GridFS用于以二进制格式保存大文件,这种方案的优点的简单,将数据整块打包为二进制即可。缺点也很明显:

  1. 对大对象的序列化时间通常较长(毫秒级别)
  2. 二进制存储,DB对运维运营和数据分析不友好
  3. GridFS只能覆写,也就和读写优化搭不上边了

因此通常我不建议在GS中使用GridFS。

二. 存储优化

1. 批量读写

这个是DB操作层的优化,用于提高落地效率,主流的MongoDB驱动都提供的批量操作接口。

2. 脏标记

这是逻辑层的优化,主要用于减少和DB不必要的交互。脏标记控制对对象的写操作,对对象打上脏标记。主要用于不常变动的对象,比如玩家Cache,但针对数据结构复杂的对象,维护脏标记状态的工作量就比较大了,否则可能漏存数据。脏标记通常需要语言或框架层有访问控制或Hook机制(提供统一的修改入口)。另外就是脏标记本身有一定的不一致性风险,比如DB操作失败,但脏标记已被清除。

3. 初始标记

和脏标记类似,但主要用于减少DB数据量,提升加载效率。Origin标记用于判断一个对象是否是初始状态,如果是初始状态,则不写入DB,当DB加载时,通过配置生成这些Origin对象。比如SLG大地图上的格子,通常SLG大地图绝大部分格子都处于初始状态(未被占领),因此这类对象通过初始标记优化,可以极大程度提升地图加载效率。当一个格子由初始状态被占领时,该格子落地,当这个格子被玩家放弃回到初始状态时,这个格子被删掉。这个功能在调试期间很有用,在线上也能帮助服务器平滑过渡峰值(开服期间人多,但需要落地的地少,后期地多,但人少)。

4. 局部更新

针对比较复杂的对象,比如玩家,公会,对其子模块进行局部脏标记并局部更新(mongodb $set),和脏标记一样,这减少了DB交互。但是通常用得不多,主要原因一是和脏标记一样,需要语言框架的支持,二是粒度不好把控,粒度太细会导致开发负担增大,带来的弊大于利。

三. 存储时机

1. 定时落地

GS中大部分的对象并不需要实时地将更新写入DB,只需要每隔一段时间(Tick,通常几分钟)落地即可。定时落地通常会结合分组落地(每次Tick只落地一个分组),脏标记等优化方案。

2. 实时落地

对于少数关键数据,比如支付订单,通常是实时落地的,即数据产生或变更时立即写入数据库。

3. 停服落地

停服落地通常会与定时落地不同,为了数据安全性,停服落地可以采用刷盘操作,以再一次确保DB数据一致性。

四. DB交互

1. 同异步

DB交互本质上是外部IO交互,应该放到单独的DB线程中去完成,正常情况下,逻辑线程与DB线程只能异步交互,但在开服和停服逻辑中,为了简化流程,可以同步加载/落地。

2. 时序性

为了提升DB模块的效率,通常我们会开个DB Worker Pool来完成实际的DB工作,但要考虑到同一个调用方的多个DB请求的时序性保证,最简单的实现是将来自同一个逻辑模块的DB请求全部交给同一个DB Worker来完成。

3. 深拷贝

由于对象需要交由DB模块来落地,因此需要对对象深拷贝(同步保存不需要),也可以直接序列化为Bson之后再交给DB模块。

4. 事务支持

就我目前的开发经验而言,GS逻辑中基本是用不到事务的,或者说,需要用到事务的地方,都可以通过其它方式绕过去,大部分时候,GS都只是将DB作为内存持久化工具,进行单文档读写操作,用不到复杂的多文档事务。

五. 总结

这里只是简单提了下DB落地需要考虑的方方面面,在Model层设计上,应该赋予对象存储足够的灵活性,让对象自己根据存储时机,同异步去选择自己的存储方案和存储优化,对通用的存储方案,应该尽量抽象它们的操作,比如批量写,分组保存等。设计准则仍然是开发效率和灵活度优先,然后再是优化层面。具体实现上,还要结合具体的语言框架,比如对Go语言来说,由于其访问控制很弱,也没有Hook机制,因此对对象做脏标记要谨慎。