大表加字段再建索引须滚动操作:禁在主节点直接执行updateMany+createIndex,否则全表扫描卡顿、前台锁阻塞读写、选举窗口建索引引发IndexNotFound;必须用bypassDocumentValidation、单options含background:true、partialFilterExpression过滤空值,并在secondary节点完成建索引、同步追平后再切流。

大表加字段再建索引,只要在副本集里按滚动方式操作,完全能做到业务无感知——前提是别在主节点上直接干,也别跳过验证步骤。
为什么不能直接在主节点上 updateMany + createIndex
千万级以上集合执行 updateMany 会全表扫描+逐文档写入,WiredTiger 引擎下可能卡住数小时;紧接着建索引若漏掉 background: true 或写错语法(比如把选项拆成多个对象),就会触发前台锁,整个 DB 级别阻塞读写。更糟的是:主节点刚完成选举后的 5 分钟内,oplog 同步未稳,此时建索引极易引发二次切换,新主查不到索引直接报 IndexNotFound。
- 错误示例:
db.coll.updateMany({}, {$set: {new_f: null}})—— 没加bypassDocumentValidation,遇到 schema 校验失败就中断 - 错误示例:
db.coll.createIndex({new_f: 1}, {name: "idx_new"}, {background: true})—— 三个大括号,background被忽略,退化为前台建索引 - 正确姿势:补字段用
{multi: true, bypassDocumentValidation: true};建索引只用一个 options 对象,background: true必须在里面
滚动操作的三步闭环:从备节点开始,到主节点收尾
核心逻辑是:让索引构建发生在不承担流量的节点上,等它建完、同步追平、再切流、再重复。整个过程不碰主节点的写入口,也不等索引建完才切。
- 先确认副本集状态:
rs.status()查所有节点stateStr和optimeDate,挑一个延迟最小、非隐藏的 secondary 节点(比如shard2_1) - 停该节点复制:
rs.freeze(0)+db.adminCommand({replSetSyncFrom: ""}),防止它边建索引边拉 oplog 导致冲突 - 在该节点本地执行:
db.coll.updateMany({}, {$set: {new_f: null}}, {multi: true, bypassDocumentValidation: true}),再跑db.coll.createIndex({new_f: 1}, {background: true}) - 建完后手动触发一次全量同步检查:
db.coll.stats().indexCount看是否多出新索引,db.currentOp({secs_running: {$gt: 60}})确认无长事务残留 - 恢复复制:
rs.freeze(-1),等它追平主节点的optimeDate,再用rs.stepDown()把原主切走,让这个“已建好索引”的节点升主
partialFilterExpression 不是可选,而是必须考虑的点
如果补字段时大量设为 null 或留空,MongoDB 默认把 null 和缺失值都写进索引,结果索引体积虚高、{$exists: true} 查询走不了索引、范围扫描效率断崖下跌。
- 建索引时强制过滤:
db.coll.createIndex({new_f: 1}, {background: true, partialFilterExpression: {new_f: {$type: "string"}}}) - 补字段阶段就规避:
db.coll.updateMany({old_f: {$exists: true, $ne: null}}, {$set: {new_f: "$old_f"}}, {multi: true, bypassDocumentValidation: true}),跳过空值不写 - 验证是否生效:
db.coll.find({new_f: "xxx"}).explain("executionStats")里看totalDocsExamined是否 ≈nReturned,否则说明索引没被选中
currentOp 看进度只是起点,还得盯 mongostat 和 collection.stats
db.currentOp({op: "command", "query.createIndexes": {$exists: true}}) 能看到百分比和锁状态,但后台索引构建实际是分段刷盘的,progress.done 到 100% 后还有写磁盘、更新元数据、广播到其他节点的过程,这阶段 currentOp 已不显示,但 mongostat 的 netIn/netOut 和 collection.stats().indexDetails 才反映真实负载。
- 建索引期间持续跑:
mongostat -n 1 --host shard2_1:37017,观察qr/qw(查询/写入队列)是否突增 - 建完立刻查:
db.coll.stats().indexDetails["new_f_1"].size,对比预估大小,偏差过大说明有大量 null 被误索引 - 别信 shell 卡住就失败:即使
createIndex命令行没返回,只要currentOp里有记录,它就在后台跑,Ctrl+C 不影响
真正容易被忽略的是:滚动操作不是“做完一个节点就完事”,每个节点升主前必须确认 rs.printSecondaryReplicationInfo() 里的同步延迟归零,且 db.coll.getIndexes() 输出一致——差一个字段名大小写或选项参数,切过去就查不到索引。

















