从百万日活社区重构看聚合管道:为什么我们弃用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,后来我们针对status和created_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秒:
- 无索引 + 错误顺序:QPS 12,平均延迟 4200ms
- 有索引 + 错误顺序:QPS 18,平均延迟 2800ms(因为
$addFields阻断了索引利用)
- 有索引 + 正确顺序(先
$match):QPS 135,平均延迟 220ms
数据不会骗人。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在递归查找时,如果图结构复杂,会产生大量的中间数据,内存消耗非常恐怖。
我后来仔细对比了两种方案:
- $lookup非关联子查询:适合关系明确、数量可控的场景。它的好处是逻辑清晰,子管道里可以做各种复杂的过滤和排序。坏处是如果关联数量多,性能会线性下降。
- $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 新增了「聚合表达式索引」,我们给 status 和 stock 字段建了复合索引,同时把 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%。
还有个内存问题:我们一开始没给 timestamp 和 device_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_id 和 action_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 }) // 现在不用开磁盘溢出了
改完之后我再跑 explain,totalDocsExamined 降到了 360 万,executionTimeMillis 是 1800ms,内存占用只有 62MB。这里有个小技巧:explain 有三种模式:queryPlanner(只看执行计划)、executionStats(看实际执行统计)、allPlansExecution(看所有候选计划)。排查性能问题一定要用 executionStats,不然看不到实际扫描的文档数和内存占用,很容易误判。
后来我给团队定了几个聚合管道的规矩:$match 必须放在最前面,能过滤掉 50% 以上数据的条件一定要前置;$lookup 之前必须检查关联字段有没有索引;如果管道里有 $unwind 或者多个 $lookup,一定要先跑 explain 看 nReturned 是不是符合预期。现在我们所有聚合相关的接口上线前都要过一遍 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,你才知道自己没白忙。