数据API 案例 开发者 关于
掌握聚合最新动态了解行业最新趋势
API接口,开发服务,免费咨询服务
新闻动态 > 媒体报道

MongoDB 3.0挂起原因?WiredTiger实现:一个LRU cache深坑引发的分析

【作者简介:袁荣喜,学霸君工程师,2015 年加入学霸君,负责网络实时传输和分布式系统的架构设计和实现,专注于基础技术领域,在网络传输、数据库内核、分布式系统和并发编程方面有一定了解。】

从 MongoDB 3.0 版本引入 WiredTiger 存储引擎(以下称为 WT)以来,一直有同学反应在高速写入数据时 WT 引擎会间歇性写挂起,有时候写延迟达到了几十秒,这确实是个严重的问题。

引起这类问题的关键在于 WT 的 LRU Cache 的设计模型,WT 在设计 LRU Cache 时采用分段扫描标记和 hazard pointer 的淘汰机制,在 WT 内部称这种机制叫 eviction Cache 或者 WT Cache,其设计目标是充分利用现代计算机超大内存容量来提高事务读写并发。在高速不间断写时内存操作是非常快的,但是内存中的数据最终必须写入到磁盘上,将页数据(page)由内存中写入磁盘上是需要写入时间,必定会和应用程序的高速不间断写产生竞争,这在任何数据库存储引擎都是无法避免的,只是由于 WT 利用大内存和写无锁的特性,让这种不平衡现象更加显著。

下图是一位网名叫 chszs 同学对 mongoDB 3.0 和 3.2 版本测试高速写遇到的 hang 现象。


从上图可以看出,数据周期性出现了 hang 现象,笔者在单独对 WT 进行高并发顺序写时遇到的情况和上图基本一致,有时候挂起长达 20 秒。针对此问题我结合 WT 源码和调试测试进行了分析,基本得出的结论如下:

  1. WT 引擎的 eviction Cache 实现时没有考虑 LRU Cache 的分级淘汰,只是通过扫描 btree 来标记,这使得它和一些独占式 btree 操作(例如: checkpoint)容易发生竞争。
  2. WT btree 的 checkpoint 机制设计存在 bug,在大量并发写事务发生时,checkpoint 需要很长时间才能完成,造成刷入磁盘的数据很大,写盘时间很长。容易引起 Cache 满而挂起所有的读写操作。
  3. WT 引擎的 redo log 文件超过 1GB 大小后就会另外新建一个新的 redo log 文件来继续存储新的日志,在操作系统层面上新建一个文件的是需要多次 I/O 操作,一旦和 checkpoint 数据刷盘操作同时发生,所有的写也就挂起了。

要彻底弄清楚这几个问题,就需要对从 WT 引擎的 eviction Cache 原理来剖析,通过分析原理找到解决此类问题的办法。先来看 eviction Cache 是怎么实现的,为什么要这么实现。

eviction cahce 原理


eviction Cache 是一个 LRU Cache,即页面置换算法缓冲区,LRU Cache 最早出现的地方是操作系统中关于虚拟内存和物理内存数据页的置换实现,后被数据库存储引擎引入解决内存和磁盘不对等的问题。所以 LRU Cache 主要是解决内存与数据大小不对称的问题,让最近将要使用的数据缓存在 Cache 中,把最迟使用的数据淘汰出内存,这是 LRU 置换算法的基本原则。但程序代码是无法预测未来的行为,只能根据过去数据页的情况来确定,一般我们认为过去经常使用的数据比不常用的数据未来被访问的概率更高,很多 LRU Cache 大部分是基于这个规则来设计。

WT 的 eviction Cache 也不例外的遵循了这个 LRU 原理,不过 WT 的 eviction Cache 对数据页采用的是分段局部扫描和淘汰,而不是对内存中所有的数据页做全局管理。基本思路是一个线程阶段性的去扫描各个 btree,并把 btree 可以进行淘汰的数据页添加到一个 LRU queue 中,当 queue 填满了后记录下这个过程当前的 btree 对象和 btree 的位置(这个位置是为了作为下次阶段性扫描位置),然后对 queue 中的数据页按照访问热度排序,最后各个淘汰线程按照淘汰优先级淘汰 queue 中的数据页,整个过程是周期性重复。 WT 的这个 evict 过程涉及到多个 eviction thread 和 hazard pointer 技术。

WT 的 evict 过程都是以 page 为单位做淘汰,而不是以 K/V。这一点和 Memcache、 Redis 等常用的缓存 LRU 不太一样,因为在磁盘上数据的最小描述单位是 page block,而不是记录。

eviction 线程模型


从上面的介绍可以知道 WT 引擎的对 page 的 evict 过程是个多线程协同操作过程,WT 在设计的时候采用一种叫做 leader-follower 的线程模型,模型示意图如下:


Leader thread 负责周期性扫描所有内存中的 btree 索引树,将符合 evict 条件的 page 索引信息填充到 eviction queue,当填充 queue 满时,暂停扫描并记录下最后扫描的 btree 对象和 btree 上的位置,然后对 queue 中的 page 按照事务的操作次数和访问次做一次淘汰评分,再按照评分从小到大做排序。也就是说最评分越小的 page 越容易淘汰。下个扫描阶段的起始位置就是上个扫描阶段的结束位置,这样能保证在若干个阶段后所有内存中的 page 都被扫描过一次,这是为了公平性。

这里必须要说明的是一次扫描很可能只是扫描内存一部分 btree 对象,而不是全部,所以我对这个过程称为阶段性扫描(evict pass),它不是对整个内存中的 page 做评分排序。这个阶段性扫描的间隔时间是 100 毫秒,而触发这个 evict pass 的条件就是 WT Cache 管理的内存超出了设置的阈值,这个在后面的 eviction Cache 管理的内存小节中详细介绍。

