MySQL 事务

date
Aug 7, 2021
slug
mysql-innodb-basic-concept-transaction
status
Published
tags
MySQL
summary
type
Page

MySQL 事务特性

  • Atomicity 原子性:构成事务的所有操作必须是一个逻辑单元,要么全部成功提交,要么全部失败回滚
  • Consistency 一致性:数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态
  • Isolation 隔离性:事务之间不会互相影响,由锁机制和 MVCC 机制控制
  • Durability 持久性:事务一旦提交数据的更改必须是永久性的,出现故障也应该能够恢复
这其中 C 一致性是根本目的,而 ACD 则是实现一致性的必要手段。
这里面 I 隔离性主要由锁和 MVCC 保证,而 ACD 的实现依靠 redo log 和 undo log 实现。这两种 log 又都属于 WAL 模式,即 Write Ahead Logging,即在数据存入之前,先记录日志。
 

MySQL 隔离级别

  • RU Read Uncommitted 读未提交
    • 事务会读取到其他事务未提交的数据
    • 问题:脏读
  • RC Read Committed 读提交
    • 事务可以读取其他事务提交的数据,多次读取可能会数据不一样,即事务1在事务2的写操作提交的前后两次读取,结果不一致。
    • 问题:不可重复读
  • RR Repeatable Read 可重复读
    • 在同一个事务里,select 的结果是事务开始时时间点的状态
    • 问题:幻读,事务1虽然读不到事务2写入的数据,但当事务1插入一条与事务2冲突的记录时会发生唯一冲突。
  • serializable 串行化
    • 所有事务串行执行
查看当前隔离级别:show variables like 'tx_isolation' ,InnoDB 默认是 RR 可重复读级别。
 

Buffer Pool 基本概念

页是 MySQL 中磁盘和内存交互的基本单位,也是 MySQL 管理存储空间的基本单位。一个页一般是16KB
数据最终是要存放在磁盘中的,但如果每次读写数据都需要磁盘 IO 效率就很低。为此,InnoDB 提供了缓存机制 Buffer Pool,Buffer Pool 中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:
  • 读操作:当从数据库读取数据时,会首先从 Buffer Pool 中读取,如果 Buffer Pool 中没有,则从磁盘读取后放入 Buffer Pool;
  • 写操作:当向数据库写入数据时,会首先写入 Buffer Pool,Buffer Pool 中修改的数据会定期刷新到磁盘中。未被刷到硬盘的数据成为脏页数据,而刷盘这一过程称为刷脏。
Buffer Pool 提高了读写数据的效率,但是也带了新的问题:如果 MySQL 宕机,而此时 Buffer Pool 中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。而 Redo Log 则可以解决这个问题。
 

Redo Log 基本概念

Redo Log 用来实现事务的持久性 D,还可提高读写速度,由两部分组成:
  • 内存中的 redo log buffer
  • 持久化的 redo log file
为什么要需要一个 redo log buffer
思路跟 buffer pool 一样,在磁盘 file 之上增加一个内存 buffer,提高速度。同时由于内存 buffer 和磁盘 file 两者之间存在时间差,所以需要控制从 buffer 到 file 的刷新策略。
notion image
可以抽象为:
notion image
 

Redo Log 机制下数据写入过程

当数据写入 Buffer Pool 的同时,还会写入到 redo log buffer 中,redo log buffer 中的数据按照某一策略将数据写入到 redo log file 中实现持久化。而 Buffer Pool 中的数据则会依据 checkpoint 择时刷脏。
如果 BufferPool 中的数据已提交但未刷脏,此时数据库挂了,那数据库再次启动之后,可以通过 Redo 日志将其恢复出来,以保证脏页写的数据不会丢失。
如果 BufferPool 中的数据没有提交,此时数据库挂了,就需依据 Undo Log 回滚到事务开始时的状态。
Redo Log 机制为什么比直接落盘性能高?
Redo Log 也要在事务提交时写入磁盘 IO,为什么会比直接从 Buffer Pool 中刷脏要快呢?
  • 刷脏是随机 IO,因为每次修改的数据位置随机,但写 redo log 是追加操作,属于顺序 IO。
  • 刷脏是以数据页(Page)为单位的,MySQL 默认页大小是 16KB,一个 Page 上一个小修改都要整页写入;而 redo log 中只包含真正需要写入的部分,无效 IO 大大减少。
 

