“数据只增不减"是大多数系统走向失控的第一步。日志、埋点、会话、审计记录会以稳定的速率累积,一年之后单集合动辄上亿文档,索引膨胀、备份变慢、查询退化接踵而至。解决它不能靠临时 deleteMany,而需要一套明确的数据生命周期(Data Lifecycle)策略:哪些数据是热的、何时转温、何时归档、何时删除。
MongoDB 提供了从自动过期到冷热分离的多种机制。本文按"分层策略 → TTL 机制 → 冷热分离 → 归档管道 → 合规留存"的顺序,把一套可落地的生命周期方案讲清楚。
1. 数据分层的划分标准
1.1 四层模型
先把数据按访问特征分层,再为每层选择存储与保留策略:
| 层级 | 访问特征 | 存储位置 | 典型保留期 |
|---|---|---|---|
| 热(Hot) | 高频读写,毫秒级响应 | 主副本集,SSD | 天 ~ 周 |
| 温(Warm) | 偶尔查询,可容忍秒级 | 同集群,低频节点/更大容量 | 月 |
| 冷(Cold) | 极少访问,仅合规/审计需要 | 归档集合 / 对象存储 | 年 |
| 归档(Archive) | 几乎不访问,长期留存 | 对象存储 / Atlas Online Archive | 按法规 |
1.2 划分依据
分层的依据不是"数据新旧"本身,而是访问频率。一条三年前的订单如果客户还在反复查询,它就不该被归档;一条三天前的调试日志如果没人看,就该被删除。
因此划分前应先用数据说话:
// 用索引使用统计判断哪些字段/索引在被真正使用
db.orders.aggregate([{ $indexStats: {} }])
// 观察查询的时间分布(结合慢查询日志)
db.setProfilingLevel(1, { slowms: 100 })
db.system.profile.find({ ns: "app.orders" }).sort({ ts: -1 }).limit(20)
用 $indexStats 的 accesses.ops 可以识别"从未被使用的索引”——它们往往对应已被遗忘的查询模式,是清理的第一批目标。
1.3 两种落地手段
分层落地有两种基本手段:
- 按时间滚动集合(每月一个集合,老的直接 drop 或归档):适合体量极大、需要整批迁移的数据。
- 单集合 + TTL 索引(靠数据库自动删除):适合体量中等、删除粒度要求不高的数据。
两者可以组合:热区用单集合 + TTL 处理短期数据,冷区用滚动集合 + 对象存储处理长期归档。
1.4 保留期的确定
保留期不能拍脑袋,要同时满足三方约束:
| 约束来源 | 影响 |
|---|---|
| 业务需求 | 用户要看多久的历史(如"近 6 个月订单") |
| 合规要求 | 法规强制保留或强制删除的期限 |
| 成本约束 | 存储与备份成本能承受的总量 |
取三者的最大值作为下限、最小值作为上限:保留期必须不短于业务与合规的下限,同时不因过长而突破成本上限。若下限高于上限,就需要用归档降本——把冷数据搬到更便宜的存储,而不是简单延长热区保留期。
2. TTL 索引的删除机制
2.1 基本用法
TTL 索引(TTL Index)是 MongoDB 内建的自动过期机制:在某个日期字段上建一个带 expireAfterSeconds 的单键索引,数据库会周期性删除超过该时长的文档。
// 在 createdAt 上建 TTL 索引,文档保留 7 天
db.sessions.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 60 * 60 * 24 * 7 }
)
// 已存在的索引可修改过期时长(collMod)
db.runCommand({
collMod: "sessions",
index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 }
})
2.2 后台 monitor 的行为
- 后台 TTL monitor 每 60 秒运行一次,扫描 TTL 索引,删除过期文档。因此删除不是精确准点,实际延迟可达数分钟。
- 删除是"尽力而为":TTL monitor 单次运行有时间预算,如果待删文档极多,一轮删不完,会分摊到多轮,因此大量过期数据不会瞬间消失。
- 删除操作会写 oplog 并复制到从节点,因此 TTL 删除会给复制带来负载。
这个"后台异步删除"的特性带来一个实践后果:依赖 TTL 做"到点即不可见"的强语义是不可靠的。如果业务要求"过期后立即查不到",应该在查询侧加时间过滤,而不是依赖 TTL 已删除。
2.3 限制清单
| 限制 | 说明 |
|---|---|
| 必须是单键日期索引 | 复合索引中只有单个日期字段能承担过期语义 |
字段类型必须是 Date | 或包含 Date 的数组,以最早日期为准 |
| 字段缺失即永不过期 | 最常见的"TTL 不生效"根因 |
不能对 _id 建 TTL | _id 索引不支持 TTL |
| 上限约 16 MB 的字段不适用 | 与文档大小限制一致 |
| 不能跨分片精确控制 | 分片集合的 TTL 在每个分片独立执行 |
2.4 监控删除量
监控 TTL 删除量可以从 serverStatus 的指标观察:
db.serverStatus().metrics.ttl
// { deletedDocuments: 128430, passes: 8640 }
deletedDocuments 增速异常说明过期数据积压,需要评估是缩小保留期、扩大 TTL monitor 预算,还是改用滚动集合。判断是否积压的简单方法是比对"理论应删数"与"实际删除数":
理论应删数 ≈ 写入速率 × (当前时间 - 保留期) 的窗口内文档数
2.5 TTL 索引与其他索引的关系
TTL 索引本身也是普通索引,可以同时承担查询职责。例如 { createdAt: 1 } 的 TTL 索引,既能自动过期,也能服务"按时间范围查询"的需求。因此不要为了 TTL 额外建一个"纯过期用"的索引——那会多一份索引开销。
但反过来要注意:TTL 索引的字段最好就是业务查询常用的时间字段。如果业务按 updatedAt 查询却对 createdAt 建 TTL,就会多一个几乎不被查询使用的索引。设计时应让 TTL 字段与主查询字段尽量重合。
另外,多键索引不能建 TTL,复合索引中只有单个日期字段能承担过期语义。若需要同时满足"按 tenantId 查询"和"按 createdAt 过期",正确做法是分别建两个索引:
db.events.createIndex({ tenantId: 1, createdAt: -1 }) // 业务查询
db.events.createIndex({ createdAt: 1 }, { expireAfterSeconds: 2592000 }) // TTL
3. 动态过期:per-document expireAt
3.1 基本用法
固定 expireAfterSeconds 只能表达"自某字段起 N 秒后过期"。若每个文档的过期时间不同(比如会员到期、优惠券失效),应使用per-document 过期:把 expireAfterSeconds 设为 0,索引字段直接存"过期时刻"。
db.coupons.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
// 文档里存绝对过期时刻,TTL monitor 在 expireAt 到点后删除
db.coupons.insertOne({
code: "SUMMER2026",
expireAt: new Date("2026-12-31T23:59:59Z")
})
这样每个文档可以有不同的过期时间,语义清晰。推荐新项目直接用这种写法,把"保留多久"的逻辑交给应用计算,数据库只负责按时刻删除。
3.2 时序集合的过期
时序集合(Time Series Collection)则用另一套机制:创建时通过 expireAfterSeconds 指定桶的过期时间,由时序存储引擎在桶级别过期:
db.createCollection("metrics", {
timeseries: { timeField: "ts", metaField: "deviceId", granularity: "seconds" },
expireAfterSeconds: 60 * 60 * 24 * 30
})
桶级别过期的效率远高于逐文档删除,且不产生 oplog 风暴。时序数据的生命周期管理细节见 https://plumephp.com/mongodb-time-series/。
3.3 常见不生效原因
排查 TTL 不生效时按顺序检查:
- 字段类型:
db.collection.findOne()看字段是不是Date类型。存成字符串或时间戳数字都不会过期。 - 字段缺失:TTL monitor 会跳过没有该字段的文档。
- 索引未建成功:
db.collection.getIndexes()确认 TTL 索引存在且expireAfterSeconds正确。 - monitor 未跑完:数据量大时删除滞后是正常的,观察
metrics.ttl.deletedDocuments是否在增长。 expireAfterSeconds单位:是秒,不是毫秒。
3.4 与业务代码的配合
per-document 过期的关键是应用写入时就把 expireAt 算好,而不是事后更新:
// 写入时直接算好过期时刻
await db.coupons.insertOne({
code: "WELCOME10",
createdAt: new Date(),
expireAt: new Date(Date.now() + 7 * 24 * 3600 * 1000)
});
若业务规则是"续期",直接更新 expireAt 即可,TTL monitor 会在新时刻到点后删除:
await db.coupons.updateOne(
{ code: "WELCOME10" },
{ $set: { expireAt: new Date(Date.now() + 30 * 24 * 3600 * 1000) } }
);
注意不要同时依赖 TTL 删除与业务侧查询过滤,否则会出现"数据库里已删但业务逻辑还认为存在"或反之的不一致。二选一:要么完全依赖 TTL(接受删除延迟),要么业务侧加时间过滤并把 TTL 当作兜底清理。
4. 冷热分离
当数据量大到 TTL 无法承载(例如必须保留一年但只查最近一周),就需要冷热分离:热数据留在主集合,冷数据迁移到归档集合或对象存储。
4.1 按时间滚动集合
每月建一个新集合(events_2026_10),应用按查询时间路由。到期后整集合 drop(秒级)或 renameCollection 后归档:
// 每月初创建下月集合,并为旧集合建 TTL 或归档
db.createCollection("events_2026_11")
db.events_2026_11.createIndex({ tenantId: 1, ts: -1 })
// 归档整个旧集合(同库重命名,或 mongodump 后 drop)
db.events_2025_10.renameCollection("archive_events_2025_10")
优点:删除成本极低(drop 一个集合 vs 删除千万文档)、天然分片友好。缺点:应用需要感知集合命名与路由逻辑,跨月查询要合并多个集合。
4.2 $merge 归档管道
用聚合管道把冷数据从热集合搬到归档集合,再删除热集合中的副本。这种方式可以顺便做聚合压缩(比如把明细聚成日汇总):
db.events.aggregate([
{ $match: { ts: { $lt: new Date("2026-01-01") } } },
{ $group: {
_id: { tenantId: "$tenantId", day: { $dateTrunc: { date: "$ts", unit: "day" } } },
count: { $sum: 1 },
total: { $sum: "$amount" }
} },
{ $merge: { into: "events_daily_archive", whenMatched: "replace" } }
])
$merge 的 whenMatched 可设为 replace、merge 或 keepExisting,whenNotMatched 设为 insert,实现幂等的归档写入。归档完成后再分批 deleteMany 热数据:
// 分批删除,避免长事务与 oplog 膨胀
let deleted;
do {
const ids = db.events.find({ ts: { $lt: cutoff } }, { _id: 1 }).limit(5000).toArray();
if (!ids.length) break;
deleted = db.events.deleteMany({ _id: { $in: ids.map(d => d._id) } }).deletedCount;
} while (deleted > 0)
务必分批,单次删除过多会长时间持有锁、撑爆 oplog,还可能让从节点跟不上。
4.3 导出到对象存储
用 mongodump + 压缩上传到 S3,然后 drop 原集合。适合合规归档——数据需要长期留存但几乎不再查询:
# 导出指定集合为归档包
mongodump --uri="mongodb://host:27017/app" \
--collection=events_2025 \
--archive=events_2025.archive --gzip
# 上传对象存储
aws s3 cp events_2025.archive.gz s3://archive-bucket/mongodb/2025/
对象存储的分层与生命周期策略(如 S3 Glacier)通常与数据库侧的归档配合使用,从热到冷再到归档的整体设计可参考 数据归档与生命周期管理 。
4.4 三种方案对比
| 方案 | 删除成本 | 查询便利 | 压缩能力 | 适用 |
|---|---|---|---|---|
| 滚动集合 | 极低(drop) | 需路由与合并 | 无 | 体量大、按时间切分 |
$merge 归档 | 中(分批删) | 需查归档集合 | 可聚合压缩 | 需保留明细或汇总 |
| 导出对象存储 | 极低(drop) | 差(需恢复) | 高(gzip) | 合规留存 |
5. 合规删除与不可变留存
生命周期策略还要满足合规要求,两类需求方向相反。
5.1 合规删除
用户注销后必须彻底删除其数据(Right to Erasure)。难点在于数据可能散落在多个集合、备份与归档里。实践要点:
- 在热集合里用 TTL 或显式删除。
- 归档数据用不可变的删除清单记录待删主键,恢复时按清单过滤。
- 备份中的删除受限于备份的不可变性,通常以"备份保留期到期即销毁"来满足合规窗口。
5.2 不可变留存
审计日志、金融流水往往要求"写入后不可修改、保留 N 年"(WORM)。MongoDB 层面可用的手段:
- 用只读用户 + 最小权限限制应用对归档集合的写权限。
- 用变更流(Change Streams)或审计日志记录所有写操作,形成独立的审计轨迹,做法见 https://plumephp.com/mongodb-change-streams/。
- 用 Schema 校验禁止修改归档集合的既有字段(
$jsonSchema设为validationLevel: strict),防止意外改写,校验与治理方式见 https://plumephp.com/mongodb-schema-validation-governance/。 - 真正的 WORM 语义通常落在对象存储侧(S3 Object Lock),数据库只做热区。
5.3 审计
无论哪种策略,删除与归档都必须有审计记录:谁在什么时候按什么规则删了多少数据。没有审计的自动删除是生产事故的温床。审计记录本身也应纳入生命周期管理——通常保留期比被删数据更长。
6. 生命周期策略的落地流程
6.1 评估
先用数据回答三个问题:各集合的增长速率是多少?哪些时间窗口的数据在被查询?哪些索引从未被使用?这三点决定了分层边界与保留期。
6.2 实施
按"先易后难"推进:先给纯日志类集合加 TTL(改动最小、收益最快),再处理需要归档的业务数据,最后才是合规敏感的审计数据。每一步都要有回滚方案。
6.3 验证
验证的重点不是"数据被删了",而是"该留的数据还在、该删的数据确实删了":
// 校验:热集合不应包含超过保留期的数据
db.events.countDocuments({ ts: { $lt: new Date("2026-01-01") } }) // 应接近 0
// 校验:归档集合应包含对应数据
db.events_daily_archive.countDocuments({ "_id.day": { $lt: new Date("2026-01-01") } })
6.4 自动化与调度
除 TTL 由数据库自动执行外,归档与清理任务通常需要外部调度。三种常见载体:
| 载体 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| Cron + mongosh 脚本 | 简单直接 | 无重试、无监控 | 小型系统 |
| K8s CronJob | 与集群统一调度 | 需管理镜像与 Secret | 云原生环境 |
| 应用内定时任务 | 复用现有框架与监控 | 与业务进程耦合 | 有成熟调度框架的团队 |
无论用哪种,归档任务都应满足三条:幂等(重复执行不产生重复数据)、可断点续跑(中途失败能从上次位置继续)、有审计(记录每批处理量与时间)。幂等靠 $merge 的 whenMatched 保证,可续跑靠"按时间窗口分批 + 记录水位线"实现。
调度频率不必太密:归档任务通常每天或每周跑一次即可,跑得太频繁反而增加数据库负载。
7. 实践建议
- 先用访问数据划分层级,别按"数据新旧"拍脑袋定保留期。
- 新项目优先用 per-document
expireAt+expireAfterSeconds: 0,语义清晰、灵活。 - 确认 TTL 字段类型是
Date,字段缺失或类型错误会让文档永不过期。 - 大批量过期优先用滚动集合 drop,避免 TTL 逐文档删除带来的 oplog 与复制压力。
- 归档用
$merge或mongodump+ 对象存储,删除热数据务必分批。 - 删除与归档全程留审计,合规删除与不可变留存分别设计,不可混淆。
- 不要依赖 TTL 做强语义,需要"立即不可见"时在查询侧加时间过滤。
- 归档任务要幂等、可续跑、有审计,这是长期无人值守运行的前提。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。