副本集下留存率易算错,因读取延迟节点或日期解析时区不一致导致数据偏差;需强制 readpreference=primary、统一时区为utc、显式指定$datefromstring格式、前置$match并建索引、慎用$datediff单位。

MongoDB 7.0 副本集本身不改变聚合管道的语法或逻辑,但会影响执行稳定性、读写偏好和时钟一致性——留存率统计若跨节点读到未同步的 login 数据,结果会偏低;必须显式控制读取行为,并确保 register_date 和 date 字段在所有节点上格式统一。
为什么副本集下留存率容易算错
副本集默认读取主节点,但若应用配置了 readPreference=secondary 或启用了读写分离中间件,可能从延迟数秒甚至数分钟的从节点读取 login 行为日志,导致「当天注册、当天登录」被漏计为「次日留存」;更隐蔽的问题是:不同节点系统时区或 $dateFromString 解析失败时,"2026-09-01" 可能被转成 ISODate("2026-09-01T00:00:00.000Z") 或 ISODate("2026-08-31T16:00:00.000Z"),$dateDiff 计算结果直接偏移 1 天。
- 始终在应用层强制指定
readPreference=primary,避免从节点读取行为日志 - 所有日期字符串字段(如
register_date、date)必须显式用$dateFromString并带format: "%Y-%m-%d",不能依赖默认解析 - 检查副本集各节点
db.runCommand({getCmdLineOpts: 1})中的timezone配置是否一致,不一致就统一设为 UTC
聚合管道中必须前置 $match 并对齐索引
副本集不缓解低效聚合带来的主节点压力——全表 $group 会拖慢整个集群的写入吞吐。留存率统计通常只关心最近 30 天注册用户,必须把时间过滤压到第一个阶段,且对应字段要有索引。
-
$match阶段必须放在最前,例如:{ $match: { register_date: { $gte: "2026-08-08" } } } - 在
retention.register集合上建索引:db.retention.register.createIndex({ register_date: 1 }) - 在
retention.login集合上建复合索引:db.retention.login.createIndex({ register_date: 1, date: 1 }),让$lookup或后续$match能走索引扫描 - 避免在
$group后再$match留存天数(如{ days_since_register: { $eq: 1 } }),这会导致内存膨胀;应先算出days_since_register,再用$bucketAuto或嵌套$group分桶
用 $dateDiff 算天数时必须处理时区与精度
MongoDB 7.0 支持 $dateDiff,但它按自然日计算,不是按 24 小时滚动窗口。如果注册发生在 9 月 1 日 23:59,登录在 9 月 2 日 00:01,$dateDiff 返回 1 —— 这符合「次日留存」定义;但若你业务要求「注册后 24 小时内登录才算当日回访」,就不能用 $dateDiff,得改用 $subtract 算毫秒差再除以 86400000 并 $trunc。
- 自然日留存(推荐):
{ $dateDiff: { startDate: { $dateFromString: { dateString: "$register_date", format: "%Y-%m-%d" } }, endDate: { $dateFromString: { dateString: "$date", format: "%Y-%m-%d" } }, unit: "day" } } - 严格 24 小时窗口(慎用):
{ $trunc: { $divide: [ { $subtract: [ { $dateFromString: { dateString: "$date" } }, { $dateFromString: { dateString: "$register_date" } } ] }, 86400000 ] } } - 无论哪种,都必须确保
$dateFromString的format参数存在,否则字符串日期(如"2026-09-01")可能被当作 ISO 格式解析失败,返回null,整条文档被丢弃
副本集不会自动帮你校准时间或修复字段格式,留存率偏差往往来自日期解析链路中某个环节的静默失败——建议在聚合开头加 $addFields + $cond 检查 register_date 是否可解析,不可解析的文档打上标记,而不是直接过滤掉。