Redo Log file 三种落盘策略:

数据从 redo log buffer 到 redo log file 的写入是有时间差的,需要控制刷新策略。
在 MySQL 配置文件中有一个配置项 innodb_flush_log_at_trx_commit 控制 redo log 刷新到磁盘的策略,有 0、1、2 三个可选值,默认为 1:
  • 0:表示事务提交时不进行 redo log 落盘,写入到 redo log buffer 之后就返回了,之后由单独的线程每隔固定时间落盘。这种性能最高,但安全性最差,无论 MySQL 还是 OS 挂掉了,数据都会丢失。
  • 1:默认值,表示事务提交必须进行一次 fsync 操作,即调用操作系统的文件操作去落盘。这种数据最安全,只要磁盘没问题数据就不会丢,但效率也最低。
  • 2:表示提交时写入 redo log,但是只写入文件系统缓存,不进行 fsync 操作,同样每隔固定时间落盘。这种情况下如果 MySQL 挂了,但硬件系统没挂,则重启 MySQL 后可以从文件系统缓存中获取到之前那部分 redo log 内容。这种情况考虑的是 MySQL 可能会挂,但 OS 挂掉的概率很小,所以写入到 OS 的 buffer 中就返回了,不过一但 OS 挂掉了,数据就会丢失。
notion image
 

Redo Log 与 binlog 区别

  • 作用不同:redo log 是用于 crash recovery 的,保证 MySQL 宕机也不会影响持久性;binlog 是用于 point-in-time recovery 的,保证服务器可以基于时间点恢复数据,此外 binlog 还用于主从复制。
  • 层次不同:redo log 是 InnoDB 存储引擎实现的,而 binlog 是 MySQL 服务器层实现的,同时支持 InnoDB 和其他存储引擎。
  • 内容不同:redo log 是物理日志,内容基于磁盘的 Page;binlog 的内容是二进制的,根据binlog_format 参数的不同,可能基于 sql 语句、基于数据本身或者二者的混合。
  • 写入时机不同:binlog 在事务提交时写入;redo log 的写入时机相对多元,有更多配置。
 

Undo Log 基本概念

Undo Log 主要用于回滚以及 MVCC。保证事务的原子性和隔离性。
原子性即当事务进行修改时会生成对应的 undo log,当事务发生回滚时,从 undo log 中查询之前的数据随之回滚。
隔离性具体来说分两种:
  • 写-写操作隔离:锁机制
  • 写-读操作隔离:MVCC 机制,MVCC 依赖 Undo Log 实现
 

Undo Log 结构

InnoDB 行记录有三列隐藏字段 DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,简记为 row_id,trx_id 和 db_roll_ptr
  • trx_id 表示最近修改的事务的 id
  • db_roll_ptr 回滚指针,指向 undo log 中上一个版本。
  • row_id 是可选的,在没有主键和唯一键是才会生成。
在修改数据之前,会记录原始数据,从下到上是修改的顺序,下面两行存在于 Undo Log 中,形成一个版本链。
notion image
InnoDB 还存在 purge 线程,purge 线程延时删除那些 delete 和 update 的操作,以及最终清理掉已完成的记录版本。
 

MVCC Multi Version Concurrency Control 多版本并发控制

原本写锁是排他的,理论上加了写锁后,其他事务的读写都会被阻塞,而 MySQL 的读操作不会被写锁阻塞,就是因为读操作可以从 Undo Log 中读取历史版本,接着通过 ReadView 控制哪个版本是对当前事务可见的。
MVCC 中读操作可以分为:
  • 快照读 snapshot read:读取的是记录的可见版本(可能是 Undo Log 中的历史版本),不用加锁。普通 select 都是这种。
  • 当前读 current read:读取的是记录最新版本,并且当前读返回的记录都会加上锁,保证其他事务不会再并发修改这条记录。insert、update、delete、select for update 等
比如 session1 开启事务,修改一条数据,session2 直接 select 是不会阻塞的,会从 Undo Log 中做快照读。而如果 session2 使用 select ... for update,则会等待该行的写锁释放。
notion image
图示表示如果查询发现某行被加了写锁,其会转而从快照中读取最新的可见的快照。这就是所谓的一致性非锁定读
但 undo log 的版本链中有那么多版本,到底要用哪一个版本呢,这需要通过 readview 维护的状态来判断当前可见的版本
 

ReadView

ReadView 是一个结构,其中维护了一些与当前活跃事务相关的状态数据:
  • m_ids:当前活跃的事务 id 列表
  • min_trx_id:当前活跃事务中最小的事务 id,即 m_ids 中最小值
  • max_trx_id:系统分配给下一个事务的事务 id,如 m_ids 是 1 2 3,则下一个就是 4
  • creator_trx_id:生成这个 ReadView 的事务的 事务 id
通过 readview 来判断哪个版本可见,trx_id 为版本链中的 trx_id 列:
  • trx_id == creator_trx_id:可见。即这个版本是当前事务生成的
  • trx_id < min_trx_id:可见。即 trx_id 比活跃事务 m_ids 小,说明该事务已经提交了
  • trx_id > max_trx_id:不可见。即该版本在当前事务之后生成
  • min_trx_id ≤ trx_id ≤ max_trx_id:trx_id 不在 m_ids 中时可见,否则不可见。即已经产生该 trx_id 的事务已提交则可见。
在 RC 隔离级别下,每个快照读 select 时都会生成并获取最新的 ReadView;在 RR 隔离级别下,则是同一个事务中第一个快照读 select 才会创建 ReadView,之后的快照读获取的都是同一个 ReadView。
这也就解释了为什么在 RC 级别下是读提交,在 RR 级别下是可重复度。因为在 RC 级别下每次读取,都会重新获取 ReadView,相当于获取了最新的 Undo Log,而 RR 级别在整个事务内都读的一个 ReadView。
 

总结 MySQL 如何实现 ACID

C 一致性是目的,AID 是手段。
A 原子性通过 Undo Log 的回滚机制实现,开启事务时记录原始数据,回滚时可用到。
D 持久性通过 Redo Log 在事务 commit 时落盘机制实现,虽然数据可能还没真正存储到数据库中,但 Redo Log file 中肯定有。
I 通过锁和 MVCC 来控制,MVCC 同样依赖于 Undo Log 的快照来实现。
 

RR 级别下的幻读问题

InnoDB 三种行锁:
  • record lock:单个行记录上的锁
  • gap lock:间隙锁,锁定一个范围,但不包括记录本身
  • next key lock:record lock + gap lock,锁定一个范围,并且锁定记录本身
 
幻读现象,加入表中 id 大于 8 的之后一条记录:
  • step1:session1 先 select 一次:select * from user where id>8;
  • step2:session2 insert 一条 id 大于 8 的记录 11 并提交
  • step3:session1 再 select 一次,此时查不到 session2 添加的记录,查询结果与 step1 一致
  • step4:session1 insert 一条与 session2 id 一样的记录,此时会报 Duplicate entry '11' for key 'PRIMARY'
  • step5:session1 再 select 一次却发现结果还是跟 step1 一致,并没有 id=11 的记录
此时发生了幻读问题,要想避免此问题,就要用到 next key lock 或者 gap lock:
  • 当 session1 中的条件是唯一的等值匹配且已经存在时,InnoDB 会在该行上加行锁 record lock
  • 当 session1 中的条件数据不存在时,InnoDB 会在该范围内加上行锁 + 间隙锁,这两种锁也就组成了 next key 锁。此时其他 session 不可以在这个间隙范围内写入,但可以在范围外写入。
如果条件不在聚簇索引上而是在辅助索引上,则会锁定住辅助索引的间隙,以及对应的聚簇索引行。
前面的问题如果想解决,需要再 session1 查询时加上排他锁 select * from user where id>8 for update; 此时因为间隙锁,防止其他 session 在 id>8 区间内插入数据。
 

丢失更新问题

第一类问题,由于回滚导致的丢失
notion image
第二类问题,由于覆盖或二次更新导致的丢失
notion image
丢失问题主要由于在 RR 级别下,select 会生成 readview,之后不再更新,似的看不到其他事务的更新从而丢失更新。解决办法要么走悲观锁,将 select 换成 select for update,从而加上 X 锁。要么使用乐观锁,即 CAS 法,或者加入版本号列。
 

几个命令

查看事务执行情况:select * from information_schema.innodb_trx
查看锁的占用情况:select * from information_schema.innodb_locks
查看锁等待信息:select * from information_schema.innodb_lock_waits
 

参考:

 
 
 

© 菜皮 2020 - 2023