[{"data":1,"prerenderedAt":387},["ShallowReactive",2],{"content:\u002F2026\u002Frpg_game_mongodb_design":3,"surround:\u002F2026\u002Frpg_game_mongodb_design":380},{"id":4,"title":5,"body":6,"categories":354,"date":356,"description":357,"draft":358,"extension":359,"image":360,"meta":361,"navigation":363,"path":364,"permalink":365,"published":365,"readingTime":366,"recommend":365,"references":365,"seo":371,"sitemap":372,"stem":373,"tags":374,"type":377,"updated":378,"__hash__":379},"content\u002Fposts\u002F2026\u002Frpg_game_mongodb_design.md","三元组单集合的游戏数据存储方案",{"type":7,"value":8,"toc":337},"minimark",[9,13,17,21,24,27,30,38,43,46,69,72,82,85,91,102,110,118,128,131,134,153,156,164,170,173,192,195,201,204,212,247,255,259,262,265,271,287,303,313,316,319,327,334],[10,11,12],"h2",{"id":12},"存储方案",[14,15,16],"h3",{"id":16},"单文档",[18,19,20],"p",{},"优点是读取简单，缺点是文档大小会逐渐膨胀，而 MongoDB 限制单文档大小在 16 MB 以内，并且该方案总是全量更新。读多写少，能预估对象大小的场景可以使用。",[14,22,23],{"id":23},"分组保存",[18,25,26],{},"将 N 个对象拆分到 M 张表中，常见的是将每个业务模块创建一张表，例如 Players 和 Bags，它们通过 PlayerID 作为外键关联。优点是直观，缺点是集合数量膨胀、索引重复、业务层读写放大。",[14,28,29],{"id":29},"三元组单集合",[18,31,32,33,37],{},"在我们游戏中选择了这种方案，也是我",[34,35,36],"strong",{},"推荐","的设计。",[18,39,40],{},[34,41,42],{},"只建立一张表存储玩家数据，并通过三元组寻址。",[18,44,45],{},"所谓三元组，指的是通过文档中的三个字段，定位唯一的文档，可以理解成逻辑主键。每一个文档都会包含这三个固定的基础字段：",[47,48,49,57,63],"ul",{},[50,51,52,56],"li",{},[53,54,55],"code",{"code":55},"collection","：文档所属的业务模块。",[50,58,59,62],{},[53,60,61],{"code":61},"key","：同业务模块下对象的 key。",[50,64,65,68],{},[53,66,67],{"code":67},"player_id","：玩家唯一标识。",[18,70,71],{},"例如，获取玩家拥有的某个角色（玩家有多个角色），三元组可以表示为：",[73,74,80],"pre",{"className":75,"code":77,"language":78,"meta":79},[76],"language-text","collection = role\nkey = 角色ID\nplayer_id = 玩家ID\n","text","",[53,81,77],{"__ignoreMap":79},[18,83,84],{},"获取玩家的背包（玩家只有一个背包）：",[73,86,89],{"className":87,"code":88,"language":78,"meta":79},[76],"collection = bag\nkey = main\nplayer_id = 玩家ID\n",[53,90,88],{"__ignoreMap":79},[18,92,93,94,97,98,101],{},"无论是",[34,95,96],{},"一对一","的数据，还是",[34,99,100],{},"一对多","的数据，都可以用同一套地址结构表达，在集合中定位到唯一文档。",[18,103,104,105,109],{},"相比 ",[106,107,16],"badge",{":square":108},"true"," , 三元组设计有文档级拆分能力。例如背包、角色、任务都可以独立读写, 不需要每次都更新整份玩家大文档。",[18,111,104,112,114,115,117],{},[106,113,23],{":square":108}," , 统一表后不需要为每个模块重复维护一套索引、初始化逻辑和 CRUD 封装。新增一个业务模块时, 只是新增一个逻辑上的 ",[53,116,55],{"code":55}," 值, 而不是新增一张物理表。",[18,119,120,121,124,125,127],{},"它对索引优化也比较友好。因为所有业务文档都落在同一个表中, 核心访问路径最终都会收敛到 ",[53,122,123],{"code":123},"collection + key + player_id"," 或 ",[53,126,67],{"code":67}," 这类固定模式上。一次索引优化, 不是只服务某一张业务表, 而是可以让所有接入这套存储抽象的业务模块一起受益。",[10,129,130],{"id":130},"索引设计",[18,132,133],{},"好的索引准则：",[47,135,136,144,147,150],{},[50,137,138,139,143],{},"遵循 ",[106,140,142],{"link":141},"https:\u002F\u002Fwww.mongodb.com\u002Fzh-cn\u002Fdocs\u002Fmanual\u002Ftutorial\u002Fequality-sort-range-guideline\u002F#std-label-esr-indexing-guideline","ESR 原则","；",[50,145,146],{},"精准匹配业务层的查询模式；",[50,148,149],{},"索引字段区分度高。比如 Bool 类型，只有两个值，选择性很差；",[50,151,152],{},"索引能完全放入 WiredTiger 缓存，如果索引超出内存，会频繁触发磁盘 I\u002FO。",[18,154,155],{},"三元组单集合方案，需要创建两个索引，均为等值查询：",[47,157,158,161],{},[50,159,160],{},"主键索引：collection + key + player_id（完全覆盖查询过滤条件）",[50,162,163],{},"按玩家查询索引：player_id（避免登录场景，加载玩家完整数据时全表扫描）",[165,166,167],"blockquote",{},[18,168,169],{},"进入运营阶段，还可以考虑建立 collection + player_id + key",[14,171,172],{"id":172},"索引命中分析",[18,174,175,176,179,180,188,189,191],{},"分析索引是否被有效使用，主要依赖 ",[53,177,178],{"code":178},"explain()"," 函数的输出，主要关注 totalKeysExamined（MongoDB 扫描的索引项数）、totalDocsExamined（MongoDB 回表读取了多少个文档）和 nReturned（返回文档数量）的值是否接近，甚至相等，相等是最理想的情况。",[181,182,187],"a",{"href":183,"icon":184,"rel":185},"https:\u002F\u002Fwww.mongodb.com\u002Fzh-cn\u002Fdocs\u002Fmanual\u002Ftutorial\u002Fanalyze-query-plan\u002F","devicon:mongodb",[186],"nofollow","获取更多"," 关于 ",[53,190,178],{"code":178}," 返回值的解释。",[18,193,194],{},"下面是我们游戏测试服的 MongoDB 索引分析报告：",[73,196,199],{"className":197,"code":198,"language":78,"meta":79},[76],"\u002F\u002F [!code word:idx_collection_key_playerid_unique:80]\n\u002F\u002F [!code word:idx_playerid:80]\n\u002F\u002F [!code word:nReturned:80]\n\u002F\u002F [!code word:keysExamined:80]\n\u002F\u002F [!code word:docsExamined:80]\n\u002F\u002F [!code word:IXSCAN:80]\n\u002F\u002F [!code word:0ms:80]\n── 1. 文档统计 ──\n\n  文档总数: 9702\n\n── 2. 子集合分布 (collection 字段) ──\n\n  集合名称                 文档数\n  -------------------- ----------\n  Role                 1565\n  Album                960\n  CompletedTask        960\n  NpcStore             960\n  Player               960\n  World                960\n  Bag                  960\n  Transform            960\n  Timer                959\n  Partner              457\n  DropPity             1\n\n── 3. 索引大小 ──\n\n  索引总大小: 696.00 KB\n\n  索引名称                                          大小\n  --------------------------------------------- ------------\n  _id_                                          152.00 KB\n  idx_collection_key_playerid_unique            420.00 KB\n  idx_playerid                                  124.00 KB\n\n── 4. 索引使用统计 ($indexStats) ──\n\n  索引名称                                          使用次数         统计起始时间\n  --------------------------------------------- ------------ -------------------------\n  _id_                                          1            2026-07-06 08:45:20\n  idx_playerid                                  155          2026-07-06 08:45:20\n  idx_collection_key_playerid_unique            1487         2026-07-06 08:45:20\n\n── 5. 关键查询执行计划 (explain) ──\n\n  使用样本: collection=\"Album\", player_id=\"2051981144961855488\"\n\n  [查询 1] 单文档精确查询: {collection, key, player_id}\n  filter: {\"collection\":\"Album\",\"key\":\"main\",\"player_id\":\"2051981144961855488\"}\n  \u002F\u002F [!code ++:2]\n  结果: nReturned=1, keysExamined=1, docsExamined=1, time=0ms\n  判定: 单文档查询命中唯一索引，扫描量与返回量完全一致\n    stage: FETCH\n      stage: IXSCAN, index: idx_collection_key_playerid_unique, pattern: {collection: 1, key: 1, player_id: 1}, direction: forward\n\n  [查询 2] 全量加载: {player_id}\n  filter: {\"player_id\":\"2051981144961855488\"}\n  \u002F\u002F [!code ++:2]\n  结果: nReturned=9, keysExamined=9, docsExamined=9, time=0ms\n  判定: 登录加载命中 player_id 索引，没有全表扫描\n    stage: FETCH\n      stage: IXSCAN, index: idx_playerid, pattern: {player_id: 1}, direction: forward\n\n  [查询 3] 列表查询 (无 ownerID): {collection} + sort {key, player_id}\n  filter: {\"collection\":\"Album\"}\n  sort:   {\"key\":1,\"player_id\":1}\n  \u002F\u002F [!code ++:2]\n  结果: nReturned=20, keysExamined=20, docsExamined=20, time=0ms\n  判定: 复合索引同时服务过滤和排序，分页列表查询稳定\n    stage: LIMIT\n      stage: FETCH\n        stage: IXSCAN, index: idx_collection_key_playerid_unique, pattern: {collection: 1, key: 1, player_id: 1}, direction: forward\n",[53,200,198],{"__ignoreMap":79},[10,202,203],{"id":203},"代码层落地",[18,205,206,207,211],{},"基于 ",[106,208,210],{"link":209},"https:\u002F\u002Fgithub.com\u002Fmongodb\u002Fmongo-go-driver","官方 mongo-go-driver"," 进行封装，我们游戏封装了以下语义：",[47,213,214,217,220,227,234,240],{},[50,215,216],{},"初始化 MongoDB 连接；",[50,218,219],{},"创建主键索引和玩家索引；",[50,221,222,223,226],{},"提供 ",[53,224,225],{"code":225},"Version"," 乐观锁能力；",[50,228,229,230,233],{},"初始化 ",[53,231,232],{"code":232},"storage"," 通用存储表；",[50,235,236,237,143],{},"提供事务执行入口 ",[53,238,239],{"code":239},"ExecuteInTx",[50,241,242,243,246],{},"基于三元组的 ",[53,244,245],{"code":245},"Write\u002FRead\u002FList\u002FDelete\u002FBulkWrite\u002FBulkDelete","。",[248,249,250,251,254],"blur",{},"我以前一直不明白封装的意义是什么，官方驱动不是已经把 API 封装好了吗？现在才明白，不是二次包装 API（我做过这样的蠢事 😭），而是",[34,252,253],{},"封装业务语义","。换句话说, 业务层并不直接面对 MongoDB 的原始 Collection 和查询语法, 而是面对一套“玩家文档存储”的接口。",[14,256,258],{"id":257},"基于-version-的乐观锁","基于 Version 的乐观锁",[18,260,261],{},"在游戏服务器中，很多玩家数据都是“读出一份文档 -> 在内存中修改 -> 再整体写回 MongoDB”。MongoDB 可以保证单次写入是原子的，但如果两个写入方基于同一个旧版本同时修改，后写入的一方仍然可能覆盖前一个写入结果。",[18,263,264],{},"例如，两个服务同时读到了同一份角色数据:",[73,266,269],{"className":267,"code":268,"language":78,"meta":79},[76],"A 读取 role 文档, version = v1\nB 读取 role 文档, version = v1\n\nA 修改等级, 写入成功, version 变成 v2\nB 修改装备, 如果不检查 version, 就可能用旧快照覆盖 A 的等级修改\n",[53,270,268],{"__ignoreMap":79},[18,272,273,274,276,277,279,280,282,283,286],{},"我的做法是把 ",[53,275,225],{"code":225}," 一起放进更新条件里，只有数据库中当前文档的 ",[53,278,225],{"code":225}," 和调用方读到的版本一致，这次写入才会成功。写入成功后，会更新 ",[53,281,225],{"code":225}," ，这里有个小巧思，我是根据新文档的 ",[53,284,285],{"code":285},"value"," 计算 SHA-256 得到，而不是使用额外的计数器。",[18,288,289,290,293,294,296,297,302],{},"当然，不是所有写入都需要乐观锁。在我们项目中，使用 ",[53,291,292],{"code":292},"BulkWrite"," 时, 这条路径不检查 ",[53,295,225],{"code":225},"。原因是玩家数据的主要写入权由游戏节点和玩家 ",[298,299,301],"tip",{"tip":300},"后续写文章总结一下 Actor (｡･ω･｡) 喵~","Actor"," 生命周期约束，正常情况下不存在多个写入方同时改同一份文档的问题。",[18,304,305,306,308,309,312],{},"而 ",[53,307,225],{"code":225}," 乐观锁则保留给存在并发竞争的写入场景，例如 GM 工具、跨服务修复、后台任务或者任何需要",[34,310,311],{},"基于旧值修改后再写回","的流程。",[10,314,315],{"id":315},"结语",[18,317,318],{},"存储对象的完整表示（数据库中的一条文档）如下：",[73,320,325],{"className":321,"code":323,"language":324,"meta":79},[322],"language-go","type Object struct {\n\t\u002F\u002F 集合名称, 用于逻辑分组\n\tCollection string `bson:\"collection\" json:\"collection\"`\n\t\u002F\u002F 对象在集合内的唯一键\n\tKey string `bson:\"key\" json:\"key\"`\n\t\u002F\u002F 对象玩家 ID\n\tPlayerID string `bson:\"player_id\" json:\"player_id\"`\n\t\u002F\u002F 对象的值, 存储为 BSON 原始数据\n\tValue bson.Raw `bson:\"value\" json:\"value\"`\n\t\u002F\u002F 值的 SHA-256 哈希, 用于乐观锁并发控制\n\tVersion string `bson:\"version\" json:\"version\"`\n\t\u002F\u002F 创建时间\n\tCreateTime time.Time `bson:\"create_time\" json:\"create_time\"`\n\t\u002F\u002F 最后更新时间\n\tUpdateTime time.Time `bson:\"update_time\" json:\"update_time\"`\n}\n","go",[53,326,323],{"__ignoreMap":79},[18,328,329],{},[330,331],"img",{"alt":332,"src":333},"意义不明的舞蹈 gif","https:\u002F\u002Fimg2.tofaka.com\u002Fautoupload\u002FZ3wg1auvHGH_fxQcOFgj2SfNcKcqEnRmcljopnyJoMs\u002F20260708\u002FsiXm\u002F250X250\u002F%E8%B7%B3%E8%88%9E.gif\u002Fwebp",[18,335,336],{},"回望这套存储设计, 它并不复杂，对我来说，最大的价值是让后面的业务少做选择题。它不是万能的，例如排行榜这类数据，访问模式就不是按玩家去定位一份文档了，需要单独建表建模。",{"title":79,"searchDepth":338,"depth":338,"links":339},4,[340,347,350,353],{"id":12,"depth":341,"text":12,"children":342},2,[343,345,346],{"id":16,"depth":344,"text":16},3,{"id":23,"depth":344,"text":23},{"id":29,"depth":344,"text":29},{"id":130,"depth":341,"text":130,"children":348},[349],{"id":172,"depth":344,"text":172},{"id":203,"depth":341,"text":203,"children":351},[352],{"id":257,"depth":344,"text":258},{"id":315,"depth":341,"text":315},[355],"技术","2026-07-07 17:09:00","现在的游戏服务器通常使用 NoSQL 作为 DB 以满足 Model 设计上的灵活性，MongoDB 则是 NoSQL 的代表之一。本文看点是 MongoDB 在游戏场景的存储方案、索引设计和代码层落地，每个部分都可以单独食用。",false,"md","https:\u002F\u002Fimg2.tofaka.com\u002Fautoupload\u002FZ3wg1auvHGH_fxQcOFgj2SfNcKcqEnRmcljopnyJoMs\u002F20260708\u002FHeRQ\u002F1036X441\u002Fbili%E5%A4%8F%E5%A4%A9.jpg\u002Fwebp",{"slots":362},{},true,"\u002F2026\u002Frpg_game_mongodb_design",null,{"text":367,"minutes":368,"time":369,"words":370},"10 min read",9.67,580200,1934,{"title":5,"description":357},{"loc":364},"posts\u002F2026\u002Frpg_game_mongodb_design",[375,376],"MongoDB","游戏","story","2026-07-08 16:22:00","nSBvXlu3F6-z-je7WUtowEbeL8RxXPF09s5M4knuMUo",[381,365],{"title":382,"path":383,"stem":384,"date":385,"type":386,"children":-1},"把健康找回来","\u002F2026\u002Fregain-your-health","posts\u002F2026\u002Fregain-your-health","2026-07-01 17:49:00","tech",1783501617054]