MongoDB WiredTiger 存储引擎(1) cache_pool设计

本文深入分析了MongoDB v3.0引入的WiredTiger存储引擎,探讨了其多核心和Page IO优化技术,特别是缓存池(cachepool)的设计与实现。文中详细解释了cache的读入、淘汰机制、checkpoint机制以及cache evict线程模型等内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

0. 前言

最近由于工作需要,阅读了WiredTiger部分代码,感觉有的地方还是很值得学习的,准备搞个系列文章,笔者不是精通WiredTiger的大神,国内对WiredTiger深入分析的人不多,只是想在这里跟大家一起讨论一起学习。笔者在Gitlab上建了一个WiredTiger代码注释版本,基于v2.8.1,如有问题大家可以在Gitlab留言,https://github.com/gugemichael/wiredtiger

1. MongoDB 多引擎体系 – WiredTiger

MongoDB v.3.0之前的版本,默认使用MMAP(MMap引擎)方式对内存中的数据进行写盘存储,遭受了很多诟病。比如并发受限的表锁、不支持压缩、不可控的IO操作等,MMAP甚至不能称作一个完整的存储引擎(笔者的个人观点),对数据(Btree的数据页、索引页)的操作甚至要依赖os的mmap(in_page_cache)刷盘,并且os的page 4k为IO单元对数据库本身就是不友好的,再加上其实数据库自身应该比OS更懂数据的layout,比如哪些是热数据,哪些是索引(较数据页更需要留在内存里)来组织LRU。

MongoDB v3.0版本一个重大的更新是加入了WiredTiger,也使得Mongo代码架构里支持了StorageEngine的抽象层,为后面RocksDB、Memory等做了铺垫。WiredTiger引擎(后面简称wt引擎)面向多核Page IO优化做了很多工作(比如加入HazardPointer、分离锁,为了增大并发内存Page结构和磁盘Page结构解耦以及支持变长Page-面向大吞吐设备的IO适配)。很多测试报告显示使用了wt之后Mongo的性能有很大提升。本文通过项目里对wt的子模块进行了解,主要关注cache相关设计,更多wt引擎相关请见后续文章。

2. WiredTiger整体架构

wt引擎是基于btree索引实现的,和InnoDB引擎有很多设计和名词上相似的地方,大家可以类比。wt对外的api接口是以table(类比Mysql的表和MongoDB的collection) + kv存储形式提供的,key是int64_t类型(类似MongoDB的_id),value是byte[]类型(这里通过byteArray来实现MongoDB上层schema free的bson结构)。上层通过cursor方式来实现数据的search/insert/update/remove操作。内部通过journal_log、checkpoint、cache(btree/cache_evict)、block_manager子模块协调访问IO存储(这里主要列出本文相关模块,sweep、transaction、bloom/lsm等未列出)

arch.jpg

  • session 模块,负责和wt引擎上层交互的句柄,每个session会关联多个cursor,cursor属于一个session
  • cache 模块,主要有内存中的btree page(数据页,索引页,溢出页)构成
  • evict 模块,如果cache内存紧张,触发cache淘汰,便利btree,并根据lru排序淘汰
  • Journal 模块,WAL log,类似InnoDB的redolog,保证数据持久化,通过定时和定量阈值来flush
  • checkpoint 模块,类似InnoDB checkpoint机制,异步执行btree刷盘,checkpoint之后通知log模块更新log_ckpt_lsn(lsn概念和InnoDB一致)
  • block manager模块,负责磁盘IO的读写,cache、evict、checkpoint模块均通过该模块访问磁盘

3. cache pool

wt有个shard cache(cache pool)的概念,MongoDB并未使用。因为cache本身的功能和InnoDB buffer pool类似,这里暂且把wiredTiger的cache称作cache pool也算合理。cache pool主要存放内存中btree page为单位的页,cache的读入和淘汰写入都是以page为单位(基本上所有存储引擎都是如此)。基本结构如图:

