跳至内容
一种新型网游服务器引擎设计思路

一种新型网游服务器引擎设计思路

2024-05-08 03:15

有没有一种游戏服务器架构:既是分布式,又像写单线程程序一样简单;数据自动推送,隐藏无需关心的 API,只专注业务逻辑?

缘起

游戏服务器早期大多用 C 语言开发,周期慢、调试酸爽。后来开始改用 LuaJIT,我们算比较激进的,2009 年就切了。毕竟早期网游比较看重性能,后来技术进步才让性能没那么重要。

在不断改进功能和重构中,我们意识到代码任务依然繁重,跟不上越来越快的迭代需求。很多功能无法立即实验并测试,想要改善,就得从概念上重新设计游戏服务器。

服务器即数据库(Schema 即 API)

以往结构一般是:游戏服务器接受客户端指令,服务器逻辑去操纵数据库,最后返回更新数据给客户端。这个过程本身就带来了复杂度,所以不如直接隐藏数据库,或者说合为一体——游戏服务器即数据库。

有这样的想法其实很自然。一直以来,网游就不太喜欢关系型数据库:要么直接用简单的 MemoryMapping 追求最高性能,要么用 Redis 之类 NoSQL。有句话叫“最好的数据库永远是定制数据库”,那不如直接把服务器设计成一种专门为游戏客户端服务的数据库接口。当然,后端仍由真正的数据库负责持久化,但数据库操作彻底隐藏,内部自动处理。

下面把游戏服务器称为 db,后端真正的数据库称为 backend

  • 首先,游戏客户端可直接对 db 进行订阅查询(select ... where ...)。订阅范围内任何数据变动,db 都会自动推送,包括 on_insert / on_update / on_delete 等事件。
  • 表要设置简单的行级权限,让客户端只能查询自己相关的数据。但有些表应能设为 guest 权限,比如服务器在线人数状态,让未登录客户端也可以查询。
  • 客户端只能订阅,没有写入权限;写入要通过 db 函数,也就是传统游戏服务器逻辑。既然是 db,也可以叫存储过程。只不过这个存储过程专注于有状态的内存计算,这是数据库存储过程通常不具备的。
  • db 逻辑代码中的读取/写入操作都包装在一个事务中,然后自动提交给 backend。如果事务冲突则自动重试。这样写代码时不用考虑竞态,部署也更灵活。
    graph TD;
    subgraph "游戏服务器(DB)"
        DB1["订阅"];
        DB2["逻辑"];
    end
    Client["游戏客户端"]<--订阅数据流-->DB1;
    Client--Call-->DB2;
    subgraph "Backend"
        DB1<--只读订阅-->副本;
        DB2--事务读写-->Master;
        DB2--事务读-->副本;
    end
  

这种形式下,无论客户端还是服务器端,写起来都会非常舒服。 客户端只要订阅数据,通过响应式编程就能自动处理 UI 或场景中的物体,并与服务器逻辑大幅解耦。 服务器端则可以只写真正的逻辑,不再需要关心数据写入、更新和推送。

特性取舍

我们还要平衡不可能三角(变种):性能、灵活性和数据一致性。提高某一项,通常会拉低其他项。

性能

可采用 Web App 常见的负载均衡分布式结构,这样性能主要受制于 backend 数据库。若采用性能较高的 Redis,其优缺点如下:

  • 自带 MQ,不需要再套一层。
  • 可以做垂直扩展来提升性能,但会牵涉到数据一致性取舍。
  • 数据维护和展现不方便,但现在可以靠 AI 辅助解决。

灵活性

灵活性包括故障和维护方面:

  • 可实现近似高可用,遇到服务器错误时靠客户端无感重连。
  • 一键开服,方便动态增减配。
  • 单台服务器故障不能影响集群,这需要良好的数据一致性,让故障时无需数据回档/修复等操作。
  • backend 的动态增减配也需要考虑。

数据一致性

游戏对数据一致性的要求很高。分布式数据竞态怎么解决?程序 crash / 宕机时写到一半的数据怎么处理?总不能扣了玩家钱却没发放道具,这是必选项。

