新闻详情

新闻详情

首页 / 资讯中心 / 详情

MongoDB复杂删除清理:聚合定位+分批删除的完整实战复盘

发布时间:2026/9/28 14:05:11来源:尧图网络
MongoDB复杂删除清理:聚合定位+分批删除的完整实战复盘
接到这个需求文档时我连着看了三遍才确认自己没理解错他们要的不是简单清理而是一套带着七八条“例外条款”的MongoDB删除清理程序。业务方最初的诉求只有一句话——把活动报名表里过期的数据清一清降低集合体积。但真正落到文档上的规则已经被运营、风控、渠道三个团队改成了三道互相叠加的阅读理解题。今天把这个项目完整复盘一遍从需求拆解到方案选型再到实测阶段踩过的四个坑一步步还原当时的处理思路希望能给同样面对“奇葩业务设计”的人一个可以直接参考的模板。1. 需求是怎么一步步变得奇葩的从“删掉过期数据”到“每条记录都有例外”1.1 最初的需求明明很简单业务背景是一个营销活动报名系统核心集合叫signups一年半跑了 2600 多万条文档。最早提需求的时候业务方给的原话是“报名记录太多了查询变慢把 90 天前已经取消的清理一下吧。”这个需求如果只有一句话处理起来确实轻松——deleteMany({status: cancelled, createTime: {$lt: cutoff}})就结束了。但需求评审会开了半小时之后味道开始变了。运营、风控、渠道三个团队各加了一条自己的规则每条单独看都有道理合在一起就成了连锁的例外条件。这也是我后来反复在团队里强调的一点所谓“奇葩业务设计”很少是一开始就奇葩的基本都是不同时期、不同角色的人不断往上叠加规则堆出来的。1.2 三条例外规则是怎么叠上去的第一条来自运营团队。运营要做流失用户召回取消记录是分析用户为什么离开的关键数据不能全删。他们的要求是“每个手机号最近一次取消记录必须留下。”第二条来自风控。风控那边发现同一个人经常会重复报名同一个活动状态还是“已参加”这些重复记录明显是脏数据影响统计口径。他们的要求是“同手机号重复报名的已参加记录只保留最早一条其余删除。”第三条来自渠道合作方。API 导入渠道的记录是合作方用来对账的一条都不能动必须全量保留。三条规则翻译成技术语言就是删除 90 天前、非 API 渠道、状态为cancelled、且不是该手机号最近一条取消记录的文档删除 90 天前、非 API 渠道、状态为attended、重复报名且不是最早一条的文档API 渠道的记录一律不参与清理。第一版实现我差点踩进一个陷阱两条删除规则的保留方向是反的。cancelled要保留“组内最新”attended要保留“组内最早”。如果图省事混在一个聚合管道里处理结果必然错一半。这个问题后面实现章节会展开讲先记住结论保留方向相反的规则必须拆成独立管道分别处理。1.3 把规则翻译成决策表先确认边界再动手规则叠加之后边界问题很快就暴露了。比如一个手机号有 5 条cancelled记录最新一条恰好来自 API 渠道那这条到底删不删“渠道规则优先”还是“取消记录保留规则优先”这类问题不去问清楚程序写出来一定和业务预期对不上。我当时做了一件事把规则画成一张决策表直接发给业务方确认。规则编号状态来源渠道处理动作R1cancelled非 API删除但每个手机号保留最近一条R2attended非 API保留最早一条删除其余重复记录R3任意API全部保留不参与清理表里还标注了冲突处理逻辑当 R1/R2 与 R3 冲突时R3 优先。业务方邮件回复确认之后我才开始写代码。这个确认动作看着多花了一天时间实际上省了后面一大半返工。拿到复杂需求先做规则拆解用表格替代需求文档里的自然语言描述是所有后续工作的地基。2. 为什么没直接写 deleteMany 和 TTL 索引方案选型的全过程2.1 deleteMany 一条命令为什么搞不定最直觉的方案当然是deleteMany但这条命令能处理的只是静态条件过滤。statuscancelled、channel 不等于 api_import、createTime 小于某个时间点这些条件确实可以写进 filter可“每个手机号保留最近一条取消记录”这种带组内比较的逻辑普通查询语句根本表达不出来。有人会说用$expr硬写。理论上确实可以比如先按手机号分组取最大时间再反过来查不在最大时间集合里的记录。但$expr一旦上场查询优化器基本就放弃 B-tree 索引了因为索引扫描是建立在字段值之上的而$expr的比较对象是表达式计算结果优化器没法直接做 range scan。几千万文档全表扫描哪怕只是跑一遍找出待删记录对生产库的压力都是不可接受的。2.2 TTL 索引看着很合适实际死路一条TTL 索引在清理场景里是常客createTime字段上建一个索引过期记录自动删除连运维脚本都省了。但这个机制有几个硬限制只能基于单个 Date 类型字段判断过期判断条件只有“时间到期”这一个维度不支持状态、渠道等业务条件也没有“保留组内某一条”这种语义。这个场景里要同时判断状态、渠道、手机号分组、组内排序四个维度TTL 索引完全覆盖不了。它适合的场景是“日志表删除 30 天前数据”这种朴素需求。业务规则越复杂自动化机制能帮的忙就越少最终只能回到自己写程序控制全流程。2.3 方案对比后选型聚合定位加分批删除当时列了四种方案出来对比方案表达能力对线上影响可回滚性结论deleteMany $expr弱复杂逻辑难表达全表扫描影响大差不采用TTL 索引弱只能按时间判断自动删除不可控差不采用逐手机号遍历删除强但每个手机号一次查询千万次查询性能最差一般不采用聚合定位 分批删除强管道表达能力完整可控按批限速执行较好支持 dry-run采用最终选了“聚合定位 分批删除”。核心思路是先把待删记录的_id算出来再按批delete_many。这么做最大的好处是“定位”和“删除”两个动作解耦可以先跑 dry-run 预览结果也可以把待删清单导出给人审阅确认无误后再执行真正的删除。删除动作本身还方便控制节奏不至于一口气把库打垮。3. 聚合定位 分批删除清理程序的核心实现与参数设计3.1 动手前的索引准备与执行计划确认写脚本前先解决索引问题。聚合管道第一步要过滤时间、状态、渠道第二步要根据手机号分组并排序没有配套的索引聚合跑起来就是灾难。我当时建了两个复合索引{channel: 1, status: 1, createTime: 1}用于快速过滤初步范围{phone: 1, createTime: -1, _id: -1}用于$sort阶段按手机号聚合时走索引扫描。第二个索引容易被忽略。$sort如果走内存排序几千万条数据分分钟触发 100MB 内存限制即便开了allowDiskUse也会大量落盘速度完全不可控。有了phone createTime的复合索引$sort可以走 IXSCAN等到$group阶段时数据已经天然按手机号聚好了。索引建完之后不要急着跑全量先拿一小部分数据explain一下确认执行计划是 IXSCAN 而不是 COLLSCAN。这个确认步骤花不了两分钟但能避免脚本上线跑一半才发现走了全表扫描。3.2 两条聚合管道分别处理“留最新”和“留最早”前面说了R1 和 R2 的保留方向相反必须拆成两个管道。这里直接给出核心代码用的 Python pymongo这是运维清理脚本最常用也最容易改的组合。from pymongo import MongoClient, ASCENDING, DESCENDING import datetime import time BATCH_SIZE 5000 client MongoClient( mongodb://clean_user:passhost:port/?replicaSetrs0readPreferencesecondaryPreferred ) db client[activity] col db[signups] def build_pipeline(keep_rule, cutoff): keep_rule 取值: - cancelled_latest: 保留每个手机号最近一条取消记录 - attended_earliest: 保留每个手机号最早一条已参加记录 if keep_rule cancelled_latest: status cancelled time_order DESCENDING # 时间倒序first 即最新 elif keep_rule attended_earliest: status attended time_order ASCENDING # 时间正序first 即最早 return [ {$match: { status: status, channel: {$ne: api_import}, createTime: {$lt: cutoff} }}, {$sort: {phone: 1, createTime: time_order, _id: time_order}}, {$group: { _id: $phone, keep_id: {$first: $_id}, all_ids: {$push: $_id} }}, {$project: { delete_ids: { $filter: { input: $all_ids, as: item, cond: {$ne: [$$item, $keep_id]} } } }} ]def iter_batches(pipeline, batch_sizeBATCH_SIZE): batch [] for doc in col.aggregate(pipeline, allowDiskUseTrue, batchSize1000): for oid in doc.get(delete_ids, []): batch.append(oid) if len(batch) batch_size: yield batch batch [] if batch: yield batch def run_clean(dry_runTrue): cutoff datetime.datetime.utcnow() - datetime.timedelta(days90) total_deleted 0 for rule in (cancelled_latest, attended_earliest): pipeline build_pipeline(rule, cutoff) for batch in iter_batches(pipeline): if dry_run: total_deleted len(batch) else: result col.delete_many({_id: {$in: batch}}) total_deleted result.deleted_count time.sleep(0.2) print(f已处理 {total_deleted} 条) print(f待删除总数: {total_deleted})几点说明readPreferencesecondaryPreferred让聚合查询走从节点避免聚合的大查询直接压到主节点$filter那段逻辑是把组内所有_id集合中不等于keep_id的全部挑出来这就是要删的记录一次$in传 5000 个 ObjectId距离 BSON 文档 16MB 上限还很远安全time.sleep(0.2)是删除节奏控制不能省后面第 4 章会详细讲原因。pipeline里有一点要注意$match必须放在最前面把不可能命中的记录尽量挡掉。这一步做得越好后面$group落盘的数据量就越小整个聚合跑的越快。3.3 分批删除、限速与 dry-run 的必要性我习惯把清理脚本拆成两个模式dry_run和real_run。默认永远先跑dry_run而且dry_run阶段不只是打印一个待删总数。我要求脚本额外导出一份待删_id清单文件留作审计底稿。万一真删错了这份清单就是反查的唯一凭证。def export_ids_to_file(filename): cutoff datetime.datetime.utcnow() - datetime.timedelta(days90) with open(filename, w) as f: for rule in (cancelled_latest, attended_earliest): pipeline build_pipeline(rule, cutoff) for batch in iter_batches(pipeline): for oid in batch: f.write(f{oid}\n)这一步看起来只是额外的 IO实际价值非常大。删除操作不可逆任何“万无一失”在数据量面前都不可靠。多花几分钟导出一份清单比出了事故之后满地找备份要值钱得多。4. 实测阶段踩过的四个坑内存限制、漏删、磁盘不释放和主从延迟4.1 坑一$group 内存超限聚合直接报错第一次跑聚合管道大概跑了一个小时直接报了一行错误pymongo.errors.OperationFailure: Exceeded memory limit for $group, but didnt allow external sort. Pass allowDiskUse:true to opt in.这个报错的原因很明确$group默认最多使用 100MB 内存做分组操作数据量大时直接拒绝继续执行。解决方法是给聚合加allowDiskUseTrue让中间结果落盘。但allowDiskUse不是银弹开了之后聚合速度肉眼可见变慢。我当时用了一个额外的手段把$match条件尽可能前置。原本全表 2600 万条把时间、状态、渠道三个条件压进$match之后真正进入$group的数据量降到了 400 万左右聚合才稳定跑完。这个坑的通用解法是报错之后先别急着无脑加allowDiskUse回头看一眼$match是不是已经把能滤掉的数据滤干净了。过滤前置做到极致再加外部排序才是性能最优的组合。4.2 坑二游标执行期间业务持续写入漏删又误删这个坑最隐蔽。dry-run 统计出来 180 万条待删实际执行完只删了 170 万条差了 10 万。排查链路是这样的先怀疑是不是有_id被业务改动导致匹配不到查了一圈没有。然后对比聚合游标跑的时间段发现问题出在“聚合不是一瞬间完成的”。我的聚合游标要跑几十分钟期间业务系统还在不断写入新报名记录。这些新记录在游标启动时不存在自然不会被算进待删集合但如果它们同样满足清理条件就漏掉了。反向的问题也存在有些记录在聚合阶段被算进待删集合但到真正执行删除时业务已经把状态改了比如从cancelled改回attended这种情况下delete_many仍然会按_id把它删掉造成误删。根因是一次性离线脚本没有快照隔离能力。我的处理方式是给聚合管道加createTime上界也就是只处理“脚本启动时刻之前”产生的记录逻辑上等于处理一份启动时刻的快照。清理完第一轮之后隔天再补跑一次增量把启动时间点之后新产生的满足条件的记录再清一遍。这样即使有误差误差的范围也是可控的。4.3 坑三删了几百万条磁盘空间纹丝不动删除完成后集合统计信息db.collection.stats()里的size已经明显变小但看服务器磁盘占用率几乎没变化。很多第一次处理大集合清理的人都会在这个地方懵一下。这是因为 MongoDB 的 WiredTiger 引擎删除文档后释放出来的空间是标记为“可复用”归还给集合内部的空闲列表而不是直接交还给操作系统。换句话说“删了数据”和“磁盘变干净”是两回事。要让磁盘空间真正释放常规手段有三种db.runCommand({compact: signups})在线压缩集合文件但会阻塞所在节点的读写必须在低峰期执行副本集逐节点滚动维护把节点逐个下线、单独执行 compact、再拉回副本集观察同步追平后处理下一个重建集合路线把保留数据导到新集合原子 rename 替换旧集合空间一次性彻底释放。我当时用的是第二种副本集逐节点 compact。流程是确认当前节点不是 Primarystop 该节点进程以单实例方式启动执行 compact恢复节点身份等它追平 oplog 后再处理下一个。期间每个节点操作完都得用rs.status()盯一眼复制延迟确认optimeDate追平了再动下一个节点。这里要额外提醒compact 回收空间的上限是集合内部“空洞”的大小。删除的数据量越小compact 效果越差。如果你删掉的只是总数据量的 5%那就别指望磁盘空间能降多少。4.4 坑四删除风暴把副本集从节点拖垮第一轮真实执行时我把BATCH_SIZE调得很大一次delete_many直接删 5 万条循环间隙很短。跑了几轮之后rs.status()里从节点的optimeDate落后主节点十几秒而且还在持续拉大。原因不复杂删除操作产生的 oplog 量远大于普通业务写入从节点同步不过来就会产生复制延迟。生产环境里复制延迟一旦拉大读从库的业务会拿到越来越旧的数据这是绝对不能接受的。解决办法也很朴素BATCH_SIZE降回 5000每批之间保留 0.2 到 0.5 秒的 sleep同时在脚本里增加复制延迟检测。延迟超过阈值就自动暂停等追平了再继续。代码很简单def check_repl_lag(max_lag_seconds10): status client[admin].command(replSetGetStatus) primary_time None for member in status[members]: if member[stateStr] PRIMARY: primary_time member[optimeDate] break if primary_time is None: return False for member in status[members]: if member[stateStr] PRIMARY: continue lag (primary_time - member[optimeDate]).total_seconds() if lag max_lag_seconds: return True return False加了这个逻辑之后删除任务虽然整体耗时变长了但再也不需要半夜盯监控可以放心让脚本自己跑。大数量删除慢就是快。5. 复盘清理任务的规则翻译、安全底线与大流量扩展思路5.1 奇葩需求的本质历史规则的叠加先拆规则再写代码这个项目带给我的最大教训不是 MongoDB 用得不够熟而是业务规则没拆干净就急着写代码。后来接触的清理类需求多了我越来越确定一件事所有“奇葩业务设计”本质上都是历史规则的叠加。运营要留痕、风控要查重、渠道要对账每一条规则单独看都有明确动机但叠在一起就会制造大量矛盾和特例。技术人不能只吐槽业务更实际的是把自然语言翻译成二义性为零的决策表再让业务方签字确认。状态、渠道、时间、同实体多条记录怎么处理、规则冲突谁优先这五类信息齐了代码只是翻译工作。表上哪怕留一个空格没填最后都可能变成线上事故。5.2 我给清理任务定下的安全底线经历过这轮踩坑之后我在团队内部立了几条清理任务的硬规矩先确认备份或快照可靠至少要有一个能落地的恢复点默认跑 dry-run导出待删清单业务方确认后再执行真实删除分批执行、限速控制实时观察主从延迟删除过程保留审计日志谁删的、删了多少、哪些_id、什么时间全部可追溯所有清理作业放在维护窗口内执行非紧急情况绝不在业务高峰跑。这些规矩看着都是常识但真到项目里很多人会因为“这个需求很简单”“今天就要上线”跳过第一条第二条。删除类任务一旦出问题影响面通常远超预期能把安全底线守住比写一手漂亮的聚合管道重要得多。5.3 数据量再翻几倍怎么办重建替换法的思路如果集合到了几亿甚至十亿级别分批删除依然可行但耗时太长这时候换一种思路反而更快与其删掉 60% 的冗余数据不如把需要保留的 40% 导到一个新集合里然后原子替换旧集合。核心步骤是聚合管道里用$out或$merge把保留数据写到signups_clean确认两边数据一致、索引重建完毕、应用连接切换到新集合之后再做一次 rename 替换旧集合直接 drop。两个维度对比一下维度分批删除重建替换数据一致性删除期间可继续写需增量补漏需要维护窗口避免切换期写入执行时间长可能数小时到数天短取决于保留数据量空间回收不直接需 compact彻底释放干净利落风险控制可控随时中断切换瞬间有风险需回滚预案这个方案效率确实高垃圾数据占比越高越明显。但它属于大手术前提是业务能接受一次短暂的写停顿同时要做好 rename 失败的应急预案。数据量到亿级之后我反而更倾向这种“建新替旧”的方式而不是死磕逐条删除。最后分享一点个人实操体会我现在处理任何清理任务都默认先产出“待删清单 规则决策表”两个文件让业务方签字确认再执行脚本。流程确实变重了但这套组合拳救过我很多次。数据清理这件事不出事的时候觉得流程多余一旦出一次删错的事故代价远超流程本身。与其赌运气不如把每一步都做得可追溯、可回滚。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

睡岗玩手机检测数据集:VOC转YOLO与YOLOv8训练全流程 2026/9/28 16:48:58

睡岗玩手机检测数据集:VOC转YOLO与YOLOv8训练全流程

简介:面向安防监控、行为识别方向的算法工程师与高校研究者,这份睡岗与玩手机检测数据集可直接用于目标检测模型的训练与验证。资源包含4653张原始图像,并配套VOC XML格式标注文件,覆盖睡岗、玩手机两类典型违规行为,适…

阅读更多 →
Substrate本质解析:区块链构造套件而非开发框架 2026/9/28 16:48:57

Substrate本质解析:区块链构造套件而非开发框架

1. Substrate不是框架,是区块链的“乐高底盘”——先破一个最大误解很多人第一次听说Substrate,是在某个公链项目官宣“基于Substrate构建”时。接着翻文档,看到一堆术语:Runtime、WASM、FRAME、Pallet、Consensus、Authoring………

阅读更多 →
Codex 工具开发如何接入 GitHub 插件:版本管理与协作实践 2026/9/28 16:48:57

Codex 工具开发如何接入 GitHub 插件:版本管理与协作实践

1. 为什么工具类项目绕不开 GitHub 插件这道坎做工具类项目的人,迟早会撞上一个很现实的问题:代码写完了,本地跑得挺欢,可一旦要协作、要回溯、要给别人复现,整个链路就开始散架。我见过太多团队,工具本身做…

阅读更多 →
Substrate 区块链开发实战:从 Runtime 编写到上线踩坑全指南 2026/9/28 16:48:57

Substrate 区块链开发实战:从 Runtime 编写到上线踩坑全指南

1. 从一条报错日志说起:为什么我要啃 Substrate去年冬天,我在调试一条基于 Substrate 的链时,节点日志里反复出现同一行错误:Bad signature on transaction。交易明明在本地签名成功,广播出去却被拒。翻遍官方文档&…

阅读更多 →
基于Simulink的风光储互补微电网建模与仿真全流程解析 2026/9/28 16:48:57

基于Simulink的风光储互补微电网建模与仿真全流程解析

1. 内容整体设计与思路拆解1.1 为什么选择风光储互补微电网做建模先说点实在的。这两年“双碳”目标推着新能源往前跑,光伏和风电的装机量一年比一年大,但这两个东西天生有个毛病——出力看天吃饭。光伏晚上不工作,风电无风就趴窝&#xff0c…

阅读更多 →
MiMo-V2.6实战指南:多模态MoE模型轻量化部署与动态路由详解 2026/9/28 16:48:51

MiMo-V2.6实战指南:多模态MoE模型轻量化部署与动态路由详解

1. 项目概述:这不是一篇普通论文,而是一份“可执行的视觉理解升级说明书”“MiMo-V2.6”这五个字符最近在CV(计算机视觉)和多模态技术圈里频繁闪现,不是某个新出的消费级硬件型号,也不是某家大厂刚发布的AP…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