一文带你搞懂MySQL事务的各个疑惑,不要再在脑子里一团浆糊啦!!
事务的四大特性
事务的四大特性分别为 ACID,即原子性、一致性、隔离性、持久性
-
原子性( Atomicity ) 是指事务中的所有操作要么全部完成,要么全部不完成,不会结束在某个中间环节,原子性是由 undo log 保证的
-
一致性( Consistensy ) 是指事务执行前后,数据库的状态必须保持一致性,一致性是通过持久性 + 原子性 + 隔离性这三个共同保证的
-
隔离性( Isolation ) 是指多个事务并发读写数据库,可以防止多个事务并发读写同一份数据时,导致的数据不一致问题,隔离性是由 MVCC 和 锁保证的
-
持久性( Durability ) 是指保证事务完成后的对数据的修改就是永久的,不会因为系统故障而丢失,持久性是由 redo log 日志保证的
MySQL的三种日志
-
undo log(回滚日志):是 Innodb 存储引擎层生成的日志,实现了事务中的原子性,主要用于事务回滚和 MVCC。 在事务没提交之前,Innodb 会先记录更新前的数据在 undo log 中,回滚时就利用 undo log 进行回滚。
-
redo log(重做日志):是 Innodb 存储引擎层生成的日志,实现了事务中的持久性,主要用于掉电等故障恢复。比如某个事务提交了,脏页数据还没有刷盘,如果 MySQL 机器断电了,脏页的数据就丢失了,MySQL 重启后就可以通过 redo log 日志,将已提交的事务的数据恢复过来。
-
binlog (归档日志):是 Server 层生成的日志,主要用于数据备份和主从复制。在完成一条更新操作后,Server 层会生成一条 binlog,放入内存的缓冲区,等之后事务提交的时候,会将该事务执行过程中产生的所有的 binlog 统一写入 binlog 文件中。binlog 文件是记录了所有数据库表结构和表数据修改的日志,不会记录查询类的操作
详细文章讲解:MySQL 日志:undo log、redo log、binlog 有什么用?
事务的原子性是如何保证的?
事务的原子性是通过 undo log 实现的。
在事务还没有提交前,历史数据会记录在 undo log 中,如果事务执行过程中,出现了错误或用户执行了 rollback 语句,MySQL 可以利用 undo log 中的历史数据,做原来相反的操作,将数据恢复到事务开始之前的状态,从而保证事务的原子性
事务的隔离性是如何保证的?
事务的隔离性是通过 MVCC 和 锁 的机制保证的。(MVCC 在后面有讲解)
可重复读隔离级别下的快照读,也就是普通的 select,是通过 MVCC 来保证事务隔离性的,当前读,也就是 update,select … for update 等,是通过行级锁来保证事务的隔离性的
事务的持久性是如何保证的?
事务的持久性是由 redo log 保证的。
因为 MySQL 是通过 WAL(先写日志再写数据)的机制,在修改数据的时候,会将本次对数据页的修改以 redo log 的形式记录下来,这个时候更新就算完成了,Buffer Pool 的脏页会通过后台线程刷盘,即使在刷页还没刷盘的时候发生了数据库的重启,由于修改操作都记录到了 redo log 中,之前已提交的记录都不会丢失,重启后就通过 redo log,恢复脏页数据,从而保证了事务的持久性
数据库事务的隔离级别
MySQL 隔离级别有四种,分别是 读未提交、读已提交、可重复读、串行化
-
读未提交 指的是读到其他事务没有提交的事务
-
读已提交 指的是读到了其他事务更改的数据
-
可重复读 指的是一个事务在执行过程中读到的数据,始终与他在开始时读到的一致
-
串行化 指的是通过强制事务排序,使其不可能冲突
针对不同的隔离级别,并发事务时也可能出现并发问题:
可能会出现三种并发问题:脏读、不可重复读、幻读
-
脏读 指的是一个事务读取了另一个事务还未提交的数据,如果另一个事务回滚,则读取的数据是无效的。脏读可能导致数据的不一致性
-
不可重复读 指的是一个事务多次读取同一条记录时,在此期间另一个事务修改了该记录,导致前后读取的数据不一致。不可重复读可能导致数据的不一致性
-
幻读 指的是一个事务多次执行同一条查询时,在此期间另一个事务插入了符合该查询条件的新数据,导致前后的结果不一致。幻读可能导致数据的不完整性
各隔离级别都各自解决了什么并发问题?
-
读未提交一个问题也没有解决
-
读已提交避免了脏读问题,但还是存在不可重复读和幻读的问题
-
可重复读避免了脏读和不可重复读问题,并且对于幻读问题在很大程度上避免了,但还是没有完全避免
-
串行化避免了所有的并发问题,但是事务的并发性能是最差的
什么是 MVCC?
MVCC 是多版本并发控制
通过记录历史版本数据,解决读写并发冲突问题,避免了读数据时加锁,提高了事务的并发性能
MySQL 将历史数据存储在 undo log 中,结构逻辑上类似于一个链表,MySQL 数据行上有两个隐藏列,一个是事务 ID ,一个是指向 undo log 的指针
事务开启后,执行第一条 select 语句时,会创建 ReadView,ReadView 会记录当前未提交的事务
通过与历史数据的事务 ID 进行比较,就可以根据可见性规则来进行判断,判断这条记录是否可见,如果课件就直接将这个数据返回给客户端,如果不可见就继续往 undo log 版本链中查找第一个可见的数据返回给客户端。
详情参考 小林coding:Read View 在 MVCC 里如何工作的?
读已提交和可重复读隔离级别实现 MVCC 的区别?
他们 都是由 MVCC 实现的,区别在于创建 Read View 的时机不同。
-
读已提交隔离级别 是在事务开启后,每次执行一条 select 语句时都会生成一个新的 Read View,所以每次 select 都能看到其他事务最近提交的数据
-
可重复读隔离级别 是在事务开启后,执行第一条 select 语句时生成一个 Read View,然后整个事务期间都会复用这个 Read View,所以一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的。
可重复读隔离级别是如何解决不可重复读问题的?
快照读,也就是普通的 select,是通过 MVCC 解决不可重复读问题,在第一次查询的时候,会生成一个 Read View,之后的每次 select 查询都会复用这个 Read View,不会读到其他事务的更新的操作,这样就不会发生不可重复读的问题。
当前读,也就是 select … for update 等,在第一次进行 select … for update 语句查询的时候,会对记录加上 next-key 锁,也就是记录锁 + 间隙锁
而这里的记录锁,当其他事务更新加了锁的记录时,会被阻塞住,这样就不会发生不可重复读的问题了。
可重复读隔离级别是如何很大程度上避免幻读问题的?
快照读,也就是普通的 select,是通过 MVCC 解决不可重复读问题,在第一次查询的时候,会生成一个 Read View,之后的每次 select 查询都会复用这个 Read View,不会读到其他事务的插入的记录,这样就不会发生幻读的问题。
当前读,也就是 select … for update 等,在第一次进行 select … for update 语句查询的时候,会对记录加上 next-key 锁,也就是记录锁 + 间隙锁
这里的间隙锁,当其他事务往这个间隙插入新记录的时候,会被阻塞住,这样就不会发生幻读的问题了。
注意区分
在可重复读隔离级别下
- 针对快照读,都是通过 MVCC 机制,保证了读取的数据都是与事务开启时的数据保持一致
- 对于不可重复读问题,保证不会读取到其他事务的更新操作
- 对于幻读问题,保证不会读取到其他事务的插入操作
- 针对快照读,都是通过临键锁(记录锁+间隙锁)机制,保证其他事务的操作不会影响到本事务的数据
- 对于不可重复读问题,通过记录锁保证不修改该条记录
- 对于幻读问题,通过临键锁保证不修改符合条件查询的记录
可重复读隔离级别为什么没有完全解决幻读问题?
场景一:
数据库中不存在 id = 3 的记录
事务 A 此时执行第一次查询 id = 3 的记录,为空
事务 B 插入了一条 id = 3 的记录
事务 A 此时更新了 id = 3 的记录(没查到但更新,这确实很反直觉),因为更新语句时当前读,就会把 id = 3 这条记录的隐藏列的事务 id 变为 事务 A 的 id ,表示这条记录是事务 A 修改的
接着,事务 A 再次查询 id = 3 的记录,此时发现这条记录的事务 id 和事务 A 的是 id 是一致的,此时就能读取到这条记录,于是发生了幻读现象
场景二:
事务 A 先执行了快照读
事务 B 再插入了一条符合 A 条件的记录
事务 A 再执行了当前读
于是发生了幻读
如何避免?
在开启事务之后,马上执行 select … for update 语句,因为会对记录加临键锁,这样就可以避免其他事务插入一条新纪录,避免了幻读
(特殊)可重复读隔离级别完全解决不可重复读问题了吗?
注:这里的 “特殊” 表示一般教材上和广泛共识上默认是解决,但是我们这里死扣了细节
如果前后两次查询都是快照读,也就是普通 select 的话,就不会产生不可重复读问题。
但是如果第一次查询时快照读,第二次查询时当前读,就会产生不可重复读的问题。
举例:
表里现在有 id = 1,value = 1 的记录
-
事务 A ,先执行 select ,查询到 id = 1,value = 1。
-
事务 B ,更新该 id = 1 的记录的 value 为 2,然后提交事务。
-
事务 A ,执行 select for update,然后就读到了这条 id = 1,value = 2 的记录了,发生了不可重复读的问题。
眼花缭乱了吧?
俺也一样
再梳理一下
对于幻读问题:
场景一:
- A 查询(快照读)一条不存在的记录,结果为空
- B 插入了一条符合条件的记录并提交了事务
- A 突然更新(当前读)刚刚不存在的记录,此时 A 再去读就又能读到了,发生幻读问题
场景二:
- A 普通查询(快照读)出三条符合条件记录
- B 插入了一条符合条件的记录,并提交事务
- A 又select … for update(当前读)了符合条件的记录,查询出了四条,发生了幻读问题
对于不可重复读问题:
与上面场景二类似
- A 普通查询(快照读)出 1 条记录
- B 更新了该条记录,并提交事务
- A 又 select … for update(当前读)了记录,发现数据不一致
本质
其实本质上,就是因为 MVCC 机制的局限性,也就是快照读(普通 select )的问题,导致可重复读隔离级别会出现一些问题,
而如果你一上来就使用当前读(select … for update),直接给记录加临键锁(记录锁+间隙锁),
记录锁 锁该条记录,临键锁 锁符合条件的记录,
那就不会发生不可重复读问题和幻读问题了
(特殊)读已提交隔离级别可以解决不可重复读问题吗?
读已提交跟不可重复读的区别在于
-
快照读下生成的 readview 的时机不同
-
当前读下读已提交只有记录锁,没有间隙锁
所以,如果使用当前读,可以通过记录锁的方式解决不可重复读的问题,当然,因为没有间隙锁,就没有办法解决幻读问题了
最后,是不是觉得可重复读隔离级别看起来是很棒的级别?能够解决很多问题,那么,他就是最广泛应用的隔离级别吗?
NONONO!
事实上,虽然可重复读隔离级别是 MySQL 的默认隔离级别,但是互联网公司大多使用的是读已提交隔离级别!
为什么互联网公司用读已提交隔离级别?
因为读已提交的并发性能更好
上一题提到,因为读已提交没有间隙锁,只有记录锁,所以发生死锁的概率比较低。
同时,在可重复读隔离级别下,条件列未命中索引会锁表!而在读已提交隔离级别下,只锁行。
然后互联网业务对于幻读和不可重复读的问题都是能接受的,所以为了降低死锁的概率,提高事务的并发性能,都会选择使用读已提交隔离级别。
那为什么 MySQL 的默认隔离级别是可重复读呢?
这是个历史遗留问题
在MySQL5.1 版本之前的 binlog 记录方式只有 statement 方式,也就是记录 sql 语句,而 binlog 是在事务提交之后才会写入的,在主从模式下,读已提交隔离级别会有 bug
举例:
注意区分这里 master 节点 和 slave 节点 都需要完成 事务 A 和 事务 B的内容,不要搞混淆了
-
A 和 B 都开启事务,事务 A 先删除一条数据
-
此时 B 插入一条符合条件的数据 并 进行提交
-
A 再提交
读已提交级别下:
在 master 节点,会查询到这条数据的,因为先删后插
而在 slave 节点,无法查询到这条数据,因为 B事务 先提交,所以会被先写入 binlog 中去,就会先完成 B 事务中的插入,再完成事务 A 中的删除,于是这条记录就被删除了,也就是先插后删,导致主从节点数据不一致性问题
如何解决?
- 隔离级别设置为可重复读,在该隔离级别下引入间隙锁。当 session1 执行 delete 语句时,会锁住间隙,那么session2执行的插入语句就会堵塞住
- 将 binlog 的格式修改为 row 格式,此时就会是基于行的复制,自然就不会出现sql不一致的情况,但是该格式是在mysql5.1版本后开始引入。
参考文章链接:
为什么Mysql 默认的隔离级别是可重复读?
Mysql默认隔离级别为什么是可重复读?