腾讯云 NoSQL 技术之 MongoDB 篇:物理备份磁盘膨胀率减少 90% 的内核优化实践

作者:腾讯云cmongo内核团队-杨亚洲、尹超
关键词:MongoDB、WiredTiger、物理备份、空间回收、备份回档提速
MongoDB 常见的备份方式有两种:逻辑备份和物理备份。逻辑备份需要逐条遍历集合,期间持续消耗 CPU、内存与业务侧的查询带宽,TB 级实例往往要跑十几个小时,对线上业务影响明显;物理备份则直接以文件粒度拷贝 WiredTiger 的 `.wt` 数据文件,IO 几乎是顺序大块读,备份速度通常是逻辑备份的5~10倍以上,对业务读写影响小。也正因如此,物理备份成为大规模 MongoDB 集群的主流选择。
在 MongoDB 的日常运维里,物理备份长期以来都有一个隐蔽的副作用:承接备份任务的 hidden 节点,磁盘占用经常比主节点和普通从节点高出一大截。线上某核心大客户就是典型案例——主节点和普通从节点磁盘占用始终稳定在 1TB 出头,而同副本集内承担备份任务的 hidden 节点,磁盘却随着备份任务的累计运行悄然膨胀到了 1.5TB+,膨胀幅度高达50%,且备份结束后这部分空间并不会自动回收,只能依赖人工介入或重建节点。
为了解决该大客户遇到的膨胀问题,我们对 MongoDB 存储引擎做了深入分析,对 MongoDB 内核物理备份做了深入优化,最终形成了一套从 wiredtiger 内核到旁路服务的完整优化链路。结合线上真实业务场景测试验证,该优化可以保证线上业务99%的 MongoDB 集群,其物理备份膨胀率从之前极端的200%降到5%~10%以内,多表场景整体膨胀率可以减少90%。
这篇文章想把整个过程讲清楚——为什么会膨胀、我们做了什么、为什么这样做是安全的、以及在不同生产场景下实测的效果。
一、背景:MongoDB 物理备份的几个长期痛点
MongoDB 的物理备份并不是把整个 dbpath 直接 cp 走那么简单。它的核心是 wiredTiger 提供的一个叫 backup cursor 的能力:业务连上 mongod,打开一个 backup: cursor,WT 在那一刻冻结一份 checkpoint 视图,给你一份这一刻所有数据文件的清单,旁路备份服务拿着这份清单,通过网络通路,把每个`xx.wt`文件直接拷贝到 cos 对象存储。整个 cursor 在拷贝期间一直保持打开状态,直到所有文件落到 COS 才会关闭。
听起来挺合理。但只要在线上跑,几个痛点会陆续浮现。
第一个,也是最明显的——hidden 节点的磁盘占用膨胀。backup cursor一旦在 hidden 上打开,这个节点的所有 `.wt` 文件就开始集体膨胀,业务写入越多,膨胀越严重。更糟糕的是这部分膨胀出来的空间,在 backup 结束之后并不会自动收回。
第二个,单表膨胀时间随总数据量线性放大。backup cursor 是“全或无”语义——cursor 不关,全部表都被锁住;cursor 一关,所有表的状态一起恢复。这意味着即便某表已经拷贝完毕,由于还需要拷贝其他表,cursor 一直打开,因此这个拷贝完成的表还是会持续膨胀。
第三个,oplog 文件被无意义地拷贝。膨胀率越高的集群,一般其 update 等写入会较高,oplog.wt 占比也会更高,oplog 是 MongoDB 的复制日志,物理备份恢复时根本用不上它——恢复完成后节点会重新从复制集拉最新的 oplog 追齐。但 backup cursor 默认会把 oplog 也列在文件清单里,于是旁路服务老老实实地把它拷到 COS。oplog 是 capped 表,不仅用户请求 update、delete 等会膨胀,甚至 insert 也会膨胀,所以 backup 期间它的膨胀很多时候是所有表里最严重的——典型生产环境,这部分数据拷贝既慢又没有回档价值。
第四个,膨胀导致备份回档时间越来越长。膨胀不只是 hidden 节点磁盘账单这一环的事——最终影响旁路服务上传到 COS 的就是这份膨胀后的数据,原本 1TB 的集群极端情况实际要上传 2~3TB,COS 存储费、跨地域复制流量费同比例上涨;备份期间 hidden 节点到 COS 的上行带宽也成倍占用,多个实例同时备份还会把出口带宽打满,挤压其他业务的网络资源;回档时同样要把这 2~3TB 重新从 COS 拉回本地、写盘、由 mongod 加载,下载耗时、本地 IO、`wiredtiger_open` 时间全部翻倍,本来半小时能回档完的实例被拖到1-2小时以上。一次膨胀,hidden 磁盘、COS 存储、网络带宽、回档时长四本账单一起被加价。
总结一下,MongoDB 的备份痛点其实是一个连环:膨胀 → 磁盘成本 → 备份慢 → 膨胀更严重 → 恢复也慢,每一环都在给下一环加压。我们的优化要解决的就是这个连环。
二、优化收益
2.1 测试场景
分析腾讯云线上多个膨胀率较高的用户实例,可以确认大部分实例为多个大表实例,通过腾讯云副本集实例模拟多表用户场景测试,写入4个表,每个表 100G 左右,然后启用备份,并且备份期间各个表的读写流量如下:
| 文件 | 稳态大小 | 业务写入速率/s | 说明 |
| 表1(热表) | 102 GB | 约 800 update+800 read | 频繁update的业务主表,典型中型OLTP场景的热点更新 |
| 表2(温表) | 99 GB | 约 400 update+800 read | 中等更新频率,业务次要热表 |
| 表3(流水表) | 101 GB | 约 1000 insert + 1000 read | append-only insert,例如订单流水/事件日志/IM消息这种典型的写入型业务表 |
| 表4(冷表) | 98 GB | 100 read | 只读 |
2.2 收益对比
腾讯云 MongoDB 优化前后的性能对比总结如下:
| 指标 | 优化前(实测) | 优化后(实测) | 优化收益 |
| 备份总耗时 | ~85.1 min | ~44.7 min | 缩短44.4% |
| hidden节点峰值占用 | 850.9 GB | 524.3 GB | 节省326.6 GB |
| 字节数膨胀(hidden相比主节点) | +331.8 GB | +28.8 GB | 减少91.3% |
| 膨胀率(严格意义) | 63.9% | 5.8% | 减少91% |
| COS实际上传量 | 850.9 GB | ~524.3 GB | 节省~326.6 GB(−38.4%) |
| 回档要从COS拉回的数据量 | 850.9 GB | ~524.3 GB | 少传~326.6 GB(−38.4%) |
2.3 优化总结
从上表可以看出,通过优化备份链路,备份耗时、hidden 节点峰值占用、字节数膨胀、COS 实际上传量、备份期间 COS 带宽占用、回档要从 COS 拉回的数据量等指标都有明显改善。该优化最终收益可以总结如下:
1. 用户集群存储成本:减少38%
2. 物理备份膨胀率:减少91%
3. COS 存储成本:减少38.4%
4. 备份回档效率:提升44.4%
5. COS 带宽节省:节省38.4%
三、膨胀原理:为什么 backup 一开,hidden 节点就开始涨
要解决膨胀,得先看清楚膨胀的物理过程。这一节会带读者走一遍 WiredTiger 在 backup 期间的内部状态变化,把“为什么会膨胀”讲彻底。
3.1 WiredTiger 的空间回收链路
WT 的存储基本单位是 page,每个 page 在落盘时会占用文件里一段连续的 extent(可以理解成一段连续的字节区间)。一个数据文件(`xxx.wt`)由很多 extent 拼起来,extent 之间可能有空洞。
WT 维护三个核心的 extent 列表,理解了这三个列表,就理解了整个空间回收逻辑:
| 列表 | 含义 | 谁能用它 |
| live.alloc | 本checkpoint周期内分配出去的extent | 当前checkpoint的活跃page |
| live.avail | 当前可以被新写入复用的空闲extent | 新写入直接分配 |
| live.discard | 被历史checkpoint引用、暂时不能复用的extent | 等历史checkpoint被drop后,进入下一轮ckpt_avail |
正常情况下,一个 page 被覆盖写(update)或者删除(delete),WT 的处理是这样的:

kpoint 被 drop,是空间回收的唯一触发器。mongo server 内核 checkpoint 默认每60秒触发一次,每次 checkpoint 会把“过时”的老 checkpoint 从元数据里 drop 掉,被 drop 的 checkpoint 所引用的所有 extent 才能进入下一轮的可复用池。
这条链路在没有 backup 的情况下运转得非常顺畅:业务一边写,WT 一边把老 page 进 discard,下一轮 checkpoint 把老 ckpt drop 掉,extent 回到 avail 池,新写入复用空闲位置——文件大小保持稳态。
3.2 backup cursor 是如何打断这条链路的
backup cursor 一打开,WT 立刻做两件事来“冻住”快照:
第一件事,把 backup start 设成最近一次 checkpoint 的时间戳。这个时间戳是后续所有“是否能 drop 老 checkpoint”判断的依据。
第二个判断是膨胀的真正源头,它的作用就是“backup期间,所有 backup 开始之前就存在的 checkpoint 都不能被删,因为旁路服务可能还在通过 backup 视图读这些 checkpoint 引用的数据”。
正常情况下,extent 可被持续复用,如下图所示:

backup 期间,extent 无法复用 → 文件持续膨胀,如下图所示:

膨胀总结:
老 checkpoint 被 pin → 旧 extent 不能进入复用池 → 新写入只能往文件尾部 append。
3.3 oplog.wt 是膨胀最严重的“双冠王”
普通用户数据表的膨胀有上限:
由于普通数据表默认需要保持2个 checkpoint,因此最坏情况下文件大小的理论上限大约为稳态的3倍,一般受 update、delete 相关操作影响。
Oplog 表膨胀上限:
但 oplog 完全不一样,oplog 记录全节点所有写操作——业务写一次, oplog 也写一次。也就是说,oplog 的写入速率可能是任何单张用户表的几十倍。更关键的是,oplog是capped collection,`OplogCapMaintainerThread` 会持续对 oplog 头部做 range truncate 来维持 `oplogSize` 配置上限——这个 B-tree 层的 range truncate 在 backup 期间照常进行(给头部 record 打 tombstone、触发后续 reconcile 重写 page),但被淘汰 page 占用的旧 extent 在 backup 期间始终卡在 `live.discard` 里、进不到 `live.avail`,新 oplog entry 找不到任何可复用空间,只能持续向文件尾部 append。
理解了“wt 文件 size 只增不减”这一点之后,主从差异就清晰了——可以对比一下同一时刻、同一副本集内,主节点 vs hidden 节点上的 oplog.wt:两者业务负载完全一致(都收到全量写入), `OplogCapMaintainerThread` 也都在正常运行、都在按相同节奏对 oplog 头部做 range truncate(B-tree 逻辑 truncate)。主节点上没有 backup cursor,老 checkpoint 能正常 drop,被淘汰 page 占用的旧 extent 通过 `live.discard → ckpt_avail → live.avail` 这条链路回到可复用池,新 oplog insert 直接覆写文件中段那些被腾出来的空洞,文件 size 因此长期稳定在 `oplogSize` 配置值附近——稳定的本质是“新写入填回老空洞”,不是 WT 把文件截短了;而 hidden 节点上 backup cursor 持有老 checkpoint,整个老 checkpoint 引用的 extent 都被 pin 住,旧 extent 卡在 `live.discard` 里出不来、永远到不了 `live.avail`,新 insert 找不到可复用空间只能向文件尾部 append,于是出现了 oplog 逻辑大小恒定在 `oplogSize`、物理文件 size 线性膨胀的分裂状态。因此,oplog 表除了 update 会膨胀,用户 insert 写入也会膨胀。
更糟糕的是,oplog 拷出去之后在物理恢复时根本用不上——如第一章第三个痛点所述,oplog 是 MongoDB 的复制日志,恢复完成后节点会重新从复制集拉最新的 oplog 追齐,备份产物里的那份 oplog.wt 在回档时会被直接丢弃。
所以,oplog.wt 在很多时候是膨胀最严重 + 拷贝价值最低的“双冠王”。这是后面优化里会被特殊处理的对象。
四、优化过程:内核深度优化
膨胀的物理原理梳理清楚之后,优化方向就比较自然了:让外部备份旁路服务告诉 WT “这张表我已经拷完了,你可以恢复它的空间回收”。我们把这个能力做成了 WT 的一个新 C API,叫`WT_CONNECTION::backup_release_checkpoint`。围绕这个 API,内核侧做了配套改造。整个优化分成三大块。
4.1 内核侧改造一:精细化释放 checkpoint
新增的 API 语义很简单:旁路服务每拷完一张表,就调一次`releaseBackupCheckpoint(ident)`通知 WT。WT 把这个 ident 加到一个内部 hash 表里。下一次内部 checkpoint 触发时,做“是否能 drop checkpoint”判断时多查一次这个 hash 表——如果命中,就跳过`backup start`的 pin 逻辑,让这张表的老 checkpoint 进入正常的 drop 流程,extent 进入`ckpt_avail`,新写入复用——膨胀停止。
光有 API 还不够,旁路侧的拷贝顺序同样关键。一张表从`__backup_start`到被 release 之间的这段时间,就是它的“膨胀窗口”——窗口越长,文件被 append 出去的字节越多。所以最优策略是:按预估膨胀率从高到低排序,优先拷贝膨胀最快的表,让它们的窗口尽量短;膨胀慢的冷表/只读表放到最后,反正它们就算晚 release 也涨不了多少。
在我们的实测里(4表场景,1个 update 热表 + 1个 update 温表 + 1个 insert 流水表 + 1个只读冷表),把这个调度策略加上之后,hidden 节点磁盘峰值又能再降20%~30%。
整个流程可以用伪代码描述如下:


端到端流程图(以本测试用例为例)
下图把上述“4 张用户表 + oplog”的实测场景串成一张时序图,体现出“open backup cursor →立刻 release oplog →按膨胀率降序拷表→每拷完一张就 release 一张→最后 close cursor”的完整链路:

图中两个关键差异(相对优化前):
第0步:
就把 oplog 这张膨胀最快的 capped 表从 backup snapshot 中摘掉,避免它在整个备份窗口里一直累积 freed block。
第1步:
每完成一张高膨胀率用户表的上传,就立刻对该表调用一次 WT_SESSION::backup_release_checkpoint(uri),让该表的 freed block 在备份还没结束时就开始回流 free list —— 这是 hidden 节点峰值占用从 850.9GB 降到 524.3GB 的直接来源。
4.2 内核侧改造二:oplog 不备份,并第一时间释放 ckpt
接下来是 oplog.wt 这个“双冠王”的处理。前面已经论证过,oplog.wt 在backup 期间膨胀最严重、拷贝价值最低。我们的处理是两步走。
第一步,在 backup 一开始就立即 release oplog:备份方拿到 file_list 之后,第一件事不是开始拷贝,而是先识别出`local.oplog.rs`对应 ident(通常是`collection-X-<oplogId>.wt`),立即调用`releaseBackupCheckpoint(oplog_ident)`。WT 收到通知后,下一次内部 checkpoint(~60秒内)就会把 oplog 的老 ckpt drop 掉,oplog.wt 在整个 backup 期间保持稳态大小。
第二步,备份流程跳过 oplog.wt 的拷贝:既然 release 之后 WT 可能在任何时候开始改写 oplog 的 extent,我们就不能再去拷贝它了。但简单跳过会带来一个新问题:MongoDB 的 catalog 元数据(`_mdb_catalog.wt`)和 WT 的 metadata(`WiredTiger.wt`)里都记录了`local.oplog.rs`这张集合,恢复时如果元数据指向一个不存在的 wt 文件,mongod 会启动失败。具体解决优化方法参考我们之前的分享: 《「腾讯云NoSQL」技术之MongoDB 篇:MongoDB 存储引擎备份性能70%提升内幕揭秘》
备份端流程图如下(跳过 oplog.wt):