在 evict pass 后,如果 evction queue 中有等待淘汰的 page 存在就会触发一个操作系统信号来激活 follower thread 来进行 evict page 工作。虽然 evict pass 的间隔时间通常是 100 毫秒,这里有个问题就是当 WT Cache 的内存触及上限并且有大量写事务发生时,读写事务线程在事务开始时会唤醒 leader thread 和 follower thread,这就会产生大量的操作系统上下文切换,系统性能急剧下降。好在 WT-2.8 版本修复了这个问题,leader follower 通过抢锁来成为 leader,通过多线程信号合并和周期性唤醒来 follower,而且 leader thread 也承担 evict page 的工作,可以避免大部分的线程唤醒和上下文切换。是不是有点像 Nginx 的网络模型?

hazard pointer


hazard pointer 是一个无锁并发技术,其应用场景是单个线程写和多个线程读的场景,大致的原理是这样的,每个读的线程设计一个与之对应的无锁数组用于标记这个线程引用的 hazard pointer 对象。读线程的步骤如下:

  1. 读线程在访问某个 hazard pointer 对象时,先将在自己的标记数组中标记访问的对象。
  2. 读线程在访问完毕某个 hazard pointer 对象时,将其对应的标记从标记数组中删除。

写线程的步骤大致是这样的,写线程如果需要对某个 hazard pointer 对象写时,先判断所有读线程是否标记了这个对象,如果标记了,放弃写。如果未标记,进行写。

关于 hazard pointer 理论可以访问Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects

Hazard pointer 是怎样应用在 WT 中呢?我们这样来看待这个事情,把内存 page 的读写看做 hazard pointer 的读操作,把 page 从内存淘汰到磁盘上的过程看做 hazard pointer 的写操作,这样瞬间就能明白为什么 WT 在页的操作上可以不遵守 The FIX Rules 规则,而是采用无锁并发的页操作。要达到这种访问方式有个条件就是内存中 page 本身的结构要支持 lock free 访问,这个在剖析 WiredTiger 数据页无锁及压缩一文中介绍过了。从上面的描述可以看出 evict page 的过程中首先要做一次 hazard pointer 写操作检查,而后才能进行 page 的 reconcile 和数据落盘。

hazard pointer 并发技术的应用是整个 WT 存储引擎的关键,它关系到 btree 结构、 internal page 的构造、事务线程模型、事务并发等实现。 Hazard pointer 使得 WT 不依赖 The Fix Rules 规则,也让 WT 的 btree 结构更加灵活多变。

Hazard pointer 是比较新的无锁编程模式,可以应用在很多地方,笔者曾在一个高并发媒体服务器上用到这个技术,以后有机会把里面的技术细节分享出来。

eviction Cache 管理的内存


eviction Cache 其实就是内存管理和 page 淘汰系统,目标就是为了使得管辖的内存不超过物理内存的上限,而触发淘汰 evict page 动作的基础依据就是内存上限。 eviction Cache 管理的内存就是内存中 page 的内存空间,page 的内存分为几部分:

  1. 从磁盘上读取到已经刷盘的数据,在 page 中称作 disk buffer。如果 WT 没有开启压缩且使用的 MMAP 方式读写磁盘,这个 disk buffer 的数据大小是不计在 WT eviction Cache 管理范围之内的。如果是开启压缩,会将从 MMAP 读取到的 page 数据解压到一个 WT 分配的内存中,这个新分配的内存是计在 WT eviction Cache 中的。
  2. Page 在内存中新增的修改事务数据内存空间,计入在 eviction Cache 中。
  3. Page 基本的数据结构所有的内存空间,计入在 eviction Cache 中。

PS: 关于 page 结构和内存相关的细节请查看 剖析 WiredTiger 数据页无锁及压缩

WT 在统计 page 的内存总量是通过一个 footprint 机制来统计两项数据,一项是总的内存使用量 mem_size,一项是增删改造成的脏页数据总量 dirty_mem_size。统计方式很简单,就是每次对页进行载入、增删改、分裂和销毁时对上面两项数据做原子增加或者减少计数,这样可以精确计算到当前系统中 WT 引擎内存占用量。假设引擎外部配置最大内存空间为 cache_size,内存上限触发 evict 的比例为 80%,内存脏页上限触发 evict 的比例为 75%. 那么系统触发 evict pass 操作的条件为:

mem_size > cache_size * 80%

或者

dirty_mem_size > cache_size * 75%

满足这个条件 leader 线程就会进行 evict pass 阶段性扫描并填充 eivction queue,最后驱使 follower 线程进行 evict page 操作。

evict pass 策略


前面介绍过 evict pass 是一个阶段性扫描的过程,整个过程分为扫描阶段、评分排序阶段和 evict 调度阶段。扫描阶段是通过扫描内存中 btree,检查 btree 在内存中的 page 对象是否可以进行淘汰。扫描步骤如下:

1、根据上次 evict pass 最后扫描的 btree 和它对应扫描的位置最为本次 evict pass 的起始位置,如果当前扫描的 btree 被其他事务线程设成独占访问方式,跳过当前 btree 扫描下个 btree 对象

2、进行 btree 遍历扫描,如果 page 满足淘汰条件,将 page 的索引对象添加到 evict queue 中,淘汰条件为:

  • 如果 page 是数据页,必须 page 当前最新的修改事务必须早以 evict pass 事务。
  • 如果 page 是 btree 内部索引页,必须 page 当前最新的修改事务必须早以 evict pass 事务且当前处于 evict queue 中的索引页对象不多于 10 个。
  • 当前 btree 不处于正建立 checkpoint 状态

