从百万日活社区重构看聚合管道:为什么我们弃用SQL迁移至MongoDB 8.0

那年我们那个做了三年的社区产品日活冲破了150万,DAU一高,原本跑得好好的MySQL 8.0读写分离架构直接趴窝。当时MySQL里有一张posts表,数据量在3.2亿行左右,每天新增大约40万条帖子。我们有个“热门推荐”接口,需要从帖子、用户、点赞、评论四张表里做JOIN,再加上GROUP BY算热度分,高峰期这个接口的P99响应时间能飙到2.3秒,数据库CPU常驻90%以上。

我们当时做的第一件事就是分析那个查询。SQL大概长这样:

SELECT p.id, p.title, u.nickname, (COUNT(DISTINCT l.id) * 0.6 + COUNT(DISTINCT c.id) * 0.4) as hot_score FROM posts p LEFT JOIN users u ON p.user_id = u.id LEFT JOIN likes l ON p.id = l.post_id LEFT JOIN comments c ON p.id = c.post_id WHERE p.status = 1 AND p.created_at > '2024-11-01' GROUP BY p.id ORDER BY hot_score DESC LIMIT 20;

这条SQL在MySQL里执行一次大概要800ms,而且随着数据量增长,索引维护成本极高。我们尝试过分表,但分完表之后跨表JOIN更是灾难。

后来我拉着团队做了两周的技术选型,最终决定迁移到MongoDB 8.0。选它的原因很简单:我们的数据结构是嵌套的,一个帖子本身就包含了作者信息、点赞列表(部分冗余)、评论摘要。在MongoDB里,这就是一个文档。而且MongoDB 8.0的聚合管道对复杂计算的支持已经非常接近SQL了,特别是$lookup的增强,支持非关联子查询,这让我们可以把原本在应用层做的很多拼装逻辑下沉到数据库层。

迁移后的聚合管道大概是这样写的:

db.posts.aggregate([ { $match: { status: 1, created_at: { $gt: ISODate("2024-11-01T00:00:00Z") } } }, { $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "author" } }, { $unwind: "$author" }, { $lookup: { from: "likes", let: { post_id: "$_id" }, pipeline: [ { $match: { $expr: { $eq: ["$post_id", "$$post_id"] } } }, { $count: "total" } ], as: "like_info" } }, { $lookup: { from: "comments", let: { post_id: "$_id" }, pipeline: [ { $match: { $expr: { $eq: ["$post_id", "$$post_id"] } } }, { $count: "total" } ], as: "comment_info" } }, { $addFields: { hot_score: { $add: [ { $multiply: [{ $ifNull: [{ $arrayElemAt: ["$like_info.total", 0] }, 0] }, 0.6] }, { $multiply: [{ $ifNull: [{ $arrayElemAt: ["$comment_info.total", 0] }, 0] }, 0.4] } ] } } }, { $sort: { hot_score: -1 } }, { $limit: 20 }, { $project: { _id: 1, title: 1, "author.nickname": 1, hot_score: 1 } } ])

迁移完那周,我盯着监控看了一整天。同样的查询逻辑,在MongoDB 8.0上平均响应时间降到了120ms左右,P99也只有180ms。而且MongoDB 8.0的$lookup支持非关联子查询,这让我们能把点赞和评论的统计直接做成子管道,逻辑比SQL还清晰。

当然,迁移过程也不是一帆风顺。有一次线上索引没建好,导致聚合全表扫描,MongoDB的内存占用直接冲到了12GB,后来我们针对statuscreated_at建了复合索引,情况才稳定下来。现在回过头看,对于这种读多写少、数据结构灵活、且需要复杂聚合的场景,MongoDB 8.0的聚合管道确实比传统SQL的JOIN更适合我们。

为什么我们没选其他方案

我们也考虑过继续优化MySQL,比如上TiDB或者ClickHouse。但TiDB的延迟在我们这种高并发场景下还是偏高,ClickHouse又太重,不适合做实时推荐。MongoDB 8.0的聚合管道既能处理实时查询,又能通过变更流(Change Streams)对接我们的Kafka消息队列,这种灵活性是其他方案给不了的。

