# x3db **Repository Path**: ygluu/x3db ## Basic Information - **Project Name**: x3db - **Description**: No description available - **Primary Language**: C/C++ - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-11-14 - **Last Updated**: 2026-11-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # x3db 游戏数据服务器 + 客户端库,覆盖**多数**游戏需求,也为以后 AI 开发游戏服务端打数据基础。 数据模型是**以对象为颗粒**:玩家变量、钱包、背包、仓库、技能、称号、皮肤、成长线等,在 x3db 里都是同一形态——`(ownerid, storeid, objid)` 定位到一粒,粒上挂一组可扩展的字段(`slot/num/level/status/flag/intdata/strdata/bindata/relid/reltype`)。 读也按颗粒读:`get` 拿到的是一页对象,`set/sets/del/move` 改的是一粒(或一次改多列),不牵动别的业务数据。所以不必为每种业务造表、造接口:变量是单粒、容器是一组粒、成长线是一串粒,读写走同一套 `set/sets/get/del/move`。 字段集合刻意留了余量:`intdata/strdata/bindata` 三个通用值槽 + 关系列 + 状态列,新业务先在现有列上表达,真不够再加列(加列不改接口、不改索引),**目的是压低每个功能的开发成本、把产出效率提上来**。 对 AI 就是另一层价值:不必为每个系统重新建模、重新设计表结构,只要理解"`(ownerid, storeid, objid)` + 一组字段"这一种形状,读 `inc/x3db.h` 就能直接生成可运行的存取代码;表、接口、语义都不随业务漂移,AI 产出的逻辑天然落在同一层上,人和 AI 写出来的东西可以互相接。 玩家数据常驻内存快照,业务数据统一落一张 `objs` 表,脏数据异步批量写 MySQL / SQLite。 - 代码:`src/server`(服务端)、`src/client`(客户端库,C ABI 在 `inc/x3db.h`)、`src/common`(协议、网络、公共工具) - 建表脚本:`sql/create.mysql.sql`、`sql/create.sqlite.sql` - 配置:`cfg/config.ini`(不分节,直接 `k=v`,命令行同名参数覆盖 ini)。不给 `-config` 时默认读 `/../cfg/config.ini`,**读不到直接报错退出**,不会静默用默认值 - 目录基准:`dbfile` / `initsql` 这类相对路径按**项目根**(`cfg/` 的上一级)解析,不是 cwd —— 双击 `bin/x3db.exe` 也能正常起 - SQLite 库文件统一放 `data/`:服务端默认 `data/x3db.db`,自测库 `data/x3db_test.sqlite`(跑完保留数据,`x3db_test --fresh` 才重建) - 日志:`/../log/<年月日时>--.txt`,整点自动换文件(如 `2026111413-0.0.0.0-9200.txt`),每行带年月日时分秒毫秒。**有终端窗口才往窗口打**,后台模式(服务、无控制台)只写文件 - 退出:信号处理函数只置标志,事件循环退出后逐个 join DB 线程、等脏数据全部落库,最后日志文件与数据库客户端库由各模块的静态 `M` 析构收尾 - 设计定稿:`doc/obj_spec.md` - 构建:`build_win.bat` / `build_linux.sh`,产物在 `bin/`(Windows 侧为 `x3dbcli.dll` 加导入库 `x3dbcli.dll.a`) --- ## 1. 设计思路 ### 1.1 两个角色 | 角色 | 形态 | 职责 | |---|---|---| | 客户端 | `x3dbcli` 动态库(C ABI) | 拼包、按 `(线程, addr)` 维护连接、把响应解成 `x3db_rec` | | 服务端 | 单进程 `x3db` | 内存快照、脏队列、落盘线程池、协议解析,独占数据库连接 | 业务侧拿到的抽象只有一个:`(ownerid, storeid, objid) → 一组列`。没有 SQL、没有表名、没有事务、没有条件查询。"落哪张表、走哪条线程、什么时候刷盘、失败怎么重试"全部在服务端消化,业务代码不出现任何一个。 ### 1.2 一次读的路径 ``` 业务线程 x3db_get(addr, ownerid, storeid, objid, page, page_size, cb, ud) │ ▼ 客户端库 查 (本线程, addr) 的连接 → varint 组包 → 阻塞 send / recv │ TCP ▼ 服务端 IO 线程(唯一) ├─ 该 owner 已在快照里 → 直接按内存组响应 └─ 不在 → 请求挂进 Owner::pending,投一条 SELECT 给 DB 线程; 加载完成后按到达顺序重放,避免读到半份数据 ``` 首次访问某个 owner 时**整份加载**(该 owner 名下所有 store 的所有对象),之后所有读都命中内存。快照空闲 `idle_sec`(默认 300 秒)没被访问就整体淘汰,内存不会随登录人数单调上涨。 ### 1.3 一次写的路径 ``` 业务线程 ──▶ 客户端 ──▶ IO 线程:改内存快照 + 逐列记 touched + DirtyKey 入队 ──▶ 立刻回 OK │ 攒够 flush_batch(200 条) 或最老一条待满 flush_ms(200ms) ▼ 按 touched 位图分组(位图相同的行合成一条 SQL) ▼ 按 ownerid % thread_num 投进对应 DB 线程队列 ▼ 执行完回程比对 ver:没变则清 touched,变了留到下一轮 ``` 关键点:**写请求不等落库**。业务看到的写延迟是"改内存 + 一次 TCP 往返",落库是后台的事。代价是进程崩溃会丢掉最近一个刷盘窗口的改动——这是明确接受的取舍,不是遗漏。 ### 1.4 五个设计点 | 维度 | 做法 | 参数 | 位置 | |---|---|---|---| | 表结构 | 单表 `objs`,主键 `(ownerid, storeid, objid)`,无唯一键、无二级索引 | — | `sql/create.*.sql` | | 列寻址 | 所有业务列编成 `keyidx` 0..12,协议与 C API 都只认这个序号 | 13 列 | `enum KeyIdx` | | 快照 | 首次访问整份加载,只改内存,空闲淘汰 | 300 秒 | `Snapshot` | | 标脏 | `DirtyKey{ownerid, storeid, objid}` + `touched` 位图 + `ver` 回程比对 | 200 条 / 200ms | `DirtyQueue` | | 线程模型 | 1 个 IO 线程管全部内存状态,N 个 DB 线程各持一条连接 | 默认 10 | `Server::run_once` / `DbPool` | 同一份 `ownerid` 决定了三层落点:主键第一列、DB 线程序号(`% thread_num`)、脏队列归属键,三处走同一个取模。给定玩家 ID 就能手算出记录落在哪条落盘线程——排障时不需要查任何映射表。 第四层不在这条链上:**客户端连哪台由调用方传的 `addr` 决定**。跨服时数据仍归属本服,业务层按玩家当前所在服算出 addr 即可,客户端不持有节点表、不做取余选点。 ### 1.5 线程模型 - **IO 线程(1 个)**:跑 `net_.poll()` 与 `run_once()`。快照 `Snapshot` 与 `DirtyQueue` 只被这个线程访问,**全程无锁**,这是 x3db 敢把"整份玩家数据放内存"的前提。 - **DB 线程(`thread_num`,默认 10)**:每线程一条 `DbConn`(`thread_local`)+ 一个 `std::deque`,线程之间不共享任何状态。`ownerid % thread_num` 钉死 worker,保证同一 owner 的 SQL 按提交顺序串行执行,因此该 owner 的行不会被两个连接同时改,不需要行锁或冲突重试。 连接是线程私有的,`open` 时记住配置,断线后就地重连:执行期识别到 `2006 / 2013 / 2055`(gone away / lost)就把连接摘掉,下次取任务时用记住的配置重连,并把这批 SQL 整批重跑一次——写语句全是 UPSERT / UPDATE / DELETE,重放结果相同。空闲超过 10 秒先用 `mysql_ping` 探一次活,避免中间设备掐掉空闲连接后要等第一个真实请求才发现。没有连接池,因此也没有「借出 / 归还 / 脏连接」这套状态机。 - **客户端 0 线程**:`thread_local` 每线程每 addr 一条连接,调用阻塞,回调在调用者线程内同步执行。 `delete` 走 `force` 插队,不受 `max_pending`(默认 300)条数限制——删除不落库会直接导致脏读。 提交出去的脏项会打上「在途」标记:DB 慢的时候同一批不会被反复提交,也不会把新脏数据饿死。连续两次失败的行会被放弃并记 `ERR`(毒丸 SQL 不会堵住整个队列)。 ### 1.6 稀疏写:只写你改过的那几列 `set` / `sets` 即 UPSERT。服务端不重写整行,只按本次请求涉及的列更新: - 每个 `Obj` 带一个 `uint16_t touched` 位图,记录"自上次落盘以来哪些列被动过"。 - 位在而 `keys` 里没有该列 = 这一列被置 NULL,落库写 `NULL`。 - 落盘时按位图**分组**:位图相同的行合成一条 `INSERT ... ON DUPLICATE KEY UPDATE`(MySQL)/ `ON CONFLICT DO UPDATE`(SQLite),不同位图不混在一条 SQL 里。 因此 `set(level=5)` 生成的 SQL 只有 `INSERT ... (ownerid,storeid,objid,level) VALUES (...) ON DUPLICATE KEY UPDATE level=VALUES(level)`,不会顺手把 `bindata` 重写一遍——大字段零拷贝、零重复传输。 同一条 `sets` 里可以同时写值和清空:`{level:9, bindata:NULL}` 合成一条 SQL,`bindata=NULL` 直接进 `UPDATE` 子句。 ### 1.7 通讯不压,内存才压 - **通讯**:`flags` 恒为 0,请求体与响应体都**不压缩**。字符串原样传,二进制原样传。省掉两端各一次 lz4 编解码,也省掉"压缩阈值怎么调"这个旋钮。 - **落盘**:也不压缩。库里存的就是明文/原始字节,运营能直接 `select` 出来看。 - **内存快照**:只在这里压。`strdata` / `bindata` 超过 `snap_min`(默认 1024 字节)就 lz4 存,编码是 `[u32 tag][payload]`:`tag` 最高位 0 表示原文(`tag` = 字节数),最高位 1 表示 lz4(`0x80000000 | 原始长度`)。定长列(0..8、12)直接 8 字节小端,连 tag 都不加。 压缩只在服务端内存里发生,客户端完全不知道这回事——它对客户端是透明的。 ### 1.8 与主流相反的取舍 | 维度 | 主流做法 | x3db 的选择 | 原因 | |---|---|---|---| | 表结构 | 一类业务一张表 / 大字段拆 blob 表 | **全部业务一张 `objs`**,大字段同表 | 一次主键范围扫描加载整份数据,避免 N 张表 N 次往返 | | 列寻址 | 每张表自己的列名 | 全局 `keyidx` 0..12,协议只传序号 | 加业务类型不改表结构、不改协议 | | 值类型 | 统一序列化成二进制 | 按列分开:整型列、`VARCHAR` 文本列、`LONGBLOB` | 运营能直接 `select`、`like`、按值判断 | | 写路径 | 整行覆盖 | 稀疏写 + `touched` 位图 | 改一列不重写 16MB 的 `bindata` | | 分片路由 | 一致性哈希 / 虚拟分片 / 分片目录 | 不切表,`ownerid % thread_num` 只分线程 | 单表 + 多连接实测比四表快,运维面最小 | | 客户端接口 | 连接池 + 工作线程 + 异步回调 | 无池无线程,每线程每 addr 一条连接,回调同步 | 业务侧本来就有自己的线程模型,客户端再开线程只增加同步成本 | | 数据层部署 | 独立部署、跨机器共享(Kafka / 共享内存 / gRPC) | 一个进程同时做缓存与落库,客户端用 TCP 连 | 中小规模下少一跳就少一类故障;加节点只是多一个 addr | | 标脏 | 冲突时重读并重试整条更新 | 只保留脏标记,等下一轮重新落盘 | 内存里始终是最新值,重试读内存即可,不必回查数据库 | | 持久化 | WAL / redo log 保证零丢失 | 只有内存脏队列 | 换取实现简单;丢失窗口 = 一个 `flush_ms`,由业务决定是否可接受 | --- ## 2. 表结构 ### 2.1 一张表装下所有对象颗粒 ```sql CREATE TABLE IF NOT EXISTS objs ( ownerid BIGINT NOT NULL COMMENT '所有者 id(玩家/公会/...)', storeid BIGINT NOT NULL COMMENT '容器 id(背包/仓库/任务...),0 = 默认变量容器', objid BIGINT NOT NULL COMMENT '对象 id(>=1);变量语义下就是 key', 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, ctime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, utime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (ownerid, storeid, objid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ``` 没有唯一键、没有二级索引——所有查询都是 `WHERE ownerid = ?` 的主键前缀扫描,二级索引只会拖慢写入。 ### 2.2 `keyidx`:列的逻辑序号 协议里不传列名,只传序号。序号写死在 `inc/x3db.h` 与 `x3db_proto.hpp` 里,两边必须一致。 | keyidx | 宏 | 列 | 类型 | 说明 | |---|---|---|---|---| | 0 | `X3DB_KEY_STOREID` | `storeid` | 整型 | **伪键:只写不读**,写它 = 换容器(等价 `move`) | | 1 | `X3DB_KEY_SLOT` | `slot` | 整型 | 格位 | | 2 | `X3DB_KEY_RELID` | `relid` | 整型 | 关联对象 `objid` | | 3 | `X3DB_KEY_RELTYPE` | `reltype` | 整型 | 关联类型(枚举由业务定义) | | 4 | `X3DB_KEY_FLAG` | `flag` | 整型 | | | 5 | `X3DB_KEY_LEVEL` | `level` | 整型 | | | 6 | `X3DB_KEY_NUM` | `num` | 整型 | 数量 | | 7 | `X3DB_KEY_STATUS` | `status` | 整型 | | | 8 | `X3DB_KEY_INT` | `intdata` | 整型 | `BIGINT`,变量语义下的整数 | | 9 | `X3DB_KEY_STR` | `strdata` | 文本 | UTF-8,≤ 8192 字节 | | 10 | `X3DB_KEY_BIN` | `bindata` | 二进制 | ≤ 16MB | | 11 | `X3DB_KEY_CTIME` | `ctime` | 时间 | **库端审计列:读写都拒**,由 `NOW()` 生成 | | 12 | `X3DB_KEY_UTIME` | `utime` | 时间 | 业务可写;不传则用服务端当前时间 | 整型列在协议里**一律 8 字节小端**,落库时按列宽转成 `INT` / `BIGINT`。`reltype` 这类逻辑上只要 4 字节的列也按 8 字节传——统一宽度省掉"这列几字节"的分支。 ### 2.3 变量和对象用同一套结构 同一张表、同一套 API 承担两种用法: | 用法 | `storeid` | `objid` | 用哪些列 | |---|---|---|---| | 变量(kv) | 分组键,常用 0 | key,`>= 1` | `intdata` / `strdata` / `bindata` 三选一 | | 对象 | 容器(背包/仓库/任务列表) | 对象 id | 按需占 `slot` / `relid` / `level` / `num` / `bindata` | 不存在"变量表"和"对象表"的区别,也不存在 `x3db_add` 和 `x3db_set` 的区别:**记录不存在时 `set` 就是插入**,一行里想放几个列就放几个列。 ### 2.4 上限 | 项 | 值 | 位置 | |---|---|---| | 每 owner 的 store 数 | 100(`-max_store`) | 只在新容器产生时校验 | | 每 store 的 obj 数 | 1024(`-max_obj`) | 只在新对象产生时校验 | | 全进程对象总数 | 0 = 不限(`-max_obj_total`) | 超了先淘汰空闲 owner,仍超则回 `X3DB_ERR_LIMIT` | | `strdata` | 8192 字节,UTF-8 | 客户端 + 服务端各校验一次 | | `bindata` | 16MB | 同上(单行上限,对应 MySQL `LONGBLOB`) | | 每 DB 线程未决 SQL | 300 条或 64MB(`-max_pending`) | 满了这批留到下一轮,不丢数据 | | 每 owner 挂起请求 | 300(`-max_pending_req`) | 超了报 `X3DB_ERR_BUSY`,防单 owner 撑爆内存 | 超配额时服务端回滚这次插入并返回 `X3DB_ERR_LIMIT`;空容器会立即回收,不占 `max_store`。 ### 2.5 为什么单表 实测(4 个 DB 线程,10 万行同构数据): | 方案 | 耗时 | |---|---| | 1 张表 + 4 连接 | 284 ms | | 4 张表 + 4 连接 | 306 ms | 分表在这个量级上**没有收益**:`InnoDB` 的聚簇索引已经按主键把同 owner 的行放在相邻页,主键前缀扫描本身就快;多表换来的是加载时要扫 N 张、连接数要翻倍、DDL/备份/迁移×N。单表 + 更多连接才是吞吐的来源。 单表的代价是**行数大**(每玩家几百到几千行)。缓解手段是主键前缀扫描 + 每 owner 整份加载进内存,线上查询不会出现跨 owner 的全表扫描。 --- ## 3. 客户端接口 ### 3.1 引入方式 ```c #include "x3db.h" // C ``` ```cpp #include "x3db.h" // C++,额外提供 x3db::set_int / put_item / link 等 inline 糖 ``` 链接 `x3dbcli` 动态库(Linux `libx3dbcli.so`,Windows `x3dbcli.dll` + `x3dbcli.dll.a`)。头文件只有 C ABI 一层,C++ 封装全部是 `inline`,跨编译器 ABI 兼容(MinGW 编出的 dll 可以直接给 MSVC 用)。 ### 3.2 六个函数 | 函数 | 语义 | |---|---| | `int x3db_init(const char* token)` | 全局初始化;`token` 为空表示不鉴权;幂等,重复调用只更新 token | | `int x3db_set(addr, ownerid, storeid, objid, keyidx, vsize, vdata, utime, cb, ud)` | 写一列;`vdata == NULL` 表示置 NULL;记录不存在即插入 | | `int x3db_sets(addr, ownerid, storeid, objid, fields, nfields, utime, cb, ud)` | 写多列,一次 1..13 列;同请求内 `keyidx` 不许重复 | | `int x3db_get(addr, ownerid, storeid, objid, page, page_size, cb, ud)` | 读单条(`objid >= 1`)或分页读整个 store(`objid == 0`) | | `int x3db_del(addr, ownerid, storeid, objid, keyidx, cb, ud)` | `keyidx == X3DB_KEY_NONE` 删整行;0..12 只把该列置 NULL | | `int x3db_move(addr, ownerid, storeid, objid, to_storeid, to_slot, utime, cb, ud)` | 同 owner 内换容器;`to_slot = -1` 保留原格位 | 没有 `add`、没有 `update_xxx`、没有 `read_all_mgrid`。写入统一走 `set` / `sets`,读取统一走 `get`。 **没有 `x3db_fini`**:连接是 `thread_local`,线程退出时随线程局部存储自动关闭;进程级资源(socket 子系统、动态库)由全局对象析构回收。客户端不需要、也不应该手动收尾。 **返回值就是服务端结果码**:成功 `0`,失败为负的 `X3DB_ERR_*`。`cb` 在同一调用内以相同 code 执行一次,方便回调风格调用。网络层失败(连不上、超时)也是负码。 ### 3.3 分页 `objid == 0` 表示读整个 `store`,按 `objid` 升序切页: | 入参 | 含义 | |---|---| | `page` | 页码,1 起;传 0 视为 1 | | `page_size` | 每页条数;传 0 用服务端默认(128);上限 1024 | 回调多给一个 `x3db_page`: ```c typedef struct x3db_page { int32_t total; // 该 store 的总行数,不是本页条数 int32_t page; // 当前页码 int32_t page_size; // 实际页大小 int32_t more; // 1 = 还有下一页 } x3db_page; ``` 翻页条件是 `more`,不是"返回条数小于 page_size"——最后一页刚好满页时后者会错判。 单页字节超过 `read_max_kb`(默认 20MB)时**整页失败**报 `X3DB_ERR_LIMIT`,不静默截断。截断会让"第 N 页"的偏移和 `page * page_size` 对不上,翻页必然漏数据。业务侧应调小 `page_size` 重试。 ### 3.4 `addr` 与连接模型 `addr` 是 `"ip:port"`,每个函数首参必填。为空或格式错误直接返回 `X3DB_ERR_PARAM`,回调仍会以该错误码执行一次。 - 连接按 `(调用线程, addr)` 建立,同一线程同一 addr 复用同一条 TCP 连接,跨线程不共享。 - 单线程同时连的节点数上限 **64**,超出返回 `X3DB_ERR_BUSY`。 - 连接在首次使用该 addr 时惰性建立,断线后下次调用自动重连一次。 - 客户端不持有节点列表、不做取余选点、不缓存"上次用的节点"。 ### 3.5 回调与线程语义 ```c typedef void (*x3db_done_cb)(int32_t code, const char* msg, void* ud); typedef void (*x3db_rec_cb )(int32_t count, const x3db_rec* arr, const x3db_page* pg, void* ud); ``` 所有接口都是**同步**的:函数返回时 `cb` 已经在调用者线程执行完毕,客户端不创建任何线程,也不需要事件循环。`ud` 原样透传。 `arr` 里的 `fields[i].data` 指向按线程复用的响应缓冲,**只在回调期间有效**——回调返回后会被下一次调用覆盖,需要留存的数据必须自己拷走。 ### 3.6 错误码 | 宏 | 值 | 含义 | |---|---|---| | `X3DB_OK` | 0 | 成功 | | `X3DB_ERR_PARAM` | -1 | 参数错误(`addr` 格式、长度越界、`keyidx` 非法等) | | `X3DB_ERR_NET` | -2 | 网络失败(连接、收发、超时断开) | | `X3DB_ERR_DB` | -3 | 服务端落库或查库失败 | | `X3DB_ERR_BUSY` | -4 | 单线程连接数超限,或服务端未决队列满 | | `X3DB_ERR_TIMEOUT` | -5 | 等响应超时 | | `X3DB_ERR_AUTH` | -6 | `token` 不匹配 | | `X3DB_ERR_PROTO` | -7 | 协议错误(ver / body 长度 / 结构不符) | | `X3DB_ERR_INIT` | -8 | 未调用 `x3db_init` | | `X3DB_ERR_LIMIT` | -9 | 配额超限 / 单页字节超限 | | `X3DB_ERR_CONFLICT` | -10 | `move` 的目标 store 已有同 `objid` | ### 3.7 使用约束 1. `x3db_init` 在起业务线程**之前**调用一次即可,可重复调用(幂等);没有对应的 `fini`。 2. **回调里不要再次调用 x3db 接口**:请求/响应缓冲按线程复用,重入会覆盖当前帧的数据。 3. 回调拿到的 `arr` 及其 `fields[].data` 只在回调期间有效,超出作用域即失效。 4. 整型列的 `data` 必须指向 **8 字节**变量。用 `int32_t` 当源会越界读 4 字节垃圾,服务端按非法值拒掉。 ### 3.8 示例 ```cpp #include #include #include "x3db.h" static void on_done(int32_t code, const char* msg, void* ud) { if (code != X3DB_OK) { fprintf(stderr, "写失败 %d: %s\n", code, msg); } } static void on_rec(int32_t count, const x3db_rec* arr, const x3db_page* pg, void* ud) { for (int32_t i = 0; i < count; ++i) { printf("objid=%lld level=%lld\n", (long long)arr[i].objid, (long long)x3db::rec_int(&arr[i], X3DB_KEY_LEVEL, 0)); } printf("total=%d page=%d more=%d\n", pg->total, pg->page, pg->more); } int main(void) { x3db_init(""); // 不鉴权 const char* addr = "127.0.0.1:9200"; // 背包(storeid=1)里放一件物品:格位 / 配置 id / 类型 / 数量 / 扩展数据 x3db::put_item(addr, 1001, 1, 9001, 5, 10086, 3, 2, 4, "abcd", on_done, nullptr); // 只改等级,其他列不动 x3db::set_int(addr, 1001, 1, 9001, X3DB_KEY_LEVEL, 42, 0, on_done, nullptr); // 该列置 NULL(行还在) x3db::unset(addr, 1001, 1, 9001, X3DB_KEY_LEVEL, on_done, nullptr); // 读整个背包,每页 50 条 x3db_get(addr, 1001, 1, 0, 1, 50, on_rec, nullptr); // 换到仓库(storeid=2),格位 3 x3db_move(addr, 1001, 1, 9001, 2, 3, 0, on_done, nullptr); return 0; } ``` --- ## 4. 参考资料 以下按 x3db 的设计维度分组。每组给出"做法相同"的出处,最后一节列整体思路接近的代码仓库。 ### 4.1 表结构:核心列 + 大字段 | 出处 | 相同点 | |---|---| | [《数据架构白皮书》(Apollo MMO)§3 Hybrid Schema](https://cuihairu.github.io/apollo/18-Data_Architecture_Whitepaper) | `t_character` 是"核心列 + Blob":`char_id` 主键,等级 / 战力 / 金币 / 地图列化供运营查询,属性、背包、任务、技能全部塞进 `data_bin`(Protobuf) | | [`英文`《Persistence for Ephemeral Game Servers》](https://redwoodmultiplayer.com/blog/persistence-for-ephemeral-game-servers) | 主张"一张角色表 + 一个 `jsonb` 类型的 `data` 字段装下全部",理由是数据结构随版本变化,拆列就要迁移 | | [`英文`《Key-Value Storage for Multiplayer Game Server Backends》](https://basekv.com/articles/kv-storage-game-server-backends) | 玩家 profile 的实际访问形态是 `player_id → JSON blob`,登录读、存档写,join 与事务在这个场景用不上 | | [`英文`《How can player inventory be managed efficiently in an online game without causing database lag?》](https://www.quora.com/How-can-player-inventory-be-managed-efficiently-in-an-online-game-without-causing-database-lag/answer/OpenBlueprint) | "把整个背包序列化成一个二进制对象存进单列",把几十次行更新变成一次 blob 替换 | | [ygluu《通用数据引擎(data-e)》](https://blog.csdn.net/GuestCode/article/details/139897257) | 数据由"名字系统"统一编号,配置表按分类分表;x3db 的 `(ownerid, storeid, objid)` 三元组是同一思路的窄实现 | x3db 与上述的差异:大字段不序列化成 blob,而是**按列拆开**(`intdata` / `strdata` / `bindata` 各占一列),因此改等级不用重写 16MB 的二进制;代价是新增值类型要动表结构,纯 blob 方案不需要。 ### 4.2 快照方式:首次全量加载 + 常驻内存 | 出处 | 相同点 | |---|---| | [`英文`《How the Metin2 DB Cache Architecture Works》](https://aslain.dev/blog/metin2-db-cache-mimarisi) | db 进程在玩家选中角色时把该角色全部数据(基础属性、背包、任务、buff)一次性读进内存,之后游戏服的所有读写都打在这份内存副本上,core 从不直连 MySQL | | [《游戏服务器选型与性能优化完全指南》3. 玩家数据缓存策略](https://hsy.com/articles/news/41.html) | "玩家数据常驻内存(对象池),脏数据标记后由独立异步线程每隔 5-10 秒批量写回;玩家登出时强制同步写入",并明确把"每次属性变化直接写库"列为反模式 | | [`英文`《Game Data Persistence Architecture: Save Systems, Write-Behind Caching, Checkpoints, and Crash Recovery》](http://www.sourcegame.net/forum/article/35-game-data-persistence-architecture-save-systems-write-behind-caching-checkpoints-and-crash-recove) | 把"Gameplay → 内存状态 → 标脏 → 写队列 → 持久化 worker → 数据库"这条链拆成独立环节,并单独讨论 Player Snapshots 与 Save Queue 的边界 | | [`英文`《Write-Behind Cache: Async DB Writes Explained》](https://layrs.me/course/hld/07-caching/write-behind) | 系统对比 write-through 与 write-behind 的适用面:高频写同一批键、能接受秒级丢失窗口时才选后者;也点明"周期快照 + 丢失窗口"比 WAL 便宜 | | [ygluu《插件式模块化软件框架的思想图解三(数据交换系统)》](https://blog.csdn.net/GuestCode/article/details/124780626) | 模块之间只经"数据平台"交换数据、不互相引用,是数据服务能独立成进程的前提 | x3db 在"常驻内存"之外多做了一步:按 `idle_sec` 淘汰整份快照。Metin2 的 db 进程只在登出时释放,x3db 允许玩家离线后快照继续存在一段时间(配合跨服读),到点再淘汰。 ### 4.3 标脏方式:脏标记队列 + 版本号回程比对 | 出处 | 相同点 | |---|---| | [《重塑数据库内存引擎的基石:深度解析 InnoDB 缓冲池》§三](https://www.ctyun.cn/developer/article/825412634447941) | 改缓冲池页 → 标脏 → 挂进脏页链表 → 后台清理线程异步刷盘;脏页比例分低水位(10% 预刷)与上限(90% 激进刷) | | [《InnoDB 脏页详解》](http://www.modb.pro/db/2100383485785137152) | 脏页产生、Page Cleaner 线程、自适应刷新、`innodb_max_dirty_pages_pct` 水位 | | [《InnoDB 脏页刷新与 Checkpoint 机制》](https://github.com/colinlet/PHP-Interview-QA/blob/HEAD/docs/storage/InnoDB%E8%84%8F%E9%A1%B5%E5%88%B7%E6%96%B0%E4%B8%8ECheckpoint%E6%9C%BA%E5%88%B6.md) | "先写 redo log,Checkpoint 异步推进将脏页刷盘",以及刷脏触发条件清单 | | [`英文`《Game Data Persistence Architecture》Versioned Saves 一节](http://www.sourcegame.net/forum/article/35-game-data-persistence-architecture-save-systems-write-behind-caching-checkpoints-and-crash-recove) | 与 x3db 的 `ver` 完全同源:快照带版本号,落库时 `WHERE version < 新版本` 拒绝过期写入,保证"旧存档不覆盖新状态" | | [《缓存更新策略深度解析与最佳实践》](https://blog.csdn.net/weixin_43076660/article/details/156019719) | 写回拆成"批量写入 / 延迟写入 / 写入合并 / 指数退避重试 / 死信队列"五个设计点,对应 `flush_batch` + `flush_ms` + 队列上限 | | [chinnurtb/slg-model](https://github.com/chinnurtb/slg-model) | `slg_m_upt_s` / `slg_m_del_s` 事件订阅,相当于 Erlang 版的脏队列 | x3db 的差异有两处:InnoDB 的脏页单位是 16KB 数据页,x3db 是一行记录,且额外带**列级 `touched` 位图**(脏的最小单位是"这一行的这几列");`ver` 解决的是落库期间快照又被改的问题,避免旧值覆盖新值,**不解决断电丢失**。 ### 4.4 线程模型:单写者 + 固定分工 | 出处 | 相同点 | |---|---| | [`英文` 单写者原则(Single Writer Principle,Martin Thompson,2011)](https://martinfowler.com/articles/mechanical-sympathy-principles.html) | 任何可变状态只由一个执行上下文写,其他线程通过队列投递写请求,写侧不需要锁——x3db 的 `Snapshot` 与 `DirtyQueue` 全程无锁正基于此 | | [ygluu《一个单生产-多消费模式下无锁方案》](https://blog.csdn.net/GuestCode/article/details/136972970) | 单生产者(低频)、多消费者的无锁哈希,与 RWMutex 做对比 | | [DGuco/myserver](https://github.com/DGuco/myserver) | DBServer 线程划分为 `SaveHuman(1) + SaveGlobal(1) + Query(4) + Logic`,存盘与查询分工,与 x3db 的"IO 线程 + N 落盘线程"结构对应 | | [《重塑数据库内存引擎的基石:深度解析 InnoDB 缓冲池》](https://www.ctyun.cn/developer/article/825412634447941) | 缓冲池按(表空间 ID, 页号)哈希对实例数取模,路由到固定实例,每实例独立 LRU / 脏页链表 / 互斥锁 | 手法相同、目的相反的一处:InnoDB 按页号取模是为了把锁竞争分散到多个实例(要**并行**);x3db 按 `ownerid` 取模是为了让同一 owner 的写强制串行(要**顺序**)。 ### 4.5 落盘路由:按 owner 钉死 worker | 出处 | 相同点 | |---|---| | [Kafka 官方中文文档《1. 入门》](https://kafka.apachecn.org/1) | "具有相同事件键(例如客户或车辆 ID)的事件被写入同一分区",且"一个分区内的消息按写入顺序被消费" | | [《Kafka 保证消息顺序性》](https://blog.csdn.net/weixin_43715864/article/details/161517465) | `partition = hash(key) % 分区数`;同 key 落同分区;"一个分区绑定一个消费线程,禁止多线程并发处理同一分区消息" | | [《游戏服务端编程实践》5.2.2 基于 PlayerID / RoomID 的路由](https://plumephp.com/game-server-in-action-522) | 取模、固定分片表、一致性哈希、rendezvous 四者的迁移成本对比,取模是其中实现最简单的一档 | | [《游戏服务端编程实践》12.4 数据分片与多租户模型](https://plumephp.com/game-server-in-action-12-4) | 3 分片扩到 4 分片只有约 1/4 键保持原位;扩容要拆成"路由变更"与"数据迁移"两步 | | [华为云《分库分表与数据迁移:从拆分策略到双写切换》](https://bbs.huaweicloud.com/blogs/520037) | `table_${hash % 1024}` 预分片:业务代码里的分片号固定,物理落点由配置决定 | | [《分库分表:ShardingSphere 的路由、扩容与分布式主键》](https://www.cnblogs.com/xuxuyao/p/23409594) | 取模扩容等于全量迁移,一致性哈希只迁 `1/(N+1)` | x3db 的差异:取模不再用于**切表**,只用于**选落盘线程**——表只有一张,取模的目的是让同一 owner 的 SQL 串行执行。因此 `thread_num` 可以随时调整而不涉及数据迁移:改完之后同一个 owner 换条线程,SQL 顺序性仍在(同一时刻只有一条线程在处理它)。 ### 4.6 SQL 语句平衡:每批多少行、单条多大 | 出处 | 相同点 | |---|---| | [`英文`《MySQL Insert Speeds Vary by 103x: The Same 100k Rows, Different Methods》](https://guibai.dev/a/7702001891030384674) | 实测:单行一条 SQL 1,903 行/秒,多行批量 100~1000 行/条约 70k 行/秒;收益在 10 行时拿到 5.6 倍,100~500 行后进入平台期;再加大只带来 `max_allowed_packet` 超限、事务膨胀、整批回滚的风险 | | [`英文`《What's the Optimal Batch Size for MySQL Inserts? A Deep Performance Test》](https://www.besthub.dev/articles/what-s-the-optimal-batch-size-for-mysql-inserts-a-deep-performance-test-f8c168e7d42e) | 给出可操作的经验法则:批大小取 `max_allowed_packet` 的一半左右;并给出 10 / 600 / 1000 / 20000 / 80000 行五档的实测耗时曲线 | | [《mysql 写入数据太慢如何提升效率?批量插入与事务优化实战详解》](https://www.ipipp.com/html/20260911/54477.html) | "一批 500 到 1000 行通常已经能拿到绝大部分性能收益,继续加大收益递减,还可能因单条 SQL 过大导致锁持有时间变长" | | [《MySQL INSERT 进阶指南:从语法到性能优化与冲突处理》](https://blog.csdn.net/weixin_33462927/article/details/164486228) | 单行约 200 字节时 500~2000 行/批最佳;超 5000 行解析与传输时间变长、内存占用上升;并提示 `max_allowed_packet` 与整批回滚的兜底做法 | x3db 的差异:批大小不是单一阈值,而是"200 行 / 单条 SQL 约 1MB / 队列 300 条或 64MB"几条闸门同时生效,取先到者;且因为稀疏写,同一条 SQL 里的列集合必须一致,所以按 `touched` 位图分桶后再拼批。**单行 16MB 的 `bindata` 是个例外**:hex 编码后这一条 SQL 就有 32MB,超过了常规的 1MB 阈值,所以它独占一批走。MySQL 侧要求 `max_allowed_packet >= 64MB`(8.0 默认即 64MB,够用;MariaDB 默认 16MB,必须调大)。 ### 4.7 客户端接口规范:只暴露按 owner 的读写 | 出处 | 相同点 | |---|---| | [ygluu《通用数据引擎(data-e)》](https://blog.csdn.net/GuestCode/article/details/139897257) | 数据仓库对上层只给 `Get` / `Set` / `Save` / `Load` 四个动作 | | [《数据架构白皮书》(Apollo MMO)§4 DataProxy Layer](https://cuihairu.github.io/apollo/18-Data_Architecture_Whitepaper) | `LoadPlayer` / `SavePlayer` / `QueryPlayers` / `UpdateEconomy`,业务只操作内存对象,序列化与 SQL 由代理层承担 | | [《游戏开发手记:数据存储》](https://m.blog.csdn.net/zyf1756401225/article/details/40623943) | "proxycache" 构想:只给游戏进程 GET / SET,SET 可带延时参数,按优先级排队落库 | | [li5414/RillServer](https://github.com/li5414/RillServer) | dbproxy 服务:业务服务不直接操作数据库,全部经代理转发 | | [《游戏服务器设计的相关内容》](https://nvdia.com.cn/article/71/1663.html) | 论证"数据服务器 + 数据库代理服务器"可以合并成一个进程;定时存盘间隔越短、数据库压力越大,只能加一层缓冲把两者解耦 | x3db 的差异:接口是 **C ABI** 而不是 RPC 或对象接口,动态库直接链进游戏进程,一次调用等于一次函数调用加一次同步收发;客户端不开线程;**节点地址由每次调用传入**,不靠服务端下发或客户端自行发现——这是跨服场景下"数据归属本服"的直接体现。 ### 4.8 整体思路接近的代码仓库 | 仓库 | 语言 | 同 | 异 | |---|---|---|---| | [DGuco/myserver](https://github.com/DGuco/myserver) | C++11 / libevent | 独立 dbserver 进程;SaveHuman / SaveGlobal 定时存盘 + 4 条 Query 线程;玩家数据缓存管理;GameServer 与 DBServer 用共享内存无锁环形队列 | 进程间走共享内存而非 TCP;无分表路由;无版本号防覆盖;无列级稀疏写 | | [chinnurtb/slg-model](https://github.com/chinnurtb/slg-model) | Erlang / ETS | 登录时把玩家数据从 MySQL load 进内存,之后只改内存、定时回写(约 1 分钟);不活跃内存自动清理;每张表一对连接池(读 3 / 写 1);带 migrate | ETS 天然单进程无锁,x3db 要自己做 owner 隔离;回写是全表级而不是按记录 + 按列标脏 | | [allan-hb/gos](https://github.com/allan-hb/gos) | Go | Hot Data 模块:按需加载玩家数据到内存,定时回写 MySQL 集群;整体是"场景服 + 数据层"分层 | 数据层是框架子模块,不对外提供独立协议;无单表 + keyidx 的列寻址;无可插拔的 SQLite / MySQL 双后端 | | [jwpttcg66/GameShardingDb](https://github.com/jwpttcg66/GameShardingDb) | Java / Spring | 对象序列化进队列后异步存储;spring-sharding + mybatis 做垂直/水平分库;代理模式采集变化字段、自动拼 SQL | 依赖 JVM 与 Redis;分片由中间件负责;无二进制协议、无客户端库 | | [li5414/RillServer](https://github.com/li5414/RillServer) | skynet / Lua | dbproxy 服务:业务服务不直接操作数据库;示例里有"数据库断线重连""关服功能" | 只是代理转发,没有内存快照与脏队列;依赖 skynet 调度;缓存层未实现(README 自述) | | [reachithard/hive](https://github.com/reachithard/hive) | C++17 / Lua | 独立 cache 服务做游戏数据缓存;同时支持 mysql / mongodb / redis / etcd;路由支持 hash 扩容与容灾 | cache 目前只支持 mongo(README 自述);无"单表 + 列序号"的自洽规则 | | [ygluu/data-e](https://github.com/ygluu/data-e) | Go + Lua | 数据仓库统一 `Get/Set/Save/Load`;名字系统给数据编 ID;数据变化监听;模块间零引用 | 定位是"模块间数据共享中间件",不负责真正落库;没有网络层、没有分片、没有批量写 | | [luyuan-cpp/xuanming-server-mmo](https://github.com/luyuan-cpp/xuanming-server-mmo) | C++ / Go / Java | `data_service` 负责玩家数据、home-zone 映射、id 段、快照;`db` 服务从 Kafka 消费 DB 任务再写 MySQL | 多语言多进程,数据层只是其中一个 Go 服务;持久化靠 Kafka 解耦而非进程内队列 | 单独拿出任何一个维度,都有比 x3db 更完整的实现:InnoDB 的脏页管理有 redo log 与 checkpoint,Kafka 的分区有 rebalance 与副本,Apollo 的 DataProxy 有 Redis 热层。x3db 的取舍是把每一维削到够用,使它们能同时装进一个进程、一张表、五个接口。