3、如果本次 evict pass 当前的 btree 有超过 100 个 page 在 evict queue 中或者 btree 处于正在建立 checkpoint 时,结束这个 btree 的扫描,切换到下一个 btree 继续扫描。

4、如果 evict queue 填充满时或者本次扫描遍历了所有 btree,结束本次 evict pass。

PS: 在开始 evict pass 时,evict queue 可能存在有上次扫描且未淘汰出内存的 page,那么这次 evict pass 一定会让 queue 填满(大概 400 个 page)。

评分排序阶段是在 evict pass 后进行的,当 queue 中有 page 时,会根据每个 page 当前的访问次数、 page 类型和淘汰失败次数等计算一个淘汰评分,然后按照评分从小打到进行快排,排序完成后,会根据 queue 中最大分数和最小分数计算一个淘汰边界 evict_throld,queue 中所有大于 evict_throld 的 page 不列为淘汰对象。

WT 为了让 btree 索引页尽量保存在内存中,在评分的时候索引页的分值会加上 1000000 分,让 btree 索引页免受淘汰。

evict pass 最后会做个判断,如果有 follower 线程存在,用系统信号唤醒 follower 进行 evict page。如果系统中没有 follower,leader 线程进行 eivct page 操作。这个模型在 WT-2.81 版本已经修改成抢占模式。

evict page 过程


evict page 其实就是将 evict queue 中的 page 数据先写入到磁盘文件中,然后将内存中的 page 对象销毁回收。整个 evict page 也分为三个阶段:从 evict queue 中获取 page 对象、 hazard pointer 判断和 page 的 reconcile 过程,整个过程的步骤如下:

  1. 从 evict queue 头开始获取 page,如果发现 page 的索引对象不为空,对 page 进行 LOCKED 原子性标记防止其他读事务线程引用并将 page 的索引从 queue 中删除。
  2. 对淘汰的 page 进行 hazard pointer,如果有其他线程对 page 标记 hazard pointer,page 不能被 evict 出内存,将 page 的评分加 100.
  3. 如果没有其他线程对 page 标记 hazard pointer,对 page 进行 reconcile 并销毁 page 内存中的对象。

evict page 的过程大部分是由 follower thread 来执行,这个在上面的线程模型一节中已经描述过。但在一个读写事务开始之前,会先检查 WT Cache 是否有足够的内存空间进行事务执行,如果 WT Cache 的内存容量触及上限阈值,事务执行线程会尝试去执行 evict page 工作,如果 evict page 失败,会进行线程堵塞等待直到 WT Cache 有执行读写事务的内存空间(是不是读写挂起了?)。这种状况一般出现在正在建立 checkpoint 的时候,那么 checkpoint 是怎么引起这个现象的呢?下面来分析缘由。

eviction Cache 与 checkpoint 之间的事


众所周知,建立 checkpoint 的过程是将内存中所有的脏页(dirty page)同步刷入磁盘上并将 redo log 的重演位置设置到最后修改提交事务的 redo log 位置,相对于 WT 引擎来说,就是将 eviction Cache 中的所有脏页数据刷入磁盘但并不将内存中的 page 淘汰出内存。这个过程其实和正常的 evict 过程是冲突的,而且 checkpoint 过程中需要更多的内存完成这项工作,这使得在一个高并发写的数据库中有可能出现挂起的状况发生。为了更好的理解整个问题的细节,我们先来看看 WT checkpoint 的原理和过程。

btree 的 checkpoint


WT 引擎中的 btree 建立 checkpoint 过程还是比较复杂的,过程的步骤也比较多,而且很多步骤会涉及到索引、日志、事务和磁盘文件等。我以 WT-2.7(mongoDB 3.2) 版本为例子,checkpoint 大致的步骤如下图:


在上图中,其中绿色的部分是在开始 checkpoint 事务之前会将所有的 btree 的脏页写入文件 OS Cache 中,如果在高速写的情况下,写的速度接近也 reconcile 的速度,那么这个过程将会持续很长时间,也就是说 OS Cache 中会存在大量未落盘的数据。而且在 WT 中 btree 采用的 copy on write(写时复制)和 extent 技术,这意味 OS Cache 中的文件数据大部分是在磁盘上是连续存储的,那么在绿色框最后一个步骤会进行同步刷盘,这个时候如果 OS Cache 的数据量很大就会造成这个操作长时间占用磁盘 I/O。这个过程是会把所有提交的事务修改都进行 reconcile 落盘操作。

在上图的紫色是真正开始 checkpoint 事务的步骤,这里需要解释的是由于前面绿色步骤刷盘时间会比较长,在这个时间范围里会有新的写事务发生,也就意味着会新的脏页,checkpint 必须把最新提交的事务修改落盘而且还要防止 btree 的分裂,这个时候就会获得 btree 的独占排他式访问,这时 eviction Cache 不能对这个 btree 上的页进行 evict 操作(在这种情况下是不是容易造成 WT Cache 满而挂起读写事务?)。

PS:WT-2.8 版本之后对 checkpoint 改动非常大,主要是针对上面两点做了拆分,防止读写事务挂起发生,但大体过程是差不多的。

写挂起


通过前面的分析大??知道写挂起的原因了,主要引起挂起的现象主要是因为写内存的速度远远高于写磁盘的速度。先来看一份内存和磁盘读写的速度的数据吧。顺序读写的对比:


从上图可以看出,SATA 磁盘的顺序读写 1MB 数据大概需要 8ms,SSD 相对快一点,大概只需 2ms. 但内存的读写远远大于磁盘的速度。 SATA 的随机读取算一次 I/O 时间,大概在 8ms 到 10ms,SSD 的随机读写时间比较快,大概 0.1ms。