拒绝COLLSCAN:基于MongoDB 8.0索引策略与$match前置的压测对比

上个月我们新上了一个用户行为分析功能,需要实时统计过去7天每个用户的发帖、点赞、评论数。数据量大概在8000万条行为记录,放在user_actions集合里。一开始我写的聚合管道是这样的:

db.user_actions.aggregate([ { $addFields: { action_type: "$type" } }, { $match: { created_at: { $gt: ISODate("2024-11-20T00:00:00Z") } } }, { $group: { _id: "$user_id", post_count: { $sum: { $cond: [{ $eq: ["$action_type", "post"] }, 1, 0] } }, like_count: { $sum: { $cond: [{ $eq: ["$action_type", "like"] }, 1, 0] } }, comment_count: { $sum: { $cond: [{ $eq: ["$action_type", "comment"] }, 1, 0] } } } } ])

写完我在测试环境跑了一次,数据量大概500万条,耗时竟然要4.2秒。我当时就觉得不对劲,这明显是没走索引。我打开explain("executionStats")看了一下,果然是COLLSCAN,全表扫描了。

问题出在哪?$match放在了$addFields后面。MongoDB 8.0的查询优化器虽然比以前聪明,但它没法在$addFields之后再利用索引去过滤created_at。我立刻把管道顺序调整了一下:

db.user_actions.aggregate([ { $match: { created_at: { $gt: ISODate("2024-11-20T00:00:00Z") } } }, { $addFields: { action_type: "$type" } }, { $group: { _id: "$user_id", post_count: { $sum: { $cond: [{ $eq: ["$action_type", "post"] }, 1, 0] } }, like_count: { $sum: { $cond: [{ $eq: ["$action_type", "like"] }, 1, 0] } }, comment_count: { $sum: { $cond: [{ $eq: ["$action_type", "comment"] }, 1, 0] } } } } ])

调整完之后,我先在created_at字段上建了一个单字段索引:

db.user_actions.createIndex({ created_at: 1 })

再跑一次,耗时降到了1.1秒。但这还不够,因为我们的查询里还有user_id的分组,我又加了一个复合索引:

db.user_actions.createIndex({ created_at: 1, user_id: 1, type: 1 })

这次再跑,耗时直接降到了280ms。我做了个简单的压测,用wrk模拟100个并发,持续30秒:

数据不会骗人。MongoDB 8.0虽然引入了基于代价的优化器(CBO),但管道阶段的顺序依然至关重要。特别是$match$sort,如果能前置就一定要前置,因为这两个阶段能直接利用索引减少进入管道的数据量。

有一次线上接口突然变慢,我排查下来发现是同事在$match前面加了一个$project,把某些字段重命名了,结果导致后面的$match条件没法命中索引。后来我们把$project挪到管道最后,性能立刻恢复了。

MongoDB 8.0还支持聚合表达式索引,这个特性我们也在用。比如我们经常要按action_type的某种计算结果来查询,直接把表达式放进索引里,查询时能省掉不少CPU开销。

深度实战:$lookup非关联子查询与$graphLookup社交图谱的选型对比

我们社区有个“好友动态”功能,需要展示用户好友的帖子。一开始我们用的是$lookup的非关联子查询,逻辑大概是这样:先查出当前用户的好友列表,再去帖子表里找这些好友发的帖子。

db.users.aggregate([ { $match: { _id: ObjectId("654321abcdef0123456789") } }, { $lookup: { from: "friendships", localField: "_id", foreignField: "user_id", as: "friends" } }, { $unwind: "$friends" }, { $lookup: { from: "posts", let: { friend_id: "$friends.friend_user_id" }, pipeline: [ { $match: { $expr: { $eq: ["$user_id", "$$friend_id"] }, status: 1 } }, { $sort: { created_at: -1 } }, { $limit: 10 } ], as: "friend_posts" } }, { $unwind: "$friend_posts" }, { $replaceRoot: { newRoot: "$friend_posts" } } ])

