MongoDB 空间又满了,你不会认为删除数据就解决问题了吧?
先说结论,省流版
deleteMany / remove 不会释放磁盘空间。
WiredTiger 只做标记删除,空间留给后续复用,不会返还给操作系统。
真正能回收空间的是compact(部分回收)和drop()(完全释放)。
一、问题背景
前几天在帮一个朋友问我,MongoDB所在服务的磁盘满了。要删除数据行不行?
我第一反应你要怎么删除?
因为我多年经验告诉我这又是一个开发人员想出来的。因为在Oracle MySQL上我见过太多的开发写的脚本就是delete from。一般的关系型数据库删除数据以后,你可以继续往里面写,但是操作系统上的空间是不会释放的。(除非你做碎片整理,而Oracle上连最大使用空间的高水位线都不会回收)
其实对于NoSQL来说也差不多是这样。MongoDB 底层存储引擎 WiredTiger 的空间管理机制也这样。但是很多开发者不了解数据库都会误以为 deleteMany({}) 和 remove() 会像 MySQL 的 DELETE 一样,删除后空间立即返还给操作系统。
事实并非如此。
为了把这个事情讲清楚,我在 Docker 环境(mongo:4.4)做了一组对照实验,每一步都记录 storageSize 和物理 .wt 文件大小的变化。
二、实验环境
说明:MongoDB 5.0+ 要求 CPU 支持 AVX 指令集,本次实验的 102 服务器 CPU 不支持 AVX,因此使用 mongo:4.4 版本。结论在 4.4 ~ 7.x 均适用。
三、实验过程
本身我是知道结论的,为了写这个还是把过程重现一下。重现的过程都是agent去完成的。
3.1 准备:启动干净容器
# 清理旧容器
docker rm -f mongo_exp 2>/dev/null# 启动 MongoDB 4.4
docker run -d --name mongo_exp -p 27017:27017 mongo:4.4 mongod --bind_ip_allsleep 5
echo"MongoDB 启动完成"
3.2 Step 1:插入 20 万条数据
插入脚本(mongo shell):
var coll = db.exp_coll;
var bulk = [];
for (var i = 0; i < 200000; i++) {
bulk.push({
_id: i,
name: 'user_' + i,
email: 'user_' + i + '@test.com',
age: i % 80,
score: Math.random() * 100,
note: 'x'.repeat(300) // 人为放大文档体积
});
if (bulk.length === 1000) { coll.insertMany(bulk); bulk = []; }
}
if (bulk.length) coll.insertMany(bulk);
print("插入完成");
插入后 stats 结果:
> db.exp_coll.stats()
{
"count" : 200000,
"size" : 81999904, // dataSize ≈ 78.18 MB
"storageSize" : 0, // 压缩后几乎为 0
"totalIndexSize" : 0,
"nindexes" : 1
}
物理文件大小(插入后):
$ docker exec mongo_exp ls -lh /data/db/collection-*.wt /data/db/index-*.wt
-rw------- 1 mongodb mongodb 4.0K collection-7-xxxx.wt # 数据文件
-rw------- 1 mongodb mongodb 4.0K index-8-xxxx.wt # 索引文件$ docker exec mongo_exp du -sh /data/db
301M /data/db # 数据目录总大小
发现 1:WiredTiger 默认 snappy 压缩效果极强,78MB 逻辑数据压缩后物理文件只有 4K。
3.3 Step 2:deleteMany({}) 删除全部文档
删除命令:
> var r = db.exp_coll.deleteMany({})
> print("删除: " + r.deletedCount + " 条")
删除: 200000 条> var s = db.exp_coll.stats()
> print("count: " + s.count)
> print("storageSize: " + s.storageSize)
count: 0
storageSize: 0// 显示 0,但实际物理文件没变
物理文件大小(deleteMany 后):
# deleteMany 前
$ docker exec mongo_exp ls -lh /data/db/index-8-*.wt
-rw------- 1 mongodb mongodb 4.0K index-8-xxxx.wt# deleteMany 后
$ docker exec mongo_exp ls -lh /data/db/index-8-*.wt
-rw------- 1 mongodb mongodb 1.3M index-8-xxxx.wt # 反而变大了!$ docker exec mongo_exp du -sh /data/db
303M /data/db # 数据目录反而增加了 2MB
对比表格:
核心发现:deleteMany 后:
storageSize显示 0(压缩统计的假象) 物理 .wt文件大小完全不变索引文件反而从 4K 涨到 1.3MB(删除操作产生了索引空洞) 磁盘使用量不会减少
为什么会这样?
WiredTiger 使用 标记删除(tombstone) 机制:
删除文档时,只是在 B-Tree 叶子节点上标记"已删除" 这些被标记的空间被记录为"可用空洞" 后续插入新文档时,会优先复用这些空洞空间 但空洞空间不会返还给操作系统
3.4 Step 3:compact 回收空间
compact 命令:
> var r = db.runCommand({compact: 'exp_coll'})
> print("compact ok: " + r.ok + " bytesFreed: " + r.bytesFreed)
compact ok: 1bytesFreed: 1318912// 回收了约 1.26 MB
物理文件大小(compact 后):
# compact 前
$ docker exec mongo_exp ls -lh /data/db/index-8-*.wt
-rw------- 1 mongodb mongodb 1.3M index-8-xxxx.wt# compact 后
$ docker exec mongo_exp ls -lh /data/db/index-8-*.wt
-rw------- 1 mongodb mongodb 12K index-8-xxxx.wt # 明显缩小$ docker exec mongo_exp du -sh /data/db
301M /data/db # 减少了 2MB
对比表格:
compact 确实可以回收空间,但有两个重要限制:
- 会阻塞写入
(MongoDB 4.4 的 compact 是阻塞操作,5.0+ 支持在线 compact) - 只能回收集合内部的碎片
,不能把空间返还给其他集合或系统
3.5 Step 4:drop() 完全释放
drop 命令:
> var r = db.exp_coll.drop()
> print("drop result: " + r)
drop result: true
drop 后物理文件:
$ docker exec mongo_exp ls -lh /data/db/collection-*.wt /data/db/index-*.wt 2>/dev/null
# 部分文件仍存在(WiredTiger 延迟清理机制)
# 但集合元数据已完全删除,空间会被自动复用
drop() 是最彻底的方式:集合元数据完全清除,空间最终会返还给操作系统(可能有延迟)。
四、三种操作对比总结
deleteMany({})remove() | 不释放 | 不变 | |||
compact | 部分回收 | 缩小 | |||
drop() | 完全释放 | 最终删除 |
五、WiredTiger 空间管理机制详解
5.1 为什么 delete 不释放空间?
WiredTiger 使用 B-Tree + 页管理 的存储结构:
插入数据:
-> 分配新的 B-Tree 页
-> 数据写入页中
-> 页满后分配新页删除数据:
-> 在 B-Tree 页中标记文档为"已删除"
-> 页不会立即合并或释放
-> 被标记的空间记录为空洞(hole)
后续插入:
-> 优先使用空洞空间
-> 空洞用完才分配新页
类比:就像你删除了硬盘上的文件,文件系统只是标记这些扇区为"可用",但硬盘的物理容量不会变少。
5.2 compact 做了什么?
compact 命令会:
遍历集合的所有 B-Tree 页 把还在使用的文档重新整理到新的页中 丢弃全是"已删除"标记的页 将释放的页空间返还给文件系统
代价:整理过程中集合不可用(MongoDB 4.4),5.0+ 支持在线 compact 但仍有性能影响。
5.3 WiredTiger 压缩的影响
实验中有一个有趣现象:dataSize 78MB,但 storageSize 几乎为 0。
这是因为 WiredTiger 默认使用 snappy 压缩:
snappy:速度快,压缩率中等(实验中约 100:1,因为测试数据有大量重复 ‘x’) zlib:压缩率高,但 CPU 开销大 zstd:MongoDB 4.2+ 支持,压缩率和速度均衡
生产建议:不要仅凭 storageSize 判断数据量,要结合 dataSize 和压缩率一起看。
六、MongoDB 有"分区"吗?过期数据怎么处理?
很多从 Oracle / MySQL 转过来的 DBA 都会问这个问题:Oracle 有 PARTITION BY RANGE + DROP PARTITION,MySQL 有 PARTITION BY RANGE + ALTER TABLE DROP PARTITION,MongoDB 有没有类似做法?
先说结论:MongoDB 没有原生 DROP PARTITION 语法,但有三种等价方案,其中"时间桶集合 + drop()"最接近 Oracle/MySQL 的分区删除,而且正好用上了我们前面实验的结论。
6.1 Oracle / MySQL 的做法回顾
PARTITION BY RANGE(created_date) | ALTER TABLE t DROP PARTITION p202607; | |
PARTITION BY RANGE (TO_DAYS(created_at)) | ALTER TABLE t DROP PARTITION p202607; |
核心思路:把数据按时间切成物理分区,过期整个分区直接删掉,空间立刻释放。
6.2 MongoDB 方案一:TTL 索引(自动过期,最省事)
MongoDB 原生支持 TTL 索引,对某个时间字段设置过期时间,后台线程自动删除过期文档:
// 在 createdAt 字段上建 TTL 索引,30 天后自动删除
db.logs.createIndex({ createdAt: 1 }, { expireAfterSeconds: 2592000 })// 查看 TTL 状态
db.runCommand({ collMod: 'logs', index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: 2592000 } })
特点:
MongoDB 后台每 60 秒扫描一次,删除过期文档 完全自动化,无需人工干预 - 缺点(结合我们实验的结论)
:TTL 删除本质就是 deleteMany,走的是标记删除,磁盘空间不会释放,只是空间可复用 适合:日志、会话、验证码等"删了不心疼空间"的数据
⚠️ 注意:TTL 索引不能建在
_id上;如果文档被删后又有新文档插入,TTL 不会复活旧文档。
6.3 MongoDB 方案二:时间桶集合 + drop()(最接近 DROP PARTITION,强烈推荐)
这是最接近 Oracle/MySQL 分区表的做法:按时间片建集合,过期直接 drop 整个集合。
// 1. 按天建集合:logs_20260801, logs_20260802 ...
db.createCollection('logs_20260801')
db.createCollection('logs_20260802')// 2. 写入时按当前日期路由到对应集合
// (应用层根据日期拼集合名,或用 MongoDB 4.4+ 的视图 + $merge 自动化)// 3. 每天定时任务:drop 30 天前的集合 = Oracle 的 DROP PARTITION
// 等价于:ALTER TABLE logs DROP PARTITION p20260701;
db.getCollection('logs_20260701').drop()
特点:
- drop() 完全释放磁盘空间
(我们实验已验证:集合元数据清除、物理文件最终删除) 删除开销极低:drop 是元数据操作,秒级完成,不像 deleteMany 要逐条扫描 查询跨桶时可以用 视图 统一入口:
// 创建视图,聚合多个时间桶,应用层无感知
db.createView('logs', ['logs_20260801', 'logs_20260802', 'logs_20260803'], [])
清理脚本(定时任务,每天执行):
#!/bin/bash
# 删除 30 天前的日志集合(等价 Oracle: ALTER TABLE logs DROP PARTITION)
RETENTION_DAYS=30
CUTOFF=$(date -d "$RETENTION_DAYS days ago" +%Y%m%d)# 列出所有 logs_ 开头的集合,删除过期桶
for coll in $(docker exec mongo_exp mongo exp_db --quiet --eval"
db.getCollectionNames().filter(c => c.startsWith('logs_'))
"); do
TS=${coll#logs_}
if [[ "$TS" < "$CUTOFF" ]]; then
docker exec mongo_exp mongo exp_db --quiet --eval"db.${coll}.drop()"
echo"已删除过期集合: $coll"
fi
done
6.4 MongoDB 方案三:Atlas Online Archive(云端自动分层)
如果用的是 MongoDB Atlas 云服务,还有 Online Archive 功能:
自动把超过 N 天的冷数据迁移到 S3 对象存储 查询时对应用透明,热数据在集群、冷数据在归档层 相当于"自动分区 + 自动归档",但这是商业版功能,社区版没有
6.5 三种方案对比
| 完全释放 | ||||
6.6 和本文实验结论的呼应
为什么"时间桶 + drop()"是首选?
因为本文实验已经证明:
deleteMany不释放空间、compact会阻塞写入,只有drop()是完全释放且开销最低 的。
时间桶方案把"删数据"变成了"删集合",每次清理都是秒级 drop,空间立即归还,完全避开 deleteMany 的空间陷阱。
七、总结
- deleteMany / remove 不释放磁盘空间
,这是 WiredTiger 也是几乎所有数据库的特性, - compact 可以回收碎片空间
,但会阻塞写入,生产环境需谨慎 - drop() 是最彻底的空间释放方式
- WiredTiger 压缩率很高
,不要仅凭 storageSize 判断数据量 如果磁盘空间持续增长,优先检查是否有大量"已删除但未 compact"的集合