跳至主要內容

两阶段提交有什么问题

AruNi_Lu数据库MySQL约 2310 字大约 8 分钟

本文内容

1. 两阶段提交的问题

select 执行流程open in new window 中,讲到了可以利用两阶段提交解决 redo log 和 binlog 一致性的问题。但是有两阶段提交有一个明显的问题,就是性能很差。主要体现在两个方面:

  • 磁盘 I/O 次数多:在 “双一” 配置下,每次事务提交时,都需要先将 redo log 刷盘、然后再将 binlog 刷盘,这涉及到 两次刷盘

  • 锁竞争激烈:两阶段提交虽然能保证「单事务」的两份日志的一致,但是在「多事务」情况下的两阶段提交过程中可能存在穿插进行,如果需要 保证某个事务在两个提交阶段的过程中不被别的事务插进来,就需要使用

    也就是经典的并发问题。

1.1 为什么磁盘 I/O 次数多?

由于 redo log 和 binlog 都是先存储在内存中的,分别在 redo log buffer 和 binlog cache 中,所以对这两份文件的持久化时机十分重要。

redo logopen in new windowbinlogopen in new window 中说过,它们的刷盘时机都有对应的策略,这里就不具体展开说了。

先来说说,我们通常讲的 “双一” 配置是什么:

  • 在 redo log 进行事务提交时,有一个参数 innodb_flush_log_at_trx_commit,当它设置为 1 时,表示每次事务提交时,都将 redo log 持久化到磁盘;
  • 在 binlog 进行事务提交时,也有一个参数 sync_binlog,当它设置为 1 时,表示每次事务提交时,就将 binlog 持久化到磁盘。

可以发现,“双一” 配置极大程度的 避免了日志丢失的风险。而且有一个崩溃恢复的逻辑需要依赖于 prepare 的 redo log,再加上 binlog 来恢复(见 update 执行流程open in new window)。

但是,“双一” 配置带来这个好处的同时,也大大 增加了磁盘的 I/O 次数,降低了效率。

1.2 为什么锁竞争激烈?

上面也说了,在「多事务」的情况下,两阶段提交不能保证两个阶段执行时不会被其他事务插进来,因此需要使用 ,一个事务两阶段提交完成后,才能进行下一个。

在早期的 MySQL,通过使用 prepare_commit_mutex 锁来保证事务提交的顺序,事务获取到该锁时,才能进入 prepare 阶段,直到 commit 阶段结束后,才能释放该锁

因此,这样的加锁方式在 并发量较大 时,就存在 很激烈的锁竞争,效率是很低的。

2. binlog 的组提交

MySQL 后续引入了 binlog 的组提交机制,用来解决上述的两个问题。具体来说,就是 把多个日志的刷盘操作合并成一个,从而 减少磁盘 I/O 次数

那为什么要叫做组提交呢?注意提交这个字眼。其实是因为这个 合并的过程发生在 commit 阶段,也就是说,组提交没有改变 prepare 阶段,只是在 commit 阶段下了功夫。

具体来说,组提交把 commit 阶段又拆分为了三个阶段

  • flush 阶段:多个事务按顺序将 binlog 从 binlog cache 写入文件(Page Cache),不刷盘
  • sync 阶段:对 binlog 文件做 fsync 操作,持久化到磁盘,这就 将多个事务的 binlog 合并成一次刷盘了
  • commit 阶段:各事务按顺序做 InnoDB commit 操作(将 redo log 置为 commit 状态)。

在这三个阶段中,每个阶段都有一个对应的队列,每个阶段有锁进行保护,所以能保证事务的写入顺序,第一个入队的事务会成为 leader,leader 领导所在队列的所有事务,全权负责整队的操作,完成后通知队内其他事务操作结束

这样每个阶段引入队列后,就可以 只针对每个队列加锁,而不是锁整个事务提交的过程。所以 锁的粒度更小,只用阻塞冲突的阶段,让更多阶段能并发的执行,从而提高了效率

整体过程如下:

3. redo log 的组提交