我们来分析 checkpoint 时挂起读写事务的几种情况,假设系统在高速写某一张表(每秒以 100MB / S 的速度写入),每 1 分钟做一次 checkpoint。那么 1 分钟后开始进行图 3 中绿色的步骤,这个时候可能会这一分钟写入的脏数据压缩先写入到 OS Cache 中,这个时候可能 OS Cache 可能存有近 2GB 的数据。这 2GB 的 sync 刷到磁盘上的时间至少需要 10 ~ 20 秒,而且磁盘 I/O 是被这个同步刷盘的任务占用了。这个时候有可能发生几件事情:

  1. 外部的写事务还在继续,事务提交时需要写 redo log 文件,这个时候磁盘 I/O 被占用了,写事务挂起等待。
  2. 外部的读写事务还在继续,redo log 文件满了,需要新建一个新的 redo log 文件,但是新建文件需要多次随机 I/O 操作,磁盘 I/O 暂时无法调度来创建文件,所有写事务挂起。
  3. 外部读写事务线程还在继续,因为 WT Cache 触发上限阈值需要 evict page。 Evict page 时也会调用 reconcile 将 page 写入 OS Cache,但这个文件的 OS Cache 正在进行 sync,evict page 只能等 sync 完成才能写入 OS Cache,evict page 线程挂起,其他读写事务在开始时会判断是否有足够的内存进行事务执行,如果没有足够内存,所有读写事务挂起。

这三种情况是因为阶段性 I/O 被耗光而造成读写事务挂起的。

在图 3 紫色步骤中, checkpoint 事务开始后会先获得 btree 的独占排他访问方式,这意味这个 btree 对象上的 page 不能进行 evict, 如果这个 btree 索引正在进行高速写入,有可能让 checkpoint 过程中数据页的 reconcile 时间很长,从而耗光 WT Cache 内存造成读写事务挂起现象,这个现象极为在测试中极为少见(碰见过两次)。 要解决这几个问题只要解决内存和磁盘 I/O 不对等的问题就可以了。

内存和磁盘 I/O 的权衡


引起写挂起问题的原因多种多样,但归根结底是因为内存和磁盘速度不对称的问题。因为 WT 的设计原则就是让数据尽量利用现代计算机的超大内存,可是内存中的脏数据在 checkpoint 时需要同步写入磁盘造成瞬间 I/O 很高,这是矛盾的。要解决这些问题个人认为有以下几个途径:

  1. 将 MongoDB 的 WT 版本升级到 2.8,2.8 版本对 evict queue 模型做了分级,尽量避免 evict page 过程中堵塞问题 ,2.8 的 checkpoint 机制不在是分为预前刷盘和 checkpoint 刷盘,而是采用逐个对 btree 直接做 checkpoint 刷盘,缓解了 OS Cache 缓冲太多的文件脏数据问题。
  2. 试试 direct I/O 或许会有不同的效果,WT 是支持 direct I/O 模式。笔者试过 direct I / O 模式,让 WT Cache 彻底接管所有的物理内存管理,写事务的并发会比 MMAP 模式少 10%,但没有出现过超过 1 秒的写延迟问题。
  3. 尝试将 WT Cache 设小点,大概设置成整个内存的 1 / 4 左右。这种做法是可以缓解 OS Cache 中瞬间缓存太多文件脏数据的问题,但会引起 WT Cache 频繁 evict page 和频繁的 leader-follower 线程上下文切换。而且这种机制也依赖于 OS page Cache 的刷盘周期,周期太长效果不明显。

  4. 用多个磁盘来存储,redo log 文件放在一个单独的机械磁盘上,数据放在单独一个磁盘上,避免 redo log 与 checkpoint 刷盘发生竞争。
  5. 有条件的话,换成将磁盘换成 SSD 吧。这一点比较难,mongoDB 现在也大量使用在 OLAP 和大数据存储,而高速写的场景都发生这些场景,成本是个问题。如果是 OLTP 建议用 SSD。

这些方法只能缓解读写事务挂起的问题,不能说彻底解决这个问题,WT 引擎发展很快,开发团队正对 WT eviction Cache 和 checkpoint 正在做优化,这个问题慢慢变得不再是问题,尤其是 WT-2.8 版本,大量的模型和代码优化都是集中在这个问题上。

后记


WT 的 eviction Cache 可能有很多不完善的地方,也确实给我们在使用的过程造成了一些困挠,应该用中立的角度去看待它。可以说它的读写并发速度是其他数据库引擎不能比的,正是由于它很快,才会有写挂起的问题,因为磁盘的速度就那么快。以上的分析和建议或许对碰到类似问题的同学有用。

WT 团队的研发速度也很快,每年会发布 2 到 3 个版本,这类问题是他们正在重点解决的问题。在国内也有很多 mongoDB 这方面相关的专家,他们在解决此类问题有非常丰富的经验,也可以请求他们来帮忙解决这类问题。

在本文问题分析过程中得到了阿里云张友东的帮助,在此表示感谢。

对 WiredTiger 及 MongoDB 引擎设计及使用感兴趣的同学,欢迎在本文留言,介绍对 WiredTiger/MongoDB 的使用及了解,我们将邀请评论中有意向的同学与本文作者及业内相关专家在 『高可用架构—WiredTiger/MongoDB』 微信群进行交流。

技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。转载请注明来自高可用架构「ArchNotes」微信公众号及包含以下二维码。

高可用架构

改变互联网的构建方式


长按二维码 关注「高可用架构」公众号

原文来自: 高可用架构

掌握聚合最新动态了解行业最新趋势
API接口,开发服务,免费咨询服务
新闻动态 > 媒体报道
MongoDB 3.0挂起原因?WiredTiger实现:一个LRU cache深坑引发的分析
发布:2016-09-14

【作者简介:袁荣喜,学霸君工程师,2015 年加入学霸君,负责网络实时传输和分布式系统的架构设计和实现,专注于基础技术领域,在网络传输、数据库内核、分布式系统和并发编程方面有一定了解。】

从 MongoDB 3.0 版本引入 WiredTiger 存储引擎(以下称为 WT)以来,一直有同学反应在高速写入数据时 WT 引擎会间歇性写挂起,有时候写延迟达到了几十秒,这确实是个严重的问题。

引起这类问题的关键在于 WT 的 LRU Cache 的设计模型,WT 在设计 LRU Cache 时采用分段扫描标记和 hazard pointer 的淘汰机制,在 WT 内部称这种机制叫 eviction Cache 或者 WT Cache,其设计目标是充分利用现代计算机超大内存容量来提高事务读写并发。在高速不间断写时内存操作是非常快的,但是内存中的数据最终必须写入到磁盘上,将页数据(page)由内存中写入磁盘上是需要写入时间,必定会和应用程序的高速不间断写产生竞争,这在任何数据库存储引擎都是无法避免的,只是由于 WT 利用大内存和写无锁的特性,让这种不平衡现象更加显著。

下图是一位网名叫 chszs 同学对 mongoDB 3.0 和 3.2 版本测试高速写遇到的 hang 现象。


从上图可以看出,数据周期性出现了 hang 现象,笔者在单独对 WT 进行高并发顺序写时遇到的情况和上图基本一致,有时候挂起长达 20 秒。针对此问题我结合 WT 源码和调试测试进行了分析,基本得出的结论如下:

  1. WT 引擎的 eviction Cache 实现时没有考虑 LRU Cache 的分级淘汰,只是通过扫描 btree 来标记,这使得它和一些独占式 btree 操作(例如: checkpoint)容易发生竞争。
  2. WT btree 的 checkpoint 机制设计存在 bug,在大量并发写事务发生时,checkpoint 需要很长时间才能完成,造成刷入磁盘的数据很大,写盘时间很长。容易引起 Cache 满而挂起所有的读写操作。
  3. WT 引擎的 redo log 文件超过 1GB 大小后就会另外新建一个新的 redo log 文件来继续存储新的日志,在操作系统层面上新建一个文件的是需要多次 I/O 操作,一旦和 checkpoint 数据刷盘操作同时发生,所有的写也就挂起了。

要彻底弄清楚这几个问题,就需要对从 WT 引擎的 eviction Cache 原理来剖析,通过分析原理找到解决此类问题的办法。先来看 eviction Cache 是怎么实现的,为什么要这么实现。

eviction cahce 原理


eviction Cache 是一个 LRU Cache,即页面置换算法缓冲区,LRU Cache 最早出现的地方是操作系统中关于虚拟内存和物理内存数据页的置换实现,后被数据库存储引擎引入解决内存和磁盘不对等的问题。所以 LRU Cache 主要是解决内存与数据大小不对称的问题,让最近将要使用的数据缓存在 Cache 中,把最迟使用的数据淘汰出内存,这是 LRU 置换算法的基本原则。但程序代码是无法预测未来的行为,只能根据过去数据页的情况来确定,一般我们认为过去经常使用的数据比不常用的数据未来被访问的概率更高,很多 LRU Cache 大部分是基于这个规则来设计。

WT 的 eviction Cache 也不例外的遵循了这个 LRU 原理,不过 WT 的 eviction Cache 对数据页采用的是分段局部扫描和淘汰,而不是对内存中所有的数据页做全局管理。基本思路是一个线程阶段性的去扫描各个 btree,并把 btree 可以进行淘汰的数据页添加到一个 LRU queue 中,当 queue 填满了后记录下这个过程当前的 btree 对象和 btree 的位置(这个位置是为了作为下次阶段性扫描位置),然后对 queue 中的数据页按照访问热度排序,最后各个淘汰线程按照淘汰优先级淘汰 queue 中的数据页,整个过程是周期性重复。 WT 的这个 evict 过程涉及到多个 eviction thread 和 hazard pointer 技术。

WT 的 evict 过程都是以 page 为单位做淘汰,而不是以 K/V。这一点和 Memcache、 Redis 等常用的缓存 LRU 不太一样,因为在磁盘上数据的最小描述单位是 page block,而不是记录。

eviction 线程模型


从上面的介绍可以知道 WT 引擎的对 page 的 evict 过程是个多线程协同操作过程,WT 在设计的时候采用一种叫做 leader-follower 的线程模型,模型示意图如下:


Leader thread 负责周期性扫描所有内存中的 btree 索引树,将符合 evict 条件的 page 索引信息填充到 eviction queue,当填充 queue 满时,暂停扫描并记录下最后扫描的 btree 对象和 btree 上的位置,然后对 queue 中的 page 按照事务的操作次数和访问次做一次淘汰评分,再按照评分从小到大做排序。也就是说最评分越小的 page 越容易淘汰。下个扫描阶段的起始位置就是上个扫描阶段的结束位置,这样能保证在若干个阶段后所有内存中的 page 都被扫描过一次,这是为了公平性。

这里必须要说明的是一次扫描很可能只是扫描内存一部分 btree 对象,而不是全部,所以我对这个过程称为阶段性扫描(evict pass),它不是对整个内存中的 page 做评分排序。这个阶段性扫描的间隔时间是 100 毫秒,而触发这个 evict pass 的条件就是 WT Cache 管理的内存超出了设置的阈值,这个在后面的 eviction Cache 管理的内存小节中详细介绍。

在 evict pass 后,如果 evction queue 中有等待淘汰的 page 存在就会触发一个操作系统信号来激活 follower thread 来进行 evict page 工作。虽然 evict pass 的间隔时间通常是 100 毫秒,这里有个问题就是当 WT Cache 的内存触及上限并且有大量写事务发生时,读写事务线程在事务开始时会唤醒 leader thread 和 follower thread,这就会产生大量的操作系统上下文切换,系统性能急剧下降。好在 WT-2.8 版本修复了这个问题,leader follower 通过抢锁来成为 leader,通过多线程信号合并和周期性唤醒来 follower,而且 leader thread 也承担 evict page 的工作,可以避免大部分的线程唤醒和上下文切换。是不是有点像 Nginx 的网络模型?

hazard pointer


hazard pointer 是一个无锁并发技术,其应用场景是单个线程写和多个线程读的场景,大致的原理是这样的,每个读的线程设计一个与之对应的无锁数组用于标记这个线程引用的 hazard pointer 对象。读线程的步骤如下:

  1. 读线程在访问某个 hazard pointer 对象时,先将在自己的标记数组中标记访问的对象。
  2. 读线程在访问完毕某个 hazard pointer 对象时,将其对应的标记从标记数组中删除。

写线程的步骤大致是这样的,写线程如果需要对某个 hazard pointer 对象写时,先判断所有读线程是否标记了这个对象,如果标记了,放弃写。如果未标记,进行写。

关于 hazard pointer 理论可以访问Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects

Hazard pointer 是怎样应用在 WT 中呢?我们这样来看待这个事情,把内存 page 的读写看做 hazard pointer 的读操作,把 page 从内存淘汰到磁盘上的过程看做 hazard pointer 的写操作,这样瞬间就能明白为什么 WT 在页的操作上可以不遵守 The FIX Rules 规则,而是采用无锁并发的页操作。要达到这种访问方式有个条件就是内存中 page 本身的结构要支持 lock free 访问,这个在剖析 WiredTiger 数据页无锁及压缩一文中介绍过了。从上面的描述可以看出 evict page 的过程中首先要做一次 hazard pointer 写操作检查,而后才能进行 page 的 reconcile 和数据落盘。

hazard pointer 并发技术的应用是整个 WT 存储引擎的关键,它关系到 btree 结构、 internal page 的构造、事务线程模型、事务并发等实现。 Hazard pointer 使得 WT 不依赖 The Fix Rules 规则,也让 WT 的 btree 结构更加灵活多变。

Hazard pointer 是比较新的无锁编程模式,可以应用在很多地方,笔者曾在一个高并发媒体服务器上用到这个技术,以后有机会把里面的技术细节分享出来。

eviction Cache 管理的内存


eviction Cache 其实就是内存管理和 page 淘汰系统,目标就是为了使得管辖的内存不超过物理内存的上限,而触发淘汰 evict page 动作的基础依据就是内存上限。 eviction Cache 管理的内存就是内存中 page 的内存空间,page 的内存分为几部分:

  1. 从磁盘上读取到已经刷盘的数据,在 page 中称作 disk buffer。如果 WT 没有开启压缩且使用的 MMAP 方式读写磁盘,这个 disk buffer 的数据大小是不计在 WT eviction Cache 管理范围之内的。如果是开启压缩,会将从 MMAP 读取到的 page 数据解压到一个 WT 分配的内存中,这个新分配的内存是计在 WT eviction Cache 中的。
  2. Page 在内存中新增的修改事务数据内存空间,计入在 eviction Cache 中。
  3. Page 基本的数据结构所有的内存空间,计入在 eviction Cache 中。

PS: 关于 page 结构和内存相关的细节请查看 剖析 WiredTiger 数据页无锁及压缩

WT 在统计 page 的内存总量是通过一个 footprint 机制来统计两项数据,一项是总的内存使用量 mem_size,一项是增删改造成的脏页数据总量 dirty_mem_size。统计方式很简单,就是每次对页进行载入、增删改、分裂和销毁时对上面两项数据做原子增加或者减少计数,这样可以精确计算到当前系统中 WT 引擎内存占用量。假设引擎外部配置最大内存空间为 cache_size,内存上限触发 evict 的比例为 80%,内存脏页上限触发 evict 的比例为 75%. 那么系统触发 evict pass 操作的条件为:

mem_size > cache_size * 80%

或者

dirty_mem_size > cache_size * 75%

满足这个条件 leader 线程就会进行 evict pass 阶段性扫描并填充 eivction queue,最后驱使 follower 线程进行 evict page 操作。

evict pass 策略


前面介绍过 evict pass 是一个阶段性扫描的过程,整个过程分为扫描阶段、评分排序阶段和 evict 调度阶段。扫描阶段是通过扫描内存中 btree,检查 btree 在内存中的 page 对象是否可以进行淘汰。扫描步骤如下:

1、根据上次 evict pass 最后扫描的 btree 和它对应扫描的位置最为本次 evict pass 的起始位置,如果当前扫描的 btree 被其他事务线程设成独占访问方式,跳过当前 btree 扫描下个 btree 对象

2、进行 btree 遍历扫描,如果 page 满足淘汰条件,将 page 的索引对象添加到 evict queue 中,淘汰条件为:

  • 如果 page 是数据页,必须 page 当前最新的修改事务必须早以 evict pass 事务。
  • 如果 page 是 btree 内部索引页,必须 page 当前最新的修改事务必须早以 evict pass 事务且当前处于 evict queue 中的索引页对象不多于 10 个。
  • 当前 btree 不处于正建立 checkpoint 状态

