ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MongoDB数据丢失排查实战:六大隐形杀手与恢复策略

MongoDB数据丢失排查实战:六大隐形杀手与恢复策略 1. 数据凭空消失前我经历过的那几次心跳骤停先交代一下背景。我手上维护着好几套 MongoDB 集群版本从 4.4 到 7.0 都有承载的业务包括用户行为日志、消息队列的落库、还有一部分偏核心的交易流水。按理说 MongoDB 的稳定性在 NoSQL 里算是能打的但数据莫名其妙没了这种事我前前后后遇到过六次每一次都够喝一壶的。最典型的一次是凌晨三点被电话吵醒。业务方说某个集合里昨天的数据还在今天一查全没了不是少了几条是整段整段地没。我当时第一反应是有人误删了于是赶紧查 oplog、查慢日志、查审计日志折腾了一个多小时最后发现根本不是人为问题而是写入根本就没有成功只是应用层没报错。这就是 MongoDB 数据丢失最坑的地方大多数数据消失不是被谁删了而是它压根就没写进去或者写进去之后被某些机制静默清理掉了。如果没有一套系统化的排查思路你会在错误的方向上浪费大量时间。这篇文章我就把这几年踩过的坑、排查过的 case、以及最终沉淀下来的一套排查流程完整梳理一遍。不管你是刚接触 MongoDB 的开发还是已经上了生产环境的运维按着这套方法来能帮你少走很多弯路。需要说明的是文中的排查步骤都是基于通用实践总结的具体命令在不同版本下可能略有差异但排查思路是通用的。2. 第一现场先搞清楚没数据到底是哪一层的问题数据没了第一件事不是翻数据而是先界定问题范围。我把排查过程分成三个层次每一层都有对应的检查点。2.1 先确认是数据真没了还是你查的方式不对这个听起来像废话但我在实际排查中至少遇到三次假消失。最经典的一种情况你用了错误的数据库或集合名。MongoDB 的连接串里如果没有显式指定数据库默认会连到test库上。而某些驱动在写入时如果你没有调用useDatabase()数据会静默写入test库等你下次用 Compass 去看发现业务库空空如也以为数据丢了其实数据都在test里躺着。还有一种常见情况是读取时使用了错误的过滤条件。比如你存的时候用的是ISODate(2024-06-01T00:00:00Z)查的时候却传了一个字符串2024-06-01MongoDB 在比较时会做类型匹配字符串和日期类型不匹配查出来就是空结果。所以排查的第一步永远是确认连接串里的数据库名、集合名是否正确确认查询条件的数据类型是否匹配用 Compass 或者命令行直接看集合的count()和最近的文档我用一个简单的命令序列来快速判断# 查看当前连接下的所有数据库 show dbs # 切换到你认为有数据的库 use your_db_name # 查看所有集合 show collections # 统计集合文档数 db.your_collection.countDocuments() # 看最近一条数据的写入时间 db.your_collection.find().sort({_id: -1}).limit(1)如果countDocuments()返回 0但show dbs里能看到该库的磁盘占用不是 0说明数据可能真的在只是集合名不对或者你在错误的库里查。2.2 锁定时间窗口是全部没了还是某段时间内的没了这一步决定了你排查的方向。全部没了大概率是drop()、deleteMany({})、或者整个数据库被删了。某段时间内的没了大概率是 TTL 索引过期、写入失败、或者数据被定期清理任务做掉了。部分文档的某些字段没了大概率是 update 操作覆盖了字段或者文档结构被修改。我遇到的大多数生产事故属于第二种——某个时间段范围内的数据缺失。这种问题最隐蔽因为它不像是被删除更像是从未写入。2.3 检查应用日志写入端有没有报错有没有被吞掉的异常很多数据丢失的根源在应用层。最常见的场景是代码里没有确认写入结果就继续执行了。MongoDB 驱动在默认情况下如果写入失败会抛异常吗分情况使用insertOne时如果发生网络错误或主节点不可用驱动会抛异常。但如果是insertMany使用了非有序ordered: false插入部分失败时不会整体报错只是返回一个writeErrors数组。如果你没有检查这个返回值失败的那几条就悄无声息地丢了。更隐蔽的是有些语言驱动默认开启了重试机制。比如 MongoDB 官方驱动在写入时如果遇到网络抖动会自动重试一次。但重试可能导致写入了两次也可能导致数据实际落盘了但客户端收到的是超时异常。如果你在捕获到异常后做了补偿逻辑——比如更新一条记录的状态为写入失败——后续排查时你看到的是失败状态但数据其实已经写进去了这会严重干扰判断。所以排查时一定要做一件事查应用日志里有没有任何写入相关的 warning 或 exception特别要注意那些被 try-catch 吞掉的异常。很多时候数据没了的真相就藏在这些被你忽略了很久的日志里。3. 找到真凶六大常见隐形杀手逐个拆解在确认不是查询问题、也不是应用日志表面问题之后接下来就要对 MongoDB 自身的各种隐形删除机制做逐一排查。我把这几年遇到的全部整理成了一张清单。3.1 TTL 索引你以为在优化实际在删数据TTLTime To Live索引是 MongoDB 提供的一种自动过期删除机制。创建了 TTL 索引的字段MongoDB 后台线程会周期性扫描删除那些字段值加上指定秒数后仍然小于当前时间的文档。TTL 索引的威力我在生产环境体验过。有一回运营团队为了清理日志表要求加一个数据保留 30 天的清理策略。开发同事直接在日期字段上建了 TTL 索引测试环境跑了一周没问题上线后一个月也没人注意。直到有一天某条业务线的数据分析师说我要看 45 天前的数据结果发现 30 天前的数据全没了。当时排查过程也很痛苦因为mongostat看不到 TTL 的删除动作explain也看不出来。最后是通过db.currentOp()发现了一直在跑的delete操作顺着操作类型才定位到 TTL 索引。如何排查// 查看集合上的所有索引 db.your_collection.getIndexes() // TTL 索引的特征是 options 里有 expireAfterSeconds 字段如果发现某个索引带有expireAfterSeconds立刻检查它是不是建在了不该建的字段上。特别是如果这个字段是时间字段且你更新文档时该字段会变那么 TTL 的删除时间会以最后一次更新后的值为准这可能导致数据比预期更早被清掉。我碰到过一个真实案例一个会话集合字段lastActiveAt每次用户操作都会更新TTL 设置 7 天。正常情况下7 天没活跃的会话才会被清。结果因为某个版本代码 buglastActiveAt被错误地赋值为new Date(0)1970 年TTL 线程一看这个文档早就过期了立刻删光。用户下次打开应用发现会话全部失效被迫重新登录。排查到最后数据层面没错是业务代码的锅。建议能用定时任务清理的数据尽量不要交给 TTL。如果非用不可一定在索引命名上区分清楚并且每天巡检索引列表。3.2 误执行了 drop / deleteMany / remove这类问题最直接也最容易被发现但导致它的场景往往很离奇。手滑在错误的数据库上执行了db.dropDatabase()脚本里写死了集合名连接串换了环境但脚本没换清理数据的定时任务没有加过滤条件本来应该deleteMany({status: expired})结果写成了deleteMany({})MongoDB Compass 里打开集合后按了Shift Delete快捷键直接删了整个集合而不只是选中的文档我的建议是如果你还没有启用访问控制Authorization现在就去启用。哪怕只是一个简单的用户名密码也能挡住一大半误操作。更进一步创建专用的运维账号只授readWrite权限的账号不要给dropCollection和dropDatabase的权限。排查误删除的办法是查 oplog// 切换到 local 库 use local // 查询最近的删除操作 db.oplog.rs.find({ op: d, ns: your_db.your_collection }).sort({$natural: -1}).limit(10)oplog 里每一条删除记录都有ts时间戳和o._id能精准定位到删除发生的时间和被删文档的 ID。如果你开启了审计日志那排查起来更快直接按用户名和时间过滤。3.3 主从切换后还没同步到从节点的数据全部丢失这是 MongoDB 数据丢失中最经典、也最无奈的一种——非正常的主从切换导致的数据回滚。MongoDB 复制集的原理是写入先到主节点写入主节点的 oplog然后从节点异步地从主节点拉取 oplog 并重放。如果主节点在某个写入还没有被绝大多数从节点复制时就宕机了那么新选举出来的主节点可能没有这条数据。旧的节点恢复后发现自己有超前的数据比新主节点的 oplog 位置更靠后它会把这些数据回滚掉这些数据就永久消失了。这个回滚量有多大如果用默认配置MongoDB 会把回滚的数据写入一个名为rollback/的目录下的 BSON 文件里。这些文件在回滚发生后不会立即删除除非设置了rollbackViaRefetch这是救命稻草。排查步骤查看所有从节点的复制延迟rs.status()里的secs字段。查看主节点的 oplog 窗口大小。如果怀疑发生了回滚去每个节点的dbPath目录下找rollback文件夹。我经历的一次事故是某个从节点因为磁盘 IO 紧张复制延迟达到了惊人的 3 个小时而 oplog 窗口只有 1 小时结果主节点宕机后新主节点直接断开了这个落后太多的从节点的心跳这个节点上积压的 3 小时数据全部无效。建议每天用rs.status()检查复制延迟异常时第一时间处理。oplog 窗口尽量调大尤其是业务有周期性批量写入的情况。3.4 分片集群的 chunk 迁移失败如果你用的是分片集群sharded cluster数据丢失的风险又多了一层——chunk 迁移过程中可能出现的孤儿文档或迁移失败。MongoDB 的分片机制是按 shard key 把数据拆成多个 chunk后台 balancer 会在各分片之间迁移 chunk 以平衡负载。迁移过程中如果源分片在迁移尚未完成时收到了新的写入这些写入会被标记为孤儿文档orphaned documents最终在分片中重复或不一致。更麻烦的是如果你在迁移过程中手动做了聚合查询某些查询在旧版本4.4 之前的某些版本里可能会扫描到正在迁移的 chunk 的不完整快照导致部分数据看不见。排查方法是查看 balancer 的状态// 查看 balancer 是否在运行 sh.status() // 查看是否有正在进行的 chunk 迁移 db.adminCommand({ currentOp: 1, shard: true })我从 MongoDB 5.0 的官方文档里还了解到如果你是启用了会话session和因果一致性的事务迁移期间的读写能在一定程度上避免孤儿文档问题但并不能完全消除。建议shard key 的选择要慎重避免出现 jumbo chunk超过 chunk size 且无法分裂的块。一旦出现 jumbo chunkbalancer 无法迁移它数据会长期卡在某个分片上遇到这种问题要主动手工拆分或调整 chunk size。3.5 磁盘损坏与 WiredTiger 的未持久化窗口MongoDB 默认的存储引擎是 WiredTiger它在数据安全性和性能之间做了平衡。WiredTiger 使用 checkpoint 机制定期把内存中的数据落盘默认 60 秒或 2GB 日志写入后触发一次 checkpoint。在两次 checkpoint 之间如果进程被强制 kill比如kill -9或者机器宕机WiredTiger 会通过 journal预写日志来恢复数据。但如果 journal 也坏了呢那就真的无力回天了。磁盘损坏的情况我遇到过一次是发生在云服务器突发 IO 错误之后。现象是集合能查出来但某些文档的某些字段变成了null或空值。后来用mongodump导出时直接报错提示corrupt document。WiredTiger 在遇到损坏的文档时有时候不会主动报错而是返回异常结果这在数据层面最坑。如何提前发现定期执行db.collection.validate()它会扫描集合中所有文档的结构完整性。检查 MongoDB 日志里有没有 WiredTiger 的 warning。注意mongod进程的退出码如果是异常的退出码比如非 0需要重点关注。// 校验集合完整性 db.your_collection.validate({ full: true })如果validate()报错了说明文档确实损坏只能从备份恢复或者用mongodump的--forceTableScan参数尝试导出尽可能多的数据。3.6 数据被业务逻辑覆盖而不是删除有一种数据消失从数据库层面看一切正常文档还在只是里面的字段被覆盖成了意想不到的值。这类问题最难排查因为纯粹是业务逻辑的 bug。举个例子某业务用一个集合存储用户配置信息更新时用了updateOne({userId: xxx}, {$set: 整个新的配置对象})。如果上游传来的配置对象是null或者空对象{}就会把所有已有配置全部清空。数据没有消失但看起来就像是没了。还有一种是使用upsert: true且搭配错误的过滤条件导致每次都创建新文档而旧文档再也查不到。MongoDB 默认_id唯一但如果你自定义的过滤条件没有走唯一索引就可能导致看起来像覆盖了的更新实际创建了重复文档。排查思路查_id是否出现重复冲突查文档更新时间如果文档没有updatedAt字段你需要靠 oplog 查看 update 的操作记录打开 MongoDB 的 profiler慢查询日志记录所有 update 操作// 开启 profiling记录所有慢操作超过 100ms 的 db.setProfilingLevel(1, { slowms: 100 }) // 查看最近更新过该集合的操作 db.system.profile.find({ ns: your_db.your_collection, op: update }).sort({ts: -1}).limit(20).pretty()profiler 默认在生产环境是关闭的这一点需要注意。如果数据消失的同时你没开 profiler前面那段排查日志就只能靠 oplog 来看了。4. 从 0 到 1 的完整排查链路一条命令一条命令敲出来的经验在这一节我把前面提到的零散步骤串成一条完整链路。下次你遇到MongoDB 数据莫名其妙没了直接按这个顺序执行能省下至少两小时无头苍蝇式的排查时间。4.1 阶段一确认可见性5 分钟内先做最基础的确认排除查错了地方的可能# 1. 列出所有数据库及其大小 show dbs # 2. 确认你连的是不是对的那台机器 db.getMongo()特别注意如果你用了 MongoDB Atlas 或者云数据库连接串里可能带了?authSourceadmin参数如果你指定了错误的库名只能看到部分数据库看起来就像是某些库的数据消失了。确认库之后用 Compass 或者命令行看目标集合的文档数。此时如果文档数是 0走阶段二如果文档数明显少于预期跳转到阶段三。4.2 阶段二定位删除操作30 分钟内如果文档数是 0快速锁定删除时间窗口// 进入 local 库查看 oplog use local // 统计当前 oplog 的时间范围 db.oplog.rs.find().sort({$natural: 1}).limit(1).toArray()[0].ts db.oplog.rs.find().sort({$natural: -1}).limit(1).toArray()[0].ts // 查询目标集合的所有写操作插入、更新、删除 db.oplog.rs.find({ ns: your_db.your_collection }).sort({$natural: -1}).limit(50).pretty()如果你在 oplog 里看到了大量op: d删除记录马上用o._id去备份里捞数据。如果你在主节点上开启了审计日志直接查审计日志定位是谁在什么时间、从哪个 IP 执行的操作。如果 oplog 里连插入记录都没有说明写入可能从未到达 MongoDB问题大概率出在应用层和网络层检查驱动配置和连接池状态。4.3 阶段三体检式排查1 小时内如果文档数只是减少或者查询时有时无执行下面全套体检检查项命令/工具关注的异常复制延迟rs.status()中的secs任一节点延迟大于 oplog 窗口TTL 索引db.collection.getIndexes()字段上是否意外存在expireAfterSeconds慢查询db.setProfilingLevel(1)后查system.profile意外的deleteMany或全表update校验完整性db.collection.validate({full: true})出现 corruption 报错后台任务db.currentOp()是否有大范围的 delete / migrate 任务在运行连接来源db.serverStatus().connections是否有陌生 IP 的活跃连接磁盘状态df -h、dmesggrep -i error备份状态确认最近的备份是否成功备份与当前数据的差异时间4.4 阶段四从备份恢复视情况而定如果确认数据被删且无法通过 oplog 回放找回就只能走恢复方案了。这里我给你两个思路最近备份 oplog 增量恢复先恢复到最近一个全量备份的时间点然后用 oplog 把从备份点到灾难点之间所有操作都回放一遍。这个方案要求你当时有有效的全量备份并且备份之后的 oplog 还没被覆盖。只读节点恢复如果你有隐藏的从节点hidden secondary可以从它上面mongodump导出数据。但需要注意隐藏节点也会参与复制数据延迟和普通从节点一样。我记得有一次事故备份策略是每天凌晨全量 每小时增量但恢复时发现增量备份用的工具不兼容 MongoDB 7.0 的 oplog 格式导致无法回放最后只能用全量恢复到前一天晚上白白丢了十几个小时的数据。从那以后我就特别强调备份工具的兼容性验证比备份本身更重要。5. 血泪教训哪些安全配置其实并不安全这块内容我要倒出点真东西了。下面几条都是我或身边同行在生产环境里实际踩出来的坑不是官方文档会明确警告你的。5.1 不带 --wmajority 的写入在切换时可能直接丢MongoDB 的写入级别write concern决定了一个写操作在返回成功前需要被多少个节点确认。默认的 write concern 是w: 1意思是在主节点写入完成后就返回成功。如果这时候主节点挂了其他从节点还没同步这条数据那么新主节点上就没有这条数据。生产环境强烈建议使用w: majority这样写入会等待复制集中大多数节点确认后才返回成功。这样即使主节点宕机数据也已经同步到了多数节点不会丢。当然w: majority会带来更大的写入延迟因为需要跨节点同步。对延迟极其敏感的业务可以做折中关键数据用majority非关键数据用w: 1。我曾经做过一个压测对比在同一台机器上w: 1的写入延迟大约是 1msw: majority在三节点集群里大约是 8-12ms。这个差距对大部分业务可以接受但如果你有类似实时计数器那种超高频写入的场景all-or-nothing 的取舍要仔细权衡。5.2 开了 auth 不等于安全很多人觉得只要启用了账号密码MongoDB 就安全了。实际上如果用的是弱口令等于没开。2017 年那次大规模勒索事件至今让很多人记忆犹新——被攻击的 MongoDB 实例基本都是没开认证或弱口令的。即便开了认证权限也要尽量精细。我见过一个生产库运维统一用一个拥有root角色或者dbAdminAnyDatabase角色的账号做日常连接业务代码也用它连接数据库。结果有一次这个账号被钓鱼泄露攻击者直接把整库拖走了。正确的做法是分权分账号业务应用只用readWrite 指定数据库的权限定时任务额外给dbAdmin权限但不给dropDatabase运维人员用独立账号且开启 MFA云数据库支持的情况下审计开启 MongoDB Enterprise 的审计功能或者云数据库的 SQL 审计5.3 云数据库的快照≠绝对恢复点如果你用的是托管云数据库阿里云、腾讯云、AWS 等快照备份确实省心但你要搞清楚快照的恢复粒度。有些云厂商的快照是每天一次有些是每 6 小时一次如果你在两次快照之间发生了数据删改恢复后你丢失的正好是那段时间的增量数据。云厂商一般还会提供一个按时间点恢复的功能这个功能依赖的是底层基于 binlog/oplog 的持续备份能力。但要注意这个能力并非所有版本都有也不是默认开启的。你需要主动确认自己的实例是否开启了秒级恢复或按备份集恢复的选项。一种比较稳的生产策略在业务侧自己做一份独立的逻辑备份mongodump定期上传到异地的对象存储里。虽然听起来原始但它不依赖云厂商的底层机制恢复时完全可控。5.4 hidden secondary你可以多一个后悔药MongoDB 支持隐藏节点hidden secondary。它能同步数据但不参与客户端读取也不参与投票选举。它的最大价值是当主库出现逻辑错误比如误删时你可以立即从隐藏节点拿到一份相对最新的数据。很多人觉得复制集只要有 3 个节点就万事大吉但普通从节点是可以被应用读取的如果你把脏数据同步到了从节点那从节点也不可靠。隐藏节点由于业务不可见数据被污染的概率低得多。要注意的是隐藏节点也会应用 TTL 删除和普通的删除操作所以它只能帮你抵抗物理故障和主从切换回滚这类问题不能抵抗逻辑删除。在 TTL 场景下你需要的是定期快照而不是隐藏节点。6. 最终防线一套靠谱的备份策略关键时候真的能救命说了这么多排查手段最后来聊聊预防。其实数据莫名其妙没了这个问题不可控因素太多但可控的只有一件事备份的可靠性与可恢复性。备份不是策略对了就行还要保证备份文件本身是有效的恢复流程是可跑的。6.1 备份方案怎么选我梳理了三种主流的备份方案的优缺点方案恢复粒度优点缺点mongodump逻辑数据跨版本兼容性好可选择性恢复单个库/集合恢复速度慢对大数据量不友好文件系统快照如 LVM物理快照恢复快几乎无损只适用于单机复制集场景需要所有节点同时快照云厂商自带备份物理/逻辑运维成本低支持按时间点恢复粒度受产品限制成本会随存储量上升我的推荐是大库用云厂商的物理备份小库用mongodump两者都跑且每天至少验证一次备份文件是否能正常启动。6.2 一个我踩过的备份恢复坑有一次我用mongodump做全量备份命令是mongodump --host localhost:27017 --db test --out /backup/test操作很顺利日志没有任何报错。一个月后真的需要恢复时我用mongorestore来导数据结果发现备份包里少了两个集合。排查原因是备份期间刚好有均衡器在迁移 chunk分片集群mongodump默认不等待迁移完成导致某些集合被 dump 成空集合。后来我改用--forceTableScan参数并在备份前手动停止 balancersh.stopBalancer()备份完成后sh.startBalancer()这样才彻底解决了备份成功但数据不完整的问题。6.3 备份验证的自动化脚本我平时会写一个简单的 Java 工具类或者 shell 脚本每天凌晨备份完以后自动做三件事检查mongorestore能否在临时目录里正确恢复恢复后随机抽查几个集合的文档数是否与源库一致把mongodump的退出码和日志发到告警群这个脚本的原理不复杂但它能保证你在灾难发生时手里那份备份一定是能用的。#!/bin/bash # 备份后校验文档数是否匹配 SOURCE_COUNT$(mongosh --quiet --eval db.test.countDocuments() mongodb://localhost:27017/test) RESTORE_COUNT$(mongosh --quiet --eval db.test.countDocuments() mongodb://localhost:27018/test_restore) if [ $SOURCE_COUNT ! $RESTORE_COUNT ]; then echo Backup verification failed: $SOURCE_COUNT vs $RESTORE_COUNT fi这一套流程跑起来之后我对数据到底丢没丢这个问题就变得特别有底气就算它真丢了我也能在明确的时间内给出恢复方案而不是徒手翻 oplog 碰运气。7. 一点总结性质的个人体会写了这么多最后说点掏心窝子的话。排查 MongoDB 数据丢失最怕的不是技术细节复杂而是你心态崩了之后开始瞎试。正确姿势是先按链条把问题定界做清楚——数据到底在哪里丢的、哪一层丢的、什么时候丢的再对症下药。另外平时一定要养成一个习惯每次接线上问题先在群里丢一条消息记录当前时间 问题现象 影响范围然后才去查。这既是对业务的交代也是对自己排查过程的时间锚点。数据丢失类问题最忌讳的就是查到最后自己也忘了是从哪个时间点开始查的。如果你手头也正被类似的问题困扰我的建议是先把本文 4.1 到 4.3 那几条命令完整跑一遍。大部分莫名其妙的问题跑完这几步就莫名其妙地定位到了。如果最后真的查无实据那就坚定地走备份恢复流程不要在一个方向上死磕超过一个小时。时间拖得越久业务受到的影响就越大。
RELATED READING

延伸阅读

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