天天看点

MySQL 8.0:新的无锁,可扩展的WAL设计

Write Ahead Log(WAL)是数据库中最重要的组件之一。对数据文件的所有更改都记录在WAL中(称为InnoDB中的重做日志)。这允许推迟将修改后的页面刷新到磁盘的时刻,仍然可以防止数据丢失。

在写入重做日志时,写入密集型工作负载的性能受到同步的限制,其中涉及许多用户线程。在具有多个CPU核心和快速存储设备(如现代SSD磁盘)的服务器上测试性能时,这一点尤为明显。

MySQL 8.0:新的无锁,可扩展的WAL设计

我们需要一种新设计来解决当前和未来客户和用户所面临的问题。调整旧设计以实现可扩展性不再是一种选择。新设计也必须具有灵活性,以便我们可以将其扩展为将来进行分片和并行写入。使用新设计,我们希望确保它能够与现有API一起使用,最重要的是不要违反InnoDB其余部分所依赖的合同。在这些限制下的挑战性任务。

MySQL 8.0:新的无锁,可扩展的WAL设计

重做日志可以看作是生产者/消费者持久队列。执行更新的用户线程可以看作是生产者,当InnoDB必须进行崩溃恢复时,恢复线程就是消费者。服务器运行时,InnoDB不会从重做日志中读取。

MySQL 8.0:新的无锁,可扩展的WAL设计

但是,编写具有多个生产者的可扩展日志只是问题的一部分。还有InnoDB特定的细节也需要工作。最大的挑战是保留脏页面列表的总顺序(也就是刷新列表)。每个缓冲池有一个。对页面的更改应用于所谓的迷你事务(mtr)中,这允许以原子方式修改多个页面。当一个迷你事务提交时,它会将自己的日志记录写入日志缓冲区,从而增加名为LSN(日志序列号)的全局修改号。mtr具有需要添加到缓冲池特定刷新列表的脏页列表。每个刷新列表在LSN上排序。在旧设计中,我们保存了log_sys_t :: mutex和log_sys_t :: flush_order_mutex 以锁步方式确保修改LSN上的总订单保持在刷新列表中。

MySQL 8.0:新的无锁,可扩展的WAL设计

请注意,当某个mtr添加其脏页(持有flush_order_mutex)时,另一个线程可能正在等待获取flush_order_mutex(即使它想要将页面添加到其他刷新列表)。在这种情况下,等待线程持有log_sys_t :: mutex (以维持总顺序),因此任何其他想要写入日志缓冲区的线程都必须等待...删除这些互斥锁后,无法保证顺序刷新清单。

MySQL 8.0:新的无锁,可扩展的WAL设计

第二个问题是我们无法将完整的日志缓冲区写入磁盘,因为LSN序列中可能存在漏洞,因为对日志缓冲区的写入没有按任何特定顺序完成。

MySQL 8.0:新的无锁,可扩展的WAL设计

第二个问题的解决方案是跟踪哪些写入完成,为此我们发明了一种新的无锁数据结构。

MySQL 8.0:新的无锁,可扩展的WAL设计

新数据结构具有固定大小的插槽阵列。插槽以原子方式更新并以循环方式重用。单个线程用于遍历和清除它们,在一个洞(空槽)处暂停。该线程更新最大可达LSN(M)。

MySQL 8.0:新的无锁,可扩展的WAL设计

使用了此数据结构的两个实例:recent_written和recent_closed。最近写的instance用于跟踪对日志缓冲区的已完成写入。它可以提供最大LSN,从而完成对较小LSN值的所有写入日志缓冲区的写入。潜在的崩溃恢复需要在这样的LSN处结束,因此它是我们考虑下一次写入的最大LSN。插槽由相同的线程遍历,然后将日志缓冲区写入磁盘。读取/写入插槽时设置的障碍保证了对日志缓冲区的读写操作的正确内存顺序。

MySQL 8.0:新的无锁,可扩展的WAL设计

我们来看看上面的图片。假设我们再次写入日志缓冲区:

MySQL 8.0:新的无锁,可扩展的WAL设计

现在,专用线程(log_writer)进入,遍历插槽:

MySQL 8.0:新的无锁,可扩展的WAL设计

并更新没有漏洞的最大LSN可达 - buf_ready_for_write_lsn:

MySQL 8.0:新的无锁,可扩展的WAL设计

新数据结构的recent_closed实例用于解决与缺少log_sys_t :: flush_order_mutex相关的问题。要理解刷新列表顺序问题和无锁解决方案,需要更详细的解释。

单个刷新列表受其内部互斥锁保护。但是我们不再保证按照增加LSN值的顺序将脏页添加到刷新列表的保证。但是,必须满足的两个约束是:

  1. 检查点- 如果LSN = L1有一个脏页,其中L1 <L2,我们不能在LSN = L2处写模糊检查点。那是因为恢复从这样的checkpoint_lsn开始。
  2. 法拉盛-  红晕列表冲洗应始终从冲洗列表中最早的网页。这样我们更喜欢刷新很久以前修改过的页面,并且还有助于推进checkpoint_lsn。

在recent_closed实例中,我们跟踪将脏页添加到刷新列表的并发执行,并跟踪最大LSN(称为M),以便完成较小LSN值的所有执行。在线程将其脏页添加到刷新列表之前,它等待直到M不是那么远。然后它添加页面,然后将完成的操作报告给recent_closed。

MySQL 8.0:新的无锁,可扩展的WAL设计

我们来举个例子吧。假设某些mtr在提交期间将其所有日志记录复制到start_lsn和end_lsn之间的LSN范围的日志缓冲区中。它报告了对recent_written的完成写入(日志记录可能从现在开始写入磁盘)。然后mtr必须等到它成立:start_lsn - M <L,其中L是一个常量,它限制了刷新列表中的顺序可能会失真的程度。条件成立后,mtr会将所有脏页添加到缓冲池特定的刷新列表中。现在,让我们看一下刷新列表。假设last_lsn是刷新列表中最后一页的LSN(最早添加在那里)。在旧设计中,它是那里最早的修改页面,因此保证了刷新列表中的所有页面都具有oldest_modification> =last_lsn。在新设计中,只保证刷新列表中的所有页面都具有oldest_modification> = last_lsn - L.条件成立,因为我们总是在插入页面之前等待M太远。

证明。假设我们有两页:P1为LSN = L1,P2为LSN = L2,P1先加入冲洗列表,但L2 <L1-L。在插入P1之前,我们确保L1-M <L。那么M <= L2,因为还没有插入P2,所以我们无法将L2推进到L2。因此L> L1 - M> = L1 - L2,所以L2> L1 - L.矛盾 - 我们假设L2 <L1 - L.

MySQL 8.0:新的无锁,可扩展的WAL设计

因此,我们放宽了之前的总订单约束,但与此同时,我们为新订单提供了足够好的属性。刷新列表中的顺序仅在本地失真,并且较小的LSN值的丢失脏页面仅在最近的大小为L的时段内可能。这对于约束#2来说足够好,并且它还允许选择last_lsn -L作为候选对于检查点LSN,满足约束#1。

MySQL 8.0:新的无锁,可扩展的WAL设计

这会影响恢复的方式。恢复逻辑可以从指向某个mtr中间的LSN开始,在这种情况下,它需要找到之后开始的第一个mtr,然后从那里开始解析。现在,让我们回到我们的例子。将所有页面添加到刷新列表后,start_lsn和end_lsn之间的已完成操作将报告给recent_closed。从那时起,log_closer线程可以遍历完成的添加,从start_lsn到end_lsn,并更新所有添加完成的最大LSN(将M设置为end_lsn)。

MySQL 8.0:新的无锁,可扩展的WAL设计

由于无锁日志缓冲区和刷新列表中的轻松顺序,并发迷你事务的提交之间的同步可以忽略不计!

到目前为止,我们描述了将页面更改写入重做日志缓冲区并将脏页面添加到缓冲池特定的刷新列表。让我们来看看当我们需要将日志缓冲区写入磁盘时会发生什么。

我们为与重做日志写入相关的特定任务引入了专用线程。用户线程不再对重做文件本身进行写入。他们只是等待他们需要重做刷新到磁盘并且还没有刷新。

