一、TTL 索引:自动数据过期的核心机制
1. TTL 索引概述
"生存时间"(Time-To-Live,TTL)索引是 MongoDB 中一种特殊的单字段索引,MongoDB 可以利用它在指定时间后或特定时钟时间自动从集合中删除文档。数据过期机制对于机器生成的事件数据、日志以及仅需有限留存时间的会话信息等场景尤为适用。
TTL 索引的核心价值在于:让数据库自动完成数据淘汰工作,DBA 无需编写额外的清理脚本或定时任务。
适用场景:
- Session 会话数据(用户登录态)
- 临时验证码、令牌
- 操作日志、审计日志
- IoT 传感器数据(部分场景)
- 缓存类数据
2. TTL 索引的创建方法
创建 TTL 索引使用 createIndex() 方法,需要指定一个日期类型(Date Type) 的索引字段,并通过 expireAfterSeconds 选项指定 TTL 值(以秒为单位)。
基本语法:
javascript
db.collection.createIndex(
{ "expireAt": 1 }, // 日期类型字段
{ expireAfterSeconds: 0 } // TTL 值(秒)
)
两种典型模式:
| 模式 | 配置方式 | 适用场景 | 示例 |
|---|---|---|---|
| 相对时间模式 | 基于文档插入/更新时间 + 固定时长 | 固定保留期限的数据(如保留7天日志) | expireAfterSeconds: 604800(7天) |
| 绝对时间模式 | 每个文档独立设置过期时间点 | 不同文档有不同过期时间的场景 | expireAfterSeconds: 0 + 文档内 expireAt 字段 |
实操示例:
javascript
// 场景一:Session 数据,30分钟后自动过期
db.sessions.createIndex(
{ "createdAt": 1 },
{ expireAfterSeconds: 1800 }
)
// 场景二:每个文档独立过期时间(expireAfterSeconds: 0)
db.tokens.createIndex(
{ "expireAt": 1 },
{ expireAfterSeconds: 0 }
)
// 插入时指定过期时间
db.tokens.insertOne({
token: "abc123",
expireAt: new Date(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 索引
javascript
// 创建集合(可选,插入时自动创建)
use testdb
// 创建 TTL 索引,过期时间为 60 秒
db.events.createIndex(
{ "createdAt": 1 },
{ expireAfterSeconds: 60 }
)
Step 2:插入测试文档
javascript
// 插入 5 条测试文档
for (let i = 0; i < 5; i++) {
db.events.insertOne({
eventId: i,
message: `Test event ${i}`,
createdAt: new Date()
})
}
// 验证插入
db.events.find().pretty()
Step 3:观察过期删除
javascript
// 立即查询(应有 5 条)
db.events.count() // 预期: 5
// 等待 60-90 秒后再次查询
db.events.count() // 预期: 0(已全部删除)
// 查看索引信息
db.events.getIndexes()
Step 4:修改已有 TTL 索引的过期时间
javascript
// 使用 collMod 命令修改 expireAfterSeconds
db.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 天前的数据迁移到归档集合
javascript
// 定义截止时间:30 天前
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 30);
// 使用 $merge 将过期数据迁移到归档集合
db.events.aggregate([
// 1. 筛选 30 天前的数据
{ $match: { createdAt: { $lt: cutoffDate } } },
// 2. 可选:添加归档时间戳
{ $set: { archivedAt: new Date() } },
// 3. 写入归档集合(幂等)
{ $merge: {
into: "events_archive",
on: "_id", // 以 _id 为匹配键
whenMatched: "replace", // 已存在则替换
whenNotMatched: "insert" // 不存在则插入
}
}
])
归档后的删除(分批执行,避免性能冲击):
javascript
// ⚠️ 重要:删除前务必确认归档已完成!
// 建议分批删除,每批 1000 条
const batchSize = 1000;
let deleted = 0;
while (true) {
const result = 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字段:javascript// 写入时计算过期时间 db.logs.insertOne({ data: "...", expireAt: new Date(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 配置:
javascript
// 创建时间序列集合,设置 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:
javascript
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 线程可能被延迟。