背景
当前几乎所有的关系数据库都采用日志先行的方式, 也就是所谓 WRITE-AFTER-LOG(WAL), 这是因为日志通常是顺序写的, 并且写入量相比修改的数据通常要小很多. 通过 redo log 来确保提交的事务必然具有持久性.(目前也有另外一种理论叫做 Write Ahead Log, 由 CMU 的教授提出, 主要适用于 Nvme, 这里在 CMU 的 peloton 项目里有个介绍 https://github.com/cmu-db/peloton/wiki/Write-Ahead-Logging )
然而日志由于要保证顺序性, 需要锁来保护所有日志拷贝到 buffer 都是有序的, 引入了一个严重的锁竞争点, 特别是在多核场景下, 这里的竞争会非常明显, 无法发挥出多核心的优势.
为了解决这个问题, MySQL8.0 对日志系统进行了重新设计, 将整个模块变成了 lock-free 的模式(小道消息, 目前官方也在对事务模块和锁模块改造成 lock-free 模式, 相信到时候 InnoDB 的扩展性必然会提升一大截, 未来可期!)
具体的, 我们可以对应到几个模块:
- 拷贝到 buffer: 每个 mini transaction 将自己的本地日志拷贝到全局 Buffer 中 - 写磁盘: 包括写磁盘和调用 fsync 进行持久化 - 事务提交: 当事务 undo 被标记为 prepare(如果 binlog 打开) 或者 commit 时, 需要确保日志被刷到磁盘, 以确保事务的持久性 - Checkpoint: 定期对日志做 checkpoint, 减少崩溃恢复时日志的应用量
以下是对上述几个模块的简要介绍
实现
写 log buffer
在 5.7 版本中, Innodb 的 log buffer 实际上是分成了两个区域, 轮换着来写, 从而实现在写一个 buffer 时, 另外一个 buffer 依然可以继续往里面拷贝日志. 但到了 8.0 版本, 所有日志相关的 mutex 都已经移除了, 划分缓冲区域也就没有必要了, 而是将 log buffer 当做一个环来使用.
首先, 持有一个 s lock, 并通过原子操作获取当前 mtr 的 start_lsn, 和 sn 号(lsn 减去 log block 头和尾的大小, 表示有效日志量), 这样相当于在顺序增加的 lsn 序列中保留了自己的一段范围(获得 mtr_t::start_lsn 和 mtr_t::end_lsn), 通过 start_lsn 取模 log buffer size, 得到其在 log buffer 中的位置, 然后逐个 block 进行拷贝(log_buffer_write), 每写一个 mtr log block, 就将其 start_lsn 和 end_lsn 加入到 log.recent_written 中, 维持了一个 link 结构, 一个 mtr 可能会更新多次 link_buf
(InnoDB 里增加了一个叫 link_buf 的类, 其具体的作用就是将不连续的变量维护成一个链表, 举个简单的例子:
- buf[lsn_1] = lsn_2
- buf[lsn_2] = lsn_3
- Buf[lsn_3] = 0
- Buf[lsn_4] = lsn_5
- Lsn_1 = 10
- Lsn_2 = 100
- Lsn_3 = 200
- Lsn_4 = 300
- Lsn_5 = 400
通过这种方式, 实际可以追踪到所有并发写入到 buffer 的 mtr 范围, 并快速检测到 buffer 中的 hole, 例如上例中, lsn_3 ~ lsn_4 属于还没有写入日志的空洞 如上提到的 log.recent_written, 可以确保写到磁盘的日志不存在空洞, 如上例, 只能写到 lsn_3 这个位置)
在拷贝完日志后, 就需要将脏块加入 flush list 中. 注意由于现在实现了完全并发, 我们无法做到按照 LSN 顺序插入到 flush list 上, 而有序性是用于保证 checkpoint 点的正确性. 因此在这里同样也引入了另外一个 link_buf, 名为 log.recent_closed, 来辅助获取一个安全的 checkpoint 点. 因此在加入 flush list 后, 该 mtr 也会加入到 recent_closed 中(类似 buf[mtr->start_lsn] - mtr->end_lsn)
注意 log.recent_writtern 和 log.recent_closed 都是有空间限制的, 如果超出其 capability, 就需要等待, 但这种情况一般很少见
可以看到这里的代码和 5.7 及之前版本已经完全不同了:
- 日志可以并发拷贝, 但会存在 hole
- Flush list 不再有序
我们之前惯用 log_get_lsn 或者直接 log_sys->lsn 来获得最新的 lsn 点, 而在 8.0 版本, 通过将 log.sn 转换成最新的 lsn, 但这个 lsn 点并不代表该点之前的日志都拷贝到 buffer. 之前我们提到在拷贝 buffer 之前需要加一个 s_lock, 如果我们在持有 x 锁的前提下去取 lsn, 才能保证是最新的.
写磁盘
目前有两个后台线程来做日志持久化, 一个是 log_writer 线程, 一个是 log_flusher 线程, 顾名思义, 前者负责写日志到磁盘, 后者负责 fsync 日志
Log_writer 会根据 log.recent_written 中的记录找到安全的 lsn, 将对应日志写磁盘, 同时回收 log.recent_written 中的空间. 如果当前 srv_flush_log_at_trx_commit 设置为 1 的话, 还回去唤醒 log_flusher 线程
log_flusher 线程的主要工作是 fsync 日志文件, 同时推进 log.flushed_to_disk_lsn. 随后尝试去唤醒等待的用户线程 (如果只涉及一个 event slot) 或者唤醒 log_flush_notifier 线程.
Log_notifier 线程专门用于唤醒等待日志写入的线程, 根据上次 flush 的 log lsn 和当前 flush lsn, 来计算对应的 event slot, 并遍历数组唤醒等待的线程.
可以看到这里已经完全做到了异步化, 再加上并发拷贝 log buffer, 可以极大的发挥硬件性能.
事务提交
在 innodb 事务提交时, 对应的 Undo 状态被修改后, 需要调用 log_write_up_to 去确保日志已经写盘了. 在 5.7 及之前版本中, 该函数就是用于写日志到磁盘. 而到了 8.0 版本, 该函数只有唤醒后台线程及等待的逻辑.
一个有趣的问题是, 由于目前用户线程仅需要等待唤醒, 而无需去操作临界区域, 我们可以在其退出 innodb 后再调用 log_write_up_to 进行等待(参考 bug#90641)
Checkpoint
由于现在脏页并不是按照 LSN 顺序写入的, 因此选择一个安全的 checkpoint 点至关重要, 这个工作主要由后台线程 log_checkpointer 来完成.
计算最老 lsn 的工作在 log_get_available_for_checkpoint_lsn 中完成:
- 首先找到 log.recent_closed 中的最小 lsn, 这个 lsn 点之前的 page 肯定已经加入到 flushlist 上了
- 其次取出当前 flushlist 中最后一个非临时表 page 的 lsn, 并取多个 Buffer Pool 中的最小值返回, 然后减去一个安全的阈值(即 log.recent_closed 的最大空间)
- 上面两个值去最小的那个
很显然, 为了避免扫描全部 flush list 链表, 这里采用了乐观的算法, 只要最大限度的保证做 checkpoint 的点是安全的即可. 这里引入的一个问题是, 做 checkpoint 时可能是在一个 mtr log 的中间, 在崩溃恢复时, 可能需要对其定位的 log block 做特殊处理(在之前的版本中, 可以确保 checkpoint lsn 是一个 mtr log 的安全边界)
隐藏参数
如上所述, 这里引入了多个后台线程来增加系统的并发度, 而在内部也有大量参数来对系统进行调整, 以获得最优性能, 但为了避免引起用户困惑, 有一些参数是被隐藏的(在定义时通过 PLUGIN_VAR_EXPERIMENTAL 来控制).
如果你想使用这些参数, 需要自己去编译 mysql 代码, 并在 cmake 时增加参数 - DENABLE_EXPERIMENT_SYSVARS=1
如下, 打开选项后和日志相关的参数包括:
通过这些参数, 你可以对新的日志系统进行各种微调来获得最优性能. 注意这里很多参数目前还看不到官方文档的描述, 你可能需要结合代码来看. 有一些比较有趣的参数例如 innodb_log_spin_cpu_pct_hwm/lwm 可以控制 user cpu 超过多少百分比时, 是否还允许用户线程继续 spin loop.
来源: http://www.jianshu.com/p/3dca93e7fce9