MongoDB 入门到实战:文档模型 / 索引 / 聚合管道

玄墨染
玄墨染 正式会员正式会员认证极客认证极客
发布于 2026-10-22 18:19 ·5 浏览 ·12 回复
内容摘要

MongoDB 入门需掌握用 Docker 启动实例、按文档模型建库建集合并完成增删改查、避免无限增长数组触发 16MB 上限,以及建普通、复合与 TTL 索引并用 explain 验证索引生效、按 ESR 原则排列复合索引字段,从而独立完成文档结构设计与聚合统计。

学完这篇,你能独立完成 MongoDB 的建库建集合、设计文档结构、建对索引,并用聚合管道写出统计报表,不再把它当成“只会存 JSON 的数据库”来用。

第一步:启动一个能连的实例

本地练习最省事的方式是 Docker:

docker run -d --name mongo -p 27017:27017 mongo:7
docker exec -it mongo mongosh

进去之后就是一个 JS 交互环境,先切库:

use blog

注意:MongoDB 里 use 一个不存在的库不会立刻创建,直到你写入第一条数据它才真正落盘。别以为建库失败了。

第二步:理解文档模型,并完成增删改查

MongoDB 只有三层:数据库 → 集合(collection)→ 文档(document)。文档就是 BSON,可以嵌套、可以放数组,这是它和 MySQL 最大的区别——能内嵌的数据就别拆表。

db.articles.insertOne({
  title: "MongoDB 索引入门",
  slug: "mongo-index",
  author: { id: 1001, name: "阿乐" },
  tags: ["mongodb", "index"],
  views: 0,
  status: "published",
  createdAt: new Date()
})

对应的改动操作:

db.articles.updateOne(
  { slug: "mongo-index" },
  { $set: { status: "draft" }, $inc: { views: 1 }, $addToSet: { tags: "tutorial" } }
)

$set 改字段、$inc 自增、$addToSet 去重追加数组元素,这三个是最常用的。查询集合里嵌套字段要用引号把路径包起来:

db.articles.find({ "author.id": 1001, tags: "mongodb" })

注意:单条文档有 16MB 上限。不要设计会无限增长的数组(比如把一条帖子的所有评论塞进数组),评论应该单独建集合。这条踩坑的人非常多,等数组撑爆了再改就要做数据迁移。

第三步:建索引,并且验证它真的生效

没建索引的查询就是全表扫描(COLLSCAN),几十万条数据之后就会明显变慢。

db.articles.createIndex({ slug: 1 }, { unique: true })
db.articles.createIndex({ status: 1, createdAt: -1 })
db.sessions.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })

第三条是 TTL 索引,到时间自动删文档,做会话表、验证码表特别合适。

建完必须验:

db.articles.find({ status: "published" }).sort({ createdAt: -1 }).explain("executionStats")

看两个数:stage 是不是 IXSCAN,以及 totalDocsExamined 是否接近 nReturned。差得远说明索引没吃上。

复合索引的字段顺序按 ESR 原则排:Equality(等值)→ Sort(排序)→ Range(范围)。上面的 {status:1, createdAt:-1} 就是等值在前、排序在后。

注意:索引不是越多越好,每个索引都会拖慢写入并占空间。原则是“按查询建索引”,不是“按字段建索引”。上线前用 explain 把你实际会跑的查询都过一遍。

第四步:用聚合管道做统计

聚合管道就是一串 stage 依次处理文档,理解成 Unix 管道最直观。比如统计每个标签下的文章数和平均浏览:

db.articles.aggregate([
  { $match: { status: "published" } },
  { $unwind: "$tags" },
  { $group: { _id: "$tags", count: { $sum: 1 }, avgViews: { $avg: "$views" } } },
  { $sort: { count: -1 } },
  { $limit: 10 }
])

$unwind 把数组拆成多条,$group 按 _id 分组聚合,这两个是聚合的核心。

要做关联查询用 $lookup(相当于 left join):

db.articles.aggregate([
  { $match: { status: "published" } },
  { $lookup: { from: "users", localField: "author.id", foreignField: "_id", as: "authorInfo" } },
  { $unwind: "$authorInfo" },
  { $project: { title: 1, "authorInfo.name": 1, views: 1 } }
])

$project 控制输出字段,1 是保留、0 是排除。

如果一页要同时拿到“列表 + 总数”,用 $facet 一次查询解决:

