InnoDB的锁机制可以解决并发控制,但开销大,常常与MVCC结合使用,在大多数情况下代替行级锁,降低开销。只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作。
基本原理:
通过保存数据在某个时间点的快照来实现,当对某条记录做了变更时,老版本的数据被放在undo log里,并以指针的形式关联起来,形成一个链表。在查找老的版本时,按链表顺序查找,直到找到当前事务ID之前 已经提交的事务对应的最新那条记录即可。基于 版本链、undo log 和 Read View 实现。
版本链
InnoDB 聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,我们创建的表中有主键或者非NULL唯一键时都不会包含row_id列):
- trx_id:每次对某条聚簇索引记录进行改动时,会把对应的事务id赋值给trx_id隐藏列。
- roll_pointer:每次改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
假设表中只含有一条插入记录:
之后两个id分别为100、200的事务对这条记录进行UPDATE操作,操作流程如下:
注: 两个事务中不能交叉更新同一条记录。
每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表:
对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。
回滚段中的undo logs分为: insert undo log 和 update undo log
- insert undo log : 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
- update undo log : 事务对记录进行delete和update操作时产生的undo log, 不仅在事务回滚时需要, 一致性读也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除。
ReadView
用于判断版本链中的哪个版本是当前事务可见的,主要包含4个比较重要的内容:
- m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
- min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
- max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
注意:max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。
- creator_trx_id:表示生成该ReadView的事务的事务id。
注意:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE)才会为事务分配事务id,在一个只读事务中的事务id值都默认为0。
只需要按照下边的步骤判断记录的某个版本是否可见:
- 如果被访问版本的trx_id属性值与creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id属性值小于min_trx_id,表明生成该版本的事务在生成ReadView前已经提交,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id属性值大于或等于max_trx_id,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
- 如果被访问版本的trx_id属性值在min_trx_id 和 max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
在MySQL中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。
READ COMMITTED — 每次读取数据前都生成一个ReadView
比如现在系统里有两个id分别为100、200的事务在执行:
#Transaction 100
BEGIN;
UPDATE t SET c = '关羽' WHERE id = 1;
UPDATE t SET c = '张飞' WHERE id = 1;
#Transaction 200
BEGIN;
#更新了一些别的表的记录
...
假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:
#使用READ COMMITTED隔离级别的事务
BEGIN;
#SELECT1:Transaction 100、200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'
这个SELECT1的执行过程如下:
- 在执行SELECT会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200]。min_trx_id为100,max_trx_id为201,creator_trx_id为0。
- 从版本链中挑选可见的记录,从图中看出,最新版本的内容是’张飞’,该版本的trx_id 值为100,在m_ids列表内,不符合可见性要求,根据roll_pointer跳到下一个版本。
- 下一个版本的内容是’关羽’,该版本的trx_id值也为100,也在m_ids列表内,不符合。
- 下一个版本的列c的内容是’刘备’,该版本的trx_id值为80,小于m_ids列表中最小的事务id100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为’刘备’的记录。
若把事务id为100的事务提交一下,然后再到事务id为200的事务中更新一下表t中id为1的记录:
此时m_ids列表的内容就是[200],在使用READ COMMITTED隔离级别的事务中查询表t中id值为1的记录时,得到的结果就是’诸葛亮’了。
REPEATABLE READ — 在第一次读取数据时生成一个ReadView
对于使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。ReadView的m_ids列表的内容就是[100, 200]。min_trx_id为100,max_trx_id为201,creator_trx_id为0。
#SELECT1:Transaction 100、200均未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'
#SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值仍为'刘备'
幻读问题
假设按照上面的例子,ReadView的m_ids列表的内容就是[100, 200]。min_trx_id为100,max_trx_id为201,creator_trx_id为0。此时事务201插入一条记录,因为没有锁,可以自由插入,max_trx_id为202,m_ids列表内容不变,导致幻读。
在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
快照读(snapshot read)
简单的select操作(不包括 select … lock in share mode, select … for update)
在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”。
当前读(current read)
select … lock in share mode
select … for update
insert
update
delete
其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。
在RR级别下,快照读是通过MVCC(多版本控制)和undo log来实现的,当前读是通过加record lock(记录锁)和gap lock(间隙锁)来实现的。innodb在快照读的情况下并没有真正的避免幻读, 但是在当前读的情况下避免了幻读!