1. 事务隔离级别
四种隔离级别及对应问题的可能性。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | 可能 | 可能 | 可能 |
读已提交 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
串行化 | 不可能 | 不可能 | 不可能 |
2. 快照读(非锁定读, 普通select语句)
一致性非锁定读是通过MVCC来读取数据库中对当前事务而言可读版本中最新数据。在查询时,有其他事务正在完成写入,那么不会等待行锁的释放,而是读取旧一点版本的数据。
2.1 undo log
innodb中,当事务提交时,需要先将该事务的所有操作日志写入到重做日志文件,然后等提交操作完成,事务才算完成。这里的重做日志文件包含了两部分:redo log和undo log。 redo log被用来保证事物的持久性,undo log用来帮助实现事务回滚和MVCC。这里重点讲一下undo log, 当事务回滚时,利用undo逻辑地将数据库恢复到之前的状态。
那么如何通过undo 来回滚和实现MVCC呢。 聚簇索引记录中都包含两个必要的隐藏列:
- trx_id:每次对某条聚簇索引记录进行改动时,都会把对应的事务id赋值给trx_id隐藏列。
- roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
每次对记录进行改动时,生成对应的undo log, undo log中同样有roll pointer,指向了更早的一个版本。于是就形成了一个链表。
事务回滚时,逻辑地删除该版本链中对应trx_id的数据就行了。
2.2 MVCC
read uncommitted级别下, 直接读取版本链中的头节点就好了。
-
read committed级别下, MVCC在每个select执行时,先创建一个read view。 read view 包含了以下信息:
- 当前事务id: cur_trx_id
- 当前系统中最小的活跃事务id: min_active_trx_id
- 当前系统中最大事务id: max_trx_id
通过read view,可以确定哪些事务写入的数据是本次select可读的:readable_trx = (0, min_active_trx_id) U (min_active, max_trx_id)区间内非活跃的事务 U 当前事务
select读取时,通过版本链,读取出trx_id在readable_trx中且版本最新的记录,作为返回值。由于读取的要么是已提交事务的写入,要么是当前事务的写入,因此避免了脏读,然而由于每次select都会生成read view, 可读取版本可能会不同,因此无法避免不可重复读。
repeatable read 级别下, 在事务中的第一个select时,去创建read view。确定当前事务中select的可读版本。之后每次select时,都根据该read view 去读取可读且版本最新的数据。从而保证了该事务中的可重复读。
serializable级别下,串行化,那么就无法通过版本连来控制了。读加共享锁,写加排他锁,读写互斥。
3. 当前读(锁定读)
在RC和RR隔离级别下,快照读总是在当前事务中先创建read view,然后读出可读版本的最新数据。然而在某些场景下,我们希望的是,在当前事务中要读取数据时,如果有其他事务正在写入与查询相关的数据,我们先等他写入完成,提交后获取到读取的机会,然后读出写入后的最新数据。这种模式下,快照读就无能为力了。这是一个很典型的读写问题,innodb为了解决这种问题,采用了读写锁的解决办法。
- read uncommitted级别下, 读写均不互斥,假设事务b,更新某条记录,此时事务a 去select for update/ in share mode, 由于未作任何同步互斥, 此时事务a即可读取到b对数据的写入,如果事务b回滚,那么可能带来很多负面的影响。
- read committed级别下锁定当前记录。脏读是由于一个事务中对数据的写入,在提交之前被另一个事务读取到。那么解决思路就是写入时对该数据加X锁,读取时根据需要加S锁(select ... in share mode)或者X锁(select ...for update)。也就是所谓的record lock。假设数据库内有非聚簇索引[1,3, 5, 5, 5, 7 ], 事务a在读取索引为5的记录时加record lock,此时另一个事务b想要修改索引为5的数据,发生阻塞。直到事务a提交事务,释放锁之后,事务b继续执行才能访问。
但是RC级别下,无法解决幻读的问题,假设事务b是要插入一条索引为5的记录,根据b+树,它可以在(3,5), 5,(5,7)之间的任何位置进行插入,此时仅仅锁住了5这个点,那么仍然可以在(3,5),(5,7)两个区间的中间插入。
事务a | 事务b |
---|---|
begin; | begin; |
select count(1) from xx where sec_id = 5; (结果:3) | |
insert into xxx set ... sec_id =5 | |
commit | |
select count(1) from xx where sec_id = 5; (结果:4) | |
... |
repeatable read级别下,使用next-key lock的方式对索引加锁,对非唯一索引而言,它不仅锁住记录本身(record lock),而且还对(3,5), (5,7)两个区间加锁(gap lock)。最终的效果就是,在(3,7)区间内的任何写入,都将被阻塞。对于唯一索引而言,只需要锁住当前记录本身即可,因此next-key-lock降级为record lock。因此锁定读下的RR级别,避免了幻读的问题。