三元组单集合的游戏数据存储方案

三元组单集合的游戏数据存储方案

现在的游戏服务器通常使用 NoSQL 作为 DB 以满足 Model 设计上的灵活性,MongoDB 则是 NoSQL 的代表之一。本文看点是 MongoDB 在游戏场景的存储方案、索引设计和代码层落地,每个部分都可以单独食用。

存储方案

单文档

优点是读取简单,缺点是文档大小会逐渐膨胀,而 MongoDB 限制单文档大小在 16 MB 以内,并且该方案总是全量更新。读多写少,能预估对象大小的场景可以使用。

分组保存

将 N 个对象拆分到 M 张表中,常见的是将每个业务模块创建一张表,例如 Players 和 Bags,它们通过 PlayerID 作为外键关联。优点是直观,缺点是集合数量膨胀、索引重复、业务层读写放大。

三元组单集合

在我们游戏中选择了这种方案,也是我推荐的设计。

只建立一张表存储玩家数据,并通过三元组寻址。

所谓三元组,指的是通过文档中的三个字段,定位唯一的文档,可以理解成逻辑主键。每一个文档都会包含这三个固定的基础字段:

  • collection:文档所属的业务模块。
  • key:同业务模块下对象的 key。
  • player_id:玩家唯一标识。

例如,获取玩家拥有的某个角色(玩家有多个角色),三元组可以表示为:

text
collection = role
key = 角色ID
player_id = 玩家ID

获取玩家的背包(玩家只有一个背包):

text
collection = bag
key = main
player_id = 玩家ID

无论是一对一的数据,还是一对多的数据,都可以用同一套地址结构表达,在集合中定位到唯一文档。

相比 单文档 , 三元组设计有文档级拆分能力。例如背包、角色、任务都可以独立读写, 不需要每次都更新整份玩家大文档。

相比 分组保存 , 统一表后不需要为每个模块重复维护一套索引、初始化逻辑和 CRUD 封装。新增一个业务模块时, 只是新增一个逻辑上的 collection 值, 而不是新增一张物理表。

它对索引优化也比较友好。因为所有业务文档都落在同一个表中, 核心访问路径最终都会收敛到 collection + key + player_idplayer_id 这类固定模式上。一次索引优化, 不是只服务某一张业务表, 而是可以让所有接入这套存储抽象的业务模块一起受益。

索引设计

好的索引准则:

  • 遵循 ESR 原则
  • 精准匹配业务层的查询模式;
  • 索引字段区分度高。比如 Bool 类型,只有两个值,选择性很差;
  • 索引能完全放入 WiredTiger 缓存,如果索引超出内存,会频繁触发磁盘 I/O。

三元组单集合方案,需要创建两个索引,均为等值查询:

  • 主键索引:collection + key + player_id(完全覆盖查询过滤条件)
  • 按玩家查询索引:player_id(避免登录场景,加载玩家完整数据时全表扫描)

进入运营阶段,还可以考虑建立 collection + player_id + key

索引命中分析

分析索引是否被有效使用,主要依赖 explain() 函数的输出,主要关注 totalKeysExamined(MongoDB 扫描的索引项数)、totalDocsExamined(MongoDB 回表读取了多少个文档)和 nReturned(返回文档数量)的值是否接近,甚至相等,相等是最理想的情况。获取更多 关于 explain() 返回值的解释。

下面是我们游戏测试服的 MongoDB 索引分析报告:

代码层落地

基于 官方 mongo-go-driver 进行封装,我们游戏封装了以下语义:

  • 初始化 MongoDB 连接;
  • 创建主键索引和玩家索引;
  • 提供 Version 乐观锁能力;
  • 初始化 storage 通用存储表;
  • 提供事务执行入口 ExecuteInTx
  • 基于三元组的 Write/Read/List/Delete/BulkWrite/BulkDelete
我以前一直不明白封装的意义是什么,官方驱动不是已经把 API 封装好了吗?现在才明白,不是二次包装 API(我做过这样的蠢事 😭),而是封装业务语义。换句话说, 业务层并不直接面对 MongoDB 的原始 Collection 和查询语法, 而是面对一套“玩家文档存储”的接口。

基于 Version 的乐观锁

在游戏服务器中,很多玩家数据都是“读出一份文档 -> 在内存中修改 -> 再整体写回 MongoDB”。MongoDB 可以保证单次写入是原子的,但如果两个写入方基于同一个旧版本同时修改,后写入的一方仍然可能覆盖前一个写入结果。

例如,两个服务同时读到了同一份角色数据:

text
A 读取 role 文档, version = v1
B 读取 role 文档, version = v1

A 修改等级, 写入成功, version 变成 v2
B 修改装备, 如果不检查 version, 就可能用旧快照覆盖 A 的等级修改

我的做法是把 Version 一起放进更新条件里,只有数据库中当前文档的 Version 和调用方读到的版本一致,这次写入才会成功。写入成功后,会更新 Version ,这里有个小巧思,我是根据新文档的 value 计算 SHA-256 得到,而不是使用额外的计数器。

当然,不是所有写入都需要乐观锁。在我们项目中,使用 BulkWrite 时, 这条路径不检查 Version。原因是玩家数据的主要写入权由游戏节点和玩家 Actor 生命周期约束,正常情况下不存在多个写入方同时改同一份文档的问题。

Version 乐观锁则保留给存在并发竞争的写入场景,例如 GM 工具、跨服务修复、后台任务或者任何需要基于旧值修改后再写回的流程。

结语

存储对象的完整表示(数据库中的一条文档)如下:

go
type Object struct {
	// 集合名称, 用于逻辑分组
	Collection string `bson:"collection" json:"collection"`
	// 对象在集合内的唯一键
	Key string `bson:"key" json:"key"`
	// 对象玩家 ID
	PlayerID string `bson:"player_id" json:"player_id"`
	// 对象的值, 存储为 BSON 原始数据
	Value bson.Raw `bson:"value" json:"value"`
	// 值的 SHA-256 哈希, 用于乐观锁并发控制
	Version string `bson:"version" json:"version"`
	// 创建时间
	CreateTime time.Time `bson:"create_time" json:"create_time"`
	// 最后更新时间
	UpdateTime time.Time `bson:"update_time" json:"update_time"`
}

意义不明的舞蹈 gif

回望这套存储设计, 它并不复杂,对我来说,最大的价值是让后面的业务少做选择题。它不是万能的,例如排行榜这类数据,访问模式就不是按玩家去定位一份文档了,需要单独建表建模。

新故事即将发生
把健康找回来

评论区

评论加载中...