MySQL 8.0:新的无锁,可扩展的WAL设计

该log_writer线程保持写日志缓冲区的OS页面缓存,宁愿只写全块,以避免以后需要覆盖不完整的块。只要数据在日志缓冲区中,就可以写入。在旧设计中,写入是在发生写入数据的要求时启动的,在这种情况下,写入了整个日志缓冲区。在新设计中,写入由专用线程驱动。它们可能更早开始,每次写入的数据量可能由更好的策略驱动(例如,跳过不完整的块)。log_writer线程还负责write_lsn的更新(写完成后)。

有一个log_flusher线程,负责读取write_lsn,调用fsync()调用和更新flushed_to_disk_lsn。这种对OS缓存和fsync()调用的写入由两个不同的并行线程以自己的速度驱动,它们之间的唯一同步发生在OS / FS的内部(write_lsn的原子读写除外) 。

MySQL 8.0:新的无锁,可扩展的WAL设计

当事务提交时,相应的线程执行最后一个mtr,然后它需要等待刷新到mtr的end_lsn的重做日志。在旧设计中,用户线程要么启动fsync()本身,要么等待其他用户线程先前启动的挂起fsync()的全局IO完成事件(然后在需要时重试)。

MySQL 8.0:新的无锁,可扩展的WAL设计

在新设计中,它只是等待,除非  flushed_to_disk_lsn 已经足够大,因为它始终是执行fsync()的log_flusher线程。用于等待的事件被分片以提高可伸缩性。连续的重做块以循环方式分配给连续的分片。等待flushed_to_disk_lsn的线程> = X,选择X所属的分片。这会减少尝试等待时所需的同步。但更重要的是,由于这种分裂,我们只能唤醒那些对高级flushed_to_disk_lsn感到满意的线程(除了一些在最后一个块中等待的线程)。

MySQL 8.0:新的无锁,可扩展的WAL设计

当flushed_to_disk_lsn被提前时,log_flush_notifier线程唤醒等待LSN中间值的线程。请注意,当log_flush_notifier忙于通知时,可以在log_flusher线程中启动下一个fsync()调用!

MySQL 8.0:新的无锁,可扩展的WAL设计

当innodb_flush_log_at_trx_commit = 2 时使用相同的方法,在这种情况下,用户不关心fsyncs()那么多,只等待对OS缓存的完成写入(在这种情况下,log_write_notifier线程会通知它们   ,这与log_writer同步)write_lsn上的线程)。

因为等待事件并被唤醒会增加延迟,所以可以使用可选的自旋循环。除非我们在服务器上没有太多的空闲CPU资源,否则默认使用它。您可以通过新的动态系统变量来控制它:innodb_log_spin_cpu_abs_lwm和innodb_log_spin_cpu_pct_hwm。

MySQL 8.0:新的无锁,可扩展的WAL设计

正如我们在开始时提到的,重做日志可以被视为生产者/消费者队列。InnoDB依赖于模糊检查点,潜在的恢复需要从这些检查点开始。通过刷新脏页,InnoDB允许向前移动检查点LSN。这允许我们回收重做日志中的空闲空间(在检查点LSN基本上被认为是空闲之前的块)并且还使得潜在的恢复更快(更短的队列)。

MySQL 8.0:新的无锁,可扩展的WAL设计

在旧设计中,用户线程在选择将写入下一个检查点的线程时相互竞争。在新设计中,有一个专用的log_checkpointer线程,它监视刷新列表中最旧的页面,并决定写下一个检查点(根据多个标准)。这就是为什么主线程不再需要处理定期检查点的原因。通过新的无锁设计,我们还将默认时间从7s减少到1s。这是因为我们可以更快地处理事务,因为设置了7s(我们写了更多的数据/ s,因此更快的潜在恢复是这种变化的动机)。

新的WAL设计在更新数据时提供更高的并发性,在用户线程之间提供非常小的(可忽略的)同步开销!

让我们看看在新的重做日志之前和之后的版本之间进行的简单比较。这是一个针对8个表的sysbench oltp update_nokey测试,每个表有10M行,innodb_flush_log_at_trx_commit = 1。

继续阅读