既然性能选择分布式,此架构下,数据一致性无非是事务,但 WATCH + MULTI 事务会大幅降低 Redis 性能,也会降低灵活性,比如在 ClusterRedis 代理下无法使用。

取舍后的选择是“版本乐观锁 + Lua 事务”方式:只有版本号完全一致、检查全部通过时,Lua 才执行批量写入,保证多条数据写入要么全部完成,要么全部失败。这种方式支持 Redis 各类 Fork 和架构。虽然用了 Lua,但实测性能比 WATCH + MULTI 快。

为了灵活性,放弃了给索引上锁,也就是无法阻止幻读(查询用户是否存在,如果不存在则插入新用户),但一般此类问题本来就是通过加Unique索引解决的。牺牲了索引查询的一致性,带来的灵活性和性能提升都是非常巨大。

    ---
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<br/>(Redis Cluster/Replica)"];
    Worker_A2-->Backend;
    Worker_B1-->Backend;
    Worker_B2-->Backend;
  

语言和更多性能考量

其实 Lua 和现代语言比起来,并不是那么便捷,甚至 C++11 的很多特性都比 Lua 舒服。加上 Lua 库少、维护弱,游戏服务器淘汰 Lua 已然是必选项。

Rust 是一个不错的选择:安全性、性能、现代化都很全面。但我更倾向于动态语言,这是基于灵活性的考虑,最好还能方便人类和 AI 书写。是的,答案呼之欲出:Python

其实只要支持分布式,瓶颈就完全卡在 Redis 上,语言性能反而没那么关键。现在 CPU 比人便宜;如果服务器维护也方便,通过竞价服务器还可以再便宜 50%

Python 的库很多,比如表读写完全可以用 NumPy array 来包装,处理和筛选数组会非常顺手。举个交叉索引的例子:

array = money_table.range("last_update", left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()

在本地进行二次筛选,选出所有行中 money < 999 的数据,最后再向量化处理。

这种 Fortran 式的广播/向量化非常适合游戏中的数据处理模式。向量化会自动启用 SIMD,比 C 里单纯 for loop 计算快很多倍。这样的包装也解决了前面提到的 Redis 数据维护不方便问题;终归数据处理和展现是 Python 的专长。

服务器 NPCAI,以往我们都用行为树、状态机等方式手写。用 Python 后可能性会更大,完全可以采用真正的 AI 模型方式,例如强化学习 Q-learning 等:通过未来奖励反推过去各种行为时机的价值,暴力得出 NPC 最佳行为逻辑。当然,对游戏性要求高的 Boss 还需要更多调优,毕竟行为合理不代表好玩。

Redis 性能瓶颈和数据结构设计

Redis 的性能制约游戏服务器的处理能力。

首先应通过读写分离,把客户端(订阅)和服务器(事务)的负载拆开。客户端所有订阅都通过只读 Redis 副本进行,不影响处理事务的 master Redis;服务器所有事务写入代码则运行在 master 上。这套方案对大多数游戏已经足够。

如果还要考虑更大规模的游戏(比如早年那种单服 MMO),就要考虑 master 的扩展性。master 可以通过多台 Redis 组成 Cluster 扩展,但有个关键限制:事务无法跨 Redis 服务器。因此,引擎要计算表之间的相关性,把相关表放在同一台 Redis 上。以往关系数据库那种大表往往彼此相关、难以解耦,这就需要引入 ECS 结构。

简单说,就是把属性拆成一个个小表。比如把 money 属性做成名为 money 的表,称为组件 Component,只存放 moneyowner 字段,然后通过 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
  

总结

好了,你再也不需要写任何恶心的锁,不用考虑数据冲突,可以像写单线程一样高效专注;客户端 MVC 里的 C 也再也不需要了。

这些设计其实还是很轻量化的,需要的代码估计并不多,只是比较费脑子。我目前正在开发并已开源:  Heerozh/hetu,后续会用到下一款 SLG 游戏中,也欢迎贡献。

最后修改
heerozh
heerozh · · 精炼 · 347ef6b
其他贡献者
暂无