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

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

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

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

好看的文生图 AI 提示词
把健康找回来

评论区

评论加载中...