留存率是某日新增用户在后续第N天仍有行为的占比,需用$group分两步统计:先按注册日聚合新增量,再按注册日与行为日差值聚合回访量;关键要统一日期格式、预存register_date字段、用$dateDiff计算天数,并通过嵌套$group实现分桶统计。

用 $group 阶段分层统计新增与回访用户数
留存率本质是「分母(某日新增)」和「分子(该新增群体在后续第N天仍有行为)」的比值。MongoDB 里不能直接跨日期 join,得靠 $group 拆成两步:先按注册日聚合出新增量,再按注册日+行为日差值聚合出回访量。
关键点在于:必须把用户注册时间(register_date)和每次行为时间(login_date)都归一为字符串格式的日期(如 "2026-05-01"),否则无法用 $dateDiff 或字符串操作算间隔。
- 注册表(
retention.register)只存account_id和date(注册日) - 行为表(
retention.login)需额外存register_date字段,且和注册表中一致 - 两个集合都要对
date和register_date建索引,否则$lookup或后续$match极慢
用 $dateDiff 计算行为距注册的天数(MongoDB 5.0+)
旧版靠 $subtract 手动转毫秒再除 86400000 容易出浮点误差,且不直观。5.0 起推荐直接用 $dateDiff,它能自动处理时区、闰秒、月末等边界。
示例阶段:
{
$addFields: {
"days_since_register": {
$dateDiff: {
startDate: { $dateFromString: { dateString: "$register_date" } },
endDate: { $dateFromString: { dateString: "$date" } },
unit: "day"
}
}
}
}-
$dateFromString必须显式指定格式(如format: "%Y-%m-%d")才能解析字符串日期,否则默认只认 ISO 格式 -
unit: "day"返回的是完整自然日差,不是 24 小时整,符合业务定义(比如 5月1日注册、5月2日00:01登录,days_since_register= 1) - 若用
$subtract,务必确保两个时间都已转为Date类型,且用$trunc截断小时分钟,否则次留可能漏掉当天早于注册时刻的登录
多阶段 $group 实现“同注册日用户 → 按留存天数分桶”
单个 $group 无法同时产出新增总数和各日留存数,必须嵌套或流水线式分组。推荐以下结构:
[
{ $match: { "date": { $gte: "2026-05-01", $lt: "2026-05-08" } } },
{ $addFields: { /* 上面的 days_since_register */ } },
{ $match: { "days_since_register": { $gte: 0, $lte: 30 } } },
{ $group: {
_id: { register_date: "$register_date", day_offset: "$days_since_register" },
count: { $sum: 1 }
}
},
{ $group: {
_id: "$_id.register_date",
new_users: { $sum: { $cond: [{ $eq: ["$_id.day_offset", 0] }, "$count", 0] } },
day1: { $sum: { $cond: [{ $eq: ["$_id.day_offset", 1] }, "$count", 0] } },
day3: { $sum: { $cond: [{ $eq: ["$_id.day_offset", 3] }, "$count", 0] } },
day7: { $sum: { $cond: [{ $eq: ["$_id.day_offset", 7] }, "$count", 0] } }
}
}
]- 第一个
$group按(register_date, day_offset)组合计数,这是原子粒度,不可省略 - 第二个
$group再按register_date合并,用$cond把不同day_offset的计数投到对应字段 —— 这比用$pivot更可控,也兼容老版本 - 如果要算 30 日留存但数据只覆盖最近 7 天,
day30字段会是 0,但分母new_users仍存在,此时留存率 = 0 / new_users = 0,逻辑正确
性能陷阱:$lookup 关联注册与行为表不如预存 register_date
有人试图用 $lookup 在行为表中动态关联注册时间,这在千万级日志下必然超时。原因有三:
-
$lookup是左连接,行为表每条记录都要查一次注册表,IO 放大 N 倍 - 即使注册表有
account_id索引,高并发下锁竞争严重 - 无法利用
register_date索引做范围扫描,$match阶段只能走 collection scan
真正可行的做法是在写入行为日志时,就从用户资料或注册事件中带出 register_date,固化为字段。这样整个聚合全程走内存 + 索引,1000 万行日志通常 3 秒内完成。
最易被忽略的一点:register_date 和 date 字段类型必须都是字符串(且格式统一)或都是 Date,混用会导致 $dateDiff 返回 null,后续所有 $group 统计全丢弃。上线前务必用 db.retention.login.findOne() 实际检查字段类型。


