恢复端流程图如下:

4.3 内核侧改造三:crash recovery 路径切换
前面还有一个潜在问题没处理——backup 期间 mongod 进程异常退出之后的恢复路径。
内核逻辑是:mongod 启动时检查 dbpath,看到`WiredTiger.backup`元文件,就认为这是个 backup 中间状态,走 hot backup restore 路径,从 backup 开始那一刻的 LSN 重放 WAL。如果 backup 已经持续了几小时,这一段 WAL 可能有 几百GB,重放下来要数十分钟级。
目前内核没有给 mongod 留下“上次进程死亡时是否处于 backup 中”这个信息——`WiredTiger.backup`元文件本身只能表示“曾经开过 backup”,无法区分“backup 还活着”和“backup 已经完成但元文件未清理”两种情况,更没法和“crash 自愈”逻辑联动。我们的做法是在 mongod 进程内部引入一个 sentinel 标记文件(`backup.in_progress`,下文简称sentinel),让进程自己负责打标和清理;下次启动时由 mongod 启动路径自检该文件是否残留,自动完成`WiredTiger.backup`的invalidate。整个机制全部内聚在mongod 内部,运维侧零介入,不需要外部 wrapper、systemd 钩子或人工脚本。
sentinel 的三处时机如下:

五、注意事项:备份过程中crash异常处理与解法
还有一类隐患是 crash 后 mongod 自身能否正常启动:backup 期间若有部分表已 release,其老 ckpt 已被 drop、extent 已被新写入覆盖;若此时crash,dbpath 里`WiredTiger.backup`元文件仍然残留,原生路径会据此走 hot backup restore,试图按“backup开始那一刻的视图”打开这些表,而它们的老 ckpt 在物理上已不复存在——轻则`wiredtiger_open`报元数据不一致,重则读到错乱数据,启动直接失败。也就是说,release 优化本身在语义上已经让原生 hot backup restore 路径不再可用,必须有配套的启动路径切换。
上文介绍的4.3节的 sentinel 方案也把这条路径堵住了:mongod 启动时若发现`backup.in_progress`残留,会主动把`WiredTiger.backup`删除掉,强制走 normal crash recovery——只依赖最新checkpoint + WAL,不再尝试还原任何已被 release 的视图,正确性与启动速度一次性兜住。
换句话说,4.3节的优化不只是“启动快了10x”,更是 release 机制能安全上线的必要前提。
六、总结
这次备份膨胀的优化,本质上是把 WiredTiger 的 hot backup 从“全或无”语义升级到“表级粒度”,把备份对生产环境的副作用降到最低。
结合本文这套表级 release 优化 + 凌晨低峰期错峰备份的调度策略,腾讯云MongoDB 线上99%的实例膨胀率可以从最极端场景下的200%收敛到5%~10%的稳定区间——这意味着 hidden 节点的磁盘 buffer 预留可以从原来的2x大幅压缩到1.1x以内,单实例磁盘成本下降约60%,TB 级大集群的备份窗口也不再需要为膨胀峰值留冗余。剩余不足1%的极端场景(典型如备份期间超大写入压力 + 单表体量极大)我们通过无损在线 compaction 进一步优化解决——在不阻塞业务读写、不影响主从同步的前提下回收备份遗留的碎片空间,把这部分尾部实例也拉回到正常水位。
该方案的技术细节与生产实践我们将在后续文章中专门分享。
客户价值:前文提到的线上客户多表核心集群在启用本优化后,集群在数月间进行了上百次全量物理备份,Hidden 节点磁盘占用空间始终保持平稳,未再因备份导致的显著空间膨胀、需人工介入清理的情况。整体 Hidden 节点的磁盘空间膨胀率由约50%下降至5%,单节点磁盘空间节省约500GB。
更多推荐




所有评论(0)