ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MongoDB 删除文档实战指南:原理、方法、避坑与误删恢复

MongoDB 删除文档实战指南:原理、方法、避坑与误删恢复 做 MongoDB 开发这些年要说集合操作里最容易出事故的删除文档绝对排前三。原因很简单增删改查里只有删除不给人后悔药——集合删错了能重建索引丢了能重建但文档被deleteOne或者deleteMany真删掉了没有备份就得干瞪眼。所以这篇文章我打算把 MongoDB 集合中文档删除这件事一次性讲透。文章会涵盖删除的底层原理、官方提供的几种删除方法、过滤条件的安全写法、返回值的正确解读、大批量清理的实操方案、以及误删之后怎么办。适合两类人看一是刚接触 MongoDB、想搞懂删除基本写法的初学者二是已经在生产环境写代码、想避开删除操作里的暗坑的服务端开发。我会用 MongoDB Shell、Node.js 和 Python 的常见写法来演示示例都以 MongoDB 7.x/8.x 的默认行为为准老版本有差异的地方我会单独标注。1. 删除文档前先把这几个概念捋清楚1.1 MongoDB 里的删除到底删掉了什么MongoDB 以集合为容器组织数据集合里存的每一条记录都叫文档。所谓删除文档就是把满足过滤条件的 BSON 文档从集合所在的数据文件里摘出去。这里有个坑很多人不知道WiredTiger 存储引擎删掉文档之后空间并不一定立刻还给操作系统。它只是先把这部分空间标记成可复用后续新插入的文档会优先占掉这些空洞。这就导致一个常见现象你频繁地插入再删除集合文件体积可能一直不降甚至越涨越大。这不代表你有数据没删干净而是存储引擎的复用机制在起作用。真想让文件体积缩回去得用compact命令或者在运维窗口做db.collection.validate()之外的重建操作但这已经超出删文档的范畴了。第二个容易混的概念是删光文档和删掉集合。deleteMany({})能把集合里的文档全部清空但集合本身、它的索引、统计信息都还在后续还能继续插入新数据。如果你想把整个集合连同索引定义一起移除得用db.collection.drop()。前者是数据操作DML后者是结构操作DDL千万别混着用。我见过有人想清空一张日志表结果执行了drop重建索引建了半天这种教训一次就够。1.2 官方删除接口全家桶怎么选MongoDB 为删除文档提供的方法看起来有好几个实际核心就三种剩下的要么是变体要么是历史遗留。我做了一个对照表方便你一眼看懂差异方法删除范围返回值典型场景deleteOne只删除第一条匹配的文档结果对象按_id清理单条数据deleteMany删除所有匹配的文档结果对象批量清理过期数据findOneAndDelete删除第一条匹配文档并返回被删文档被删除的文档任务队列取出并删除remove旧版删除接口旧式返回结构维护老代码时才会碰到drop删除整个集合布尔值连索引一起清理官方现在推荐的方向很明确deleteOne和deleteMany负责业务级别的删除findOneAndDelete负责取出即删的原子操作。remove在 4.x 之后的 Shell 和驱动里已经逐步边缘化新代码不建议写。drop不是删除文档的工具它的定位是清理整个集合两者不能互相替代。选择上有个原则能按唯一键删的不要靠非唯一条件去删能小批量删的不要一把梭全量删能先查后删的不要直接蒙眼删。后面每一个原则我都会展开讲。2. 实战单条删除和批量删除的正确姿势2.1deleteOne并不是按条件精准删除很多新手对deleteOne有个误解以为传一个条件进去就能把所有符合条件的文档删干净。实际上deleteOne的语义是删除第一条匹配的文档而不是删除一个匹配的文档。它到底删哪一条在不指定sort的情况下取决于存储引擎的物理存储顺序这个顺序在大多数场景下你不可预期。我拿个例子说明。假设users集合里有三条文档db.users.insertMany([ { _id: 1, name: alice, status: active }, { _id: 2, name: bob, status: active }, { _id: 3, name: carol, status: inactive } ])现在执行db.users.deleteOne({ status: active })你以为是删 alice但它可能删的是 alice也可能删的是 bob。你如果心里默认反正只有一个,那迟早要吃大亏。要精确定位必须把条件收紧到业务唯一键上db.users.deleteOne({ _id: 1 })这条命令就是确定性的因为_id在集合内唯一天然就匹配一条。所以我的经验是写单条删除时过滤条件里至少要包含唯一键。如果业务上只能用非唯一字段删那就用findOneAndDelete配合sort明确删哪一条或者干脆改用deleteMany接受可能删多条的现实。2.2deleteMany的威力、风险与先查后删习惯deleteMany的特点是删除所有匹配的文档威力大风险也大。最容易出事故的一句话是db.orders.deleteMany({})这条命令会清空集合里所有文档。是的一个空过滤条件就等于全删。很多线上事故就是运维或者开发在测试环境敲习惯了换到生产环境没加条件就直接回车。MongoDB 官方没有默认给deleteMany({})加二次确认所以这个安全防线只能靠自己。一个能救命的好习惯是先查后删。同一个过滤条件先countDocuments看一眼数量再决定要不要删var filter { status: expired } db.orders.countDocuments(filter) // 先看命中多少条 db.orders.deleteMany(filter) // 数量确认没问题再删如果是在代码里更好的是把过滤条件抽成一个常量先查、打印日志、人工确认再执行删除。尤其线上环境我建议宁可多写两行也别省这个确认步骤。2.3 常用过滤条件写法速查表删除的核心是过滤条件条件写对删除才准确。这里整理了一份我平时用得最多的条件写法全部可以直接复制到你的项目里改字段名用需求条件写法说明等值匹配{ status: inactive }精确等于数值范围{ price: { $lt: 10 } }小于某个值多选匹配{ status: { $in: [A, B] } }命中其中一个即可字段存在{ nick: { $exists: true } }字段存在才删正则模糊{ email: /example\.com$/ }按尾部匹配日期过期{ expireAt: { $lt: new Date() } }删除过期时间早于当前时间的记录数组包含{ tags: vip }tags 数组里有 vip 字段数组元素级匹配{ items: { $elemMatch: { sku: A, qty: { $gt: 2 } } } }数组里存在一个满足全部条件的元素嵌套字段{ profile.active: false }嵌套文档里的字段注意带引号// 组合条件示例删除状态过期且更新时间早于 2024 年 6 月的订单 db.orders.deleteMany({ status: expired, updatedAt: { $lt: new Date(2024-06-01T00:00:00Z) } })注意嵌套字段的键必须用引号包起来比如profile.active这是 MongoDB 查询语法里最容易写错的地方。3. 进阶场景排序删除、队列任务与分批清理3.1 用findOneAndDelete实现原子任务队列做消息队列、任务调度这类功能时常见需求是从队列里取出一条待处理任务处理完就删掉。很多人的第一版写法是先findOne查出一条再deleteOne({ _id: doc._id })删掉。这个方法在小流量下没毛病但在并发场景下两个 worker 可能同时查到同一条任务造成重复消费。findOneAndDelete就是解决这个问题的它在一次原子操作里完成找到第一条匹配文档并删除它然后返回被删的文档。Shell 里的写法db.jobs.insertMany([ { _id: 1, type: email, status: pending, createdAt: new Date(2024-01-01T00:00:00Z) }, { _id: 2, type: sms, status: pending, createdAt: new Date(2024-01-01T00:01:00Z) }, { _id: 3, type: email, status: done, createdAt: new Date(2024-01-01T00:02:00Z) } ]) db.jobs.findOneAndDelete( { status: pending }, { sort: { createdAt: 1 } } )这里的sort参数很关键它决定了并发场景下优先处理哪一条。上面这个写法实现了优先取最早创建的待处理任务并且取完即删不会有重复消费的窗口。Node.js 驱动里的对应写法const { connection } require(./db) async function pickJob() { const deletedDoc await connection.collection(jobs).findOneAndDelete( { status: pending }, { sort: { createdAt: 1 } } ) return deletedDoc.value // 被删除的完整文档 }注意拿到的不是deleteResult而是包含value字段的对象value里就是被删掉的那条文档。拿不到就是null说明队列空或者没有匹配项。3.2 大批量清理的正确姿势分批而不是一把梭生产环境经常要清理过期数据比如保留 90 天的日志、历史订单、过期的 session。如果表里有一千万条过期数据直接一个deleteMany砸下去会发生什么单个大事务会在复制集上产生巨大幅度的 oplog 增长可能把 oplog 窗口撑爆。删除操作会长时间占用写入资源拖慢同集合的正常业务写入。如果中途报错重试成本极高。我的做法是批量清理。先把符合条件的_id捞一批出来删除歇一会儿再捞下一批。核心代码如下以 Node.js 为例const BATCH_SIZE 1000 async function purgeExpired() { const filter { status: expired, expireAt: { $lt: new Date() } } while (true) { // 1. 捞一批 _id注意用 limit 控制范围 const docs await collection .find(filter) .limit(BATCH_SIZE) .project({ _id: 1 }) .toArray() if (docs.length 0) break const ids docs.map((d) d._id) // 2. 用 _id 精确删除这一批 const result await collection.deleteMany({ _id: { $in: ids } }) console.log(deleted batch: ${result.deletedCount}) // 3. 让出一部分资源避免压垮复制集 await sleep(100) } } function sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)) }这段代码有三个要点。一是永远用_id的$in去删_id索引一定存在效率最高不会因为过滤条件没索引而全表扫。二是每批结束后sleep一下给复制集追赶和磁盘 IO 一点喘息时间。三是批大小取 500 到 5000 之间比较均衡太小了循环次数多太大了又回到一把梭的问题上。3.3 删除条件里数组和嵌套字段的几个暗坑数组字段的删除条件有一个很容易踩的坑。比如tags是数组你想删掉所有带 vip 标签的用户写成{ tags: vip }是正确的MongoDB 会自动判断数组里是否包含这个元素。但如果你写成{ tags: [vip] }它匹配的就是tags 数组恰好等于只有一个元素 vip的文档语义完全变了。再比如判断数组里面是否有一个元素同时满足多个条件很多人会直接写{ tags: { $in: [vip] } }这没问题因为$in对数组字段本身会展开。但如果要判断数组里的对象元素比如每个用户有多个地址地址里有type和active字段你要删掉存在一个激活的主地址的用户就不能用两个独立等值条件硬拼那会被解释为可能存在某个元素满足 type另一个元素满足 active。这时候得用$elemMatchdb.users.deleteMany({ addresses: { $elemMatch: { type: home, active: true } } })$elemMatch强制要求同一个数组元素同时满足里面所有条件。这个区别看起来细微实际线上非常容易因为数组本身命中逻辑而误删数据。我建议你涉及数组删除条件时先用find把命中的文档捞出来肉眼确认一遍再执行删除。4. 删除结果怎么读acknowledged、deletedCount 与异常处理4.1deleteResult到底长什么样deleteOne和deleteMany执行完返回的是一个结果对象Shell 里你通常会看到这样的输出db.users.deleteOne({ _id: 1 }) // 输出: // { acknowledged: true, deletedCount: 3 }两个字段的含义分别是acknowledged操作是否被 MongoDB 确认。正常情况下为true如果你设置了不安全的writeConcern: { w: 0 }它会是false意味着服务器根本没给你确认结果deletedCount也不可信。deletedCount实际删除的文档条数。deleteOne只会是 0 或 1deleteMany可能是任意非负整数。在 Node.js 驱动里这个对象是DeleteResult同样有deletedCount属性。Python 的pymongo里则是DeleteResult可以通过result.deleted_count拿到条数。这几种驱动都遵循同一个模型理解了字段含义换语言也就一分钟的事。4.2 写脚本时如何根据返回结果做二次校验很多人写完删除压根不看返回值。如果删错了或者删多了就没有任何挽留余地。我的习惯是严格要求deletedCount必须在预期区间。举个例子你根据订单号删除一条线上支付订单删除前你先数了订单的前置状态正常情况下应该删 1 条const result await collection.deleteOne({ orderNo: ORD-20240601-001 }) if (result.deletedCount ! 1) { // 记录日志、发告警甚至可以抛异常阻止后续流程 throw new Error(expected delete 1, but deleted ${result.deletedCount}) }这个习惯在批量清理里尤其重要。比如你清理过期 session预期删除 500 条但实际删了 3 万条那一定是过滤条件出了问题deletedCount是你发现问题的第一道警报。还要注意异常处理。删除命令如果因为超时、网络抖动、写关注失败等原因抛异常代码里必须捕获并区分情况。比如writeConcernError这类问题操作可能已经在服务器上部分生效不能简单重试否则可能重复删除或漏删。最稳妥的做法是把异常的message、过滤条件和删除前countDocuments的数量一并记录到日志人工介入确认。4.3writeConcern的一个提醒删除不可儿戏writeConcern是 MongoDB 写入可靠性的关键配置。默认是{ w: 1 }主节点写完就算确认。如果你追求更高的可靠性可以设成{ w: majority }表示大多数副本节点都写入后才返回成功。对于删除关键业务数据我建议至少保留默认值不要图快改成w: 0。我把话放这儿删除操作是最不能接受丢已删除确认的场景。w: 0的性能优势在绝大多数业务里根本不值得赌一旦网络异常你连删除有没有执行都不知道备份和恢复都会陷入混乱。5. 性能与安全删数据之前一定想清楚的几件事5.1 删除前先备份还是用假删除删除数据前最可靠的防线就是备份。MongoDB 提供了mongodump你可以只备份符合条件的子集而不必全库备份mongodump --db mydb --collection orders --query {status: expired} --out /backup/orders-expired这条命令会把orders集合里status为expired的文档单独导出到/backup/orders-expired目录。等备份跑完再执行删除脚本就算删错了也能用mongorestore局部恢复。另一个不可忽视的方案是业务逻辑上的假删除不是真的物理删文档而是给文档加一个标记字段比如deletedAt或者status: deleted日常查询里自动过滤掉这些标记。我整理了两者的取舍方案优点缺点物理删除存储空间可复用查询不用排除标记不可逆需备份防线假删除可回溯能恢复适合审计场景存储占用持续增长查询条件要带过滤我的建议很简单数据有审计需求或者恢复窗口没保障的一律先假删除后台定时清理超过保留期的标记数据。日志、临时表这种可再生的数据直接物理删就行。5.2 索引跟不上删除就变成全表扫deleteMany的过滤条件如果没有任何索引支撑MongoDB 就得把整个集合扫描一遍去筛选匹配文档。集合越大这个操作越可怕。所以在写删除条件之前务必先确认过滤字段上有合适的索引。你可以在删除前用同样条件的find查看执行计划db.orders.find({ status: expired }).explain(executionStats)看输出里的totalDocsExamined。如果这个数字等于集合总文档数说明你在裸扫全表如果它和nReturned差不多说明索引用上了。对删除操作来说最理想的是按_id删或者按两个字段的复合索引等值匹配删。另外还有一个偷懒又好用的技巧TTL 索引。如果你经常删除的是过期数据比如验证码、session、日志可以直接在时间字段上建 TTL 索引让 MongoDB 的后台线程自动清理db.session.createIndex( { lastActiveAt: 1 }, { expireAfterSeconds: 3600 } // 一小时没活跃就删 )TTL 索引每隔大约 60 秒扫描一次发现lastActiveAt超过 3600 秒的文档就自动删掉。这就不需要你写任何删除代码运维压力小很多。5.3 大批量删除对复制集和 oplog 的影响一个经常被忽视的问题是删除操作同样会产生 oplog。在复制集架构下主节点上的每个写操作都会被记录到 oplog然后同步给从节点。如果你在凌晨跑一个脚本删掉几千万条历史数据oplog 会被瞬间填满从节点可能来不及拉取导致复制延迟飙升严重情况下从节点会进入RECOVERING状态。解决办法有两个。一是把删除拆成前面说的批量循环每批之间留间隔二是选业务低谷期执行同时盯住rs.printSecondaryReplicationStatus()的输出观察从节点的滞后时间。批量循环里每批之间sleep(100)到sleep(500)毫秒看着不起眼对复制集的保护是实打实的。6. 常见问题与排查技巧实录6.1 条件看起来没问题为什么删不到这是我被问得最多的一类问题。同一个过滤条件数据明明存在但deleteMany返回deletedCount: 0。通常有五个原因第一类型不匹配。_id在数据库里是ObjectId但你在过滤条件里写的是字符串// 错误数据库里的 _id 是 ObjectId 类型 db.users.deleteOne({ _id: 6654efb5a18f8e8b9a2f4d38 }) // 正确需要用 ObjectId 包装 db.users.deleteOne({ _id: ObjectId(6654efb5a18f8e8b9a2f4d38) })第二日期字段传了字符串。比如createdAt存的是Date类型你在代码里传入2024-06-01MongoDB 不会自动转类型这一天的时间匹配大概率落空。第三字段名大小写或者拼写错。这个看起来最傻但真实占比最高。第四字符串里有隐藏空格。比如userName实际存的是 alice你按alice去删永远匹配不上。排查时可以用find({ userName: /alice/ })或者countDocuments({ userName: /alice/ })先确认。第五走了错误的库或集合。有时候明明连的是测试库却拿着生产库的集合名在操作。每个人的排查路径不一样但通用步骤是先用find加countDocuments跑一遍同样的条件确认命中数量再检查类型最后检查字段名。一旦find能查到delete却删不到99% 是类型问题。6.2 老的remove方法不香了脚本报错怎么办在维护老项目时很容易见到这种写法db.users.remove({ status: inactive })在 MongoDB 4.x 之后官方驱动的remove方法逐步进入废弃通道。到新版 Shell 和驱动里有些版本已经不再支持或者返回行为和deleteMany完全不同。如果你在跑老脚本时报TypeError: db.users.remove is not a function或者提示remove is deprecated处理方法很简单迁移到deleteMany。// 旧写法 db.users.remove({ status: inactive }) // 新写法 db.users.deleteMany({ status: inactive })注意新写法返回对象结构变了不再返回nRemoved而是deletedCount。涉及读返回值的代码都要改一遍。6.3 误删了文档还能不能救这个问题没有漂亮答案我不打算给你画饼。如果你有备份用mongorestore恢复即可。如果没有备份挽回的可能性很低。复制集环境下即使 oplog 里还有删除前的那条数据你也没有官方工具能轻松地把这条删除取消。所以在生产环境执行删除前我永远建议删除脚本里先countDocuments打印预期数量脚本运行完再打印实际删除数量两边一对比异常立刻暴露。上线脚本走代码评审别在终端里临时拼命令。关键表删除前先mongodump导出对应子集。如果你已经误删了第一件事是把实例设为只读或者暂停业务写入避免新数据覆盖掉可能还有机会恢复的物理空间。不过坦率说这条措施只是给专业恢复多留一线机会真正的兜底还是备份。6.4 删除时提示not authorized怎么办运行删除命令时如果看到下面这个错误not authorized on mydb to execute command说明你的数据库用户权限不够。删除文档需要delete权限。MongoDB 自带的readWrite角色包含delete权限但如果你用的是自定义角色就要手动补上。在admin库下给用户授权use admin db.grantRolesToUser(yourser, [ { role: readWrite, db: mydb } ])如果你执行的是drop()那需要的权限级别更高至少是dbAdmin角色。这个问题在云数据库比如 Atlas上也比较常见控制台里的数据库用户默认只给了读取权限你需要去云控制台把权限升到读写级别。最后顺手说一个我自己的习惯凡是线上要执行deleteMany的地方我从来不在终端里现场敲命令而是把过滤条件写进一个命名脚本先对着 staging 库跑一遍再把同一个脚本压到生产。同一个过滤条件脚本里先find看一眼命中数量再执行deleteMany每个批次都打印deletedCount。这套流程看着笨但救过我至少两次。删除是 MongoDB 操作里性价比最极端的功能写对了很平常删错了很致命。希望这篇能帮你在动手之前把所有风险点都压在可控范围里。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进