这种方式在好友数量不多(比如50个以内)的时候表现还行,平均响应时间在150ms左右。但后来我们发现,有些用户的好友数量超过了500个,这个查询的响应时间直接飙到了1.5秒以上。原因很简单,$lookup的非关联子查询虽然灵活,但它是针对每个用户去执行一次子管道的,好友一多,执行的次数就爆炸了。

后来我们重构了这个功能,改用$graphLookup来实现。因为我们的好友关系是单向关注(类似Twitter),其实是一个图结构。$graphLookup可以递归地查找连接节点,非常适合这种场景。

db.users.aggregate([ { $match: { _id: ObjectId("654321abcdef0123456789") } }, { $graphLookup: { from: "friendships", startWith: "$_id", connectFromField: "friend_user_id", connectToField: "user_id", as: "friend_chain", maxDepth: 1 } }, { $unwind: "$friend_chain" }, { $lookup: { from: "posts", localField: "friend_chain.friend_user_id", foreignField: "user_id", as: "posts" } }, { $unwind: "$posts" }, { $match: { "posts.status": 1 } }, { $sort: { "posts.created_at": -1 } }, { $limit: 20 } ])

改完之后,同样是500个好友的用户,查询时间降到了350ms。但$graphLookup也不是万能的。有一次我们想做“二度人脉推荐”(即好友的好友),把maxDepth设成了2,结果查询直接超时。因为$graphLookup在递归查找时,如果图结构复杂,会产生大量的中间数据,内存消耗非常恐怖。

我后来仔细对比了两种方案:

现在我们系统的策略是:一度好友动态用$graphLookup,因为深度固定为1,性能可控;二度人脉推荐我们放弃了实时计算,改成离线任务通过Spark跑,结果存到Redis里。

还有个细节,$graphLookup在MongoDB 8.0里依然不支持在递归过程中使用索引优化中间结果,所以如果你的图很大,一定要控制maxDepth,或者像我们一样,把实时和离线计算分开。

我之前还试过用$lookup嵌套$lookup来实现二度人脉,代码写出来像面条一样,性能更是惨不忍睹。后来换成$graphLookup,代码清爽了很多,但也踩了内存的坑。这两个功能在MongoDB里都是利器,但得看场景选,不能乱用。

AI原生集成实战:利用$vectorSearch与$facet构建RAG推荐系统

去年我们团队接手了一个电商平台的个性化推荐重构项目,当时用的是 MongoDB 7.0,后来升级到 8.0(2024年10月刚发布的版本)后,发现原生向量搜索的性能比之前用第三方插件稳多了。我们那个商品库大概有 1200 万 SKU,之前做相似商品推荐得先把用户行为数据扔给 Python 服务算向量,再回查数据库,整个链路延迟经常飙到 800ms 以上,QPS 上到 200 就扛不住了。

后来我试着用 MongoDB 8.0 的 $vectorSearch 直接做向量检索,结合 $facet 做多维度聚合,把原来的三步走逻辑压缩成一个管道。这里有个细节要注意:$vectorSearch 必须配合 Atlas Search 索引用,我们当时给商品向量字段建索引时,一开始没设 dimensions 参数,结果索引建了半小时还没完成——后来才发现 8.0 版本要求向量维度必须显式声明,我们的商品向量是 384 维,补上之后 2 分钟就建好了。

具体场景是这样的:用户点击一个商品后,我们要同时返回「相似商品列表」「同品类热销 Top10」「关联配件推荐」三个模块。之前是分别查三次数据库,现在用 $facet 在一个管道里并行跑。下面是我们生产环境用的聚合代码(脱敏后):

