关于MMORPG数据模型设计、数据库访问和无栈Python的建议
我正在开发一个回合制的休闲MMORPG游戏服务器。
我们的底层引擎(不是我们自己写的)负责网络、线程管理、定时器、服务器间通信、主游戏循环等,使用的是C++。而游戏的高层逻辑则是用Python编写的。
我想讨论一下我们游戏的数据模型设计。
最开始,我们尝试在玩家登录时把所有玩家的数据加载到内存和一个共享的数据缓存服务器中,并定期安排一个定时器将数据刷新到数据缓存服务器,之后再将数据持久化到数据库中。
但我们发现这种方法存在一些问题:
1) 有些数据需要即时保存或检查,比如任务进度、升级、物品和金钱的获得等。
2) 根据游戏逻辑,有时我们需要查询一些离线玩家的数据。
3) 一些全球游戏世界的数据需要在不同的游戏实例之间共享,这些实例可能在不同的主机上运行,或者在同一主机的不同进程中运行。这是我们需要一个数据缓存服务器在游戏逻辑服务器和数据库之间的主要原因。
4) 玩家需要能够自由切换不同的游戏实例。
以下是我们过去遇到的一些困难:
1) 所有的数据访问操作都应该是异步的,以避免网络I/O阻塞主游戏逻辑线程。我们必须向数据库或缓存服务器发送消息,然后在回调函数中处理数据回复消息,继续执行游戏逻辑。这使得编写一些中等复杂度的游戏逻辑变得非常痛苦,因为这些逻辑需要多次与数据库交互,而游戏逻辑又分散在许多回调函数中,导致理解和维护变得困难。
2) 临时的数据缓存服务器让事情变得更加复杂,我们很难保持数据的一致性,并有效地更新、加载和刷新数据。
3) 游戏内的数据查询效率低且繁琐,游戏逻辑需要查询很多信息,比如背包、物品信息、角色状态等。还需要一些事务机制,例如,如果某一步失败,整个操作应该回滚。我们尝试在内存中设计一个好的数据模型系统,建立很多复杂的索引以简化信息查询,并添加事务支持等。很快我意识到,我们正在构建一个内存数据库系统,简直是在重复造轮子……
最后,我转向了无栈Python,我们去掉了缓存服务器。所有数据都保存在数据库中。游戏逻辑服务器直接查询数据库。借助无栈Python的微任务和通道,我们可以以同步的方式编写游戏逻辑。这让编写和理解变得容易得多,生产力也大大提高。
实际上,底层数据库的访问也是异步的:一个客户端任务向另一个专用的数据库I/O工作线程发出请求,而这个任务在一个通道上被阻塞,但整个主游戏逻辑并没有被阻塞,其他客户端的任务会被调度并自由运行。当数据库数据回复时,阻塞的任务会被唤醒并在“断点”处继续运行。
基于以上设计,我有一些问题:
1) 数据库的访问频率会比之前的缓存方案更高,数据库能支持高频率的查询/更新操作吗?未来是否需要一些成熟的缓存解决方案,比如Redis、Memcached?
2) 我的设计中是否存在严重的缺陷?你们能给我一些更好的建议吗,特别是在游戏内数据管理模式方面。
任何建议都非常感谢,谢谢。
2 个回答
在不了解整个软件的情况下,很难对设计和数据模型进行评论。不过,听起来你的应用可以从内存数据库中受益。* 将这样的数据库备份到硬盘上相对来说是个便宜的操作。我发现通常情况下,以下操作会更快:
A) 创建一个内存数据库,建立一个表,然后往这个表里插入一百万**条数据,最后将整个数据库备份到硬盘上。
比
B) 在一个基于硬盘的数据库中往一个表里插入一百万**条数据要快。
显然,在内存中单条记录的插入、更新和删除操作也会更快。我在使用JavaDB/Apache Derby作为内存数据库时取得了不错的效果。
*注意,数据库不一定要嵌入到你的游戏服务器中。
**一百万条数据可能不是这个例子的理想大小。
我曾经使用过一个MMO引擎,它的工作方式和你说的有点像。这个引擎是用Java写的,而不是Python。
关于你提到的第一组观点:
1) 异步数据库访问 我们采取了不同的做法,避免了有一个“主游戏逻辑线程”。所有的游戏逻辑“任务”都是作为新线程来启动的。创建和销毁线程的开销在输入输出的噪音中几乎是微不足道的。这也保持了每个“任务”都是一个相对简单的方法,而不是让人抓狂的回调链(虽然还是有这种情况)。这也意味着所有的游戏代码都必须是并发的,我们越来越依赖于带有时间戳的不可变数据对象。
2) 临时缓存 我们使用了很多弱引用对象(我相信Python也有类似的概念?),并且在数据对象(比如“玩家”)和“加载器”(实际上是数据库访问方法,比如“PlayerSQLLoader”)之间做了区分;实例保持对它们加载器的指针,加载器由一个全局的“工厂”类来调用,这个工厂类处理缓存查找和网络或SQL加载。数据类中的每个“设置器”方法都会调用一个名为changed
的方法,这个方法是继承的模板,内容是myLoader.changed(this);
为了处理从其他活跃服务器加载对象,我们使用了“代理”对象,这些对象使用相同的数据类(比如“玩家”),但我们关联的加载器类是一个网络代理,它会(同步地,但通过千兆局域网)更新另一个服务器上该对象的“主”副本;反过来,“主”副本会自己调用changed
。
我们的SQL UPDATE
逻辑有一个定时器。如果后端数据库在过去的($n)秒内收到了该对象的UPDATE
(我们通常保持在5秒左右),它会将对象添加到一个“脏列表”中。一个后台定时任务会定期唤醒,尝试异步地将仍在“脏列表”中的对象刷新到数据库后端。
由于全局工厂维护了对所有内存中对象的弱引用,并会在任何活跃服务器上查找给定游戏对象的单一实例,我们不会尝试实例化一个由单个数据库记录支持的游戏对象的第二个副本,因此游戏的内存状态可能与SQL中的状态在5到10秒内有所不同,这并不重要。
我们的整个SQL系统运行在内存中(是的,使用了很多内存),作为另一个服务器的镜像,那个服务器努力地写入磁盘。(那台可怜的机器平均每3-4个月就会因为“老化”烧坏RAID硬盘。RAID是不错的。)
值得注意的是,当对象从缓存中移除时,比如因为超出了缓存的内存限制,必须将它们刷新到数据库。
3) 内存数据库 … 我没有遇到过这种具体情况。我们确实有“事务式”的逻辑,但都是在Java的getter/setter层面上进行的。
关于你后面提到的观点:
1) 是的,PostgreSQL和MySQL特别擅长处理这个,尤其是当你使用RAM磁盘镜像数据库来尽量减少实际硬盘的磨损时。不过根据我的经验,MMO通常会对数据库的压力比必要的要大。我们的“5秒规则”*就是专门为了避免必须“正确”解决这个问题而设计的。我们的每个设置器都会调用changed
。在我们的使用模式中,我们发现一个对象通常要么只有1个字段被更改,然后一段时间没有活动,要么就是有一阵“更新风暴”,很多字段连续更改。构建适当的事务(例如,通知对象它即将接受多个写入,应该等一会儿再保存到数据库)会涉及更多的规划、逻辑和系统的大规模重写;因此,我们选择了绕过这个情况。
2) 嗯,以上就是我的设计 :-)
实际上,我目前正在开发的MMO引擎对内存中的SQL数据库的依赖甚至更大,而且(我希望)会做得更好。不过,这个系统是基于实体-组件-系统模型构建的,而不是我上面描述的面向对象模型。
如果你已经基于面向对象模型,转向ECS是一个相当大的范式转变,如果你能让面向对象模型满足你的需求,可能还是更好地坚持你团队已经熟悉的东西。
*- “5秒规则”是美国的一种民间说法,意思是如果食物掉在地上,只要在5秒内捡起来就还是可以吃的。