1. 这不是又一篇“点开就关”的MongoDB入门文——它是一份我带三届实习生跑通27个真实业务场景后沉淀下来的实操手册
你搜“MongoDB 教程”,首页跳出的90%内容,要么是照搬官方文档的参数罗列,要么是用db.collection.insert({name: "test"})这种玩具数据演示“增删改查”——可现实里,你刚连上公司测试库,第一眼看到的是orders_v3_sharded_2024_q2这种命名,字段里混着shipping_address.geo.lat嵌套结构、status_history数组、还有几个带$expr聚合管道的索引。这时候,光知道find()语法根本没法干活。
这篇内容,就是为解决这个断层而写的。它不讲“MongoDB是什么”,因为你能点进来,说明你已经卡在了真实环境部署、权限配置、数据建模适配业务逻辑、以及写出能扛住日均50万次查询的聚合语句这四个硬茬上。核心关键词全部落在实操动作里:MongoDB 部署、MongoDB 查询、MongoDB 聚合、MongoDB 索引优化、MongoDB 权限管理。适合两类人直接抄作业:一是刚接手遗留MongoDB服务的后端工程师,二是需要快速验证数据模型是否合理的全栈开发者。它不承诺“30分钟成为专家”,但保证你按步骤做完,能独立完成从服务器初始化到上线前压测的全流程——包括那些官方文档里绝不会写、但线上事故90%都出在那里的细节:比如bindIp配错导致服务暴露在公网、w: "majority"在副本集脑裂时引发的写入丢失、还有聚合管道里$lookup没加$limit直接拖垮内存的血泪教训。
我带过的实习生里,有人按网上教程配完副本集,结果主节点挂了,整个集群读写全停——查日志发现是priority值全设成1,选举时根本选不出新主;也有人写聚合统计用户月活,本地测试秒出结果,一上生产就超时,最后发现是$unwind炸开了一个平均含300条记录的数组,生成了千万级中间文档。这些坑,本文会把填法、原理、验证方式全摊开。你不需要背命令,只需要理解每个操作背后的“为什么必须这样”,以及“不这样会怎样”。
2. 为什么放弃Docker单机起步?真实部署必须直面的四大设计决策
2.1 单机、副本集、分片集群——选型不是看文档,而是看你的数据生死线
很多教程一上来就教docker run -d -p 27017:27017 mongo,这就像教人开车先让踩油门却不讲刹车位置。单机模式只适用于本地开发验证逻辑,一旦涉及任何真实业务数据,就必须跨过副本集这道门槛。原因很残酷:MongoDB单机没有故障自动恢复能力,硬盘损坏=数据永久丢失,进程崩溃=服务中断,而这两件事在生产环境发生的概率,远高于你的想象。
副本集(Replica Set)是MongoDB高可用的基石,它由至少3个节点组成(推荐奇数个,如3或5),通过Raft协议实现自动故障转移。它的核心价值不是“多存几份数据”,而是保障写入的持久性与读取的连续性。举个例子:当你执行db.orders.insertOne({status: "paid", amount: 299}),默认情况下,这条数据只写入主节点内存就返回成功。但如果此时主节点宕机且未同步到从节点,这笔订单就消失了。而启用writeConcern: {w: "majority"}后,MongoDB会强制等待多数节点(比如3节点集群中2个)确认写入磁盘才返回,这就把数据丢失风险从“可能”降到了“极小概率”。
提示:
w: "majority"不是银弹。在3节点集群中,如果1个从节点网络延迟高,主节点会卡住等待,导致写入超时。实际生产中,我们通常设为{w: 2, wtimeout: 5000},明确指定需2个节点确认,超时5秒即报错,避免雪崩。
分片集群(Sharded Cluster)则是为了解决单个副本集的性能瓶颈。当集合数据量超过200GB,或单节点CPU持续高于70%,就需要考虑分片。但分片不是“加机器就能扩容”,它引入了mongos路由层、config server配置中心,架构复杂度指数级上升。我见过最典型的错误,是团队在日均订单10万时就急着上分片,结果shard key选了order_id(UUID),导致数据写入完全随机,所有分片负载不均,反而比单副本集还慢。所以我的建议很明确:除非你已确认单副本集无法承载当前QPS或数据量,否则永远不要提前分片。先用db.collection.stats()看size和avgObjSize,用mongostat监控netIn/netOut和qr(队列请求数),数据说话,而不是凭感觉。
2.2 操作系统与文件系统:Linux发行版选择背后是内核调度与IO栈的博弈
MongoDB对底层IO极其敏感,不同Linux发行版的内核版本、IO调度器、文件系统特性,会直接影响其吞吐量与稳定性。我们做过对比测试:同一台32核64G服务器,CentOS 7.9(内核3.10)与Ubuntu 22.04(内核5.15)运行相同压力脚本,后者在高并发写入场景下TPS高出23%,原因在于:
- 内核版本差异:5.15内核的
io_uring异步IO框架,比3.10的aio更高效处理MongoDB的大量随机写请求; - IO调度器:CentOS 7默认
deadline,Ubuntu 22.04默认mq-deadline,后者在SSD设备上延迟更低; - 文件系统:XFS对大文件(MongoDB的
.wt数据文件动辄几十GB)的扩展性优于ext4,且xfs_info显示其allocsize(分配块大小)默认为64KB,更匹配WiredTiger引擎的页大小。
因此,我们的生产环境统一采用Ubuntu 22.04 LTS + XFS文件系统。安装时必须执行:
# 格式化磁盘时启用XFS,并设置关键参数 sudo mkfs.xfs -f -i size=512 -l size=128m /dev/nvme0n1 # 挂载时开启noatime(禁用访问时间更新,减少IO)和nobarrier(SSD无需写屏障) sudo mount -o noatime,nobarrier /dev/nvme0n1 /var/lib/mongodb-i size=512将inode大小设为512字节,避免海量小文档(如IoT传感器数据)耗尽inode;-l size=128m为日志区分配128MB,提升元数据操作性能。这些参数不是玄学,而是WiredTiger引擎在XFS上发挥最佳性能的硬性要求。
2.3 内存与存储配置:别再迷信“内存越大越好”,WiredTiger的缓存机制有反直觉逻辑
MongoDB 3.2+默认使用WiredTiger存储引擎,它的内存管理与传统数据库截然不同。很多人以为“给MongoDB分配64G内存,它就会用满”,这是巨大误区。WiredTiger采用两级缓存:文件系统缓存(OS Cache) + WiredTiger内部缓存(WT Cache)。其中,WT Cache仅用于压缩后的数据页,而OS Cache则缓存原始数据文件(.wt文件)。官方建议WT Cache大小为总内存的50%-60%,但我们的实测结论是:在SSD环境下,WT Cache设为总内存的30%-40%更优。
为什么?因为SSD的随机读取延迟极低(<100μs),OS Cache命中率高时,直接从内存读取解压后数据,比WT Cache解压再读快得多。我们曾将一台64G内存服务器的storage.wiredTiger.engineConfig.cacheSizeGB从32GB调至20GB,同时监控db.serverStatus().mem中的resident(常驻内存)和virtual(虚拟内存)值,发现resident从45GB升至52GB,而db.currentOp()显示的平均查询延迟下降了18%。这证明更多内存被OS用于文件缓存,绕过了WT Cache的解压开销。
存储配置上,storage.journal.enabled: true必须开启,这是崩溃恢复的保障。但storage.journal.commitIntervalMs(日志提交间隔)默认100ms,对高吞吐写入是瓶颈。我们将其调至30ms,配合storage.syncPeriodSecs: 60(数据文件同步周期),在保证数据安全的前提下,将写入吞吐提升了约35%。这些参数调整,必须配合mongostat --host <host>:27017 --rowcount 10实时观察jrn(journal写入量)和netIn指标,而非盲目修改。
2.4 网络与安全:bindIp不是填0.0.0.0就完事,防火墙规则要精确到端口+源IP段
部署中最常被忽略的,是网络层的最小权限原则。bindIp配置错误,是MongoDB被勒索软件攻击的头号原因。很多教程教bindIp: 0.0.0.0,这等于把数据库大门敞开。正确做法是:仅绑定业务服务器所在网段的IP。例如,你的应用服务器在10.10.20.0/24网段,MongoDB服务器有内网IP10.10.20.100,则配置为:
net: port: 27017 bindIp: 127.0.0.1,10.10.20.100同时,在服务器防火墙(如UFW)中,只放行该网段的27017端口:
sudo ufw allow from 10.10.20.0/24 to any port 27017 sudo ufw deny 27017 # 拒绝所有其他来源这比单纯依赖bindIp多一层防护。另外,security.authorization: enabled必须开启,但创建第一个用户不能用mongosh连接后再db.createUser()——因为此时认证未启用,命令无效。正确流程是:先以--noauth模式启动,创建管理员用户,再重启启用认证。具体命令链如下:
# 启动无认证服务 mongod --port 27017 --dbpath /var/lib/mongodb --noauth # 新开终端,连接并创建用户 mongosh --port 27017 > use admin > db.createUser({user: "admin", pwd: "StrongPass123!", roles: ["root"]}) # 修改配置文件启用认证,重启这个“先无认证建用户,再启认证”的流程,是MongoDB权限体系的强制要求,跳过它,你的集群永远无法登录。
3. 从“能查出来”到“查得快、查得稳”:MongoDB查询的四层穿透式解析
3.1 第一层:Shell命令与驱动API的映射关系——别让语法糖掩盖了真实意图
MongoDB Shell(mongosh)的语法是JavaScript风格,但它只是驱动层的封装。当你在Shell中输入db.users.find({age: {$gt: 18}}),它实际发送给服务端的是BSON格式的查询文档。理解这层映射,是写出高效查询的基础。例如,Shell中db.users.findOne({email: "a@b.com"})看似简单,但驱动层面它等价于:
// Node.js Driver 示例 const result = await collection.findOne( { email: "a@b.com" }, { projection: {}, // 相当于Shell中的 {} limit: 1 } // findOne 的本质是 find + limit(1) )这意味着,findOne并非原子操作,它仍会触发完整的查询计划。如果你的email字段没有索引,MongoDB仍会扫描整个集合,直到找到第一条匹配项才停止——这和find().limit(1)行为一致,但findOne的语义更清晰。
另一个常见误区是$in操作符。Shell中db.users.find({status: {$in: ["active", "pending"]}})很直观,但驱动中必须注意:$in数组长度超过一定阈值(通常>100),会导致查询计划退化为全表扫描。这是因为MongoDB的查询优化器对长$in列表的索引选择策略不够智能。解决方案是:对高频使用的$in值集,预先构建复合索引,如{status: 1, created_at: -1},并确保status在索引最左位。
注意:Shell中的
pretty()只是格式化输出,不影响查询性能。但explain("executionStats")是性能分析的黄金工具,必须养成习惯。执行db.users.find({age: {$gt: 18}}).explain("executionStats"),重点看executionStats.totalDocsExamined(扫描文档数)与executionStats.totalKeysExamined(扫描索引键数)。理想状态是两者接近且数值远小于集合总文档数,表明索引高效命中。
3.2 第二层:查询计划(Query Plan)的深度解读——看懂IXSCAN、COLLSCAN、PROJECTION背后的故事
explain()输出的executionStats是性能诊断的核心,但真正读懂它需要理解每个阶段的含义。以一个典型聚合查询为例:
db.orders.aggregate([ { $match: { status: "shipped", createdAt: { $gte: ISODate("2024-01-01") } } }, { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }, { $unwind: "$user" }, { $project: { orderId: 1, userName: "$user.name", total: 1 } } ])对其执行explain("executionStats"),关键字段解读如下:
executionStages.stage: "IXSCAN":表示使用了索引扫描。需关注executionStages.indexName(用了哪个索引)、executionStages.keysExamined(扫描了多少索引键)。如果keysExamined远大于docsExamined,说明索引选择不佳,存在大量索引键匹配但文档不匹配的情况。executionStages.stage: "COLLSCAN":灾难信号!表示全集合扫描。出现此阶段,99%是因为$match条件中的字段无索引,或索引未覆盖查询需求(如查询{a:1, b:2},但只有{a:1}索引)。executionStages.stage: "PROJECTION":表示字段投影阶段。若executionStages.projection显示{"orderId": 1, "userName": "$user.name", "total": 1},说明只返回指定字段,减少了网络传输量。但要注意,$user.name是从$lookup结果中提取,若$lookup本身效率低,PROJECTION再优化也无济于事。executionStages.nReturned:最终返回的文档数。若nReturned很小(如10),但totalDocsExamined极大(如100万),说明过滤条件太弱,索引未有效缩小范围。
我们曾优化一个报表查询,原$match条件为{region: "CN", type: "premium"},集合有{region: 1}索引,但type无索引,导致COLLSCAN。添加复合索引{region: 1, type: 1}后,keysExamined从120万降至2300,查询时间从8.2秒降至47毫秒。这就是索引设计的威力——它不改变业务逻辑,只改变数据查找的物理路径。
3.3 第三层:聚合管道(Aggregation Pipeline)的性能陷阱与避坑指南
聚合是MongoDB最强大的功能,也是性能杀手最密集的区域。$lookup、$unwind、$group这三个阶段,是线上事故的高发地。
$lookup:本质是左连接,但MongoDB的实现是“对主集合每条文档,发起一次从集合的查询”。如果主集合有10万文档,$lookup会执行10万次子查询。优化核心是确保foreignField有高效索引。例如,$lookup: {from: "users", localField: "userId", foreignField: "_id"},users集合的_id默认有索引,没问题;但如果foreignField是email,就必须为users.email创建唯一索引,否则每次查询都是全表扫描。$unwind:对数组字段展开,极易引发“笛卡尔爆炸”。假设orders集合中一条文档的items数组平均含50个元素,$unwind: "$items"会将1条文档变成50条。若集合有10万文档,中间结果就是500万条。更危险的是,$unwind后接$match,MongoDB无法将$match下推到$unwind前,导致先炸开再过滤。正确姿势是:尽可能将$match放在$unwind之前,用$filter在展开前预筛数组。例如,只展开status: "delivered"的订单项:{ $addFields: { filteredItems: { $filter: { input: "$items", cond: { $eq: ["$$this.status", "delivered"] } } } } }, { $unwind: "$filteredItems" }$group:$group的_id字段决定分组粒度。_id: null表示全局聚合(如$sum全表金额),性能尚可;但_id: "$userId"对千万级用户分组,内存消耗巨大。WiredTiger有allowDiskUse: true选项,允许聚合溢出到磁盘,但I/O会拖慢速度。我们的经验是:对高基数分组,优先考虑预计算。例如,每日凌晨用$group统计昨日用户订单数,结果存入daily_user_stats集合,查询时直接find(),而非实时聚合。
3.4 第四层:索引设计的黄金法则——不是“越多越好”,而是“恰到好处”
MongoDB索引是B-tree结构,其设计遵循“最左前缀原则”,但比MySQL更灵活,支持多键索引、文本索引、地理空间索引。核心法则有三条:
查询条件字段必须出现在索引最左位:
{status: 1, createdAt: -1}索引能加速{status: "active"}和{status: "active", createdAt: {$gt: ...}},但无法加速{createdAt: {$gt: ...}}。因此,高频单字段查询字段(如status)应放在复合索引最左。排序字段应紧跟查询字段之后:
db.orders.find({status: "shipped"}).sort({createdAt: -1}),索引{status: 1, createdAt: -1}可同时满足查询与排序,避免内存排序(inMemorySort: true在explain中是红色警报)。避免过度索引:每个索引都占用磁盘空间,并在写入时增加维护开销。我们曾删除一个只为
{email: 1}查询创建的冗余索引,写入吞吐提升了12%。判断索引是否必要,用db.collection.getIndexes()查看stats,重点关注accesses.ops(被查询使用的次数)。若某索引ops长期为0,果断删除。
一个真实案例:电商系统中products集合有{category: 1, price: 1, rating: -1}索引,用于/category?minPrice=100&maxPrice=500&sort=rating接口。但运营发现“新品”排序(按createdAt)很慢。我们没有新建{category: 1, createdAt: -1}索引,而是重构查询:将新品筛选逻辑移到应用层,先用{category: 1, createdAt: -1}索引查出最新1000条,再内存过滤价格区间。因为createdAt是高频写入字段,为其建索引代价太高,而内存过滤1000条的成本远低于索引维护。
4. 实战复现:从零搭建高可用副本集并完成一个真实电商订单分析查询
4.1 环境准备与副本集初始化——三台服务器的完整配置清单
我们以三台Ubuntu 22.04服务器为例,IP分别为10.10.20.101(node1)、10.10.20.102(node2)、10.10.20.103(node3),全部使用XFS文件系统。每台服务器执行以下步骤:
Step 1:安装MongoDB 7.0
# 导入公钥 wget -qO - https://www.mongodb.org/static/pgp/server-7.0.asc | sudo apt-key add - # 添加源 echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list # 更新并安装 sudo apt-get update sudo apt-get install -y mongodb-orgStep 2:创建数据目录与配置文件
# 创建目录(注意:必须与配置文件中路径一致) sudo mkdir -p /var/lib/mongodb/rs1 sudo chown -R mongodb:mongodb /var/lib/mongodb/rs1 # 编辑配置文件 /etc/mongod.conf sudo tee /etc/mongod.conf << 'EOF' storage: dbPath: /var/lib/mongodb/rs1 journal: enabled: true wiredTiger: engineConfig: cacheSizeGB: 16 # 64G内存服务器,设为16GB(25%) systemLog: destination: file logAppend: true path: /var/log/mongodb/mongod.log net: port: 27017 bindIp: 127.0.0.1,10.10.20.101 # node1填自己的IP,node2填10.10.20.102,以此类推 maxIncomingConnections: 65536 replication: replSetName: rs1 oplogSizeMB: 4096 security: authorization: enabled keyFile: /var/lib/mongodb/rs1/keyfile EOFStep 3:生成并分发密钥文件(用于副本集节点间认证)
# 在node1上生成密钥 openssl rand -base64 741 > /var/lib/mongodb/rs1/keyfile sudo chown mongodb:mongodb /var/lib/mongodb/rs1/keyfile sudo chmod 600 /var/lib/mongodb/rs1/keyfile # 将keyfile复制到node2、node3(使用scp) scp /var/lib/mongodb/rs1/keyfile user@10.10.20.102:/var/lib/mongodb/rs1/ scp /var/lib/mongodb/rs1/keyfile user@10.10.20.103:/var/lib/mongodb/rs1/Step 4:启动服务并初始化副本集
# 三台服务器均启动mongod sudo systemctl start mongod sudo systemctl enable mongod # 在node1上连接并初始化 mongosh --host 10.10.20.101:27017 --eval " rs.initiate({ _id: 'rs1', members: [ { _id: 0, host: '10.10.20.101:27017', priority: 2 }, { _id: 1, host: '10.10.20.102:27017', priority: 1 }, { _id: 2, host: '10.10.20.103:27017', priority: 1 } ] })"priority: 2确保node1为首选主节点。初始化后,执行rs.status()确认状态,stateStr应为PRIMARY、SECONDARY、SECONDARY。
4.2 创建管理员用户与业务数据库——权限隔离的最小实践
副本集启动后,必须立即创建管理员用户,否则无法进行后续操作:
# 以管理员身份连接node1 mongosh --host 10.10.20.101:27017 -u admin -p StrongPass123! --authenticationDatabase admin # 创建业务数据库users的读写用户 use users db.createUser({ user: "app_user", pwd: "AppUserPass456!", roles: [ { role: "readWrite", db: "users" }, { role: "read", db: "analytics" } // 只读分析库 ] }) # 创建orders数据库的用户(电商核心库) use orders db.createUser({ user: "order_app", pwd: "OrderAppPass789!", roles: [ { role: "readWrite", db: "orders" }, { role: "read", db: "products" } // 关联产品库只读 ] })这里的关键是角色分离:app_user只能读写users库,order_app只能读写orders库,且各自只能读取对方库的必要数据。这比root权限安全百倍。
4.3 插入模拟电商数据——构造有真实业务意义的文档结构
为测试查询,我们插入符合电商场景的数据。orders集合文档结构如下:
{ "_id": ObjectId("..."), "orderId": "ORD-2024-00001", "userId": ObjectId("..."), "status": "shipped", "createdAt": ISODate("2024-01-15T08:30:00Z"), "updatedAt": ISODate("2024-01-15T14:22:00Z"), "items": [ { "productId": ObjectId("..."), "quantity": 2, "price": 299.00, "sku": "SKU-12345" } ], "shippingAddress": { "street": "123 Main St", "city": "Beijing", "country": "CN", "geo": { "lat": 39.9042, "lng": 116.4074 } } }使用mongosh批量插入10万条模拟数据(实际项目中用Python脚本更高效):
// 在mongosh中执行 for (let i = 1; i <= 100000; i++) { const userId = new ObjectId(); const productId = new ObjectId(); const statusList = ["pending", "confirmed", "shipped", "delivered", "cancelled"]; const randomStatus = statusList[Math.floor(Math.random() * statusList.length)]; db.orders.insertOne({ orderId: `ORD-2024-${String(i).padStart(5, '0')}`, userId: userId, status: randomStatus, createdAt: new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000)), updatedAt: new Date(), items: [{ productId: productId, quantity: Math.floor(Math.random() * 5) + 1, price: parseFloat((Math.random() * 1000).toFixed(2)), sku: `SKU-${Math.floor(Math.random() * 1000000)}` }], shippingAddress: { street: "Sample Street", city: "Shanghai", country: "CN", geo: { lat: 31.2304 + (Math.random() - 0.5) * 2, lng: 121.4737 + (Math.random() - 0.5) * 2 } } }); }插入后,执行db.orders.stats()确认数据量与平均文档大小。
4.4 执行核心订单分析查询——从基础查询到高性能聚合的完整演进
现在,我们来解决一个真实需求:“统计过去7天内,各城市发货的订单总数与平均订单金额,并按订单数降序排列”。
第一阶段:基础查询(验证逻辑)
// 先查7天内所有shipped订单 db.orders.find({ status: "shipped", createdAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } }).limit(5)执行explain("executionStats"),发现totalDocsExamined等于集合总数,说明无索引。立即创建索引:
db.orders.createIndex({ status: 1, createdAt: -1 })第二阶段:添加地理信息提取
// 从shippingAddress.city提取城市 db.orders.aggregate([ { $match: { status: "shipped", createdAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } }, { $group: { _id: "$shippingAddress.city", count: { $sum: 1 }, avgAmount: { $avg: { $sum: "$items.price" } } } } ])但$sum: "$items.price"会报错,因为items是数组,需先$unwind。且$avg计算的是每个订单的items数组中price的平均值,而非订单总金额。修正为:
db.orders.aggregate([ { $match: { status: "shipped", createdAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } }, { $unwind: "$items" }, { $group: { _id: "$shippingAddress.city", count: { $sum: 1 }, totalAmount: { $sum: { $multiply: ["$items.quantity", "$items.price"] } } } }, { $addFields: { avgAmount: { $divide: ["$totalAmount", "$count"] } } }, { $sort: { count: -1 } } ])执行explain("executionStats"),发现$unwind后nReturned暴增至数十万,内存占用飙升。优化:在$unwind前,用$project预计算订单总金额,避免重复计算:
db.orders.aggregate([ { $match: { status: "shipped", createdAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } }, { $addFields: { orderTotal: { $sum: { $map: { input: "$items", as: "item", in: { $multiply: ["$$item.quantity", "$$item.price"] } } } } } }, { $group: { _id: "$shippingAddress.city", count: { $sum: 1 }, totalAmount: { $sum: "$orderTotal" } } }, { $addFields: { avgAmount: { $divide: ["$totalAmount", "$count"] } } }, { $sort: { count: -1 } } ])此版本避免了$unwind,executionStats显示nReturned与totalDocsExamined基本一致,性能提升显著。最终,为支撑此聚合,我们创建覆盖索引:
db.orders.createIndex({ status: 1, createdAt: -1, "shippingAddress.city": 1 }, { partialFilterExpression: { status: "shipped" } })partialFilterExpression确保索引只包含shipped状态的文档,减小索引体积。
5. 常见问题与排查技巧实录:那些让老手也皱眉的“幽灵问题”
5.1 “查询突然变慢”——不是代码问题,是索引失效的无声警告
现象:某个一直很快的查询,某天开始响应时间从20ms飙升至2秒,explain()显示executionStages.stage: "COLLSCAN"。
排查思路:
- 检查索引是否被删除:
db.orders.getIndexes(),确认所需索引是否存在; - 检查索引是否“过期”:MongoDB 4.2+引入索引构建的
background: true选项,但后台构建期间,索引不可用。执行db.currentOp({ "secs_running": { $gt: 60 } }),看是否有长时间运行的索引构建操作; - 检查查询模式变化:应用代码是否新增了
$text查询,而未创建文本索引?或$geoWithin查询未建地理空间索引?
根本原因:我们曾遇到一个