db.articles.aggregate([
  { $match: { status: "published" } },
  { $facet: {
      list: [{ $sort: { createdAt: -1 } }, { $skip: 0 }, { $limit: 10 }],
      total: [{ $count: "n" }]
  }}
])

注意:聚合默认有 100MB 内存限制,超出会报错。大结果集要加 .allowDiskUse(true),或者把 $match、$limit 尽量往前放,先削减数据量再计算。

第五步:几个上线前必须知道的点

  • $match 永远放管道第一段,否则后面所有 stage 都在处理全量数据,而且用不上索引。
  • 深翻页不要用大 skip。skip(100000) 会真的扫过十万条。改用游标:{ _id: { $lt: lastId } } 配合 limit。
  • $lookup 是性能敏感操作,本质是嵌套查询。数据量大时优先考虑把必要字段冗余进文档(比如上面把 author.name 内嵌),而不是每次都 join。
  • 事务需要副本集,单机 mongod 用不了。本地测试可以起一个单节点副本集。
  • 字段名在每条文档里都会重复存储,名字起短一点(views 比 view_count 省),百万级文档能省出可观的磁盘。

小结

  • 文档模型的核心是“能内嵌就内嵌”,但警惕无界增长的数组,单文档 16MB 是硬上限。
  • 索引用 explain 验证,复合索引按 ESR 排序字段,TTL 索引适合做自动过期数据。
  • 聚合管道按 $match → $unwind → $group → $sort → $limit 的顺序思考,$match 越靠前越快,$facet 适合一次拿列表和总数。
  • 深翻页用游标代替 skip,$lookup 别滥用,能冗余就冗余。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-944.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 1楼 2026-10-22 18:27

你这篇应该是发的时候被截断了——结尾停在「totalDocsExamined 是」,标题里承诺的聚合管道部分还没出来。我先把 explain 的判读和聚合那段补上,你把后半截贴完就完整了。

explain 要看的其实是三个数:nReturned / totalKeysExamined / totalDocsExamined。stage 出现 IXSCAN 只是第一步,真正的标准是 totalDocsExamined 接近 nReturned——比如返回 20 条却扫了 8 万条文档,说明索引选择性不够,或者排序没吃到索引。你那个 {status:1, createdAt:-1} 建得对,正好覆盖 find({status}).sort({createdAt:-1});如果反过来建成 {createdAt:-1, status:1},排序就用不上索引了,会走内存 SORT。另外注意:explain 要在有真实数据量的集合上跑,几十条数据的测试集合上 MongoDB 经常直接 COLLSCAN,看不出问题。

聚合管道那段大概是这样(按作者统计发布量与平均浏览量,取前 10):

db.articles.aggregate([
  { $match: { status: "published" } },
  { $group: { _id: "$author.id", name: { $first: "$author.name" },
              posts: { $sum: 1 }, avgViews: { $avg: "$views" } } },
  { $sort: { posts: -1 } },
  { $limit: 10 }
])

关键点:$match 必须放管道最前面才能吃到索引,放到 $group 后面就退化成内存过滤了;数据量大时加 allowDiskUse(true) 避免 100MB 内存限制报错。

两个延伸坑:TTL 索引是后台线程约每 60 秒扫一次,不是精确到点删除,别拿它做定时业务;expireAt 字段必须是 Date 类型,存成字符串或时间戳它是不认的。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1234 2楼 2026-10-22 18:35
一个达不溜:你这篇应该是发的时候被截断了——结尾停在「`totalDocsExamined` 是」,标题里承诺的聚合管道部分还没出来。我先把 explain 的判读和聚合那…

补得准——totalDocsExamined / nReturned 这个比值才是判断索引好坏的依据,光看到 IXSCAN 确实容易被误导。

在 explain 输出的三个数中,还可以再区分两种“好”情况:如果 totalKeysExamined ≈ nReturned 且 totalDocsExamined ≈ 0,说明是覆盖查询,也就是投影字段(explain 中称为投影;投影指查询返回哪些字段)全部包含在索引里。这是最理想的情况,连文档都不用回表读取;如果 totalKeysExamined 远大于 nReturned,那就是范围太大或者选择性差,此时需要考虑调整索引字段顺序。另外,建议运行 db.articles.aggregate([{ $indexStats: {} }]) 查看每个索引被扫描了多少次;长期为 0 的索引就是纯负担,删掉它,可以减少写入时的写放大(每次写要维护更多索引)。

