MySQL:无锁可扩展的 WAL 设计
这篇文章翻译自MySQL官方文档,介绍了 8.0 在预写式日志上实现上的修改,我把核心观点总结如下:
在 8.0 以前,为了保证 flush list 的顺序,redo log buffer 写入过程需要加锁,无法实现并行,高并发的环境中,会同时有非常多的 min-transaction(mtr)需要拷贝数据到 Log Buffer,如果通过锁互斥,那么毫无疑问这里将成为明显的性能瓶颈。
为此,从 MySQL 8.0 开始,设计了一套无锁的写 log 机制,其核心思路是引入 recent_written,允许不同的 mtr,同时并发地写 Log Buffer 的不同位置。
1、写入 RedoLog 存在性能瓶颈
预写日志 (WAL) 是数据库最重要的组件之一,对数据文件的所有更改都记录在 WAL 中(在 InnoDB 中称为 Redo 日志),并且推迟将修改的页面刷新(Flushed)到磁盘的时间,同时仍然防止数据丢失。
在写入 Redo 日志时,数据密集型写入服务的性能受到线程同步的限制会明显下降。在具有多个 CPU 内核和快速存储设备(例如现代 SSD 磁盘)的服务器上测试性能时,这一点尤为明显。

我们需要一种新的设计来解决我们的客户和用户现在和将来面临的问题。通过新设计,我们希望确保它可以与现有 API 一起使用,最重要的是不会破坏 InnoDB 其余部分所依赖的契约,在这些限制条件下,这是一项具有挑战性的任务。

重做日志可以看作是一个生产者/消费者持久化队列。执行更新的用户线程可以看作是生产者,当 InnoDB 必须进行崩溃恢复时,恢复线程就是消费者。 InnoDB 服务在预期内正常工作时不需要读取 Redo log。

2、多线程写 Redo 日志的顺序问题
实现具有多个生产者的写日志可扩展模型只是问题的一部分。还有一些 InnoDB 特定的细节也需要起作用。最大的挑战是保持 buffer pool 中 flush list 上的脏页要满足按照 LSN 递增排布。
首先, 一个 buffer pool 实例维护一个 flush list, 由 mtr(mini transaction)负责原子的应用对物理页的修改, 因此, mtr 是 InnoDB 对物理文件操作的最小事务单元。 redo log 由 mtr 产生, 通常先写在 mtr 的 cache 里, 在 mtr 提交时, 将 cache 中的 redo log 刷入 log buffer(公共 buffer), 同时递增全局维护的日志编号(LSN, Log Sequence Number)。
随后 Mtr 负责将修改的脏页(或一列表脏页)加入到 flush list 上, 且满足 flush list 上的脏页是按照 LSN 递增排序的。
在 8.0 之前的实现中, 我们通过加内部锁 log_sys_t::mutex 和 log_sys_t::flush_order_mutex 来保证 flush list 上页按照写 log buffer 拿到的 LSN 有序。

因此, 8.0 前的工作方式如下: 某个 mtr 将脏页输入 flush list 时, 会持有锁 flush_order_mutex, 这时, 即便另外一个线程 A 需要添加脏页到其他 bp(buffer pool)的 flush list, 也必须陷入等待。 这种情况下, 这个线 A 程通过持有 log_sys_t::mutex, 阻塞其他线程写 log buffer。 单纯移除这两个锁结构, 会使得 flush list 中 LSN 递增的约束不工作。

3、解决 log buffer 上的 LSN 可能不连续
我们还面临的第二个问题是, 由于各个事务可以交叉拷贝 redolog 到 log buffer 中, log buffer 上的 LSN 可能存在空洞(如下图所示), 所以 log buffer 是不可以一口气 flush full log buffer。

我们通过跟踪已经完成的写 log buffer 操作(如下图所示的)来解决第二个问题。
在设计上我们引入一个新的无锁数据结构(元素排列与原先 log buffer 对应关系如下图)。

数据结构如下图所示。 首先这是一个定长数组, 并且保证数组元素(slot)更新是原子的, 以环形形式复用已经释放的空间(所以是个环形数组啊哈)。 并启用单独的线程负责数组的遍历和空间回收, 线程在遇到空元素(empty slot)时暂停。 因此这个 thread 中还维护了这个数据结构中的最大可达 LSN, 我们假设值为 M。

我们引入了这个数据结构的两个变量: recent_written 和 recent_closed。 recent_written 维护写 log buffer 的完成状态, recent_written 中维护的最大 LSN, M 表示, 所有小于这个 M 的 LSN 都已经将它的 redo log 写入 log buffer。 而这个 M 也是(如果这下 crash, 可能会触发的)崩溃恢复的截止位点, 同时也是下一个写 log buffer 操作的开始位点。
刷 log buffer 到磁盘和遍历 recent_written 是交由一个线程完成, 因此对 log buffer 的内存读写操作通过 recent_written 上顺序访问元素(slots)形成的屏障保证。

假设当前 log buffer 和 recent_written 状态如上图所示, 然后完成了一次 buffer log 写, 使得 log buffer 状态如下图所示。

log buffer 状态更新触发特定线程(log_writter)向后扫描 recent_written, (如下图)

然后更新它维护的最大可达 LSN 值(可以保证无空洞的), 写入到变量 buf_ready_for_write_lsn (这个变量顾名思义 XD)

我们还引入另一个无锁结构体的变量 recent_closed, 用来完成原先 log_sys_t::flush_order_mutex 锁所作的工作, 用来维护 flush list 的 LSN 递增性质。 在介绍实现细节前, 我们还需要解释一些细节来, 才能清晰的阐释图和使用无锁结构维护(flush list/bp)整体的 LSN 单调。
4、使用 CheckPoint 保证 flush list 正确
那么首先, 每个 bp 中的 flush list 有专门的内部锁保护。 但是我们已经移除了了锁结构 log_sys_t::flush_order_mutex, 这就使得并发写 flush list 的 LSN 递增性质保证不了。
虽然如此, flush list 正确工作仍然必须满足以下两个原生约束:
Checkpoint - 对于检查点推进的约束: 假设存在脏页 P1, LSN = L1, 脏页 P2, LSN = L2, 如果 L2 > L1, 且 P1 脏页未落盘, 不允许刷新 L2 对应的脏页到磁盘。
FLushing - flush list 上刷脏策略约束: 每次 flush 必须从 oldest page(即, page 对应的 LSN 最小)开始。 这个操作保证最早被修改的也最先从 flush list 更新到磁盘, 同时还向后推进 checkpoint_lsn。
新引入的无锁结构 recent_closed, 用来跟踪并发写 flush list 的状态, 同时也维护一个最大 LSN, 我们标记为 M, 满足, 小于当前 LSN 的脏页都已经加入到 flush list 中。
只有在 M 值与当前线程不那么远的时候, 才能将它的脏页刷 flush list。 在线程将脏页写入 flush list 后, 更新 recent_closed 中的状态信息。

版权声明: 本文为 InfoQ 作者【互联网工科生】的原创文章。
原文链接:【http://xie.infoq.cn/article/37c1ff8ce0e2df8bff12fab56】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论