3、如果本次 evict pass 当前的 btree 有超过 100 个 page 在 evict queue 中或者 btree 处于正在建立 checkpoint 时,结束这个 btree 的扫描,切换到下一个 btree 继续扫描。

4、如果 evict queue 填充满时或者本次扫描遍历了所有 btree,结束本次 evict pass。

PS: 在开始 evict pass 时,evict queue 可能存在有上次扫描且未淘汰出内存的 page,那么这次 evict pass 一定会让 queue 填满(大概 400 个 page)。

评分排序阶段是在 evict pass 后进行的,当 queue 中有 page 时,会根据每个 page 当前的访问次数、 page 类型和淘汰失败次数等计算一个淘汰评分,然后按照评分从小打到进行快排,排序完成后,会根据 queue 中最大分数和最小分数计算一个淘汰边界 evict_throld,queue 中所有大于 evict_throld 的 page 不列为淘汰对象。

WT 为了让 btree 索引页尽量保存在内存中,在评分的时候索引页的分值会加上 1000000 分,让 btree 索引页免受淘汰。

evict pass 最后会做个判断,如果有 follower 线程存在,用系统信号唤醒 follower 进行 evict page。如果系统中没有 follower,leader 线程进行 eivct page 操作。这个模型在 WT-2.81 版本已经修改成抢占模式。

evict page 过程


evict page 其实就是将 evict queue 中的 page 数据先写入到磁盘文件中,然后将内存中的 page 对象销毁回收。整个 evict page 也分为三个阶段:从 evict queue 中获取 page 对象、 hazard pointer 判断和 page 的 reconcile 过程,整个过程的步骤如下:

  1. 从 evict queue 头开始获取 page,如果发现 page 的索引对象不为空,对 page 进行 LOCKED 原子性标记防止其他读事务线程引用并将 page 的索引从 queue 中删除。
  2. 对淘汰的 page 进行 hazard pointer,如果有其他线程对 page 标记 hazard pointer,page 不能被 evict 出内存,将 page 的评分加 100.
  3. 如果没有其他线程对 page 标记 hazard pointer,对 page 进行 reconcile 并销毁 page 内存中的对象。

evict page 的过程大部分是由 follower thread 来执行,这个在上面的线程模型一节中已经描述过。但在一个读写事务开始之前,会先检查 WT Cache 是否有足够的内存空间进行事务执行,如果 WT Cache 的内存容量触及上限阈值,事务执行线程会尝试去执行 evict page 工作,如果 evict page 失败,会进行线程堵塞等待直到 WT Cache 有执行读写事务的内存空间(是不是读写挂起了?)。这种状况一般出现在正在建立 checkpoint 的时候,那么 checkpoint 是怎么引起这个现象的呢?下面来分析缘由。

eviction Cache 与 checkpoint 之间的事


众所周知,建立 checkpoint 的过程是将内存中所有的脏页(dirty page)同步刷入磁盘上并将 redo log 的重演位置设置到最后修改提交事务的 redo log 位置,相对于 WT 引擎来说,就是将 eviction Cache 中的所有脏页数据刷入磁盘但并不将内存中的 page 淘汰出内存。这个过程其实和正常的 evict 过程是冲突的,而且 checkpoint 过程中需要更多的内存完成这项工作,这使得在一个高并发写的数据库中有可能出现挂起的状况发生。为了更好的理解整个问题的细节,我们先来看看 WT checkpoint 的原理和过程。

btree 的 checkpoint


WT 引擎中的 btree 建立 checkpoint 过程还是比较复杂的,过程的步骤也比较多,而且很多步骤会涉及到索引、日志、事务和磁盘文件等。我以 WT-2.7(mongoDB 3.2) 版本为例子,checkpoint 大致的步骤如下图:


在上图中,其中绿色的部分是在开始 checkpoint 事务之前会将所有的 btree 的脏页写入文件 OS Cache 中,如果在高速写的情况下,写的速度接近也 reconcile 的速度,那么这个过程将会持续很长时间,也就是说 OS Cache 中会存在大量未落盘的数据。而且在 WT 中 btree 采用的 copy on write(写时复制)和 extent 技术,这意味 OS Cache 中的文件数据大部分是在磁盘上是连续存储的,那么在绿色框最后一个步骤会进行同步刷盘,这个时候如果 OS Cache 的数据量很大就会造成这个操作长时间占用磁盘 I/O。这个过程是会把所有提交的事务修改都进行 reconcile 落盘操作。

在上图的紫色是真正开始 checkpoint 事务的步骤,这里需要解释的是由于前面绿色步骤刷盘时间会比较长,在这个时间范围里会有新的写事务发生,也就意味着会新的脏页,checkpint 必须把最新提交的事务修改落盘而且还要防止 btree 的分裂,这个时候就会获得 btree 的独占排他式访问,这时 eviction Cache 不能对这个 btree 上的页进行 evict 操作(在这种情况下是不是容易造成 WT Cache 满而挂起读写事务?)。

PS:WT-2.8 版本之后对 checkpoint 改动非常大,主要是针对上面两点做了拆分,防止读写事务挂起发生,但大体过程是差不多的。

写挂起


通过前面的分析大??知道写挂起的原因了,主要引起挂起的现象主要是因为写内存的速度远远高于写磁盘的速度。先来看一份内存和磁盘读写的速度的数据吧。顺序读写的对比:


从上图可以看出,SATA 磁盘的顺序读写 1MB 数据大概需要 8ms,SSD 相对快一点,大概只需 2ms. 但内存的读写远远大于磁盘的速度。 SATA 的随机读取算一次 I/O 时间,大概在 8ms 到 10ms,SSD 的随机读写时间比较快,大概 0.1ms。