// 基于用户当前浏览商品的向量,生成推荐结果 db.products.aggregate([ { $vectorSearch: { index: "product_vector_index", path: "embedding", queryVector: [0.12, -0.34, 0.56, /* 省略 380 个维度值 */], numCandidates: 150, // 候选集大小,我们测试过 100-200 之间性能最优 limit: 20, // 最终返回 20 条相似商品 filter: { status: "active", stock: { $gt: 0 } } // 先过滤掉下架和缺货商品 } }, { $facet: { // 第一个维度:相似商品基础信息 "similar_items": [ { $project: { _id: 1, name: 1, price: 1, sales_30d: 1, embedding: 0 } }, { $sort: { sales_30d: -1 } } ], // 第二个维度:同品类热销(基于相似商品的品类做聚合) "category_hot": [ { $group: { _id: "$category_id", total_sales: { $sum: "$sales_30d" } } }, { $sort: { total_sales: -1 } }, { $limit: 10 }, { $lookup: { from: "categories", localField: "_id", foreignField: "_id", as: "category_info" } }, { $unwind: "$category_info" } ], // 第三个维度:关联配件(用 $graphLookup 找层级关联) "related_parts": [ { $graphLookup: { from: "product_relations", startWith: "$_id", connectFromField: "related_product_id", connectToField: "product_id", as: "relations", maxDepth: 1 // 只找直接关联的配件 } }, { $unwind: "$relations" }, { $group: { _id: "$relations.related_product_id", count: { $sum: 1 } } }, { $sort: { count: -1 } }, { $limit: 5 } ] } }, { // 最后把三个结果合并成一个返回结构 $project: { similar_items: 1, category_hot: 1, related_parts: 1, query_time: new Date() // 方便排查缓存问题 } } ])

这里有个坑我当初踩过:$facet 里的每个子管道是并行执行的,但共享同一个初始数据集。我们一开始在 $vectorSearch 之后没加 filter,结果候选集里有 30% 是下架商品,白白浪费了向量计算资源。后来把 filter 提到 $vectorSearch 阶段,候选集的有效率从 70% 升到 98%,整个管道耗时从 620ms 降到了 180ms。

还有个细节:MongoDB 8.0 新增了「聚合表达式索引」,我们给 statusstock 字段建了复合索引,同时把 sales_30d 的聚合结果也塞进索引里,现在 $sort 阶段根本不用临时排序,直接走索引扫描。之前我们担心向量搜索和聚合结合会吃内存,实际跑下来 1200 万数据量下,单个管道的内存占用稳定在 120MB 左右,比之前拆三次查询的总内存还省 30%。

现在这个接口 QPS 能跑到 1200,延迟稳定在 200ms 以内,刚好适配我们 RAG 架构里的实时推荐需求——用户问「有没有类似 XX 的商品」时,向量检索结果直接喂给大模型做总结,不用再中间转一层服务了。

窗口函数与实时ETL:用$setWindowFields解决时序数据痛点

我们公司有个 IoT 设备监控平台,接入了大概 8 万台工业传感器,每分钟上报一次温度、振动、电压这些数据,单表数据量已经到 4.2 亿条了。之前做实时 ETL 的时候,有个需求特别头疼:要算每个设备最近 10 分钟的移动平均振动值,还要标记出连续 3 次超过阈值的异常点。

一开始我用 $group$unwind 做,结果跑一次全量计算要 12 秒,而且内存经常爆——因为 $unwind 会把数组拆成多条文档,8 万台设备 10 分钟数据就是 80 万条,拆完直接变 800 万条,聚合管道直接 OOM。后来升级到 MongoDB 5.0 引入的窗口函数(我们今年才迁到 8.0,窗口函数已经优化得很成熟了),用 $setWindowFields 直接在原文档上算,不用拆数组,耗时直接降到 1.2 秒。

具体场景是这样的:传感器上报的原始数据里有很多噪点,比如电压值偶尔会跳变到 0,其实是传输丢包。我们之前是在应用层做清洗,但 IoT 数据每秒钟进来 1.3 万条,应用层处理不过来,经常丢数据。后来我把清洗逻辑直接放到聚合管道里,用窗口函数算移动平均和异常标记,入库前就把脏数据过滤掉。

下面是我们 ETL 管道的核心代码,现在每天处理 1800 万条传感器数据,稳定运行了 6 个月:

// 处理传感器原始数据,计算移动平均和异常标记 db.sensor_raw.aggregate([ // 第一步先过滤无效数据,减少后续处理量(必须前置,不然窗口函数会算无用数据) { $match: { device_id: { $exists: true }, timestamp: { $gte: ISODate("2024-05-01T00:00:00Z") }, vibration: { $type: "number" } // 过滤掉非数值的脏数据 } }, // 按设备分组,按时间排序,开 10 分钟的滑动窗口 { $setWindowFields: { partitionBy: "$device_id", // 每个设备单独算窗口 sortBy: { timestamp: 1 }, // 按时间升序排列 output: { // 最近 10 分钟的移动平均振动值(窗口范围是当前行往前 10 分钟) "vibration_moving_avg": { $avg: "$vibration", window: { range: [-10, "minute"], // 相对于当前行的时间范围 unit: "minute" } }, // 最近 5 条数据的振动值数组,用于判断连续异常 "recent_vibrations": { $push: "$vibration", window: { documents: [-4, 0] // 当前行 + 前 4 行,共 5 条 } }, // 设备累计运行时长(从首次上报开始算) "total_runtime_minutes": { $sum: 1, // 每一条文档代表 1 分钟,直接累加 window: { documents: [null, 0] // 从分区开头到当前行 } } } } }, // 标记异常点:最近 5 条振动值都超过 80 的算异常 { $addFields: { is_abnormal: { $cond: { if: { $and: [ { $gte: ["$vibration_moving_avg", 80] }, // 移动平均超阈值 // 检查最近 5 条是否都超阈值 { $eq: [{ $size: { $filter: { input: "$recent_vibrations", as: "v", cond: { $gte: ["$$v", 80] } } } }, 5] } ] }, then: true, else: false } } } }, // 过滤掉无效数据,只保留需要入库的字段 { $project: { _id: 0, device_id: 1, timestamp: 1, original_vibration: "$vibration", vibration_moving_avg: 1, total_runtime_minutes: 1, is_abnormal: 1 } }, // 输出到清洗后的表,或者直接写入 Atlas Stream Processing 的下游 { $out: "sensor_cleaned" } ])

这里有个优化点我当初纠结了很久:窗口范围用 range 还是 documents?我们传感器是每分钟上报一次,理论上 10 分钟就是 10 条文档,用 documents: [-9, 0] 也行。但实际场景中偶尔会有上报延迟,比如某个设备 2 分钟才上报一次,用 documents 算出来的移动平均就不准了。后来改成 range: [-10, "minute"],不管上报频率怎么变,都是取最近 10 分钟的真实数据,准确率从 92% 升到了 99.7%。

还有个内存问题:我们一开始没给 timestampdevice_id 建复合索引,管道执行时先做了 COLLSCAN,然后排序,光排序阶段就占了 2.3GB 内存,直接触发了 MongoDB 的 100MB 内存限制(聚合默认内存限制是 100MB,超过要加 allowDiskUse: true)。后来建了 { device_id: 1, timestamp: -1 } 的索引,$match$sort 都能走索引,内存占用降到了 45MB,不用开磁盘溢出了。

现在这个管道每分钟处理 1.2 万条数据,从原始数据入库到清洗完成平均延迟 800ms,之前用应用层处理延迟要 3 秒以上,而且经常因为垃圾回收卡顿丢数据。我们运维同学说,自从用了窗口函数做 ETL,服务器的 CPU 占用从 65% 降到了 28%,省了两台 8 核 16G 的服务器成本。

避坑指南:一次由聚合管道引发的OOM排查与explain执行计划解读

上个月我们线上出了个事故:运营后台的「用户留存分析」接口突然挂了,所有请求都超时,MongoDB 实例的内存占用从 8GB 飙到了 16GB(我们实例是 16GB 内存的,直接打满了),后来查下来就是聚合管道写得有问题。

那个接口的逻辑是:统计最近 30 天每天的新用户数、次日留存率、7 日留存率。数据量是用户行为表有 2.1 亿条记录,每天新增用户大概 12 万。之前的开发同学写了一个三层 $lookup 的管道,先关联用户表,再关联行为表,最后做分组,我当时看到代码的时候就知道要出问题,但没想到这么快就爆了。

先给你们看当时有问题的代码(我已经脱敏了,核心逻辑没变):

// 有问题的聚合管道,直接导致 OOM db.daily_new_users.aggregate([ { $match: { register_date: { $gte: ISODate("2024-04-01"), $lte: ISODate("2024-04-30") } } }, // 第一层 lookup:关联用户基础信息 { $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user_info" } }, { $unwind: "$user_info" }, // 第二层 lookup:关联次日行为数据 { $lookup: { from: "user_actions", let: { uid: "$user_id", reg_date: "$register_date" }, pipeline: [ { $match: { $expr: { $and: [ { $eq: ["$user_id", "$$uid"] }, { $eq: ["$action_date", { $add: ["$$reg_date", 86400000] }] } // 次日 ] } } } ], as: "next_day_actions" } }, // 第三层 lookup:关联7日行为数据 { $lookup: { from: "user_actions", let: { uid: "$user_id", reg_date: "$register_date" }, pipeline: [ { $match: { $expr: { $and: [ { $eq: ["$user_id", "$$uid"] }, { $gte: ["$action_date", { $add: ["$$reg_date", 86400000] }] }, { $lte: ["$action_date", { $add: ["$$reg_date", 604800000] }] } // 7日内 ] } } } ], as: "seven_day_actions" } }, // 分组统计 { $group: { _id: "$register_date", new_user_count: { $sum: 1 }, next_day_retention: { $sum: { $cond: [{ $gt: [{ $size: "$next_day_actions" }, 0] }, 1, 0] } }, seven_day_retention: { $sum: { $cond: [{ $gt: [{ $size: "$seven_day_actions" }, 0] }, 1, 0] } } } } ])

当时我排查的时候第一步就是跑 explain("executionStats"),MongoDB 8.0 的 explain 输出比之前版本详细多了,直接能看到每个阶段的文档处理量和内存占用。下面是当时 explain 的关键输出(我截取了核心部分):

{ "stages": [ { "$_internalLookup": { "nReturned": 3600000, // 30天 * 12万 = 360万,这里没问题 "executionTimeMillis": 1200, "totalKeysExamined": 3600000, "totalDocsExamined": 3600000 } }, { "$unwind": { "nReturned": 3600000, "executionTimeMillis": 800 } }, { "$_internalLookup": { "nReturned": 182000000, // 这里炸了!360万 * 平均50条行为数据 = 1.82亿 "executionTimeMillis": 42000, "totalDocsExamined": 182000000, // 全表扫描了 user_actions 表 "memoryUsageBytes": 10737418240 // 已经用了 10GB 内存 } } ], "executionStats": { "totalDocsExamined": 185600000, "totalKeysExamined": 3600000, "executionTimeMillis": 52000, "memoryLimitExceeded": true // 明确提示内存超限 } }

看到这个输出我瞬间就懂了:第二个 $lookup 没有走索引,user_actions 表的 user_idaction_date 没有建复合索引,导致每个用户都要全表扫描 2.1 亿条行为数据。360 万用户 * 2.1 亿扫描量,内存不爆才怪。

我当时改了两个地方,第一个是给 user_actions 表建了 { user_id: 1, action_date: 1 } 的复合索引,第二个是把三层 $lookup 改成了用 $setWindowFields 算留存,不用关联那么多次。修改后的代码如下:

// 修改后的聚合管道,耗时从 52秒降到 1.8秒,内存占用 60MB db.user_actions.aggregate([ // 先过滤最近30天的注册用户行为(缩小数据范围) { $match: { action_date: { $gte: ISODate("2024-04-01"), $lte: ISODate("2024-05-07") }, // 包含7日留存的时间范围 action_type: "login" // 只算登录行为,减少数据量 } }, // 用窗口函数按用户分区,算每个用户的注册日期和后续行为日期 { $setWindowFields: { partitionBy: "$user_id", sortBy: { action_date: 1 }, output: { "register_date": { $first: "$action_date", // 假设首次登录就是注册日期,实际场景可以关联用户表 window: { documents: [0, null] } // 分区内第一条就是注册日 } } } }, // 过滤出注册日在4月的用户 { $match: { register_date: { $gte: ISODate("2024-04-01"), $lte: ISODate("2024-04-30") } } }, // 计算每个用户的留存情况 { $addFields: { days_since_register: { $divide: [{ $subtract: ["$action_date", "$register_date"] }, 86400000] }, is_next_day: { $eq: ["$days_since_register", 1] }, is_seven_day: { $gte: ["$days_since_register", 7] } } }, // 按注册日分组统计 { $group: { _id: "$register_date", new_user_count: { $addToSet: "$user_id" }, // 用 addToSet 去重算新用户数 next_day_users: { $addToSet: { $cond: ["$is_next_day", "$user_id", null] } }, seven_day_users: { $addToSet: { $cond: ["$is_seven_day", "$user_id", null] } } } }, { $project: { _id: 0, register_date: "$_id", new_user_count: { $size: "$new_user_count" }, next_day_retention: { $size: { $filter: { input: "$next_day_users", as: "u", cond: { $ne: ["$$u", null] } } } }, seven_day_retention: { $size: { $filter: { input: "$seven_day_users", as: "u", cond: { $ne: ["$$u", null] } } } } } } ], { allowDiskUse: false }) // 现在不用开磁盘溢出了

改完之后我再跑 explaintotalDocsExamined 降到了 360 万,executionTimeMillis 是 1800ms,内存占用只有 62MB。这里有个小技巧:explain 有三种模式:queryPlanner(只看执行计划)、executionStats(看实际执行统计)、allPlansExecution(看所有候选计划)。排查性能问题一定要用 executionStats,不然看不到实际扫描的文档数和内存占用,很容易误判。

后来我给团队定了几个聚合管道的规矩:$match 必须放在最前面,能过滤掉 50% 以上数据的条件一定要前置;$lookup 之前必须检查关联字段有没有索引;如果管道里有 $unwind 或者多个 $lookup,一定要先跑 explainnReturned 是不是符合预期。现在我们所有聚合相关的接口上线前都要过一遍 explain 输出,再也没出过 OOM 的问题。

站长实战手记

去年我接了个电商社区的老项目重构,日活刚破百万,原来的 MySQL 连表查询直接把从库 CPU 干到 90%,凌晨的报表跑一次要四十多分钟。我硬着头皮把核心动态流和统计模块迁到 MongoDB 8.0,聚合管道成了主力。

当时最头疼的是用户动态列表,要拉好友关系、点赞数、最新评论,还要按热度排序。我一开始把 $lookup 放最前面,结果一次查询扫了三张千万级集合,慢到接口超时。后来我翻执行计划,发现 $match 还在最后才过滤,等于全表先关联再筛。我把时间范围和状态过滤全部提到管道最前面,又给 user_id + created_at 补了个复合索引,压测时 P99 直接从 2.3 秒掉到 180 毫秒,从库负载也降了一半。

我也试过用 $graphLookup 做粉丝层级扩散,三层以内还行,到了五层以上内存直接爆。最后我换成冗余粉丝列表加 $lookup,虽然冗余一点,但稳得多。至于 $vectorSearch,我只在推荐位这种读多写少的地方用,别在高频写入的主流程里瞎上,索引构建和内存开销会让你后悔。

我现在的看法很直接:聚合管道适合做复杂读取和实时统计,别拿它当 ETL 天天跑全量,也别在简单 CRUD 里硬套。如果数据量小、关联简单,MySQL 或单表查询反而更省心。

学这个别光看文档,一定要自己写个带 $match$group$lookup 的完整管道,再用 explain 看看索引有没有真的生效。只有亲眼看到执行计划里的 IXSCAN,你才知道自己没白忙。