数据生命周期、TTL 与冷热归档

系统构建 MongoDB 数据分层生命周期:热温冷归档四层的划分标准与评估方法、TTL 索引的删除机制与后台 monitor 行为、per-document expireAt 与时序集合过期、滚动集合与归档管道,以及合规删除与不可变留存的完整落地方案

“数据只增不减"是大多数系统走向失控的第一步。日志、埋点、会话、审计记录会以稳定的速率累积,一年之后单集合动辄上亿文档,索引膨胀、备份变慢、查询退化接踵而至。解决它不能靠临时 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 不生效时按顺序检查:

  1. 字段类型:db.collection.findOne() 看字段是不是 Date 类型。存成字符串或时间戳数字都不会过期。
  2. 字段缺失:TTL monitor 会跳过没有该字段的文档。
  3. 索引未建成功:db.collection.getIndexes() 确认 TTL 索引存在且 expireAfterSeconds 正确。
  4. monitor 未跑完:数据量大时删除滞后是正常的,观察 metrics.ttl.deletedDocuments 是否在增长。
  5. 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. 实践建议

  1. 先用访问数据划分层级,别按"数据新旧"拍脑袋定保留期。
  2. 新项目优先用 per-document expireAt + expireAfterSeconds: 0,语义清晰、灵活。
  3. 确认 TTL 字段类型是 Date,字段缺失或类型错误会让文档永不过期。
  4. 大批量过期优先用滚动集合 drop,避免 TTL 逐文档删除带来的 oplog 与复制压力。
  5. 归档用 $merge 或 mongodump + 对象存储,删除热数据务必分批。
  6. 删除与归档全程留审计,合规删除与不可变留存分别设计,不可混淆。
  7. 不要依赖 TTL 做强语义,需要"立即不可见"时在查询侧加时间过滤。
  8. 归档任务要幂等、可续跑、有审计,这是长期无人值守运行的前提。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. $graphLookup 与层次结构建模
  2. GridFS 与大文件存储实践
  3. MongoDB Kubernetes Operator 部署与运维