把游戏数据拆成"对象颗粒":x3db 的单表数据服务设计与取舍
摘要:x3db 把玩家变量、钱包、背包、仓库、技能、称号、皮肤、成长线都归约成同一形态------"一粒对象 + 一组可扩展字段"。本文讲清这个模型怎么支撑读写、稀疏落盘、为什么单表比四张表还快,以及哪些事它明确不做。
标签:游戏服务器 / 数据库设计 / C++ / 存储 / 架构
代码仓库
一、先看需求长什么样
做游戏服务端,最容易踩的坑不是性能,而是表结构随业务长。
一开始只有玩家变量表。然后加背包,建一张 bag;加仓库,建 warehouse;加技能,建 skill;加称号、皮肤、成长线......每加一个系统,要做四件事:
- 设计一张新表(字段、索引、主键)
- 写一套 DAO / ORM
- 对客户端暴露一套新接口
- 上线时跑 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 后来写的逻辑天然能接上。
统一的抽象不只是省人力,也是给自动化留接口。 这大概是这套设计里最不显眼、但长期收益最大的一点。
十、几个可以搬走的经验
- 先问"这些数据是不是同一种东西",再决定建几张表。二十张表往往只是二十个实例。
- 给字段留通用值槽(整型/文本/二进制各一个),新需求先在槽里表达,比每加功能就 DDL 一次划算。
- 稀疏写比整行写重要,尤其有大字段时。位图分组能把 N 条 UPDATE 合成一条 SQL。
- 单写者 + 无锁是"整份数据放内存"唯一省心的做法。多线程写共享快照,收益远小于复杂度。
- 把"不做什么"写进设计文档,比写一堆能力清单更能防止后面被拖进泥潭。
- 上限必须按最大响应定。我们曾把一个发送缓冲上限设成 8MB,结果 16MB 的响应在服务端被静默掐断,表现为"写成功、读回 0 条",服务端零日志。这类错误没有报错,只有现象。
代码结构:src/server(服务端)、src/client(C ABI 客户端库)、src/common(协议/网络/公共工具),建表脚本在 sql/,许可 Apache-2.0。
如果这套抽象对你正在做的项目有用,欢迎按自己的业务改列、改上限------keyidx 往后顺延就行,接口不用动。