游戏服务器早期都用c语言开发,周期慢调试酸爽,后来开始改用luajit了,我们算激进的2009年就改了,毕竟网游比较看重性能,实际动态语言易于开发,和灵活的特性比性能更重要。
在不断改进功能和重构中,意识到代码任务依然繁重,跟不上越来越快的迭代需求,很多功能无法立即实验并测试,想要改善得再换个语言,并从概念上重新设计游戏服务器。
服务器即数据库
以往的结构都是游戏服务器接受客户端指令,处理逻辑,然后去操纵数据库,最后返回更新数据给客户端。这个过程本身就带来了复杂度,所以不如直接隐藏数据库,或者说合为一体,游戏服务器就是数据库。
有这样的想法其实很自然,一直以来网游就讨厌关系型数据库,要么直接用简单的内存mapping,有效又快捷,要么用Redis之类nosql。其次最好的数据库永远是定制数据库,因为不同的业务取舍各不相同。那就不如直接把服务器设计成一种专门为游戏客户端服务的数据库接口,当然后端还是由真正的数据库来负责持久化,但彻底隐藏掉数据库操作,内部自动处理。
下面把游戏服务器称为db,后端真正的数据库称为backend。
- 首先游戏客户端可直接对db订阅查询(select ... where ...),订阅内的任何数据变动db会自动推送,包括on_insert/on_update/on_delete等事件。
- 表要设置简单的权限,让客户端只能查询自己相关的数据。但有些表应能设为guest权限,比如服务器在线人数状态,让未登录的客户端都可以查询。
- 客户端只能订阅,没有写入权限,写入要通过db逻辑代码,就是传统游戏服务器逻辑,既然是db,也可以叫储存过程。客户端直接call名字,db执行对应函数。大多数情况不用返回执行结果,因为可以通过推送自动体现。
- db逻辑代码中的读取/写入操作都要包装在一个事务中,然后自动提交给backend,如果事务冲突则自动重试。这样写的时候不用考虑竞态,部署也会更加灵活。
graph TD; subgraph "游戏服务器(DB)" DB1["订阅"]; DB2["逻辑"]; end Client["游戏客户端"]<--订阅数据流-->DB1; Client--Call-->DB2; subgraph "Backend" DB1<--只读订阅-->副本; DB2--读写事务-->Master; end
此形式无论客户端还是服务器端,写起来都会非常舒服。
客户端只要订阅数据,就能通过数据事件自动处理UI,或场景中的物体,和服务器逻辑大幅解耦。
服务器端则可以只写真正的逻辑,不再需要关心和游戏客户端传送任何更新的数据。
特性取舍
这主要是平衡变种不可能三角:性能,灵活性,和数据一致性,提高某一项会拉低其他某项。
首先是性能,可采用webserver类似的负载平衡分布式结构,因此性能只会受制于backend数据库,所以用性能较高的redis,而且redis自带mq,不需要再套一层。redis可以做横向扩展提升性能,但牵涉到数据一致性取舍。最后redis维护和展现数据不方便,一般是redis只做cache,之后再叠一层mysql来做持久化,但会增加不少复杂度,大幅降低灵活性,redis是可以胜任持久化的,也可以实现索引,不如只用redis,数据的维护和展现我打算之后交给数据科学相关的库来解决。
灵活性包括故障和维护方面。这方面放弃高可用,遇到错误客户端直接断线并连接另一台服务器,游戏可以做成无感重连,也可以直接重登录。维护上最好做到一键开服,方便动态增减配,可大幅降低成本。故障自动恢复可以通过设置自动重启实现,这需要良好的数据一致性,让故障时无需数据回档/修复等操作。
最后游戏对数据一致的要求很高,首先分布式的数据竞态怎么解决,以及程序crash/宕机时写一半的数据怎么解决,总不能扣了玩家钱东西没发放,这是必选项。最好的方法是通过backend事务来实现,但事务会大幅降低backend的性能,哪怕是redis,也会降低2个数量级。如果不通过事务实现,因为牵涉到索引,要通过锁或其他方式,这些都写起来非常麻烦,而且也会牺牲所有维护上的灵活性,因此这里选择事务,牺牲backend性能。然后是backend可以通过分割事务,来支持横向扩展,并保持一致性,后面会谈到。
--- title: 架构图 --- graph TD; Client1["客户端"]-->DNS; Client2@{ shape: processes, label: "客户端..."}-->DNS; DNS["负载均衡DNS"]-->Node_A; DNS-->Node_B; subgraph "Node1" Node_A["游戏服务器"]-->Worker_A1["Worker"]; Node_A-->Worker_A2@{ shape: processes, label: "Worker..."}; end subgraph "Nodes..." Node_B["游戏服务器..."]-->Worker_B1["Worker"]; Node_B-->Worker_B2@{ shape: processes, label: "Worker..."}; end Worker_A1-->Backend["Backend(Redis Cluster/Replica)"]; Worker_A2-->Backend; Worker_B1-->Backend; Worker_B2-->Backend;
语言和更多性能考量
其实lua和现代语言比起来,并不是那么便捷,甚至c++11的很多特性都比lua舒服。加上lua的库少缺乏维护,游戏服务器淘汰lua已然是必选项了。
rust是一个不错的选择,安全性,性能,现代化,全方面满足。但我倾向于选择动态语言,最好是一门书写很方便的动态语言比如python。其实只要支持分布式,瓶颈就完全卡死在redis上,语言的性能都无所谓,现在cpu比人便宜,如果服务器维护又方便的话,通过竞价服务器还可以再便宜50%。大家甚至对docker降低的30%性能都无所谓了,方便要紧。
python的库很多,比如表读写完全可以用numpy array来包装起来,处理和筛选数组会非常顺手,举个例子,交叉索引:
array = money_table.query('last_update', left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()
在本地进行二次筛选,选出所有行中money低于999的,最后再向量化处理数据
这种fortran式的广播/向量化非常适合游戏中的数据处理模式,向量化会自动启用SIMD,比c的for loop计算都要快几十倍,mysql对于多重索引,也是通过cpu进行二次筛选,甚至没有SIMD。这样包装也解决了之前提到的redis维护数据不方便的问题,不光维护,做报表收入分析啥的还更方便了,终归数据处理和展现是python专长。
服务器npc的ai以往我们都用行为树,状态机等方式手动写,用python后可能性就无限了,完全可以用真正的AI模型方式实现,比如强化学习q learning等,他们通过未来奖励推导过去各种行为时机的价值,暴力得出npc最佳行为逻辑,当然对游戏性要求高的boss需要更多的调整,毕竟行为合理不代表好玩。
redis性能瓶颈和数据结构设计
redis的性能制约游戏服务器的处理能力,好在现在大型mmo已经不多见,万人以内的在线还碰不到redis的事务上限。redis单索引+事务能力大约是每秒3w次的读+写。
首先应该有客户端/服务器端分离模式,什么意思,就是客户端所有的订阅,都是通过只读redis副本进行的,不影响处理事务的master redis,服务器所有逻辑代码则运行在master上。这样处理能力会上升不少。
然后设计上还是要考虑未来master的扩展性。master可以通过多台redis服务器组成 cluster来扩展,但是有一个关键限制,那就是事务无法跨redis服务器。因此引擎要计算表之间的相关性,把相关表都放在固定的一台redis上。但关系数据库那种大表最后肯定都是相关的,无法解耦,这就需要用ECS结构了。
简单说就是把属性拆成一个个小的表,比如money属性做成一个叫money的表,称之为组件Component,只存放money和owner属性,然后通过owner属性关联(attach)到相关的player id上,类似unity的脚本组件。这样不再有大表,相关性可以拆的更散。另外player(只有一个id,并不实际存在)就是ECS中的Entity实体,逻辑代码就是System。
--- title: 组件簇举例 --- graph TD; subgraph "簇1" System_A-->Component1; System_B-->Component1; end subgraph "簇2" System_D-->Component3; System_D-->Component2; System_C-->Component2; end
总结
这些设计其实还是很轻量化的,需要的代码估计并不多,只是比较费脑子,我目前开发中并已开源 https://github.com/Heerozh/hetu,将用到下一款SLG游戏中去,也欢迎贡献。