一个通用游戏数据服务器

把游戏数据拆成"对象颗粒":x3db 的单表数据服务设计与取舍

摘要:x3db 把玩家变量、钱包、背包、仓库、技能、称号、皮肤、成长线都归约成同一形态------"一粒对象 + 一组可扩展字段"。本文讲清这个模型怎么支撑读写、稀疏落盘、为什么单表比四张表还快,以及哪些事它明确不做。

标签:游戏服务器 / 数据库设计 / C++ / 存储 / 架构

代码仓库

https://gitee.com/ygluu/x3db


一、先看需求长什么样

做游戏服务端,最容易踩的坑不是性能,而是表结构随业务长。

一开始只有玩家变量表。然后加背包,建一张 bag;加仓库,建 warehouse;加技能,建 skill;加称号、皮肤、成长线......每加一个系统,要做四件事:

  1. 设计一张新表(字段、索引、主键)
  2. 写一套 DAO / ORM
  3. 对客户端暴露一套新接口
  4. 上线时跑 DDL

功能越加越多,表越来越多,DAO 越来越像复制粘贴。等想做"把玩家整份数据预热进内存"时,发现要 join 二十张表------这时候才会想:这些东西本质上是同一类数据吗?

是。玩家的背包里 100 件装备、技能面板上 30 个技能、称号列表里 50 个称号,都是"某个容器里的一批条目",每一条有 id、有数量、有状态、可能还有一段业务自定义的二进制。它们不该是二十种表,而是一种东西的二十个实例。

x3db 就是从这个判断出发做的。


二、抽象:以对象为颗粒

x3db 的数据模型只有一句话:

用 (ownerid, storeid, objid) 定位到一粒对象,粒上挂一组可扩展字段。

三层键各管一件事:

键 含义 举例
ownerid 数据属于谁 玩家 id、公会 id
storeid 装在哪个容器 0 = 玩家变量、1 = 背包、2 = 仓库、3 = 邮件
objid 容器里的哪一粒 物品 id、技能 id、任务 id

于是原本看着完全不同的系统,落到存储层是同一个形状:

业务 落法
玩家 kv 变量 storeid=0,objid=key
钱包/货币 storeid=0,objid=币种,intdata 记数量
背包/仓库/邮件附件 一个 storeid 一个容器,objid 是物品
技能/CD objid=skillid,level/num 记等级与冷却
称号/成就/任务 一册一个 storeid,status/num/flag 记进度与佩戴态
皮肤/时装 一册一个 storeid,flag 区分"已解锁/当前穿戴"
成长线 一条线一个 storeid,objid 是节点序号,relid 指向前置节点

变量也是对象 :storeid=0 只是把"一个 key"当成一粒只有一列的粒。所以不存在"变量表"和"对象表"的区别,也不存在 add 和 set 的区别------记录不存在时 set 就是插入。

这样做换来了一个很实在的结果:客户端只需要一套 CRUD,五个写读语义(set/sets/get/del/move)就能表达背包整理、装备穿戴、任务推进、货币扣减、皮肤切换。新业务先想办法在现有列上表达,实在不够再加列------加列不改表、不改接口、不改索引。


三、13 个列序号,替代 N 张表的 N 套列名

一张 objs 表,13 个业务列:

