先说背景,免得你觉得我在瞎扯
我们那个订单系统,2021 年从 MySQL 切到 MongoDB,当时理由很简单:订单扩展字段太多了,今天加个 couponInfo,明天加个 logisticsTrace,MySQL 加列ddl 锁表,半夜搞怕了。MongoDB 直接 $set 就完事,开发爽了三个月。
但爽是有代价的。2022 年 3 月,订单集合 orders 到了 4200 万文档,平均每个文档 1.8KB,总数据量 75GB 左右。服务器是阿里云 8 核 32G 的 ECS,MongoDB 4.4,wiredTiger 缓存默认给了 16G。那天下午,运营在群里喊:订单查询页面转圈要 5 秒以上。我上去一看,慢查询日志里全是同一个语句:db.orders.find({userId: "U123456", status: "PAID"}).sort({createdAt: -1}).limit(20)。
说实话,当时我第一反应是加索引。但加哪个字段?顺序怎么放?我之前一直觉得索引就是 db.collection.createIndex({field:1}),没想过复合索引还有讲究。
explain 一看,心凉了半截
我先用 db.orders.find({userId: "U123456", status: "PAID"}).sort({createdAt: -1}).limit(20).explain("executionStats") 跑了一下。输出里几个关键数字:totalDocsExamined: 1873421,nReturned: 20,executionTimeMillis: 1832。也就是说,为了返回 20 条数据,MongoDB 扫了 187 万份文档。这还没完,executionStages 里显示用了 IXSCAN 走的是 {userId: 1} 那个单字段索引,然后 FETCH 回表,再 SORT 内存排序。
问题就出在这:userId 索引只帮你过滤了用户,但 status 和 createdAt 都没在索引里。MongoDB 只能把该用户的所有订单都捞出来,在内存里过滤 status: "PAID",再按 createdAt 倒序排。如果这个用户有 5 万条订单,那就要在内存里排 5 万条。并发一上来,内存和 CPU 都扛不住。
我当时想,那我建一个 {userId: 1, status: 1, createdAt: -1} 的复合索引不就行了?结果建完再 explain,totalDocsExamined 降到了 20,executionTimeMillis 变成了 28 毫秒。但这里有个坑,我一开始把顺序写成了 {createdAt: -1, userId: 1, status: 1},发现根本没用,还是全表扫。因为复合索引的字段顺序遵循 ESR 原则:等值查询字段放前面,排序字段放后面,范围查询字段放最后。我的查询里 userId 和 status 都是等值,createdAt 是排序,所以正确顺序是 {userId: 1, status: 1, createdAt: -1}。
分片键选错,写入热点比查询慢更可怕
查询优化完没多久,又出事了。我们后来做了分片集群,3 个分片,每个分片 16 核 64G。一开始分片键用的是 {_id: 1},因为 _id 是 ObjectId,大体趋势递增。结果写入全跑到最后一个分片上了,那个分片的 CPU 常年 90% 以上,另外两个分片闲得发慌。晚上高峰期,写入延迟从 5ms 飙到 200ms,订单创建接口超时。
我查了 db.currentOp() 和 sh.status(),发现最后一个分片的 chunk 数量最多,而且 balancer 虽然开着,但迁移速度跟不上写入速度。后来把分片键改成了 {tenantId: "hashed"},因为我们的订单查询基本都带 tenantId,而且 hashed 能把写入打散。改完之后,三个分片的 CPU 都降到了 40% 左右,写入延迟回到 8ms 以内。
但 hashed 分片键也有副作用:范围查询 tenantId 没问题,但如果你要查某个时间段的所有租户订单,就变成了 scatter-gather,要问所有分片。我们后来用了复合分片键 {tenantId: 1, _id: "hashed"},把等值查询和散列结合起来。另外,balancer 窗口我也改了,db.settings.update({_id: "balancer"}, {$set: {activeWindow: {start: "02:00", stop: "06:00"}}}),避免白天迁移影响性能。
和 PostgreSQL JSONB 比,到底选哪个?
后来我们另一个项目用 PostgreSQL 14,订单表 4000 万行,扩展字段用 JSONB 存。我做了个对比测试:同样的查询条件,PostgreSQL 上建了 GIN 索引,SELECT * FROM orders WHERE data @> '{"status":"PAID"}' AND user_id = 'U123456' ORDER BY created_at DESC LIMIT 20;,耗时 800ms 左右。MongoDB 同样数据量下,走对索引是 30ms。但 PostgreSQL 的 GIN 索引大小涨到了 12GB,而且写入时索引维护成本高,vacuum 也慢。
我的结论可能有点反直觉:如果你的查询模式固定、事务要求高,PostgreSQL 的 JSONB 其实更稳,因为它的优化器更成熟,而且不用担心分片键选错。但如果你字段变化极快、写入量巨大、能接受最终一致性,MongoDB 的开发效率确实高。前提是,你得把索引和分片键当回事。我见过太多人用 MongoDB 就是 db.collection.insert 和 find,从来不 explain,等数据量上来了就骂 MongoDB 垃圾。
最后说句实在的,那次优化之后,我把所有 MongoDB 的查询都强制要求 explain,totalDocsExamined 和 nReturned 的比例超过 10:1 就打回重写。这个习惯救了我好几次。技术没有银弹,MongoDB 也不是,它只是把 MySQL 的锁表问题换成了分片和索引的设计问题。