我们来分析 checkpoint 时挂起读写事务的几种情况,假设系统在高速写某一张表(每秒以 100MB / S 的速度写入),每 1 分钟做一次 checkpoint。那么 1 分钟后开始进行图 3 中绿色的步骤,这个时候可能会这一分钟写入的脏数据压缩先写入到 OS Cache 中,这个时候可能 OS Cache 可能存有近 2GB 的数据。这 2GB 的 sync 刷到磁盘上的时间至少需要 10 ~ 20 秒,而且磁盘 I/O 是被这个同步刷盘的任务占用了。这个时候有可能发生几件事情:

  1. 外部的写事务还在继续,事务提交时需要写 redo log 文件,这个时候磁盘 I/O 被占用了,写事务挂起等待。
  2. 外部的读写事务还在继续,redo log 文件满了,需要新建一个新的 redo log 文件,但是新建文件需要多次随机 I/O 操作,磁盘 I/O 暂时无法调度来创建文件,所有写事务挂起。
  3. 外部读写事务线程还在继续,因为 WT Cache 触发上限阈值需要 evict page。 Evict page 时也会调用 reconcile 将 page 写入 OS Cache,但这个文件的 OS Cache 正在进行 sync,evict page 只能等 sync 完成才能写入 OS Cache,evict page 线程挂起,其他读写事务在开始时会判断是否有足够的内存进行事务执行,如果没有足够内存,所有读写事务挂起。

这三种情况是因为阶段性 I/O 被耗光而造成读写事务挂起的。

在图 3 紫色步骤中, checkpoint 事务开始后会先获得 btree 的独占排他访问方式,这意味这个 btree 对象上的 page 不能进行 evict, 如果这个 btree 索引正在进行高速写入,有可能让 checkpoint 过程中数据页的 reconcile 时间很长,从而耗光 WT Cache 内存造成读写事务挂起现象,这个现象极为在测试中极为少见(碰见过两次)。 要解决这几个问题只要解决内存和磁盘 I/O 不对等的问题就可以了。

内存和磁盘 I/O 的权衡


引起写挂起问题的原因多种多样,但归根结底是因为内存和磁盘速度不对称的问题。因为 WT 的设计原则就是让数据尽量利用现代计算机的超大内存,可是内存中的脏数据在 checkpoint 时需要同步写入磁盘造成瞬间 I/O 很高,这是矛盾的。要解决这些问题个人认为有以下几个途径:

  1. 将 MongoDB 的 WT 版本升级到 2.8,2.8 版本对 evict queue 模型做了分级,尽量避免 evict page 过程中堵塞问题 ,2.8 的 checkpoint 机制不在是分为预前刷盘和 checkpoint 刷盘,而是采用逐个对 btree 直接做 checkpoint 刷盘,缓解了 OS Cache 缓冲太多的文件脏数据问题。
  2. 试试 direct I/O 或许会有不同的效果,WT 是支持 direct I/O 模式。笔者试过 direct I / O 模式,让 WT Cache 彻底接管所有的物理内存管理,写事务的并发会比 MMAP 模式少 10%,但没有出现过超过 1 秒的写延迟问题。
  3. 尝试将 WT Cache 设小点,大概设置成整个内存的 1 / 4 左右。这种做法是可以缓解 OS Cache 中瞬间缓存太多文件脏数据的问题,但会引起 WT Cache 频繁 evict page 和频繁的 leader-follower 线程上下文切换。而且这种机制也依赖于 OS page Cache 的刷盘周期,周期太长效果不明显。

  4. 用多个磁盘来存储,redo log 文件放在一个单独的机械磁盘上,数据放在单独一个磁盘上,避免 redo log 与 checkpoint 刷盘发生竞争。
  5. 有条件的话,换成将磁盘换成 SSD 吧。这一点比较难,mongoDB 现在也大量使用在 OLAP 和大数据存储,而高速写的场景都发生这些场景,成本是个问题。如果是 OLTP 建议用 SSD。

这些方法只能缓解读写事务挂起的问题,不能说彻底解决这个问题,WT 引擎发展很快,开发团队正对 WT eviction Cache 和 checkpoint 正在做优化,这个问题慢慢变得不再是问题,尤其是 WT-2.8 版本,大量的模型和代码优化都是集中在这个问题上。

后记


WT 的 eviction Cache 可能有很多不完善的地方,也确实给我们在使用的过程造成了一些困挠,应该用中立的角度去看待它。可以说它的读写并发速度是其他数据库引擎不能比的,正是由于它很快,才会有写挂起的问题,因为磁盘的速度就那么快。以上的分析和建议或许对碰到类似问题的同学有用。

WT 团队的研发速度也很快,每年会发布 2 到 3 个版本,这类问题是他们正在重点解决的问题。在国内也有很多 mongoDB 这方面相关的专家,他们在解决此类问题有非常丰富的经验,也可以请求他们来帮忙解决这类问题。

在本文问题分析过程中得到了阿里云张友东的帮助,在此表示感谢。

对 WiredTiger 及 MongoDB 引擎设计及使用感兴趣的同学,欢迎在本文留言,介绍对 WiredTiger/MongoDB 的使用及了解,我们将邀请评论中有意向的同学与本文作者及业内相关专家在 『高可用架构—WiredTiger/MongoDB』 微信群进行交流。

技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。转载请注明来自高可用架构「ArchNotes」微信公众号及包含以下二维码。

高可用架构

改变互联网的构建方式


长按二维码 关注「高可用架构」公众号

原文来自: 高可用架构

×
企业用户认证,
可获得1000次免费调用
注册登录 > 企业账户认证 > 领取接口包
企业用户认证领取接口包 立即领取
× 企业用户认证,
可获得1000次免费调用,立即领取>
数 据 驱 动 未 来
Data Drives The Future