引言
MongoDB 提供了 TTL 索引自动在后台清理过期数据,该功能广泛应用在数据清理和分布式锁等业务场景,但是有些业务在使用过程中却发现并非那么理想。本文结合 4.2.11 版本的内核代码,以及腾讯云 MongoDB 产品多年的运营经验,对 TTL 索引原理、缺陷和优化措施进行描述,并对常用业务场景的解决方案进行探讨。
初识 TTL 索引
MongoDB 用户可以使用 TTL 索引淘汰过期数据,节省存储空间。比如对于存储事件日志的场景,如果只需要存储最近 1 小时的数据,可以在每条文档中指定 "lastModifiedDate" 字段记录生成的时间,然后按照这个字段创建 1 个 1 小时过期的 TTL 索引:
代码语言:javascript复制db.eventlog.createIndex( { "lastModifiedDate": 1 }, { expireAfterSeconds: 3600 } )
索引创建成功后,mongod 主节点(对于分片集群,也是各个分片的主节点)默认每轮间隔 60 秒(可调整)按照 TTL 索引发起 1 轮数据清理。由此产生的 delete 请求通过 oplog 同步到 mongod 从节点。
用户可以通过 MongoDB 内置的 ServerStatus 命令查看当前 TTL 的运转轮数和删除的文档总条数:
代码语言:javascript复制PRIMARY> db.serverStatus().metrics.ttl
{
"deletedDocuments" : NumberLong("7212988212"),
"passes" : NumberLong(412194)
}
实现原理和缺陷
每个 mongod 进程在启动时,会创建一个 "TTLMonitor" 后台线程,这个后台线程会每隔 60s 发起 1 轮 TTL 清理操作。每轮 TTL 操作会在搜集完实例上的所有 TTL 索引后,依次对每个 TTL 索引生成执行计划并执行数据清理。
结合上述原理阐述,以及腾讯云多年服务客户的经验,TTL 有以下非常明显的问题:
- 时效性差。首先是每隔 60 秒才发起 1 轮,不能保证数据只要过期就立马会删除,虽然 60 秒周期可以动态调整,但是也无法突破秒级。其次 TTL 是单线程,如果多个表都有 TTL 索引,数据清理操作也只能一个个串行执行。另外如果对 mongod 执行高并发数据插入,很有可能数据插入速度大于 TTL 清理速度,此时会有越来越多的数据积累,造成空间膨胀。
- 资源消耗带来的性能毛刺。TTL 的本质是根据索引进行数据删除操作,因此会带来一定程度上的性能压力。比如执行计划和 eviction 操作需要消耗较多的 CPU,索引扫描和数据删除操作会带来一定的 cache 和 IO 压力,删除操作记录 oplog 会增加数据同步延迟等。如果实例规格比较小,有可能影响正常业务请求,造成性能毛刺。
常见用法和风险
理解了 TTL 索引的原理和缺陷之后,我们再来审视一下常见的使用场景都有哪些风险。
场景1:使用 TTL 淘汰过期数据
空间膨胀和性能问题
有些请求量很大的业务使用 MongoDB 存储最近一个月的事件日志,在接入压测过程中发现数据清理很慢。随着不断有新数据插入,磁盘使用率持续增长。
另外也有很多中小型业务在接入时,发现在业务高峰期经常有一些慢请求毛刺。排查发现基本每次毛刺都伴随着 TTL 删除任务,CPU 毛刺明显。
推荐解决方案
对于超大量的数据清理任务,可以考虑按月按天分表,将数据删除操作转变成表的删除操作。这种方式缺点是会带来一定的业务复杂度,但是能够很好的节省实例资源,也没有数据删除太慢导致积压的问题。
对于 TTL 造成的性能毛刺问题,业务侧可以在插入数据时将过期时间均匀打散到这一天内的各个时刻。比如上文提到的 "lastModifiedDate" 字段,可以在业务可接受的范围内进行打散。这种方式的缺点是会带来一定的业务复杂度,但是能够避免数据集中在某个时间过期导致的毛刺问题。
场景2:使用 TTL 索引实现租约和分布式锁
时效性风险
MongoDB 和 Redis、Etcd、ZK 等系统一样,也能用来实现分布式锁,解决 HA 和临界区保护等问题。一般使用一条文档来存储某个临界区的加锁状态,并通过 upsert、update 等操作来实现加锁和释放锁。
说到分布式锁,就会涉及到一个老生常谈的问题:持有锁的客户端挂了,如何自动释放锁?一种马上映入脑海的想法是 续约(lease) TTL 方案:客户端挂了续约操作也停止,因此对应的文档很快会被 TTL 删除,达到自动释放锁的效果。
前面介绍了 TTL 清理机制在数据量大的时候,有很严重的时效性风险。想象一下,如果 TTL 删除延迟了几个小时,业务系统就要等待几个小时?
推荐解决方案
MongoDB 集群包含多个 mongos 和 mongod 节点,在执行 DDL 和元数据变更时也有加锁需求。因此,MongoDB 内核代码中也实现了一套分布式锁逻辑。
本着 MongoDB 作者最懂 MongoDB 用法的认知,下面学习一下 4.2.11 版本的分布式锁代码。
先看一下官方文档的描述(基本用法参考这里):
ConfigSvr 上维护了 2 表来存储锁信息:
- config.locks 表记录每个锁的 ID 、状态、持有进程等信息,文档格式如下:
{
"_id" : "db1.tt1",// 资源,对库加锁就是库名,对表加锁就是表的全称
"state" : 0,// 当前锁的状态, 0 -- 未加锁, 2 -- 加锁
"process" : "ConfigServer",// 持有锁的进程,configSvr 叫 “ConfigServer”,如果是 mongos 或 shardSvr 节点,则是host port 生成时间 随机数
"ts" : ObjectId("63195aefbeacf0098b2d7b50"),//带时间属性的 UID
"when" : ISODate("2022-09-08T03:01:03.572Z"),// debug信息
"who" : "ConfigServer:conn2369",// debug 信息
"why" : "shardCollection"// debug 信息
}
- config.lockpings 表记录每个进程的续约情况,文档格式如下:
{
"_id" : "ConfigServer", // 持有锁的进程 ID (configSvr实例)
"ping" : ISODate("2022-09-08T03:06:31.204Z") // 最近续约时间
}
{
"_id" : "111.xxx.yyy.zzz:27017:1662605616:3738722331788366530", // 持有锁的进程 ID (非 configSvr实例)
"ping" : ISODate("2022-09-08T03:06:36.776Z") // 最近续约时间
}
可以看到 config.locks 表中的 process 字段和 config.lockpings 表中的 _id 字段都是进程 ID. 当某个 Client 加锁失败时,可以联合上述 2 个表查看当前持有锁的进程的续约情况,如果超过 15 分钟没有续约则说明锁已过期,可以通过抢占(overtake) 来加锁成功。
如果我们把 MongoDB 集群中需要加锁的各个 mongos、mongod、configSvr 节点当做 Client, 将 configSvr 副本集当做存储锁信息的 MongoDB 实例。则可以得到下面的通用分布式锁解决方案:
每个 “Client” 进程在启动时初始化全局唯一的 ReplSetDistLockManager 对象用于服务上层应用的加锁和释放锁操作,ReplSetDistLockManager 对象在初始化的时候会启动 1 个 replSetDistLockPinger 后台线程用于定期续约,以及对释放锁失败的请求进行重试。
DistLockCatalogImpl 实现了具体的锁操作(本质上就是一些 upsert/update/find 操作),并对 ReplSetDistLockManager 暴露了 grabLock、overtakeLock、unlock 等接口。
了解了 MongoDB 的分布式锁实现机制后,我们再来看看常见的分布式锁问题:
1. 锁信息如何持久化?
客户端在写 MongoDB 时,使用 writeConcern majority,这样保证即使发生了主从切换,锁信息也不会丢失。
2. 如何防止客户端 A 释放客户端 B 获得的锁?
每个进程加锁时会在锁资源中设置一个携带机器和 PID 信息的标志,在释放锁时会判断这个标志,防止错误释放。
3. 如何避免客户端进程挂了,导致锁永远不会释放?
采用租约的方式,进程在获得锁之后,要启动一个后台线程定期续约。如果超过 15 分钟没有续约,则这个锁可以被其他进程抢占。
和其他大多数系统不同的是,MongoDB 没有使用 TTL 来完成租约,而是记录最后一次续约的时间,将抢占操作交给客户端进程来实现。
4. 如何避免机器时钟不同步带来的问题?
不同的客户端之间,以及客户端机器和 MongoDB 服务端的时钟可能并不同步。时钟不同步可能会对续租、发起抢占的操作造成影响。
比如 MongoDB 发生了主从切换,但是从节点的时间提前了几分钟,又或者主节点在 NTP 时钟对齐后时钟瞬间提前了几分钟等。这样可能会导致之前的正常续租失效,锁被异常抢占。为了避免时钟跳变带来的影响, MongoDB 内核代码中设置了 15 分钟没有续约才失效,如果 NTP 时钟对齐频繁一些,基本上是不会有啥问题。
5. 如何避免进程停顿(如 GC)和网络延迟等带来的影响?
进程停顿:客户端进程 A 拿到锁之后,由于其他操作(或者 GC 等)停顿了几分钟,然后再去操作临界资源。但是再停顿期间,可能由于没有续约导致锁被客户端 B 抢占了。此时就存在竞争风险。
网络延迟:和进程停顿的场景类似,也有可能 2 个客户端同时“加锁成功”的情况。
MongoDB 官方文档中明确说明无法 100% 消除这种场景。业界通常的解决方法有:
a. 调大续约超时。MongoDB 推荐的设置为 15 分钟,已经是很长的时间了,现实中很少会有 GC 停顿或者网络请求长达 15 分钟。
b. 使用(严格递增的) fencing token. 进程 A 拿到锁时, 得到的 token 是 v1,然后 GC 导致续约卡住了。然后进程 B 抢占了锁,得到的 token 是 v2 并在要保护的系统上操作了数据。此时进程 A 再使用 v1 的锁再去操作数据时,会由于 token 版本太低被拒绝。这种机制需要第3方受保护的系统支持 token 的递增判断,因此会带来一定的系统复杂度。
有读者可能会认为这个解决方案有点重,实现起来比较繁琐,还不如 TTL 方案直观。我个人的建议是:对于可用性和一致性要求高的系统,尽量不要在关键链路上依赖 TTL,除非明确知道 TTL 方案带来的风险,并确保能承受该风险。
腾讯云 MongoDB 对 TTL 索引的优化
针对 TTL 索引的问题,腾讯云 MongoDB 团队进行了如下优化:
- 做好监控。除了常规的 TTL 删除轮数和条数监控之外,对于有需求的用户,我们可以根据 TTL 索引以及当前数据的清理进度进行告警。
- 平滑减毛刺。用户可以通过配置每轮的间隔时间和每轮的最大删除条数,来避免大量清理操作突发造成 CPU 毛刺。
- 提升性能。在策略 2 进行平滑之后,有可能删除速度会进一步变慢。我们结合业务的特点,支持自定义业务的高低峰期,在业务低峰期加速删除,充分利用 CPU 资源的同时避免对现有业务的影响。另外,TTL 删除从单线程改多线程对同时存在多个 TTL 的实例来说,在理论上也会有性能提升的效果,多线程方案目前在考虑中。
下面重点介绍策略 3 ,目前在腾讯内部业务中已广泛使用。以某个业务为例,业务请求量在凌晨处于低峰期,白天处于高峰期:
为了不影响业务高峰期的服务质量,我们对 TTL 删除进行了限速,并在低峰期加快删除,TTL 数据删除情况如下:
通过这个策略,我们充分利用了低谷期的 CPU 资源(晚上10点-第二天8点,CPU也没闲着):
同时保证了服务质量,不论是业务高峰期还是低峰期,请求延迟都保证平稳在 10ms 左右,且没有毛刺:
总结
TTL 索引能够在后台自动会过期的数据进行清理,方便了很大部分的 MongoDB 用户。但是 在执行时不可避免地带来了资源消耗、延迟等问题。建议广大用户在使用时明确了解其运行机制和风险,从而根据自身的业务逻辑作出最优选择。
后记(20230401)
MongoDB v6.1 之后的开源版本,通过类似 “时间片” 和 “批量删除限制” 相关的机制实现了 TTL 的公平删除,避免有些表被 “饿死” 的情况。
可以参考 JIRA: https://jira.mongodb.org/browse/SERVER-56194
以及 GitHub Wiki 中的相关描述:https://github.com/mongodb/mongo/blob/master/src/mongo/db/catalog/README.md#fair-ttl-deletion
不过从目前已公布的资料和代码中,并没有看到后续的官方版本支持“通过设置时间窗口来控制删除高低峰期” 的功能。