MongoDB 删除数据是否会释放存储空间

2023-05-28 18:56:11 浏览数 (2)

导语

通过删除无用数据来释放存储空间,对于数据库来说是很常见的需求。但是很多 MongoDB用户发现,在执行删除操作后,存储空间并没有很快释放。

本文通过分析 MongoDB 4.0 源代码,并结合测试来讨论 MongoDB 存储空间释放的问题,最后提出一些常用的解决方案。

原理分析

MongoDB 中常见的删除操作有:

1.库表整体删除。比如 dropCollection/dropDatabase, 会将对应的表、索引文件删除。磁盘空间会很快得到释放。

2.逻辑删除部分数据。比如 delete/remove 操作,一般会指定 filter 删除部分数据。磁盘空间可能不会很快释放。

由于方案 1 的结果非常明确,下面主要分析方案 2。

备注:也有些用户会通过修改或删除文档中的部分字段,使整体数据量变小。在原理上和方案 2 类似,因此归类在一起分析。


MongoDB 底层默认使用 WiredTiger(WT) 存储引擎。因此,需要先了解 WT 引擎在删除数据时会经历哪些流程。

WT 引擎的数据存储分为内存和磁盘 2 部分。

内存:作为 cache 加速读写访问,每个表在内存中都各自对应了一个 btree,btree 的 leaf page 上存储了用户的数据。如果用户数据发生了修改和删除,对应的 page 会被标记为 dirty,然后在 evict/reconcile 阶段刷到磁盘上。

磁盘:存储持久化数据。MongoDB 中的每个 collection和 index, 都分别对应 WT 引擎中的一个 table, 对应了一个 xxx.wt 文件。每个数据库文件采用数据块的方式进行空间管理,每个数据块默认按照 4KB大小对齐(比如4KB,8KB,12KB等),并采用 extent(offset length) 进行标识。

WiredTiger 引擎对每个数据库文件都维护了 allocated list, available list 和 discard list 记录每个数据块的信息和状态。其中 allocated list 表示当前已分配数据块,available list 表示当前可以使用的数据块,discard list 表示数据块中的内容被删除,但是在当前的 checkpoint 中还不能马上复用的数据块。上述信息和 root page,file size 会作为checkpoint 信息,每隔 60 秒进行一次快照存档(一致性点,也叫检查点,checkpoint)。异常宕机后,可以通过 checkpoint journal 快速恢复到最近的一致性点。

关于 checkpoint ,以及 allocated/available/discard skiplist 的实现,可以参考 WT 代码中关于 __wt_block_ckpt 的定义

文件组织格式可以参考下面的示意图:

(备注:示意图基于 MongoDB 4.0 版本,并省略了 _mdb_catalog, sizeStorer, journal 等与本问题无关的文件)

如果内存 page 中有数据被删除变成了脏页,需要从 available list 中找一个可用的数据块 block_new 进行写入,而不是直接在原有的数据块 block_old 上进行覆盖式更新。 block_old 会被标记为 discard,并在下一个 checkpoint 中被回收利用。

如果 block_new 在文件中的位置处于 block_old 的后面,则本次删除并不会导致文件变小。

如果 block_new 的位置在 block_old 的前面,而且 block_old 处于文件的末尾。则在进行 checkpoint 操作时,会立马释放磁盘空间(通过 truncate 进行空间回收)。具体可以参考 __ckpt_process 中调用 __wt_block_extlist_truncate 的代码逻辑。

我们可以在调用链路中增加日志,来验证是否走对应的 truncate 逻辑:

代码语言:c 复制
diff --git a/src/third_party/wiredtiger/src/block/block_ext.c b/src/third_party/wiredtiger/src/block/block_ext.c
index 6826bdd7..b79cd5d1 100644
--- a/src/third_party/wiredtiger/src/block/block_ext.c
    b/src/third_party/wiredtiger/src/block/block_ext.c
@@ -1278,6  1278,7 @@ __wt_block_extlist_truncate(WT_SESSION_IMPL *session, WT_BLOCK *block, WT_EXTLIS
     WT_RET(__block_off_remove(session, block, el, size, NULL));
 
     /* Truncate the file. */
     __wt_verbose(session, WT_VERB_RECOVERY_PROGRESS, "zhenyipeng, truncate file size to: %ld", size);
     return (__wt_block_truncate(session, block, size));
 }

MongoDB数据删除测试

为了验证上面的分析,我们部署一个 MongoDB 4.0 进行下面的测试。

测试1

1.先插入1条数据

代码语言:txt复制
PRIMARY> db.mytest.insert({_id:1})

2.通过YCSB 插入一批数据(2000000 条,每条 200B),然后查看文件大小

代码语言:txt复制
PRIMARY> db.mytest.stats().storageSize
546447360

3.随便删除几条数据,发现文件反而变大了一点

代码语言:txt复制
PRIMARY> db.mytest.remove({"_id" : "user6038618303538299031"})
PRIMARY> db.mytest.remove({"_id" : "user3806302896570709622"})
PRIMARY> db.mytest.remove({"_id" : "user658327917364469196"})
PRIMARY> db.mytest.stats().storageSize
546471936

4.继续删除数据,只保留 {_id:1} 这条数据,其他的全部删除

代码语言:txt复制
PRIMARY> db.mytest.remove({_id:{$ne:1}})

文件反而更大了一点,虽然只有 1 条数据:
PRIMARY> db.mytest.stats().storageSize
547295232
PRIMARY> db.mytest.stats().count
1

5.等待 1 分钟以上,更新仅剩的这条数据,然后再等待 1 分钟以上(做 checkpoint),发现 storageSize 变小了