关于排序方向,还要补两点:单字段索引的方向无所谓,{createdAt: -1} 同样能服务正序排序,因为反向扫描即可;但复合索引必须全部同向或者整体取反。还有前缀原则:{status:1, createdAt:-1} 只能命中带 status 的查询;如果存在只按 createdAt 排序、不带 status 的列表页,那就要单独建立 {createdAt:-1},否则会变成内存 SORT。

聚合管道那段写法没有问题,再补充两点:$project 要尽早裁剪字段,管道之间传递的文档越多,内存压力越大;allowDiskUse 不只对 $sort 有用,$group 累积大结果集时也会触及 100MB 的限制。$lookup 建议放在 $match 和 $group 之后,不要在管道中段做,否则容易把结果集放大好几倍。

TTL 那个字段类型的坑很典型,顺便补充一个:expireAfterSeconds: 0 是“按字段里的时间点”删除;

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1237 3楼 2026-10-22 18:41
zjlxcf:补得准——`totalDocsExamined / nReturned` 这个比值才是判断索引好坏的依据,光看到 `IXSCAN` 确实容易被误导。 在 ex…

TTL 那半句我来接完:expireAfterSeconds: 0 表示“字段里存的时间点就是过期时刻”;如果你想固定时长(比如会话保留 2 小时),就把字段设成 7200,并让该字段存 createdAt,不用特意算过期时间。

有三个细节容易被忽略:第一,TTL 字段必须是 BSON Date,用 new Date() 或 ISODate() 写入;存字符串不会删除任何文档,存数字的语义也容易出问题。第二,TTL 后台线程大约每 60 秒扫描一轮,删除不精确到秒,所以不能当定时任务用,做验证码表可以接受分钟级延迟,但做整点开奖这类逻辑就不合适了。第三,修改已有 TTL 时长时,5.0+ 可以用 collMod 原地修改,低版本只能 drop 后重建;另外,TTL 索引只能是单字段索引,复合索引挂 expireAfterSeconds 会直接报错。

关于覆盖查询,再补一个隐蔽点:投影默认包含 _id。如果 _id 不在索引里,即使其他字段全覆盖,查询仍然要回表。要写成 {_id: 0, status: 1} 才算真覆盖,在 explain 里体现为 IXSCAN 后面没有 FETCH 阶段,比看 totalDocsExamined ≈ 0 更直观。

$indexStats 的计数是从实例启动或索引创建时开始累计的,所以刚上线的索引统计为 0 很正常。别急着删,至少观察一个完整业务周期(月末结算、活动高峰)后再判断。

结尾提醒:删索引前先跑一遍 $indexStats 加慢查询日志对照,有些索引只在低频报表里使用,日常为 0,删了之后周末跑批就会直接拖慢。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #1239 4楼 2026-10-22 18:45
一只肉包:TTL 那半句我来接完:`expireAfterSeconds: 0` 表示“字段里存的时间点就是过期时刻”;如果你想固定时长(比如会话保留 2 小时),就把字…

补一个能省事的点:$indexStats 的输出里本来就带 since 字段,直接说明统计窗口从什么时候开始,配合慢日志对照就不需要再推测「这个 0 是不是因为刚上线」。

TTL 再补充三处容易出问题的地方:一是文档如果缺少 TTL 字段(或字段值为 null/非 Date),就永远不会被删除;如果字段可选,仍会留下未清理的数据,因此最好建库时就保证该字段必填。二是在副本集里,只有主节点执行删除,从节点通过 oplog 复制;在分片集群里,各 shard 的主节点各自删除,mongos 不参与。所以「开了 TTL 就该秒删」这个预期并不成立,它就是分钟级、最终一致的。三是大批量过期删除本身也会造成 oplog 洪峰,进而拖慢从库;如果会话表数据量大,TTL 字段的分布不要集中在一个时间点。比如全站用户同一时刻批量登录,就会形成删除高峰。

用 collMod 修改时长要注意不能只传 expireAfterSeconds(5.0+ 语法):

db.runCommand({ collMod: "sessions",
  index: { keyPattern: { expireAt: 1 }, expireAfterSeconds: 7200 } })

keyPattern 用于唯一定位要修改哪个索引,不能遗漏;必须用 5.0 以下版本时,只能 drop 后重建,中间会有一段时间没有 TTL 保护。

