做游戏后端这几年,服务端框架换过好几轮,最后长期留用的还是Skynet。倒不是说它功能有多全,而是Actor模型天生适合多服务协作的场景,模块之间消息驱动、内存隔离,出了问题也不会一串服务全部躺平。另一个长期搭档是 MongoDB,负责存玩家数据。这两个东西搭一起,已经成为不少中型游戏项目的标配。
这篇就专门聊一聊 Skynet 框架里接入 MongoDB 的事:从驱动选型、配置管理、数据读写到运维监控,把“能用”到“好用”之间那些容易踩的坑一次说清楚。适合正在做游戏后端、或者刚把 Skynet 跑起来想接数据库的开发者参考。
1. 为什么游戏后端会选 MongoDB:Skynet 场景下的存储选型思考
1.1 从关系型到文档型:数据模型的现实考量
很多刚接触游戏后端的同学会有个惯性思维:数据存储优先上 MySQL。这个思路本身没错,但放到真实游戏场景里会撞上几个别扭的地方。
游戏玩家数据最大的特点是“结构多变且嵌套”。一个玩家的档案,包含了基础信息(等级、经验、金币)、背包(几十上百格、每格有道具ID和数量)、任务进度(多个任务线、每个任务有若干阶段状态)、成就、好友列表、昨日战绩……如果用 MySQL 表达,要么拆成十几张表疯狂 join,要么把一整坨 JSON 塞进 TEXT 字段里“假装在用数据库”。前者让上线后加字段变成噩梦,后者让事务和查询形同虚设。
MongoDB 的文档模型天然适合这种场景。一份玩家文档可以直接长成这样:
{ "uid": 10086, "base": {"level": 30, "exp": 204800, "gold": 99999}, "bag": [{"id": 5001, "count": 3}, {"id": 6002, "count": 1}], "quest": {"main_01": {"state": 3, "progress": 5}}, "friends": [10010, 10011, 10012] }这也和游戏策划的需求天然对应——策划改需求是常态,今天加个“每日签到连续天数”,明天加个“赛季积分”。用 MongoDB,后端只需要在文档上追加字段,不再需要 DBA 陪你做表结构迁移。上线迭代的速度能快一个量级。
1.2 Skynet 与 MongoDB 的契合点
Skynet 的 Actor 模型和 MongoDB 之间也有很自然的契合关系。
在 Skynet 里,每个功能模块独立成一个服务(service),服务之间用消息通信,服务内部完全隔离。这种模型恰好适合做“数据库访问层”:你可以起一个独立的 MongoDB 服务,所有需要读写数据的业务服务只跟它通信。这个数据库服务内部维护连接池、队列、超时重试,对外暴露的只有几条查询和写入协议。好处非常明显:
- 数据库连接不会散落在几十个服务里,连接数可控;
- 数据库故障时的重连、容错逻辑只需要在一个地方维护;
- 业务服务无状态化,后面做热更新、灰度发布都轻松。
另外,Skynet 的调度模型非常适合 MongoDB 这种“低延迟、高吞吐、偶尔毛刺”的存储。消息队列天然削峰,数据库抖动不至于立刻卡死前端。我实际测试下来,单机 Skynet 集群 + 单机 MongoDB,常规玩家的数据读写 P99 能稳定在 5 毫秒以内,完全满足 MMORPG 的动作类玩法。
2. 接入前必须想清楚的几个架构问题
2.1 驱动与 C Service 的取舍
Skynet 的 Lua 层并没有官方封装 MongoDB 驱动,社区方案五花八门。我前后试用过几套,最后稳定用的是基于 mongo-c-driver 封装的 Lua binding 方案,也就是把官方 C 驱动用 FFI 或者 C 模块方式包一层,暴露给 Lua 调用。
这里最大的取舍点是:自己写 C Service 还是用别人封好的现成库?
如果你所在项目对数据一致性要求极高、并发量又特别大,自己维护一个 C Service 是可控性最好的方案。你要完全掌控 BSON 转换、连接复用和请求分流,避免第三方库里隐藏的内存问题。但如果只是常规游戏项目,我建议直接用开源封装,把精力花在业务层的数据建模上。
选择现成库时重点看三点:
- 是否支持连接池复用,还是每次请求新建连接;
- BSON 转换是否完整,尤其是对 ObjectId、空数组、空 Table 的处理;
- 是否处理了 MongoDB 的写入确认机制,包括 writeConcern 的设置。
2.2 配置文件与连接池设计
Skynet 的配置是一个纯文本的 config 文件,业务配置通常放在一个表里。我习惯把 MongoDB 的配置单独拎出来,不散落到代码里。
-- config 片段 mongo = { host = "127.0.0.1", port = 27017, dbname = "game", username = "game_srv", password = "xxxxxx", poolsize = 8, timeout = 3000, }连接池大小的设计要跟业务形态挂钩。很多新手会犯一个错误:池子开得越大越好。实际不是这样。MongoDB 单连接就能跑很高的 QPS,真正限制吞吐的是服务端的锁竞争和设备 IO。 池子开太大反而容易把服务端连接数撑爆,触发 MongoDB 的“连接数超限”保护机制。
我的建议是:按“预估峰值 QPS / 单连接可承受 QPS”来算。比如某个区预估峰值 QPS 5000,单连接实测能扛 1500 QPS,那连接池开到 6~8 就足够,再多都是浪费。
超时设置也要讲究。MongoDB 的默认 socket 超时比较短,在游戏场景下经常出现一次网络抖动就整个请求失败。我一般会把超时调到 3 秒左右,并且加上一层重试机制。注意重试仅限于幂等操作,写入类操作要小心重复提交。
3. 实操:Skynet 里接入 MongoDB 的完整过程
3.1 环境准备与驱动编译
在动手写代码之前,先把环境准备好。
Skynet 本身跨平台,但实际部署几乎都在 Linux 上。MongoDB 驱动我用的是 mongo-c-driver 的稳定版本,编译依赖 libbson、libmongoc。这里有个细节:mongo-c-driver 默认编译的是静态库,和 Skynet 这种动态加载模块的方式要匹配好。我建议在编译驱动时加上--enable-static和--with-pic,否则最终链接到 Skynet 的 .so 文件里容易出现符号冲突。
编译步骤大致是:
# 下载 mongo-c-driver wget https://github.com/mongodb/mongo-c-driver/releases/download/1.24.4/mongo-c-driver-1.24.4.tar.gz tar zxvf mongo-c-driver-1.24.4.tar.gz cd mongo-c-driver-1.24.4 # 编译安装 cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_TESTS=OFF -DENABLE_STATIC=ON .. make -j$(nproc) sudo make install编译 LBSON 转换库时,我遇到过不少次undefined reference to bson_*之类的链接错误,最后基本都指向两个原因:要么没有-lbson-1.0 -lmongoc-1.0,要么库的搜索路径没有加-L/usr/local/lib。把这两项加进 Skynet 的编译参数里基本能解决。
3.2 配置注入与 Service 启动
Skynet 的 Lua 服务启动是有约定的。你写好一个mongod.lua,通过skynet.newservice("mongod")拉起。这个服务要放在skynet/目录下,并且被主服务依赖。
整个启动顺序是这样的:
- Skynet 的
main.lua先启动; - 读取 config 里的
start配置,指定入口服务; - 入口服务里调用
skynet.newservice("mongod"),把数据库服务拉起来; mongod服务内部完成 MongoDB 的鉴权和连接池初始化;- 一切就绪后,
mongod服务对外广播一条服务就绪消息,业务服务收到后才开始正常处理玩家请求。
这里有个细节容易忽略:Skynet 的服务启动是异步的,千万不要在newservice之后立刻发数据库请求。你要等目标服务先向自己发送一条启动完成消息,或者用一个独立的全局 ServiceID 做同步等待。
我在mongod.lua里的初始化逻辑大致是:
local skynet = require "skynet" require "skynet.manager" local mongo = require "skynet.db.mongo" skynet.start(function() local conf = skynet.getenv("mongo") --- 解析配置并 ping 一下 local ok, err = check_and_init(conf) if not ok then skynet.error("mongo init failed:", err) skynet.abort() end skynet.register(".mongod") skynet.start(function() -- 注册协议处理函数 end) end)3.3 玩家数据读写与序列化细节
玩家数据读写是游戏后端最核心的链路,这里的序列化细节直接影响性能和稳定性。
MongoDB 的存储格式是 BSON,Lua 侧拿到的自增长 ID、字符串等都需要做类型转换。最常见的一个坑是Lua 的 table 和 BSON 的数组/文档之间的歧义。
Lua 里区分不了“这是一个空文档”和“这是一个空数组”:
-- 这两个在 Lua 里是一样的 local empty_doc = {} local empty_arr = {}但 BSON 严格区分。如果你往数据库里存一个空 table,MongoDB 默认按空文档存;如果你要用$push或$addToSet更新到一个数组字段,初始化时就必须显式指定bson.type.ARRAY。
社区封装好的驱动一般有解决办法,比如用 metatable 标记数组类型,但我在项目中还是建议约定:数组字段必须在文档创建时就给初始值{},并且由封装函数统一处理成 BSON 数组。这样后面所有 $push 操作才能安全执行,否则很容易遇到 “Cannot create field” 这类诡异错误。
另一个细节是 ObjectId 的处理。MongoDB 的_id默认是 12 字节的 ObjectId,Lua 侧拿到的是一段二进制数据。如果你的业务逻辑需要把_id作为字符串传给前端,务必在存储前转成 hex 字符串,否则传到客户端就会变成一大坨不可读的乱码。
我一般在mongod服务内部封装好一层to_string/to_objectid的转换,业务代码里接触到的_id一律是字符串,避免多端格式不一致。
4. 代码层面的核心操作实现
4.1 连接管理与连接池
前面说了连接池设计,这里说代码实现。
Skynet 里做连接池没有现成的线程池组件,我采用的是一个“连接数组 + 轮询游标”的简单模型。每个连接养在独立的对象里,每次请求从连接池里取出一个、操作完放回。实现上注意加锁,Skynet 的单线程事件循环意味着“同一时刻只有一个 Lua 协程在跑”,所以尽量不用锁,用skynet.fork起的多个协程之间靠队列来串行化。
一个简化版本的连接管理思路:
local pool = {} local cursor = 1 local function get_conn() local conn = pool[cursor] cursor = (cursor % #pool) + 1 return conn end local function init_pool(conf) for i = 1, conf.poolsize do local client = mongo.client(conf) pool[i] = client end end这里要注意:MongoDB 客户端连接在被网络异常断开之后,不能直接复用。要在每次取出连接时检查连接的健康状态,如果抛了异常,就重建连接再重试一次。我通常会记录一个“连续失败计数”,超过阈值就直接报警。
4.2 数据读写的最佳实践
实际游戏后端读写,最频繁的操作不是复杂的聚合查询,而是单文档的按 UID 查询和增量更新。
我的建议是:游戏逻辑里所有玩家相关数据,尽量在一个文档里完成读写,不要频繁跨文档查询。MongoDB 的单文档写入是原子操作,这一点对“扣金币”“加经验”这类强一致场景特别有用。
典型的扣金币操作:
-- 原子自减,并保证余额不为负数 local res = mongodb:update( {uid = uid}, {["$inc"] = {["base.gold"] = -100}}, {upsert = false} )这里的["$inc"]加了一个base.gold的嵌套字段路径,MongoDB 会直接对文档里的嵌套字段做增量。这个方案比“先读后写”要可靠得多,完全规避了并发扣减问题。
但要注意,$inc 这类操作符只支持数值类型。如果你的字段用字符串存了带格式的数字(比如"100.5"),$inc 就会直接报错。我在初期就吃过这个亏,存金币时用了字符串,导致后来做增量更新全崩。
4.3 索引设计与查询性能
索引设计是 MongoDB 优化里最见效的一环。
游戏场景下的查询模式高度固定:核心永远是uid。所以uid字段必须有唯一索引,这是所有玩家数据查询的入口。其次是“排行榜”这类经常按某个属性排序的场景,需要给排名字段加索引。
我常用的建索引方式是在服务启动时执行一次:
db.players.createIndex({uid: 1}, {unique: true}) db.players.createIndex({last_login_time: -1}) db.players.createIndex({level: -1, exp: -1})这里有个决策细节:排序用 -1 还是 1。如果你的查询请求“等级最高、经验最高”的人,那按{level: -1, exp: -1}建索引,查询速度会好很多。如果你的查询偏正向顺序(比如“等级最低的 N 个玩家”)才用 1。
不要给每个可能用到的字段都建索引。索引会拖慢写入速度,尤其是文档更新频繁的玩家表,索引一多每次写入的负担都成倍增加。一般一个集合保持 3~5 个索引就够用了。
排查慢查询时,MongoDB 的explain()非常有用。但要注意在 Skynet 侧执行 explain 时,驱动不一定支持直接透传。我建议直接在 mongosh 里手动执行db.collection.find({...}).explain("executionStats"),重点看executionTimeMillis和totalDocsExamined。一个常见问题是:查询条件加了索引但没命中,导致全文档扫描,耗时飙到几百毫秒。解决办法是检查查询字段的数据类型是否一致——字符串和数值型不匹配时,索引不会被命中。
5. 常见故障与排查实录
5.1 问题速查表
下面这张表是我把过去一年线上踩过的坑整理出来的,基本覆盖了 Skynet + MongoDB 最常见的故障。
| 故障现象 | 可能原因 | 解决方案 |
|---|---|---|
| 玩家登录长时间卡在“读取数据中” | 数据库连接池耗尽 | 检查连接池大小、DB 的当前连接数、有没有慢查询占着连接 |
| 扣金币偶尔扣成负数 | 非原子更新导致竞态 | 改用 $inc 原子操作或 findAndModify |
Lua 侧拿到空的_id | 驱动未正确返回 ObjectId | 检查返回结果是否被当字符串处理,统一转换 |
| $push 失败,报 “Cannot create field” | 目标字段初始不存在且被当文档 | 字段初始化时必须声明为 BSON 数组类型 |
| 查询速度突然变慢 | 索引失效或全表扫描 | 查看 explain(),检查字段类型、查询条件是否命中索引 |
MongoDB 日志出现大量conn pool告警 | 连接数逼近上限 | 调连接池上限、复用连接、提高单连接吞吐 |
| 数据出现重复玩家记录 | uid唯一索引未建 | 补建唯一索引,修复已有重复数据 |
5.2 两个真实案例的排查思路
第一个案例是登录卡顿问题。
现象:开服当天大批玩家登录,前端反馈“加载角色数据”卡了很久。排查发现 MongoDB 连接数涨到几千,但大部分连接都在 idle,真正在工作的只有几十个。问题出在 Skynet 侧的连接池实现——每次登录请求都走了mongo.client()新建连接,没有走连接池。大量新建连接操作抵不过 MongoDB 服务端的 accept 速率,导致阻塞。
修复方案很简单:强制所有请求走连接池,启动时一次性把连接建立好,运行期不再新建连接。修复后峰值连接数从 5000+ 降到 80,登录响应从 800ms 降到 20ms。
第二个案例是数据不一致。
现象:某个玩家背包里道具数量反复横跳,甚至出现过道具消失。排查后发现业务代码用了“先读、改、再写回”的方式更新背包字段,多个协程同时操作时互相覆盖。
修复方案是把所有背包更新改成 MongoDB 原子操作。$push增加道具,$pull删除道具,数量增减一律用$inc。这里要特别提醒:即使是同一条玩家数据,Skynet 里仍然可能出现并发访问,因为“玩家个人消息”和“跨服消息”两个协程可能同时操作同一文档。所以涉及数值变更的字段全部走原子操作是铁律。
6. 长期运维与性能监控心得
6.1 备份与数据一致性
游戏数据不是丢了能重来的,备份策略必须写到运维手册里。
MongoDB 推荐开启副本集模式(Replica Set),至少三节点:一个主、一个从、一个仲裁。 Skynet 侧的连接串要写成副本集模式,连接串里带上所有节点地址,任何一个节点挂了,驱动会自动切换。
连接串示例:
mongodb://game_srv:xxxx@node1:27017,node2:27017,node3:27017/?replicaSet=rs0&w=majority日常备份用mongodump配合oplog增量,我一般每天凌晨 3 点做全量备份,每小时做一次增量备份。
这里要特别提醒:游戏服务器热更或者停服维护时,千万别省略“关服停写再备份”这一步。如果不停写就备份,恢复出来的数据可能是某个中间状态的切片,会出现玩家回档、道具重复等问题。每次大版本发版前,先停服、再备份、再开服,三分钟的事,能省掉后续无数扯皮。
6.2 监控与容量规划
MongoDB 官方提供的监控工具mongostat和mongotop是基础。我这边习惯把它接进自建的监控告警系统,重点盯几个指标:
conn:当前连接数,超过连接池上限的 60% 就预警;opcounters的query/update速率:看是否在合理范围;mem的resident:常驻内存不能无限上涨,否则容易触发内存压力;command里的find.和update.的耗时分布:超过 100ms 的慢命令单独捞出来看。
真正上线后,还要关注热点数据问题。全服排行榜、全局公告这类被频繁读取的数据,不要把压力全打给 MongoDB。我在 Skynet 里加了一层热点缓存服务,把排行榜前 100 名缓存 1 分钟,期间所有查询直接走缓存,数据库只处理排名变更的写入和刷新,整体 DB 压力能下降 40% 以上。
容量规划方面,不同游戏的差异很大。但我可以给个参考经验:一台 4 核 8G 的 MongoDB 实例,能支撑大约 5000 人的玩家数据并发读写,前提是数据模型合理、索引齐全。超过这个规模,优先考虑按区服拆分数据库实例,而不是盲目堆单机配置。
7. 一点小心得
最后说点私货。
Skynet 接入 MongoDB,真正决定上限的不是驱动好不好用,而是数据建模是否贴合业务。每一个字段是嵌套还是扁平、哪些走嵌套文档、哪些走数组、哪些只存冗余快照,这些都要按查询频率来设计。你在建模时多花一天,后面能省下一周排查问题的力气。
另一个深刻体会是:不要等到线上出事故了才去翻 mongod 日志。平时就把慢查询日志开好,设置一个准确的阈值(我一般设 100ms),每天都去看一眼。数据量上来之后,很多性能问题都是温水煮青蛙,等你注意到的时候,数据库已经背着几十个坏索引跑了大半个月了。
这套组合拳打下来,Skynet 和 MongoDB 在我的几个项目里都稳定服役了一年以上,最长的已经跑了一年半,没出过大问题。如果你也正在做游戏后端,或者正打算把 Skynet 和 MongoDB 搭起来,照着上面的思路走一遍,应该能少走不少弯路。