编辑推荐: |
本文主要价绍了事务和事务引发的问题
, MVCC多版本并发控制(一致性非锁定读), 行锁算法(一致性锁定读),死锁问题等相关内容。
本文来自简书,由火龙果软件Anna编辑、推荐。
|
|
InnoDB引擎的事务与锁
一. 背景:事务和事务引发的问题
1. ACID
原子性:表示整个事务是不可分割的,要么都执行成功,要么都执行失败。
一致性:保证完整性约束没有被破坏。
隔离性: 事务不可见行,事务与事务之间分离不可见。
持久性:事务一旦提交,其结果就是永久性的,即使发生宕机,数据也是可以恢复的。
2. 事务的分类
1. 扁平事务
扁平事务是事务中最简单的一种,也是使用最频繁的,在扁平事务中,所有操作都处于同一层次,由BEGIN开始,COMMIT
或者ROLLBACK结束,其操作都是原子性的。
2. 带有保存点的扁平事务
在扁平事务的基础上,增加了保存点, 允许回滚到同一事务中较早的一个状态,因为在某些场景,放弃整个事务会浪费不必要的开销,对于扁平事务来说,隐式的增加了一个保存点,保存点用SAVE
WORK函数建立,然后在事务中 只有这一个保存点, 保存点是依次递增的。
3. 链事务
可以看作是保存点模式的变种,当系统发生崩溃的时候,所有的保存点都会消失,因为保存点是 易失(volatile)
的和 非持久(persistent) 的,链事务的思想是:提交当前事务和开始下一个事务操作合并为一个原子操作,这意味这下一个事务是能看到上一个事务的处理结果的,如图
:
4. 嵌套事务
嵌套事务是一个层次结构框架,InnoDB本事并不能很好的支持,因此需要依据前面三种事务,自己实现。
嵌套事务由一个顶层事务控制着各个层次的事务,被嵌套的事务被称为子事务,如图:
(1). 嵌套事务是事务组成的一个树,子树既可以是嵌套事务,也可以是扁平事务。
(2). 处在叶子节点的事务是扁平事务。
(3). 叶子节点的深度可以不同。
(4). 子事务既可以提交也可以回滚,但不会立马生效,要等父事务提交,因此顶层事务是所有子事务的前置事务。
(5). 树中的任意个事务的回滚会引起它的子事务的回滚,故子事务仅保留A,C,I特性,不具有D特性。
分布式事务
通常是一个在分布式环境下运行的扁平事务,一般分为强一致性事务和柔一致性事务,比如基于XA的二阶段提交就属于强一致性事务,而基于MQ或者补偿机制的分布式事务属于柔一致性事务,基于BASE理论,强一致性事务要保证强一致性,而柔一致性事务保证的数据的最终一致性,这里不展开讨论分布式事务,在之后的文章会展开讨论分布式事务的常见实现和原理。
3. 事务的实现
事务的实现 一般依赖于Redo log 和 Undo Log。
1.Redo Log :
重做日志是用来实现事务的持久性,即事务ACID中的D。其由两部分组成:一个是内存中的重做日志缓存(redo
log buffer),它是易失的;二是重做日志文件(redo log file),是持久的。
当事务提交的时候,必须先将该事务的所有日志写入到重做日志文件进行持久化,待提交的事务COMMIT才算完成。重做日志格式是基于页的,文件记录的是每一个事务操作的物理地址和偏移量,并不是记录的数据本身,当数据库宕机重启,就是依赖于重做日志恢复的。
这里有一个有意思的点是 数据库IO的页一般都是 16K 大小,而计算机IO的页一般是 4K 大小,Redo
log在数据库和机器同步的时候,存在IO写入导致page损坏的问题,为了保证数据不会丢失,会采用Double
Write的思想,Double Write这里不做过多叙述,感兴趣的小伙伴可以去查阅下相关资料。
2.Undo Log :
undo log就是帮我们解决事务回滚的功能,与redo log不同的是, undo log存放在数据库内部的一个特殊段中,位于共享表空间。
undo log分为 insert undo log 和 update undo log,insert
就是在插入数据的时候产生的 undo log ,只对自身事务可见,对其他事物不可见;而 update
undo log记录的是对update 和 delete操作产生的 undo log,该log可能需要提供MVCC机制(下面会说到),因此不能在事务提交的时候就删除,提交的时候放到
undo log链表,等待purge线程进行最后的删除。
purge线程知识:
3.事务引发的问题:
1.脏读:
指一个事务读取了另外一个事务未提交的数据。
事务一 |
事务二 |
select * from
user; //查出id为1的数据 |
|
|
insert into user(id,name)
values (2,‘coco’); |
select * from
user; //查出id为1 和 2 的数据 |
|
|
ROLLBACK |
select * from
user; //查出id为1 |
|
2. 不可重复读:
在一个事务内读取表中的某一行数据,多次读取结果不同。(这个不一定是错误,只是某些场合不对)
事务一 |
事务二 |
select * from
user; //查出id=1,name='leeco’的数据 |
|
|
update user set
name=‘leeco2’ where id = 1; |
|
COMMIT |
select * from
user; //查出id=1,name='leeco’的数据 |
|
3.幻读:
是指在一个事务内读取到了别的事务插入的数据,导致前后读取不一致(更偏向于数量)
事务一 |
事务二 |
select * from
user; //查出id为1的数据 |
|
|
insert into user(id,name)
values(2,‘coco’); |
|
COMMIT |
select * from
user; //查出id为1 和 2 的数据 |
|
二. MVCC多版本并发控制(一致性非锁定读)
目的 : 解决一致性的非锁定读 称为快照读
该图直观的展示了非锁定读,之所以称为不锁定读,是因为不需要等待行X(排他)锁的释放。快照数据是指该行之前版本的数据,该实现是由undo段来完成的。而undo用来在事务中回滚数据,因为快照数据本身并没有额外的开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。
网上由很多人把它理解为隐式的在每行记录后面保存两个隐藏的列来实现:一个保存了创建该行的事务ID,一个保存删除该行的事务ID,虽然这个点好像并没有官方说明,但是这么去理解MVCC会更容易一些,例如
id |
name |
创建ID |
删除ID |
1 |
leeco |
3 |
null |
此时,事务一查询出来的只有ID=1的数据,然后事务二insert一条ID=2的数据
id |
name |
创建ID |
删除ID |
1 |
leeco |
3 |
null |
2 |
leeco |
4 |
null |
这个时候事务一查询的时候 只会查找事务ID<=3的数据,因此不会查出来记录ID=2的数据。
需要注意一点,update操作InnoDB其实是先删除,然后新增,
例如 update user set name = ‘coco’
where id = 2; 如下:
id |
name |
创建ID |
删除ID |
1 |
leeco |
3 |
null |
2 |
leeco |
4 |
5 |
2 |
leeco |
5 |
null |
因此,非锁定读大大提高了并发性。但是在不同的事务隔离级别下,读取的方式是不同的,在默认的可重复读(Repeatable
Read)级别下,总是读取快照的开始时候的版本,而在读已提交(Read Committed)的级别下,总是读取最新的快照版本,因此读已提交会出现幻读的问题。
MVCC解决快照读的幻读问题 : 针对与MVCC是否能解决幻读的问题,是存在争议的,绝大多数人认为是可以解决幻读的,少数人认为无法解决幻读,下例引发思考:
假设 :现在user表有1条数据
现在做如下操作:
事务一 |
事务二 |
select * from
user; //查出id为1的数据 |
|
|
insert into user(id,name)
values(2,‘coco’); |
select * from
user; //查出id为1的数据 |
|
update user set
name=‘coco’ where id=2; |
|
select * from
user; //查出id为1 和 2 的数据 |
|
思考:这种情况到底算不算幻读问题? 欢迎评论
三. 行锁算法(一致性锁定读)
锁算法都是基于索引的,且锁的就是索引本身,而InnoDB默认使用的是next-key Lock
以下内容 非特殊情况 都是基于默认的可重复读的隔离级别展开说明
本章节不对表锁进行讨论
|
共享锁(S) |
排他锁(X) |
共享锁(S) |
兼容 |
不兼容 |
排他锁(X) |
不兼容 |
不兼容 |
一致性锁定读,是显式在SELECT的时候加锁以保证数据逻辑的一致性,而这要求对操作行进行加锁语法
SELECT … FOR UPDATE; 加一个X锁,此时其他事务不能做任何操作
SELECT … LOCK IN SHARE MODE;加一个S锁,此时其他事务可以加S锁,但是加X锁会被阻塞
1. 间隙锁(Gap Lcok)
锁定一定的范围,但不包含本身 (左开右开)
2. 临键锁(Next-Key Lock)
锁定一定的范围,并且锁住本身 即GapLock + Record Lock (左开右闭)
3. 记录锁(Record Lock)
锁定单行记录
比如数据记录 1,5,9,Record Lock锁住的就是1,5,9,Gap Lcok锁住的是(-∞,1),(1,5),(5,9),(9,+∞),
Next-Key Lock锁住的是 (-∞,1], (1,5], (5,9], (9,+∞]
注意了,这里仅针对RR隔离级别,对于RC隔离级除了外键约束和唯一性约束会加间隙锁,没有间隙锁,自然也就没有了临键锁,所以RC级别下加的行锁都是记录锁,没有命中记录则不加锁,所以RC级别是没有解决幻读问题的。
那么这三种锁分别在什么时候生效呢,首先,行锁是基于索引的,InnoDB默认是的采用Next-Key
Lock锁算法, 例如上例,
当 SELECT … WHERE ID = 5; 的时候,若ID非索引,则退化成表锁;如果ID是辅助索引,则会对前一个区域使用临键锁,即锁住了
(1,5] ,然后对下一个区域使用间隙锁,即锁住了(5,9),总结就是锁住了(1,9); 只有当ID是非空唯一索引的时候,会升级为记录锁(Record
Lock), 只锁住ID=5的这一行记录的索引。
注意:虽然ID是非空唯一索引,但是当查询条件是范围查询的时候 也会退化为临键锁。
例如上例中,select * from id > 6; 此时 只能查出来ID=9的数据,然后添加一条ID=10的数据,如果没有临键锁,则下次查询会查出来ID=9和ID=10两条数据,就出现了幻读。而真是情况是:因为是范围查找,InnoDB采用临键锁,此时会对(6,9]和(9,+∞)范围进行加锁,
此时插入ID=10的数据会被阻塞,所以不会出现幻读.
因此 在当前读的环境下 临键锁解决了幻读问题。
四. 死锁问题
死锁 是指两个或两个以上的事务在执行过程中,因为抢夺资源而造成的一种互相等待的现象。
事务一 |
事务二 |
select * from
user where id = 1 for update |
|
|
select * from
user where id = 2 for update |
update user set
name=‘leeco’ where id=2 |
|
|
update user set
name=‘leeco’ where id=1 |
由于事务一等待事务二释放ID=2的锁,而事务二等待事务一释放ID=1的锁,因此造成互相等待,形成死锁。
解决死锁的方式最简单的是超时,当超过等待时间 则进行回滚;
还有一种普遍的方式就是采用 wait-for graph(等待图) ,要求数据库保存两种信息: 锁的信息链表,事务等待链表,
通过上述链表可以构造出一张图,如存在回路,则表示存在死锁问题,如图:
五. 总结:
1. InnoDB 通过 MVCC 和 NEXT-KEY LOCK,解决了在可重复读的事务隔离级别下出现幻读的问题。
2. 即使InnoDB默认是采用可重复读的事务隔离级别,但是正是由于MVCC和临键锁的存在,解决了幻读的问题,因此已经达到了串行化的隔离级别。
|