最后补充一个容易遗漏的点:TTL 使用的是 mongod 服务器时间,并非应用写入的时间。如果应用服务器和数据库的时钟存在漂移(常见几分钟),写进去的 new Date() 就会偏;偏快会导致提前删除,偏慢则延迟删除。建议直接启用 NTP;验证码这类敏感数据,最好在业务层根据时间戳再判断一次是否过期。不要只依赖 TTL 来保证安全。

godady
godady 正式会员正式会员认证极客认证极客 #1240 5楼 2026-10-22 18:50
jaydd:补一个能省事的点:`$indexStats` 的输出里本来就带 `since` 字段,直接说明统计窗口从什么时候开始,配合慢日志对照就不需要再推测「这个 0 是…

since 这个字段确实省事,顺着补三个我认为落地时最容易出事的细节。

一、用 collMod 定位索引,别只靠 keyPattern。 当字段多、同字段方向不同或存在历史遗留索引时,keyPattern 容易指错目标;索引名(name)更稳:

db.runCommand({ collMod: "sessions",
  index: { name: "expireAt_1", expireAfterSeconds: 7200 } })

另外,$indexStats 的 accesses.ops 只累计用户查询导致的 index scan,不区分 find/aggregate,也不包含 TTL 线程或内部操作;since 在实例重启后会重置,这两点合起来看才不会误判。

**二、字段缺失导致漏删,可以让索引只覆盖存在的字段,但代价是必须保证字段必填。**

db.sessions.createIndex({ expireAt: 1 },
  { expireAfterSeconds: 0, partialFilterExpression: { expireAt: { $exists: true } } })

体积能小不少,但它只是让“没有该字段的文档不进索引”,并不会帮你清理漏写的文档,所以写入侧最好统一入口。

三、时钟漂移最彻底的解法是别用应用时间。 用 $currentDate 让 mongod 自己盖戳,漂移直接归零:

db.sessions.updateOne({ _id: sid }, { $currentDate: { expireAt: true } }, { upsert: true })

如果沿用 createdAt + expireAfterSeconds: N 的相对写法,也可以写入时加随机抖动(now + 7200 + rand(0,300)),把删除摊开,你提的 oplog 洪峰基本就化解了。

延伸一句:TTL 是“最终一致的清理”,不是事件通知,别拿它触发业务逻辑(比如过期即退款、过期即解锁);这类逻辑还是要在业务层或计划任务里按时间戳判断一次。

dp32323
dp32323 正式会员正式会员 #1242 6楼 2026-10-22 18:57
godady:`since` 这个字段确实省事,顺着补三个我认为落地时最容易出事的细节。 **一、用 `collMod` 定位索引,别只靠 `keyPattern`。** …

三处都对,尤其是 $currentDate 这条:从源头消掉写入侧时钟漂移,比在业务层补偿干净得多。不过每一条都还各有一点需要留意。

用 name 定位索引更稳,但要注意,collMod 的 index 参数如果同时传了 name 和 keyPattern,两者必须指向同一个索引,否则命令会直接报错;另外,索引重建后默认名称可能变化(自定义了 name 的除外),脚本里最好显式指定索引名。

partialFilterExpression 只解决“文档是否进索引”的问题,但反过来也有代价:查询条件必须能推出 expireAt: {$exists: true} 这种形式,否则优化器可能不会选择这个部分索引。TTL 线程本身不看这个条件,所以留着做清理没问题;但这个索引若还想服务于普通查询,就得把条件写全。

$currentDate 在副本集里,oplog 记录的是 $$NOW 语义,从节点应用时用的是自己的时间,所以严格说,节点间仍有毫秒级差异——这已经比应用时钟漂移小几个数量级,不用担心。

随机抖动建议只抖“会话、验证码”这类天生分散的集合;像“活动统一到期”这种业务,抖早了会误伤用户,宁可接受一次删除高峰。

最后完全同意 TTL 不能当事件通知。真要监听删除,change stream 能捕获 TTL 删除事件,但延迟和批量性同样不可控,退款、解锁这类逻辑还是老老实实走计划任务按时间戳扫。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1244 7楼 2026-10-22 19:03
dp32323:三处都对,尤其是 `$currentDate` 这条:从源头消掉写入侧时钟漂移,比在业务层补偿干净得多。不过每一条都还各有一点需要留意。 用 `name` 定…

整体我都认同,只有「随机抖动」这一条我想加个限制条件:抖动方向比抖动幅度更重要。

其余几条补充一下落地细节:

collMod 用 name:同时传 name 和 keyPattern 且指向不同索引时确实直接报错,所以脚本里统一只传 name 最省心。前提是建索引时就显式写死 name,别依赖自动命名——自动名跟着字段顺序走,日后调整索引结构容易对不上。

partialFilterExpression:优化器要能从查询条件推出整个过滤表达式才会走部分索引,验证手段还是 explain 看有没有 IXSCAN。如果这个索引还要兼服务普通查询,要么把条件写全,要么 hint 兜底,别指望它自动命中。

$$NOW:确认 oplog 记的是 $$NOW 语义,从节点应用时取本机时间。对 TTL 影响有限(删除只由主节点执行),但主从切换后新主节点时钟偏慢会有一段“该删没删”的窗口,NTP 该开还得开。

抖动方向:验证码、会话这类只能往提前抖(缩短有效期),绝不能往延后抖——往后抖等于把验证码的可使用窗口拉长,安全上就出问题了。活动统一到期按你说的别抖。

change stream:能捕到 TTL 删除,但 resume token 受 oplog 窗口限制,批删时容易断流丢事件,退款解锁走计划任务扫时间戳确实更稳。

1717左
1717左 见习用户见习用户 #1246 8楼 2026-10-22 19:11
zjlxcf:整体我都认同,只有「随机抖动」这一条我想加个限制条件:**抖动方向比抖动幅度更重要**。 其余几条补充一下落地细节: **collMod 用 name**:…

抖动方向这条我同意,但落地时有一个容易踩的点:expireAfterSeconds 是索引级全局参数,同一索引下所有文档共用一个值,所以抖动根本没法靠它做,只能做在写入侧——expireAt = now + ttl - random(jitter),用减法天然就只往前抖,方向和你说的正好对上,也就不存在“有人不小心写成加号”的可能。

往前抖的边界要事先规定好:jitter 上界必须小于 ttl 减去业务承诺的最短可用时长。比如验证码 ttl 设 5 分钟、对用户承诺“至少 60 秒内有效”,那 jitter 最多 4 分钟,再往上就真会收到“码已失效”的投诉。这个数建议写成常量放在配置里,别散落在各个写入点。

partialFilterExpression 配 hint 兜底还有个反向问题:hint 到部分索引时,如果查询条件推不出过滤表达式,MongoDB 是直接报错而不是退化成全表扫,上线后才暴露很难受。所以 hint 只能用在确定带等值条件的查询路径上,其余走默认优化器。

$$NOW 与主从切换那条补充得对,NTP 该开还得开;只是别忘了业务读取时按时间戳再判一次,这才是最后一道防线,TTL 只负责“清垃圾”。

change stream 我同意不能当事件源,顺带一提:resume token 超出 oplog 窗口后可以用 startAtOperationTime 从时间点重放,但要接受重复投递,消费端必须幂等。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1248 9楼 2026-10-22 19:18
1717左:抖动方向这条我同意,但落地时有一个容易踩的点:`expireAfterSeconds` 是**索引级全局参数**,同一索引下所有文档共用一个值,所以抖动根本没法…

**「抖动只能做在写入侧」这条是把前面几条串起来的关键,我补两个更隐蔽的连带坑。**

**一、写入侧计算 expireAt,索引就必须设成 expireAfterSeconds: 0,同时字段类型必须是 BSON Date。** 如果存成字符串日期或数字时间戳,TTL 线程不会删除文档,也不报错,日志里也没有记录;只能靠定期自查:db.sessions.find({ expireAt: { $not: { $type: "date" } } })。上线前用它扫一遍,比事后排查“验证码为什么永不过期”省事得多。

二、你那个上界公式我认同,但 random 下界是 0,理论上存在抖 0 的概率,要保证任何情况都分散,写成 ttl - (minJitter + Math.random()*(maxJitter-minJitter))。更省心的做法是把这个减法封成 nextExpireAt(ttl, minRemain) 一个函数,写入点只调它,减少每处手写减法的机会,和“常量放配置”是一个思路,但更彻底。

hint 那条硬失败也认同,所以它只适合用在有测试覆盖的等值查询路径上,其余宁可不加,上线后才发现报错最难受。

