单实例副本集下删除后批量Upsert性能骤降问题求助
问题背景
系统以批量方式存储1k-4k条文档,结构为{_id: ObjectId(), RepositoryId: UUID(), data...},同批次文档的RepositoryId相同,已创建唯一索引{_id: 1, RepositoryId: 1}和复合索引{RepositoryId: 1, ...}。业务流程为:
- 先删除指定
RepositoryId的所有文档:db.collection.deleteMany( { RepositoryId: UUID("SomeGUID") }, { writeConcern: {w: "majority", j: true} } ) - 随后以每批300条的规模重新插入相同
RepositoryId的文档:db.collection.insertMany( [ { RepositoryId: UUID(), data... }, ... ], { writeConcern: {w: 1, j: false}, ordered: false } )
切换到单实例副本集(因业务需要事务支持)后,出现前3-5批插入耗时远高于后续批次的问题(第一批10s,第8批仅0.1s),测试环境无其他负载,问题可复现:间隔数分钟首次运行正常,后续运行出现异常。环境配置为Ryzen 7 PRO 4750U、32GB内存、Samsung 970 EVO M2 SSD,MongoDB版本5.0.5。慢查询日志显示插入操作持有大量写锁,耗时超13秒。
核心原因分析
删除操作后,副本集特有的后台进程与资源竞争是导致前几批插入变慢的主要原因:
- WiredTiger后台清理(Eviction):MongoDB删除文档仅标记为逻辑删除,WiredTiger后台eviction线程会异步清理已删除的页并合并磁盘空间。前几批插入时,新文档写入需与eviction线程竞争磁盘IO,或等待页清理完成才能复用空间,导致耗时增加。
- 副本集oplog开销:单实例副本集必须维护oplog,所有写操作(包括删除和插入)都会写入oplog。删除大量文档会生成大量oplog条目,若oplog空间不足,会触发后台truncate操作,与插入操作竞争资源;同时oplog的持久化逻辑也会额外增加磁盘写入开销。
- 索引维护开销:删除大量文档后,索引会产生碎片,前几批插入时需要调整索引B树结构(如节点分裂),后续索引结构稳定后,更新速度显著提升。
- 副本集锁竞争:慢查询日志中
ReplicationStateTransition锁的高频获取,说明副本集主节点在维护复制状态时,与插入操作产生了锁竞争,进一步拉长了耗时。
解决方案
1. 调整oplog大小
单实例副本集默认oplog大小为磁盘空间的5%,若删除数据量较大,易触发oplog截断操作。建议根据业务数据量增大oplog(如设置为10GB):
// 进入mongo shell,修改oplog大小 use local rs.reconfig({ oplogSizeMB: 10240 })
MongoDB 4.2+支持动态修改oplog大小,无需重启节点。
2. 优化删除与插入的间隔时间
在deleteMany执行完成后,等待1-2秒再启动插入操作,让后台eviction线程完成部分清理工作,减少资源竞争。可通过以下命令查看后台任务状态,确认eviction线程空闲后再插入:
// 查看WiredTiger eviction状态 db.serverStatus().wiredTiger.eviction
3. 调整WiredTiger引擎配置
- 增大缓存大小:32GB内存环境下,可将WiredTiger缓存设置为20GB,减少磁盘IO开销。修改
mongod.conf:storage: wiredTiger: engineConfig: cacheSizeGB: 20 - 增加eviction线程数:将eviction线程数调整为CPU核心数(8核设置为8),加快后台清理速度:
storage: wiredTiger: engineConfig: evictionThreads: 8
4. 优化插入操作
- 增大插入批次:将每批300条调整为1000条,减少批次数量,降低锁竞争频率。
- 改用bulkWrite替代insertMany:
bulkWrite在副本集下的写入性能更稳定,示例代码:const bulk = db.collection.initializeUnorderedBulkOp(); docs.forEach(doc => bulk.insert(doc)); bulk.execute({ writeConcern: {w: 1, j: false} });
5. 优化索引结构
若{RepositoryId: 1, ...}复合索引中的后续字段并非查询必需,建议改为单字段索引{RepositoryId: 1},减少写入时的索引维护开销——删除与插入操作均基于RepositoryId,单字段索引已满足需求。
6. 避免无意义的重复删除
在执行删除前,先检查该RepositoryId是否存在文档,若不存在则跳过删除步骤,减少后台清理压力:
if (db.collection.countDocuments({ RepositoryId: UUID("SomeGUID") }) > 0) { db.collection.deleteMany(...) }
验证方法
- 监控
db.serverStatus().wiredTiger.eviction中的pages_evicted、time_evicting_micros指标,等待数值稳定后再执行插入。 - 观察磁盘IO使用率,确认前几批插入时的IO峰值是否降低。
- 对比调整前后的插入耗时,验证优化效果。
内容的提问来源于stack exchange,提问作者Matěj Mainuš

