这篇文章翻译自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中的状态信息。