btree.png
(图片来自WiredTiger Github官方wiki)

  • 基本page类型:root_page(btree根节点), internal_page(索引页), leaf_page(数据页, 叶子节点)
  • 结构:每个page被包含在一个REF里面(可以看做是page的容器), root_page/internal_page 包含一个refArray数组,包含下层节点的指针,home(红色线)指向父节点

wt的btree相较于InnoDB有些不一样的地方(详细结构见https://github.com/wiredtiger/wiredtiger/wiki/Reconciliation-overview),InnoDB的内存中的page结构和磁盘上的page结构是一样的,都是紧凑的binary,修改产生的脏页会in place的更新到原有磁盘的page上, 这样的好处是刷盘时,直接把16k的page,按字节数组的方式一次性刷下去即可

WiredTiger将磁盘上的page读上来之后,会在内存中构建成另外一种复杂结构,这个结构较binary结构好处就是可以重新组织或嵌入具有更高并发性的结构,比如wt使用的HazardPointer,对page刷盘时,其实就是对该页的HazardPointer的’写获取’操作,并且在刷盘时保持 原有磁盘上的page不变,直接找一个新的page空间,把内存里page的修改(保存在page的modify_list中)变成磁盘page的结构写入 ,这个page刷盘的过程称为reconcile(wt里很多生僻的名词)。这样的好处是对不修改原有page,就能更好的并发,并且不像InnoDB一样,需要一个DoubleWriteBuffer保证非disk block 512B写时对原有页可能发生conrrupt。(这里有个小问题,如果是更新都写入新page,如果每次都只是更新page中很小的数据,数据的空间占用会比较大,待验证?!)

WiredTiger也提供了类似InnoDB的checkpoint机制:每个客户端的写请求会先通过journal进行持久化,这里类似redolog都是顺序IO,并且提供了类似innodb_flush_log_at_trx_commit的{j: true}参数。那么,在wt里面产生的cache脏页,就用在后台’慢慢’的刷。当写入到一定程度或者时间后,或作一次checkpoint把cache中的数据刷入磁盘,并且做fsync, 然后通知journal更新checkpoint offset,即可丢弃之前的journal。这里在每次checkpoint后,都会产生一个新的root page(也就是一个新的btree,一个bree对应一个物理文件),同时会在journal写入这个checkpoint事件。

3.1 cache pool 刷盘

写请求写入journal后就可以保证Durability,后续wt引擎遇到如下情况时,会触发cache的刷盘动作:

  • checkpoint
    checkpoint时会遍历所有btree,把btree的所有leaf_page做reconcile操作,然后对重新分配root_page,触发的阈值包括:

    1. journal容量达到阈值,默认2G
    2. 每隔60s执行一次
  • cache evict
    evict线程会根据现在cache的使用量,分段扫描一些page(可能是drity page或clean page),进行淘汰,释放cache空间,主要受如下4个参数影响:
    eviction_trigger:cache总使用量达到该百分比时,触发evict操作
    eviction_target:触发上述参数evict后,需要将cache总使用量降低到该百分比水位,才停止evict
    eviction_dirty_trigger:cache脏页使用量到该百分比时,触发evict操作
    eviction_dirty_target:触发上述参数evict后,需要将cache脏页使用量降低到该百分比水位,才停止evict
    (WiredTiger v2.8.1 一般情况使用情况eviction_trigger > eviction_target > eviction_dirty_trigger > eviction_dirty_target,WiredTiger v2.9之后版本对着四个参数的意义进行了调整!)

  • application evict
    如果写入量一直很大,那么用户请求处理线程就会阻塞并参与evict的执行,这也是一种保护措施,当IO量很大时,做到同步阻塞上面的请求

4 cache evict

4.1 evict线程模型

cache evict在wt v2.8.1早期版本时,采用的是server-worker模型,1个server线程负责扫描btree找到一些page,然后进行lru排序,放入一个evict_queue中,再由worker线程消费,进行page evict动作。后面v2.8.1在MongoDB v3.2.9这个版本中,对线程模型进行了升级, 将server线程和worker线程合并(worker通过抢一把evict_pass_lock锁来成为server),相当于N个worker线程,同一时刻,有一个worker会成为server,负责执行evict_pass(扫描btree,并填充evict_queue)的工作,较少了切换的代价 。并且把原来单一的evict_queue变成了两个,降低了server、worker之间操作一个queue的概率减少冲突,增加了并发。

虽然线程模型做了调整,但从功能上还是server-worker的模式,见下图:

server.jpg

evict.jpg

4.2 Btree扫描策略

每次evict_server执行cache pass是对所有btree的一次扫描,会根据当前cache情况最多扫描100个page出来。btree的扫描是迭代的,结束时会记录当前扫描道德btree的位置,下次继续从这个地方开始walk,每个btree最多扫描出10个page,如果这个btree被exclusive独占,如checkpoint正在访问这个btree,则跳过 ,这是为了让btree所有page都有可能被扫描到。如果扫描了足够多的page,则会停止本次cache pass,进入page的evict阶段。

这个扫描策略应该可以page尽可能全的被覆盖,但其实不具备LRU的特性(evict_queue里的page排序是基于LRU,也就是说扫描出来的page是会使用LRU)。如果看一下HBase或者InnoDB的分级扫描策略,就会感觉wt引擎的这种阶段性扫描在某些场景下还是不够友好的。比如 InnoDB的分级cache,会把buffer pool中的page list分成两部分,new_list和old_list,分别占5/8和3/8左右,新访问使用的page会在old_list里,如果这个page被再次访问到,则移动到new_list,如不访问则可能在old_list里被淘汰掉 。这就防止了select * from table类似这种fullScan时对buffer pool的污染 (详情可参考 http://dev.mysql.com/doc/refman/5.6/en/innodb-buffer-pool.html ),这里wt引擎其实做的还有欠缺。

4.3 LRU策略

扫描完成后的evict_queue,需要对每个里面page做评分排序,每个page的评分由page->read_gen(可理解成page的存活周期,如果经历过cache淘汰越多,但没被淘汰,就越高), 以及page类型共同决定,见代码:

/*
 *  获得page的评分,分数越低越容易被淘汰掉
 */
static inline uint64_t
__evict_read_gen(const WT_EVICT_ENTRY *entry)
{
    WT_BTREE *btree;
    WT_PAGE *page;
    uint64_t read_gen;

    btree = entry->btree;

    page = entry->ref->page;

    // 周期最老的页,需要马上evict,给最低分WT_READGEN_OLDEST == 1
    if (page->read_gen == WT_READGEN_OLDEST)
        return (WT_READGEN_OLDEST);

    // 如果dhandle(btree文件)已经要被回收,同样给最低分
    if (!WT_PAGE_IS_INTERNAL(page) &&
        F_ISSET(btree->dhandle, WT_DHANDLE_DEAD))
        return (WT_READGEN_OLDEST);

    // 如果page为空,也给最低分
    if (__wt_page_is_empty(page))
        return (WT_READGEN_OLDEST);

    // 如果是internal page索引页, 则加上一个倾斜分数1000000分,
  // 这里是希望索引页尽量不淘汰
    read_gen = page->read_gen + btree->evict_priority;
    if (WT_PAGE_IS_INTERNAL(page))
        read_gen += WT_EVICT_INT_SKEW;

    return (read_gen);
}

按评分排序后,得到一个分数从小到大的有序队列,这个时候需要计算candidate(evict_page是即将被淘汰的页,candidate page是本次决定淘汰的页,evict > candicate),继续上代码:

    if (FLD_ISSET(cache->state,
        WT_EVICT_PASS_AGGRESSIVE | WT_EVICT_PASS_WOULD_BLOCK)) {
          // 此时cache空间已经非常紧张了,进入aggressive模式或者application用户
         // 线程淘汰模式了,则把全部evict_page变成candidate_page
        evict_queue->evict_candidates = entries;
    } else {
           // 寻找第一个不是WT_READGEN_OLDEST的分数
        read_gen_oldest = WT_READGEN_OLDEST;
        for (candidates = 0; candidates < entries; ++candidates) {
            read_gen_oldest =
                __evict_read_gen(
                &evict_queue->evict_queue[candidates]);
            if (read_gen_oldest != WT_READGEN_OLDEST)
                break;
        }

        // 此处意味着全部page的分数都是WT_READGEN_OLDEST,则全部变成
       // candidate
        if (read_gen_oldest == WT_READGEN_OLDEST)
            evict_queue->evict_candidates = entries;
        else if (candidates >= entries / 2)
               // 如果第一个不是WT_READGEN_OLDEST的分数位置是在
                // 中间位置或中间偏后面的位置,则直接取到这个点
            evict_queue->evict_candidates = candidates;
        else {
            cache->read_gen_oldest = read_gen_oldest;

            // 去一个适中的值,差不多在这个点的值和最后点的值得3/4处
           // 也就是说在当前这个点到末尾之间去一个值作为平衡点
            cutoff =
                (3 * read_gen_oldest + __evict_read_gen(
                &evict_queue->evict_queue[entries - 1])) / 4;

            // 在至少10%和至多50%间找一个小于cutoff的边界作为
            // candidate数量
            for (candidates = 1 + entries / 10;
                candidates < entries / 2;
                candidates++)
                if (__evict_read_gen(
                    &evict_queue->evict_queue[candidates]) >
                    cutoff)
                    break;
            evict_queue->evict_candidates = candidates;
        }
    }

4.3 Cache 现存问题及CacheHang问题

上述分析了Cache相关的设计,其实可以看出evict还是有些精巧的地方,单还不够完善,尤其是在cache evict和checkpoint同时发生,或者evict_server不能找到可以evict的page时的退让策略,触发了一些线上问题,本文的下半节《MongoDB WiredTiger 存储引擎cache_pool设计 (下) – 实践篇》将对次进行深入讨论

<think>我们正在讨论的是WiredTiger存储引擎中的锁机制。用户之前询问了`schema_lock_delta`,现在转向`wiredTiger_dhandle_read_lock_delta`锁的触发行为。需要明确这个锁的触发条件和相关行为。 首先,根据WiredTiger的文档和源码分析: - `dhandle`代表Data Handle,即数据句柄,通常对应一个表(集合)或索引文件。 - `read_lock_delta`表明这是一种读锁(共享锁),并且是增量式的(可能指锁的粒度或计数方式)。 关键点: 1. **锁类型**:这是一个共享读锁(S锁),允许多个读操作同时持有,但会阻塞写锁(X锁)。 2. **作用对象**:保护的是数据文件句柄(dhandle)的元数据或结构信息,而不是具体的数据行。 3. **触发操作**: - 当打开一个表(或索引)的句柄时,会获取此读锁。 - 执行查询操作(find)时,如果需要访问表结构信息(如列定义),可能会触发。 - 事务开始时,如果需要访问表元数据,也会获取此锁。 4. **与行级锁的关系**:这个锁在更高级别(表句柄级别)上保护元数据,而行级锁保护具体的数据。它们可以并存,互不干扰。 具体触发场景: - **查询操作**:当执行一个查询时,首先需要获取表句柄的读锁(`dhandle_read_lock`)以确保表结构在查询期间不会改变(例如,不会在查询中途删除表)。 - **事务启动**:开启一个事务时,会获取所涉及表的`dhandle_read_lock`,以保证事务执行期间表结构的一致性。 - **元数据读取**:任何需要读取表元数据的操作,例如获取表的统计信息(`collStats`)或检查表是否存在。 注意:这个锁是“delta”形式的,可能意味着它是通过引用计数或轻量级机制实现的,以减少锁竞争。 **阻塞行为**: - 多个读操作可以同时持有`dhandle_read_lock_delta`,不会相互阻塞。 - 当有写操作需要获取表句柄的写锁(`dhandle_write_lock`)时,会阻塞新的读锁获取,直到写锁释放。 **性能影响**: - 通常这个锁的持有时间很短,不会成为性能瓶颈。 - 但在高并发的元数据操作(如频繁创建/删除表)时,可能会出现等待。 **监控方法**: - 使用`db.currentOp()`查看等待锁的操作。 - 通过`db.serverStatus().wiredTiger.lock`查看锁统计信息,关注`dhandle_read`相关的等待计数。 **设计原理**: - 保证表结构的读一致性:在读取表元数据时,防止表被删除或修改。 - 轻量级设计:通过共享锁提高并发度。 --- **相关问题**: 1. `wiredTiger_dhandle_read_lock_delta`和`wiredTiger_dhandle_write_lock_delta`之间如何互斥? 2. 在分片集群中,`dhandle_read_lock_delta`的行为有何变化? 3. 如何优化以减少`dhandle_read_lock_delta`的竞争? 4. WiredTiger的dhandle锁与InnoDB的表锁有何异同?</think>### WiredTiger中`wiredTiger_dhandle_read_lock_delta`锁的触发机制分析 `wiredTiger_dhandle_read_lock_delta`是WiredTiger存储引擎中的**数据句柄读锁**(Data Handle Read Lock),属于共享锁(S锁)类型。其主要作用是保护数据文件句柄(dhandle)的元数据访问,确保在读取文件结构信息时的并发安全性。以下是触发此锁的关键行为: --- #### **核心触发场景** 1. **文件句柄初始化** - 当MongoDB首次打开数据文件或索引文件时 - 例如:集合首次被访问或冷启动后加载文件 - 加锁行为: $$ \text{open\_dhandle} \rightarrow \text{acquire\_dhandle\_read\_lock} $$ 2. **元数据读取操作** - 访问文件结构信息(如文件大小、块分配状态) - 特定命令: ```javascript db.collection.stats() // 集合统计信息 db.collection.validate() // 数据文件验证 db.collection.storageSize()// 存储空间查询 ``` 3. **检查点(Checkpoint)过程** - 生成快照时读取文件元数据 - 触发条件: - 定时检查点(默认60秒) - 日志达到2GB阈值 - 手动执行`db.fsyncLock()` 4. **缓存管理操作** - 页面换出(page eviction)时验证文件有效性 - 块加载(block loading)前检查文件结构 --- #### **锁特性与行为** | 特性 | 说明 | |------|------| | **锁类型** | 共享锁(S锁),允许多个读操作并行 | | **作用层级** | 文件句柄级别(dhandle),介于全局锁和行锁之间 | | **持有时间** | 通常极短(微秒级),但文件验证可能持续较久 | | **阻塞关系** | 与`dhandle_write_lock`互斥,与行级读锁兼容 | --- #### **性能影响与诊断** 1. **锁竞争表现** - 监控指标:`wiredTiger_dhandle_read_lock_delta_wait_count` - 症状: - 元数据查询延迟增加 - `db.currentOp()`显示`waitingForLock: true` 2. **诊断命令** ```javascript // 查看锁等待 db.currentOp({ "waitingForLock": true, "lockStats.0.lockType": "dhandle_read" }) // 锁统计信息 db.serverStatus().wiredTiger.lock ``` 关键输出字段: - `dhandle_read_lock_delta`:获取次数 - `dhandle_read_lock_delta_wait_time`:总等待时间 3. **优化建议** - 避免高频执行`validate()`等元数据操作 - 分离统计查询到只读节点 - 增大缓存减少文件访问(调整`wiredTigerEngineRuntimeConfig`) --- #### **设计原理** - **元数据保护**:防止文件结构在读取时被修改(如文件截断) - **轻量级并发**:通过共享锁最大化读并行度 - **层级隔离**:与行级锁解耦,减少全局锁竞争[^1] - **事务兼容**:在MVCC机制下通过时间戳管理锁版本[^2] > ⚠️ 注意:此锁在分片集群中行为一致,但跨分片操作可能触发多个节点的并发加锁。 ---
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值