在 MySQL 5.6 的 binlog 组提交逻辑中,每个事务是各自执行 prepare 阶段的,也就是在此阶段各自将 redo log 刷盘,所以没法进行 redo log 的组提交。

在 MySQL 5.7 版本时,也引入了 redo log 的组提交,其实就是在 prepare 阶段做了一个优化:把 redo log 的刷盘延迟到 binlog 组提交的 flush 阶段,也就是把 prepare 和 flush 阶段融合了。

具体来说:

  1. 在将新行更新到内存后,先不进行 redo log 的刷盘,而是将首个事务当作 Leader,将事务先按序添加进 flush 队列

  2. 等 redo log 要进行刷盘的时候,再将 flush 队列中的事务组取出来,此时 Leader 就会将同组事务的 redo log 进行 write + fsync 刷盘prepare 阶段)。

    prepare 阶段结束后,然后 写入 binlog(flush 阶段),此时没有刷盘

    可以看出,flush 队列用于支持 redo log 的组提交

  3. flush 阶段写入 binlog 后,会把事务组保存到 sync 队列,此时并不会马上刷盘,而是会 等一段时间,等待时间由参数 binlog_group_commit_sync_delay = N(微妙) 控制。

    同时,还有一个参数 binlog_group_commit_sync_no_delay_count = N,用来 控制事务数量,如果事务数量达到了,则会无视时间参数,直接进行刷盘

    等待的目的都是为了 组合更多事务的 binlog,然后一起 刷盘sync 阶段);

    可以看出,sync 队列用于支持 binlog 的组提交

  4. sync 阶段后,会将完成刷盘的事务组保存到 commit 队列,最后进入 commit 阶段,从 commit 队列中获取事务组,调用引擎层的事务接口,将 redo log 设置为 commit 状态

    此阶段的 commit 队列主要用于接收完成了 sync 阶段的事务,让 sync 队列可以更快的接收下一组事务。

整体过程如下:

MySQL update + 两阶段提交的执行流程

4. 关于日志刷盘的性能问题

由于组提交的加持,所以有时候会看到,明明磁盘的 I/O 能力(IOPS:每秒 IO 数量)也就两万左右,而 MySQL 的 TPS 却达到了两万,每秒就会写四万次磁盘(“双一” 配置:一次 TPS 需要刷两次盘,也就是两次 QPS),这就是组提交带来的效应。

另外如果发现 MySQL 在 IO 上出现了性能瓶颈,那么可以考虑通过如下方法解决:

  • 设置 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count 参数,累计更多的事务后再进行 binlog 的刷盘,从而 减少 binlog 的刷盘次数
  • 设置 sync_binlog 为大于 1 的值 N(比较常见是 100~1000),即 每次提交事务都 write,但 累计 N 个事务提交时才进行 binlog 的刷盘。这样做的风险是,主机掉电时会丢 binlog 日志;
  • innodb_flush_log_at_trx_commit 设置为 2,把 redo log 的刷盘操作交给 OS。这样做的风险是,主机掉电的时候会丢数据。

5. 总结

由于 MySQL 需要 维护两份日志,一份来自 Server 层的 binlog,另一份来自 InnoDB 引擎层的 redo log,并且这两份日志都有先存储在内存中的,所以就要 保证刷盘时两份日志的逻辑一致性

MySQL 首先通过 两阶段提交 来解决,但是带来的 问题 是:

  • 磁盘 I/O 次数多,效率低;
  • 锁竞争激烈,性能低。

为了解决上面的问题,MySQL 又提出了 组提交,现在支持 binlog 和 redo log 两者的组提交,主要做了如下改进:

  • 让多个事务组成一组,再进行对应的刷盘,从而减少了磁盘 I/O 的次数;
  • 组提交将 commit 阶段拆分成三个小阶段,每个阶段都由一个队列维护,加锁可以只对每个阶段加锁,从而 减小了加锁的粒度,让不同阶段的事务可以并发的执行,大大提高了性能。

6. 参考文章

上次编辑于: