应优先使用 $push 与 $slice 组合维护用户足迹数组,原子性保证最新 N 条;避免先 push 再 pop 或应用层截断;Capped Collection 不适用于用户级足迹,因其不支持按用户隔离且缺乏查询灵活性;高频写入需警惕文档迁移导致的碎片与写放大;时间范围查询应改用独立集合 + 复合索引 + TTL。

用 $push + $slice 维护用户足迹数组,别硬塞满再删
MongoDB 里存最近 N 条浏览记录,最常见做法是字段设为数组,每次用 $push 加新记录,同时用 $slice 限制长度。这不是“模拟栈”,而是原子级裁剪——写入和截断一步完成,不会出现中间态超长。
常见错误是先 $push 再 $pull 或 $pop,结果并发写入时漏删、重复删、甚至把整个数组清空。也有人想用应用层判断长度再决定是否删老数据,这既慢又不一致。
-
db.users.updateOne({ _id: userId }, { $push: { history: { $each: [newRecord], $slice: -50 } } })—— 保留最新 50 条,负数表示从尾部截取 - 注意
$slice必须和$each同级,单独写$push: { history: newRecord }再加$slice无效 - 数组字段必须已存在(哪怕为空数组),否则首次插入会失败;建模时建议初始化
history: []
别用 Capped Collection 存用户级足迹,它不支持按用户隔离
Capped Collection 看似“自动淘汰旧数据”,但它是整个集合级别 FIFO,所有文档共用一个写入顺序。你没法让张三的足迹只影响张三的容量,李四的操作可能把张三刚写的记录顶掉——这不是“用户足迹”,这是“全站乱序日志池”。
除非你在做全站最近 10 万次访问快照(且不要求归属到具体用户),否则 Capped Collection 在足迹场景下是误用。它连 find() 都不能带复杂查询条件,sort() 和 skip() 效率极差,还禁用 $set 更新已有文档。
- Capped Collection 的
size是字节上限,不是文档数上限;文档大小波动大会导致实际条数不稳定 - 无法创建二级索引(除
_id外),查某用户最近 10 条得全表扫 - 一旦集合满,新插入直接覆盖最老文档,且无任何回调或通知机制
当足迹要查时间范围或带筛选条件时,数组方案立刻变重
如果只是展示“最近看过什么”,数组 + $slice 足够。但一旦要查“昨天看过的商品”“3 天内未重复的类目”,数组就扛不住了:MongoDB 无法高效在数组子元素上建带时间范围的复合索引,$elemMatch 只能加速存在性判断,对范围查询帮助有限。
这时该切回普通集合,用 userId + timestamp 建复合索引,并配合 TTL 索引自动过期(比 Capped 更可控)。
-
db.user_history.createIndex({ userId: 1, timestamp: -1 }, { name: "user_time" })支持按用户+时间倒序分页 -
db.user_history.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })让 MongoDB 自动删过期文档 - TTL 删除是后台线程异步执行,延迟通常在 60 秒内,比手动定时任务更轻量
数组方案的隐含成本:文档增长与 WiredTiger 页面分裂
每次 $push + $slice 都是原地更新(in-place update),但如果数组扩容后超出原存储页大小,WiredTiger 会触发文档迁移——复制整份数据到新位置,旧空间标记为可复用。高频足迹写入下,这会导致写放大和碎片。
实测中,单文档超过 4MB(MongoDB 文档上限)不是问题,但频繁迁移会让 oplog 增大、副本同步延迟上升,尤其在副本集 secondary 节点资源紧张时。
- 预估单条足迹平均 200 字节,50 条约 10KB,安全;但若混入截图 base64 或完整 referer,单条破 KB 就得警惕
- 避免在足迹数组里存冗余字段,比如重复存
userName;只留必要 ID 和时间戳,详情走关联查询 - 如果发现
db.collection.stats().wiredTiger.block-manager.fileSize明显大于dataSize,说明碎片已高
$slice;Capped 只留给审计日志、埋点缓冲这类不要求语义、只要顺序的场景。