代码语言:txt复制
PRIMARY> db.mytest.update({_id:1},{$set: {a:3}},{multi:true})
PRIMARY> db.mytest.stats().storageSize  #这一步要等 1 分钟以上,才有效果
40960

测试2

1.先插入 10000 条数据,然后看 storageSize

代码语言:txt复制
PRIMARY> for (var i = 0; i < 10000; i  ) {db.mytest2.insert({_id:i, text:"MongoDB is free and the source is available. Versions released prior to October 16, 2018 are published under the AGPL. All versions released after October 16, 2018, including patch fixes for prior versions, are published under the Server Side Public License (SSPL) v1"})}
PRIMARY> db.mytest2.stats().storageSize
450560

2.按照上一步的方法再插入 10000 条数据,会发现 storageSize 成倍增长

代码语言:txt复制
PRIMARY> db.mytest2.stats().storageSize
905216

文件中的“空洞”非常少
PRIMARY> db.mytest2.stats().wiredTiger["block-manager"]["file bytes available for reuse"]
16384

3.删除后插入的 1 万条,文件大小反而变大

代码语言:txt复制
PRIMARY> db.mytest2.remove({_id:{$gte:10000}})

storageSize 反而增大:
PRIMARY> db.mytest2.stats().storageSize
909312

但是文件中出现了很多“空洞”:
PRIMARY> db.mytest2.stats().wiredTiger["block-manager"]["file bytes available for reuse"]
454656

几乎占据文件大小的 50%, 这个比例和我们删除的数据比例一致

4.通过 compact 命令释放空间

代码语言:txt复制
PRIMARY> db.runCommand({compact: "mytest2", force:true})
文件变小:
PRIMARY> db.mytest2.stats().storageSize
466944

总结

逻辑删除,是否会释放空间

综合上述分析,文件变大,变小,不变都有可能。

  1. 文件变大。删除了一些数据,但是没有足够的 available list 来存储修改后的脏页。此时会进行文件扩展,弄出更多的 available list. 例如测试 1 中的第3 步和第 4 步。
  2. 文件变小。脏页写入的数据块 block_new 在文件中处于 block_old 的前面,且 block_old 处于文件的末尾,则有可能在 checkpoint 阶段触发 truncate 操作释放空间。例如测试 1 中的第5 步。感兴趣的读者也可以测试不带条件的删除 remove({}), 逻辑清空表的全部数据,等待 1 分钟以上,会发现文件的大小也接近清空了(还要存一些元数据块,所以大小不会是 0)。
  3. 文件不变。文件中有足够的 available list 来存储修改后的脏页,而且 block_old 也不处于文件的末尾。

如何节省空间

1.及时清理不需要的文档和字段

如果业务上能接受按日期等属性分库表,通过 drop 库表的方式能够最便捷的节省空间。

如果通过逻辑删除的方式,想要马上得到空间释放,可以进行 compact 操作,命令如下:

代码语言:txt复制
db.runCommand({compact: <collection name>})

参考文档:https://www.mongodb.com/docs/v6.0/reference/command/compact/

关于加锁的注意事项:

https://www.mongodb.com/docs/v6.0/reference/command/compact/#blocking

副本集遵循的操作步骤:

https://www.mongodb.com/docs/v6.0/reference/command/compact/#replica-sets

不建议在 Primary 节点上直接执行 compact. 强制执行的话,需要指定 force:true

2.选择压缩率更高的算法

MongoDB 默认的建表方式采用 snappy 压缩算法。如果有需要,可以采用压缩率更高的 zlib 和 zstd 算法。我曾经在某些业务中使用 zlib 算法,相比 snappy 能再节省 50% 的存储空间,仅供参考。

建表命令参考 :

https://www.mongodb.com/docs/v6.0/reference/command/create/#specify-storage-engine-options

代码语言:txt复制
db.runCommand({create:<collection name>, storageEngine: {wiredTiger: {configString: "block_compressor=zlib"}}})

也可以通过配置文件,对压缩算法进行全局设置,参考:

https://www.mongodb.com/docs/v6.0/reference/configuration-options/#mongodb-setting-storage.wiredTiger.collectionConfig.blockCompressor

关于不同压缩算法的压缩率和速率,可以参考 facebook 的基准测试结果:

https://github.com/facebook/zstd#benchmarks

3.避免索引占用太多空间

可以在 mongo shell 终端执行 db.collection.stats() 查看索引大小。如果索引大小过大,需要进行优化:

  1. 通过 indexStats 命令查看索引使用情况,对于不会使用的索引,可以考虑删除。参考:https://www.mongodb.com/docs/v6.0/reference/operator/aggregation/indexStats/
  2. 索引包含的字段应该尽量精简;
  3. 对于大 value 字段,可以在业务允许的场景下考虑使用 Hash 索引。参考下面的测试,可以将索引的大小降低 1 个数量级;
代码语言:txt复制
使用 YCSB 插入约 260 万条数据,对其中一个字段建索引,该字段为 100B 大小的BinData.
发现 Hash 索引比普通索引的存储空间降低了一个数量级:
Hash 索引 49MB VS 普通索引 310MB.
"field0_1" : 310861824,
"field0_hashed" : 49340416

参考文档

  1. https://www.mongodb.com/docs/v4.0/
  2. https://zhuanlan.zhihu.com/p/74130305
  3. https://github.com/wiredtiger/wiredtiger/wiki/Checkpoints
  4. https://source.wiredtiger.com/develop/arch-index.html
  5. https://github.com/mongodb/mongo/tree/r4.0.3

0 人点赞