前言
- 事务的特性
- MySQL隔离级别
事务
进行数据库的操作离不开事务,事务也是各大厂招聘的必问环节。
事务的特性
ACID是事务的基本特性,也是事务这个概念成立的基础。
- Atomic(原子性):表示一个事务中的所有操作,要么全部成功,要么全部失败回滚。
- Consistance(一致性):事务执行前后,数据库的数据要保持一致,逻辑上的一致。(这个实现必须要依赖于其他三个特性)
- 只有满足一致性,事务才有意义,事务的提交才是正确的。
- Isolation(隔离性):并发情况下,多用户开启的事务相互之间不能受影响。
- Duration(持久性):事务的提交是持久性的,会永久改变数据库。
原子性、一致性和持久性比较容易理解,主要是隔离性的满足需要深入探讨。
隔离性主要是为了满足高并发情况下事务的隔离问题,因此先明确一下常见的隔离问题。
事务的隔离问题
- 脏读:一个事务读到了另一个事务未提交的内容。
- 不可重复读:A事务中,多次读同一条数据,读到的数据不一致(可能是B事务提交了对这条数据的修改)。
- 幻读:A事务的两次读取中,读到了未出现过的数据行(可能是B事务插入了这条数据)。
- 丢失修改:A事务和B事务同时对一条数据进行了修改,B事务的提交覆盖了A事务的提交。
在事务的隔离问题中,其中丢失修改是“写”的问题,而其余是“读”的问题。
事务的隔离级别不是用来解决“写”的问题而存在的,也因此我们将“丢失修改”的问题单独拿出来说。
解决丢失修改的问题
两种策略:
- 悲观策略:事务A对于某项数据进行修改时,在数据上加上排它锁。
- 乐观策略:数据表上加上
version
字段,每次修改数据都要检验一下version
是否正确,不正确就回滚。
MVCC
在讲事务的隔离级别时,MVCC的概念必须要了解。MVCC是Innodb引擎实现隔离级别的一种具体方式,MVCC通俗点讲就是:给数据一个快照。这与CopyOnWrite的思想类似。这里有三个概念:
- 一个事务只能读取已经提交的快照
- 当前事务能够读到自身未提交的快照
- 数据的读不更新快照,数据的写更新快照
基本概念
使用MVCC之后,所有的select操作都不会加锁,而是用快照读的方式。
版本号:快照是基于版本号的
- 系统版本号
sys_id
:每开启一个事务,版本号都会递增 - 事务版本号
trx_id
:事务开启前的当前版本号
Undo日志
每一条数据行都有三个隐藏的字段:隐藏id(主键)、trx_id、roll_pt
。其中roll_pt
是回滚指针,用来指向undo日志。在事务提交前,undo日志会记录数据行一开始的状态;提交后,undo日志不会立刻删除,而是会放在删除区等待删除。
举例:行更新的过程
- 初始数据行
字段1 | 字段2 | 字段3 | 字段4 | 字段5 | 字段6 | 主键id | trx_id | rool_p |
---|---|---|---|---|---|---|---|---|
122 | 2 | 3 | 4 | 5 | 6 | 01 | 00 | Null |
2.事务1修改了这行的所有内容,首先会给这行数据加写锁:
字段1 | 字段2 | 字段3 | 字段4 | 字段5 | 主键id | trx_id | roll_pt |
---|---|---|---|---|---|---|---|
10 | 20 | 30 | 40 | 50 | 01 | 01 | 1 |
生成一个undo日志1,该数据行的roll_pt
会指向undo日志1的主键id
上,而undo日志保留了初始数据行的状态:
undo日志1:
字段1 | 字段2 | 字段3 | 字段4 | 字段5 | 主键id | trx_id | roll_pt |
---|---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 01 | 00 | Null |
3.事务一提交,释放写锁,事务二进行了修改:
字段1 | 字段2 | 字段3 | 字段4 | 字段5 | 主键id | trx_id | roll_pt |
---|---|---|---|---|---|---|---|
11 | 21 | 31 | 41 | 51 | 01 | 02 | 2 |
此时会生成一个undo日志2保存事务二修改前的状态,该数据行的roll_pt
指向undo日志2的主键id上:
undo日志2:
字段1 | 字段2 | 字段3 | 字段4 | 字段5 | 主键id | trx_id | roll_pt |
---|---|---|---|---|---|---|---|
10 | 20 | 30 | 40 | 50 | 01 | 01 | 1 |
由于undo日志1的值不会立即被删除,因此undo日志2的roll_pt
会指向undo日志1的主键id
上,形成一条版本链。
ReadView
MVCC中进行快照读的基准是ReadView。执行select
操作,可能会创建一个ReadView(依赖于隔离级别),ReadView保存着系统中所有未提交事务的trx_id
。
TRX_ID | ||
---|---|---|
TRX_ID_MIN | … | TRX_ID_MAX |
- TRX_ID_MIN:表示在生成readview时当前系统中活跃的读写事务中最小的事务id
- TRX_ID_MAX:表示生成readview时系统中应该分配给下一个事务的id值(一般情况下是当前事务id + 1,高并发情况下可能更大)
当用户想要读取某条数据行时,innodb会将这个行的版本号与readview中的版本号进行比较:
- trx_id < TRX_ID_MIN:当前事务开启时,生成这个版本记录的事务已经提交了,所以一定可以读取
- trx_id > TRX_ID_MAX:生成这个版本的事务是本次事务开启readview之后才开启的,对当前事务不可见,不可读取数据(这种情况相爱trx_id的数据一定是无法提交成功的,因为我select的时候会加读锁,之后的事务的修改想要提交成功必须要等我释放锁)
- TRX_ID_MIN < trx_id < TRX_ID_MAX:如果trx_id不在readview里,表示生成该版本号的事务已经提交, 可以读取数据;否则,说明未提交,这就不可以读了。
补充:如果出现不可读的情况,将会沿着undo日志通过版本链查找最新可用的版本号进行读取。
事务的隔离级别
读未提交(Read Uncommited)
顾名思义,可以读到其他事务未提交的数据。这种隔离级别是不加锁的,效率最高,也不用MVCC。
读已提交(Read Commited)
事务提交后,可以被其他事务读取。但是事务B在两次读取的时间间隔内,事务A提交了修改,则事务B两次读取的数据就会不一样,这就是”不可重复读“的问题。
实现原理
事务A在修改数据的时候加写锁,直到事务结束。
此时这种解决方法会遇到一个问题:其他事务想要读数据时,会等待锁的释放。
因此Innodb引擎使用了MVCC来解决读写的一个并发情况。在这种隔离级别下:每次select都会生成一个readview,从而保证每次读取都只能读到已经提交的数据。但是,因为每次select都会生成一个新的readview,因此在这个事务执行期间,可以生成多个不同的readview从而读取到不一致的数据。无法解决不可重复读的情况。
可重复读(Repeatable Read):MySQL默认
事务在执行期间,不会读取到其他事务对数据的修改。但是其他事务插入的数据依然会被update等操作捕捉到。
实现原理
事务开启后的第一次select
操作生成了一次readview,后续的所有select
都是读的第一次生成的快照。
但是所有update
、delete
操作都是当前读,所谓当前读就是读取到最新已提交版本号的数据,所以当前读不走快照的。因此,当事务A在执行update
或者delete
操作时,会读取到其他事务提交的数据,这些新数据和之间select
的数据不一致,间接产生了幻读问题。
由此可见,MVCC可以解决由select操作引起的直接幻读,但是不能解决修改操作引起的间接幻读。
业务场景:
快手公司准备给次日24:00之间注册的用户发放1元红包,这时开启事务后会执行update操作,但是24:00之后依然会有大量新用户注册入库,此时update会同时修改这些新用户的红包信息(错误发钱),这就产生了幻读问题,并给公司带来业务损失。
如何解决幻读?
Innodb其实在RR隔离级别下实现了幻读的解决:使用了Next-Key Locks。
当开启一个select
的操作时,会锁住索引前后的间隔(左开右闭,行锁),这时其他事务进行insert
操作会阻塞。
假设一个数据表如下:
Id | a |
---|---|
1 | 5 |
2 | 8 |
3 | 11 |
4 | 18 |
此时Next-Key Locks对于索引a的锁的间隔是:$(-oo, 5], (5, 8], (8, 11], (11, 18], (18, +oo]$
如果此时我们执行:select * from table_name where a = 8
此时会锁住:$ (5, 8] U (8, 11] = (5, 11]$,此时其他事务无法这个范围内的行进行任何操作。
可串行化
所有事务串行处理,效率很低,但是足够安全。