sql 复制代码
CREATE TABLE objs (
	ownerid BIGINT NOT NULL,
	storeid BIGINT NOT NULL,
	objid   BIGINT NOT NULL,
	slot     INT NULL,   -- 格位
	relid    BIGINT NULL,-- 关联对象
	reltype  INT NULL,   -- 关联类型
	flag     INT NULL,   -- 标志位(解锁/佩戴/绑定...)
	level    INT NULL,   -- 等级
	num      INT NULL,   -- 数量
	status   INT NULL,   -- 状态/进度
	intdata  BIGINT NULL,-- 通用整型值
	strdata  VARCHAR(8192) NULL, -- 通用文本值
	bindata  LONGBLOB NULL,      -- 通用二进制值(≤16MB)
	ctime    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
	utime    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
	PRIMARY KEY (ownerid, storeid, objid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 只有主键 ,没有唯一键、没有二级索引。所有查询都是 WHERE ownerid=? AND storeid=? 的主键前缀扫描。
  • 协议里不传列名,只传列序号 keyidx(0..12)。序号写死在头文件和协议常量里,两边必须一致。这样加业务类型不动物理表、不动协议。
  • 值类型故意分开:整型列、真正的 VARCHAR 文本列、LONGBLOB 二进制列。而不是一律序列化成 blob------分开之后运营能直接 select、能 like、能按值判断,排障成本差一个量级。
  • intdata/strdata/bindata 是三个通用值槽,relid/reltype 管对象间关系,status/flag/level/num/slot 管语义。真遇到现有列表达不了的,加列,keyidx 往后顺延。

keyidx = 0 是个特殊位:storeid 是伪键,只写不读 ------写它等于换容器,语义上等价于 move。这样 sets 里可以把"改字段"和"换容器"放在同一个原子请求里。


四、读:一次加载,之后全在内存

服务的角色很简单:一个进程同时做缓存和落库,客户端用 TCP 连它。

复制代码
业务线程  x3db_get(addr, ownerid, storeid, objid, page, page_size, cb, ud)
   │
   ▼
客户端库  查 (本线程, addr) 的连接 → varint 组包 → 阻塞 send/recv
   │  TCP
   ▼
服务端    唯一 IO 线程
            ├─ 该 owner 已在快照里 → 直接按内存组响应
            └─ 不在 → 请求挂进 Owner::pending,投一条 SELECT;
                      加载完成后按到达顺序重放,避免读到半份数据

首次访问某个 owner 时整份加载 (该 owner 名下所有 store 的所有对象),之后所有读都命中内存。快照有个 idle_sec(默认 300 秒)空闲淘汰,内存不会随登录人数单调上涨。

内存结构是三级,和表结构同构:

复制代码
Owner{ ownerid, unordered_map<int64_t, unique_ptr<Store>> }   // 该 owner 名下的容器
  └─ Store{ std::map<int64_t, Obj> }                            // 一个容器,按 objid 有序
       └─ Obj{ objid, keys, utime, ver, touched }                // 一粒,keys 是列数据

用 std::map 而不是哈希,是为了分页切片天然有序(std::advance(it, skip) 就能定位到第 N 页起点),不必额外排序。

分页的坑 :objid == 0 表示读整个容器,按 objid 升序切页。翻页条件用响应里的 more 字段,不是"返回条数小于 page_size"------最后一页刚好满页时后者会错判。单页字节超过阈值(默认 20MB)时整页失败 报错,不静默截断:截断会让"第 N 页"的偏移和 page * page_size 对不上,翻页必然漏数据。


五、写:改内存就回 OK,落盘按位图分组

写路径的核心是"不等落库":

复制代码
业务线程 ─▶ 客户端 ─▶ IO 线程:改内存快照 + 逐列记 touched + 脏键入队 ─▶ 立刻回 OK
                                │
                攒够 200 条 或 最老一条待满 200ms
                                ▼
                按 touched 位图分组(位图相同的行合成一条 SQL)
                                ▼
                按 ownerid % thread_num 投进对应 DB 线程
                                ▼
                执行完回程比对 ver:没变则清 touched,变了留到下一轮

业务看到的写延迟 = 改内存 + 一次 TCP 往返。代价是进程崩溃会丢掉最近一个刷盘窗口(默认 200ms)的改动,这是明确接受的取舍,不是遗漏。

关键是稀疏写 ,每个对象带一个 uint16_t touched 位图,记录"自上次落盘以来哪些列被动过":

  • 位在而值为空 = 这一列被置 NULL,落库写 NULL。
  • 位图相同的行合成一条 SQL,位图不同不混在一条里。

所以 set(level=5) 生成的 SQL 只有:

sql 复制代码
INSERT INTO objs (ownerid,storeid,objid,level) VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE level=VALUES(level)

不会顺手把 16MB 的 bindata 重写一遍。改一列不重写整行,这是大字段能同表放的前提。

两条线程规则守住了全部并发:

  • IO 线程只有 1 个,快照和脏队列只被它访问,全程无锁。这是"整份玩家数据放内存"敢做的前提。
  • DB 线程 thread_num 条 ,ownerid % thread_num 钉死 worker,同一 owner 的 SQL 按提交顺序串行执行,因此不会被两个连接同时改,不需要行锁、不需要冲突重试。

同一份 ownerid 决定了三层落点:主键第一列、DB 线程序号、脏队列归属键,三处走同一个取模。给定玩家 ID 就能手算出记录落在哪条落盘线程,排障不用查任何映射表。

另外两个容易忽略的细节:

  • 脏项提交后打"在途"标记,DB 慢时同一批不会被反复提交,新脏数据也不会被饿死。
  • 连续两次失败的行会被放弃并记 ERR,毒丸 SQL 不会堵住整个队列。回程时如果发现这行又被改过(ver 变了),只撤在途标记、把键留在队里 ,否则那几列的 touched 位再没人重投,会静默丢列。

最后一条是真踩过的坑:现象是"内存读得到、库里是空值",且没有任何日志。它只在库变大、刷盘变慢后才偶发,单轮干净库跑不出来。


六、为什么单表反而更快

直觉上"一张大表"应该更慢。实测(同机 MySQL 8.4,一批同构数据,4 个 DB 线程):

方案 耗时
1 张表 + 4 连接 284 ms
4 张表 + 4 连接 306 ms

结论:这个量级上分表没有收益 。InnoDB 的聚簇索引已经按主键把同一 owner 的行放在相邻页,主键前缀扫描本身就快;多表换来的是加载要扫 N 张、连接数翻倍、DDL/备份/迁移 × N。

吞吐的来源是多连接 ,不是多表。单表撑不住时的顺序应当是:先加连接 → 再按 ownerid 拆库 → 最后才谈分表。

代价是行数大(每玩家几百到几千行),但所有 SQL 都带 ownerid 前缀,线上不会出现跨 owner 的全表扫描。真正会先撞上的是 buffer pool 命中率和表膨胀,那是分区/归档的问题,不是分表的问题。


七、明确不做的四件事

一份设计文档,把"不做什么"写清楚比写"能做什么"更有价值:

不做 原因
跨行/跨表事务 游戏里真正需要多行原子的场景极少,真需要就把要一起改的东西放进同一粒的 bindata
全局二级索引 维护成本高(异步队列 + 一致性窗口),而查询几乎都带 ownerid,前缀索引够用
按 bindata 内容检索 那就不该放 bindata;要查的字段用 strdata(真 VARCHAR)或单独建旁表
自动分片再平衡 单表 + 拆实例已够;再平衡是运维系统的事

顺手说三个"刻意没做"的实现选择:

  • 通讯不压缩。请求体、响应体都原样传,省掉两端各一次 lz4 编解码,也省掉"压缩阈值怎么调"这个旋钮。
  • 落盘不压缩 。库里存的就是明文/原始字节,运营能直接 select 出来看。
  • 只在内存快照里压缩 。超过 1KB 的文本/二进制用 lz4 存,编码是 [u32 tag][payload],最高位区分原文和压缩。对客户端完全透明。

八、和 TcaplusDB 的关系

腾讯的 TcaplusDB 是同一类问题的工业级答案:按玩家维度切片的海量小记录、高并发点查、字段级读写。把两者的能力摆在一起看,骨架是同构的:

能力 TcaplusDB x3db
数据模型 多主键二维表 + 嵌套 三键 + 13 列(嵌套走 bindata)
部分字段读写 按字段存取 keyidx 稀疏字段
分片 hash 分表 + 路由数组 单表 + ownerid % N 选实例
TTL 有 无
全局二级索引 有(异步同步) 无
跨行事务 不支持 不支持

差距集中在"周边能力":TTL、对外暴露记录版本号做 CAS、按 id 列表批量读。这三项都不动核心结构,加起来两三百行,是后续可以按顺序补的。


九、还有一层:给 AI 写服务端留底座

这一条是后来才想明白的。

现在让 AI 生成游戏业务代码,最费劲的不是逻辑,而是数据层没有稳定形状:表结构各不相同、DAO 风格各不相同、字段名在三个地方不一致。AI 每次都要重新理解一套模型,生成的代码也就只能一次性使用。

如果数据层收敛成"一粒对象 + 一组字段",情况会变:AI 只需要理解一种模型,读一遍头文件就能生成可运行的存取代码;表、接口、语义都不随业务漂移,AI 写的逻辑和人或 AI 后来写的逻辑天然能接上。

统一的抽象不只是省人力,也是给自动化留接口。 这大概是这套设计里最不显眼、但长期收益最大的一点。


十、几个可以搬走的经验

  1. 先问"这些数据是不是同一种东西",再决定建几张表。二十张表往往只是二十个实例。
  2. 给字段留通用值槽(整型/文本/二进制各一个),新需求先在槽里表达,比每加功能就 DDL 一次划算。
  3. 稀疏写比整行写重要,尤其有大字段时。位图分组能把 N 条 UPDATE 合成一条 SQL。
  4. 单写者 + 无锁是"整份数据放内存"唯一省心的做法。多线程写共享快照,收益远小于复杂度。
  5. 把"不做什么"写进设计文档,比写一堆能力清单更能防止后面被拖进泥潭。
  6. 上限必须按最大响应定。我们曾把一个发送缓冲上限设成 8MB,结果 16MB 的响应在服务端被静默掐断,表现为"写成功、读回 0 条",服务端零日志。这类错误没有报错,只有现象。

代码结构:src/server(服务端)、src/client(C ABI 客户端库)、src/common(协议/网络/公共工具),建表脚本在 sql/,许可 Apache-2.0。


如果这套抽象对你正在做的项目有用,欢迎按自己的业务改列、改上限------keyidx 往后顺延就行,接口不用动。

相关推荐
another heaven14 分钟前
【C/C++ Ranges 是零开销抽象吗?拆开管道、惰性求值和编译期膨胀】
c语言·开发语言·c++
炫酷的大高个2 小时前
98% 连跑 20 次只剩 67%,我算了大模型重试账
人工智能·架构·开源
用户6602676118793 小时前
AI 代码上线前的第 24 道门禁:上线失败时,怎样在 10 分钟内退回?
后端·架构
YLCHUP3 小时前
【思维&图论建模】题解:债权(debt)_图论_建模_思路转化_思维题目_C++算法竞赛
c++·笔记·算法·数学建模·贪心算法·深度优先·图论
xiaoye-duck3 小时前
《C++ 项目:手写大模型接入 SDK》会话模块整合:内存 SessionManager 与 DataManager 持久化联动
开发语言·数据库·c++·语言模型
思麟呀3 小时前
背包DP(01背包+完全背包)
c++·算法
humcomm3 小时前
设计与架构 · 01 sidecar 会消失吗:从 Istio ambient GA 到 AWS App Mesh 关停,一次服务网格数据面的重新洗牌
架构·istio·aws
Eyfcom4 小时前
〖物流低代码实战 01〗物流页面可视化搭建的底层架构:协议、引擎与产物的三重奏
低代码·架构·快递系统·物料体系·物流低代码实战
旖旎夜光5 小时前
【C++ 基础入门】C++11 新特性(中):右值引用、移动语义与完美转发
开发语言·c++·笔记·学习