一、TTL 索引:自动数据过期的核心机制
1. TTL 索引概述
“生存时间”(Time-To-Live,TTL)索引是 MongoDB 中一种特殊的单字段索引,MongoDB 可以利用它在指定时间后或特定时钟时间自动从集合中删除文档。数据过期机制对于机器生成的事件数据、日志以及仅需有限留存时间的会话信息等场景尤为适用。
TTL 索引的核心价值在于:让数据库自动完成数据淘汰工作,DBA 无需编写额外的清理脚本或定时任务。
适用场景:
- Session 会话数据(用户登录态)
- 临时验证码、令牌
- 操作日志、审计日志
- IoT 传感器数据(部分场景)
- 缓存类数据
2. TTL 索引的创建方法
创建 TTL 索引使用createIndex()方法,需要指定一个日期类型(Date Type)的索引字段,并通过expireAfterSeconds选项指定 TTL 值(以秒为单位)。
基本语法:
db.collection.createIndex({"expireAt":1},// 日期类型字段{expireAfterSeconds:0}// TTL 值(秒))两种典型模式:
| 模式 | 配置方式 | 适用场景 | 示例 |
|---|---|---|---|
| 相对时间模式 | 基于文档插入/更新时间 + 固定时长 | 固定保留期限的数据(如保留7天日志) | expireAfterSeconds: 604800(7天) |
| 绝对时间模式 | 每个文档独立设置过期时间点 | 不同文档有不同过期时间的场景 | expireAfterSeconds: 0+ 文档内expireAt字段 |
实操示例:
// 场景一:Session 数据,30分钟后自动过期db.sessions.createIndex({"createdAt":1},{expireAfterSeconds:1800})// 场景二:每个文档独立过期时间(expireAfterSeconds: 0)db.tokens.createIndex({"expireAt":1},{expireAfterSeconds:0})// 插入时指定过期时间db.tokens.insertOne({token:"abc123",expireAt:newDate(Date.now()+3600*1000)// 1小时后过期})注意:
expireAfterSeconds的值必须在0 到 2147483647(含)之间。TTL 索引是单字段索引,复合索引不支持 TTL 属性。
3. TTL 索引的内部运行机制
理解 TTL 索引的内部工作原理,对于生产环境的调优和故障排查至关重要。
核心运行流程:
关键机制要点:
后台线程:MongoDB 在每个
mongod实例中默认启用一个 TTL 后台线程(TTLMonitor),该线程负责读取索引中的日期类型值并删除过期文档。扫描频率:TTLMonitor 线程默认每 60 秒唤醒一次,遍历所有 TTL 索引并清理过期文档。
副本集行为:在副本集中,只有 Primary 节点执行 TTL 删除操作,Secondary 节点通过 oplog 同步这些删除操作。这意味着如果 Primary 节点故障切换,新的 Primary 会接管 TTL 清理工作。
删除时机:TTL 索引不保证文档在过期后立即被删除。文档过期到实际被删除之间存在最长 60 秒的延迟窗口。
索引构建:如果在后台构建 TTL 索引,TTL 线程可能在索引构建过程中就开始删除文档;如果在前台构建,则索引构建完成后立即开始删除。
4. 限制与注意事项
| 限制项 | 说明 |
|---|---|
| 索引类型 | 仅支持单字段索引,不支持复合索引 |
| 字段类型 | 索引字段必须是 BSON Date 类型或包含 Date 值的数组 |
| 缺失字段 | 如果文档不包含索引字段,该文档不会过期 |
| 数组字段 | 如果字段是数组且有多个日期值,MongoDB 使用数组中最早的日期值计算过期阈值 |
| 上限集合 | 传统上不支持,但新版本正在逐步开放 |
| 多个 TTL 索引 | 同一集合可有多个 TTL 索引,每个索引独立扫描;指向同一字段时,后者替换前者 |
生产环境警告:创建 TTL 索引后,如果集合中有大量符合条件的文档需要删除,可能一次性产生巨大的删除负载,导致服务器性能问题。建议在非高峰期创建索引,或在创建索引前先批量删除符合条件的文档。
5. 实操练习:1分钟自动过期
目标:创建events集合,字段createdAt默认new Date(),设置 TTL 为 1 分钟,插入文档并观察自动删除。
Step 1:创建集合与 TTL 索引
// 创建集合(可选,插入时自动创建)use testdb// 创建 TTL 索引,过期时间为 60 秒db.events.createIndex({"createdAt":1},{expireAfterSeconds:60})Step 2:插入测试文档
// 插入 5 条测试文档for(leti=0;i<5;i++){db.events.insertOne({eventId:i,message:`Test event${i}`,createdAt:newDate()})}// 验证插入db.events.find().pretty()Step 3:观察过期删除
// 立即查询(应有 5 条)db.events.count()// 预期: 5// 等待 60-90 秒后再次查询db.events.count()// 预期: 0(已全部删除)// 查看索引信息db.events.getIndexes()Step 4:修改已有 TTL 索引的过期时间
// 使用 collMod 命令修改 expireAfterSecondsdb.runCommand({collMod:"events",index:{name:"createdAt_1",expireAfterSeconds:120// 改为 2 分钟}})注意:如果 1 分钟后文档未被删除,请检查:(1)
createdAt字段是否为 ISODate 类型而非字符串;(2)Primary 节点是否正常运行;(3)等待最多 60 秒的扫描周期。
二、归档与分片结合:时间序列数据的冷热分离
1. 为什么需要归档?
TTL 索引直接删除数据,适用于不需要保留历史数据的场景。但对于需要保留历史数据用于分析、审计或合规的场景,单纯的 TTL 删除无法满足需求。此时需要引入归档(Archive)策略——将冷数据从热集合迁移到冷存储,同时保持热集合的高性能。
2. 归档方案对比
| 方案 | 工具/方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| $merge 增量归档 | 聚合管道 + $merge | 需要幂等性、增量更新 | 原子操作、支持 upsert、可重复执行 | MongoDB 4.2+ 才支持 |
| $out 全量覆盖 | 聚合管道 + $out | 一次性全量导出、重建归档 | 简单直接 | 覆盖写入,易误删数据 |
| Atlas Online Archive | Atlas 托管服务 | Atlas 用户、超大规模数据 | 自动分层、完全托管、仍可查询 | 仅限 Atlas 环境 |
| 定时脚本 | 应用程序定时任务 | 复杂业务逻辑 | 灵活可控 | 需要额外维护 |
3. 使用 $merge 实现幂等归档
$merge 是 MongoDB 4.2 引入的聚合管道阶段,可以将聚合结果写入目标集合,支持upsert 语义——匹配则更新,不匹配则插入。这使得归档操作可以重复执行而不会产生重复数据。
实操示例:将 30 天前的数据迁移到归档集合
// 定义截止时间:30 天前constcutoffDate=newDate();cutoffDate.setDate(cutoffDate.getDate()-30);// 使用 $merge 将过期数据迁移到归档集合db.events.aggregate([// 1. 筛选 30 天前的数据{$match:{createdAt:{$lt:cutoffDate}}},// 2. 可选:添加归档时间戳{$set:{archivedAt:newDate()}},// 3. 写入归档集合(幂等){$merge:{into:"events_archive",on:"_id",// 以 _id 为匹配键whenMatched:"replace",// 已存在则替换whenNotMatched:"insert"// 不存在则插入}}])归档后的删除(分批执行,避免性能冲击):
// ⚠️ 重要:删除前务必确认归档已完成!// 建议分批删除,每批 1000 条constbatchSize=1000;letdeleted=0;while(true){constresult=db.events.deleteMany({createdAt:{$lt:cutoffDate}},{limit:batchSize});deleted+=result.deletedCount;if(result.deletedCount===0)break;print(`已删除${deleted}条`);}关键原则:归档操作必须遵循“先写后删,确认再删”的原则。所有归档动作必须有幂等标识和落盘确认,不能信任任何“应该已经完成了”的假设。
4. 分片集群中的 TTL 与归档策略
在分片集群中,TTL 和归档的行为与非分片环境有显著差异,需要特别注意。
分片集群 TTL 的核心特点:
关键要点:
每个分片独立运行:分片集群中没有全局 TTL 清理线程,每个分片的
mongod进程各自启动独立的 TTLMonitor 线程,每 60 秒扫描本分片上的 TTL 索引。副本集内仅 Primary 执行:副本集内只有该分片的 Primary 执行删除,Secondary 通过 oplog 同步。
分片键与 TTL 字段的关系:如果分片键和 TTL 字段不一致(如分片键是
userId,TTL 字段是createdAt),数据分布与过期节奏完全解耦——某个分片可能堆积大量未过期数据,另一个分片却早已清空。最佳实践:将TTL 字段作为分片键的一部分(通常是后缀),如
{region: 1, createdAt: 1}作为分片键,再对createdAt建 TTL 索引。这样相同时间范围的文档大概率落在同一分片,清理压力分散且可预期。避免哈希分片键:不要使用
{createdAt: "hashed"}作为分片键——哈希打散后,同一时间窗口的文档被随机分配到所有分片,TTL 删除会变成全集群的毛刺源。绝对时间模式更可靠:在分片集群中,推荐使用绝对时间字段 +
expireAfterSeconds: 0的模式,写入时显式计算并设置expireAt字段:// 写入时计算过期时间db.logs.insertOne({data:"...",expireAt:newDate(Date.now()+7*24*3600*1000)// 7天后过期})// TTL 索引db.logs.createIndex({"expireAt":1},{expireAfterSeconds:0})
5. MongoDB 时间序列集合(Time Series Collections)
MongoDB 5.0+ 提供了原生的时间序列集合,专为 IoT、监控等时序数据场景设计,通过列式压缩和智能分桶自动优化存储密度和查询速度。
时间序列集合的 TTL 配置:
// 创建时间序列集合,设置 24 小时自动过期db.createCollection("weather",{timeseries:{timeField:"timestamp",metaField:"deviceId",granularity:"minutes"},expireAfterSeconds:86400// 24 小时})从MongoDB 7.0开始,可以在时间序列集合上创建部分 TTL 索引,为匹配特定条件的文档设置不同的过期时间。
6. 完整冷热分离架构参考
三、常见面试题及参考答案
面试题 1:TTL 索引和普通索引有什么区别?TTL 索引能保证文档过期后立即删除吗?
参考答案:
TTL 索引是一种特殊的单字段索引,在普通索引的基础上增加了expireAfterSeconds属性,MongoDB 的后台线程会定期扫描该索引并删除过期文档。
TTL 索引不保证立即删除。MongoDB 的后台 TTLMonitor 线程默认每 60 秒运行一次,因此文档从过期到实际被删除之间存在最长 60 秒的延迟。如果系统负载较高,延迟可能更长。
此外,TTL 索引有以下限制:
- 仅支持单字段索引,不支持复合索引
- 索引字段必须是 BSON Date 类型
- 文档若缺少索引字段则不会过期
面试题 2:在 MongoDB 分片集群中,TTL 索引是如何工作的?有哪些注意事项?
参考答案:
分片集群中没有全局的 TTL 清理线程,每个分片(Shard)的mongod进程各自独立运行 TTLMonitor 线程:
- 每个分片独立扫描:各分片每 60 秒扫描本分片上的 TTL 索引并删除过期文档。
- 仅 Primary 执行删除:副本集内只有 Primary 节点执行删除,Secondary 通过 oplog 同步。
- 分片键设计至关重要:建议将 TTL 字段作为分片键的一部分(如
{region: 1, createdAt: 1}),避免使用哈希分片键。 - 删除会影响 Balancer:TTL 批量删除会改变 chunk 大小,可能触发 Balancer 迁移,引发连锁 IO 压力。
面试题 3:$out 和 $merge 在数据归档中有什么区别?应该如何选择?
参考答案:
| 对比维度 | $out | $merge |
|---|---|---|
| 写入方式 | 全量覆盖目标集合 | 按匹配键 upsert(插入或更新) |
| 幂等性 | 不幂等(重复执行会覆盖) | 幂等(可重复执行) |
| 数据安全 | 容易误删目标集合已有数据 | 安全,不会误删不匹配的文档 |
| 适用场景 | 一次性全量导出、物化视图重建 | 增量归档、定时归档任务 |
| 版本要求 | 所有版本 | MongoDB 4.2+ |
推荐:对于定期执行的归档任务,应优先选择$merge,因为它支持幂等操作,可以安全地重复执行而不会产生重复数据或误删已有数据。
面试题 4:如何修改一个已有 TTL 索引的过期时间?
参考答案:
使用collMod命令修改现有 TTL 索引的expireAfterSeconds:
db.runCommand({collMod:"events",index:{name:"createdAt_1",expireAfterSeconds:120// 从 60 秒改为 120 秒}})注意:不能直接使用
dropIndex()+createIndex()的方式,因为删除和重建索引期间会导致 TTL 清理中断,且可能产生大量删除负载。
面试题 5:TTL 索引创建后,有些文档没有被删除,可能的原因有哪些?
参考答案:
- 字段类型不正确:索引字段不是 BSON Date 类型(如存成了字符串)。
- 文档缺少索引字段:如果文档不包含该字段,则不会过期。
- 副本集 Secondary 节点:TTL 删除只在 Primary 执行,Secondary 不会主动删除。
- 等待时间不足:TTLMonitor 每 60 秒运行一次,需等待最多 60 秒。
- 索引未正确创建:检查
expireAfterSeconds值是否在有效范围(0-2147483647)内。 - 系统负载过高:高负载下 TTL 线程可能被延迟。