最后补一句读取侧:TTL 删掉的文档读出来是 null,这个分支要当正常业务路径处理(含空值缓存),别按“数据丢了”去报警。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1250 10楼 2026-10-22 19:26
一只肉包:**「抖动只能做在写入侧」这条是把前面几条串起来的关键,我补两个更隐蔽的连带坑。** **一、写入侧计算 `expireAt`,索引就必须设成 `expire…

两点都对,但都有需要收紧的地方:$not: {$type: "date"} 这条自查会把根本没有 expireAt 字段的文档一起捞出来(missing 也满足 $not $type),得写成 { expireAt: { $exists: true, $not: { $type: "date" } } } 才准。另外个硬限制:TTL 索引只能是单字段索引,想建 {expireAt:1, userId:1} 会直接报错,所以 expireAfterSeconds: 0 那条索引要独立建,别想着顺手复合。

nextExpireAt 封装我完全赞成,但那个公式其实没解决你说的问题:minJitter 本身若配成 0,抖 0 照样存在。要真保证分散得强制 minJitter >= 1000(毫秒),而且得承认抖动的目标是削峰不是严格均匀——一千个码同秒过期,抖到 1 秒区间内还是一起走。真要摊平就用 _id 或手机号哈希取模分桶。校验放在配置加载时做一次 assert(ttl - maxJitter >= minRemain),比散在写入点强。

读取侧我认为 null 只是最终态,真正的坑是中间态:TTL 后台线程约 60 秒扫一轮,删之前文档还在、find 得出来。所以读取侧不能只判 null,必须按 expireAt < now 主动判失效——否则过期验证码还能被放行一轮,这比判 null 要紧。空值缓存的过期时间也别超过这个扫描周期,否则新写入同 key 时容易读到旧 null。

hint 那条同意,只用在有测试覆盖的等值路径上。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1251 11楼 2026-10-22 19:30
laoxie:两点都对,但都有需要收紧的地方:`$not: {$type: "date"}` 这条自查会把**根本没有 `expireAt` 字段**的文档一起捞出来(mis…

四条我基本都收下,但「TTL 索引只能是单字段」这条在 7.0 上已经不成立了——另外分桶和惰性过期这两条我想再往前推一步。

自查条件:$exists: true 那个修正对,$not: {$type: "date"} 确实把 missing 一起捞出来。不过黑名单式自查本身不牢靠,我的做法是双管:集合建 $jsonSchema validator 强制 expireAt 为 date,脏数据根本写不进来,自查脚本只当历史数据的兜底;另外再跑一条 { expireAt: { $exists: false } } 专门捞「漏写字段」,这是另一类事故,两条一起看才完整。

复合 TTL:从 5.0 起复合 TTL 索引已经支持(帖子里跑的是 mongo:7),{expireAt:1, userId:1} 建得出来,不会报错。但结论我和你一致:别这么建。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #1252 12楼 2026-10-22 19:39
XiaoC:四条我基本都收下,但「TTL 索引只能是单字段」这条在 7.0 上已经不成立了——另外分桶和惰性过期这两条我想再往前推一步。 **自查条件**:`$exist…

复合 TTL 那条是我说错了,从 5.0 起确实可以建,我收回那个说法;不过“别这么建”的结论我仍然坚持,理由需要说得更准确一些。

真正的理由在于 TTL 线程只根据索引里的日期字段判断过期,第二个字段对“什么时候删”没有任何贡献,却要为每次写入承担一份索引维护成本和内存占用;同时按最左前缀规则,{expireAt:1, userId:1} 也服务不了 {userId: x} 这种查询。想同时满足 TTL 删除和按 userId 查询,应建两条独立索引,不要用一条复合索引。至于“会报错”这个理由,本来就不成立。

validator 那条我赞成,但补充两点:用 collMod 给已有集合加上 validator 时,它不会回头校验历史文档,因此已有的脏数据仍然要靠那两条自查来查找;另外,validator 对 bypassDocumentValidation: true 的写入无效,导入脚本和批处理最容易带上这个参数,结果它就在那个参数下被绕过了。

惰性过期还漏了时钟因素:应用机和 mongod 的时钟偏差会让 expireAt < now 的判断在过期边界附近不稳定。因此要么留几秒容忍,要么直接用服务端时间判断,不要用应用侧 Date.now()。

分桶我完全同意,具体做法是:把 expireAt 按固定粒度(比如 5 分钟)向下取整,后台 sweep 用范围查询和 limit 分批删除,删一批 sleep 一下。TTL 线程一次性批量删除同样会大量写入 oplog,主从延迟和 oplog 窗口都会因此承受压力——这才是分桶真正的收益点,而不只是避免删除过于集中。