InnoDB引擎--事务隔离性
事务将数据库从一个一致状态转换至另外一个一致状态,若某个事务看到了另外一个事务在状态转换过程中的中间态数据(不一致状态),将有可能导致另外一个事务的操作基于一个不一致的数据库状态,进而数据库失去一致性。事务隔离性主要用于处理数据库的并发访问问题。
事务隔离性级别
事务隔离性分为4个级别,可以在事务的一致性与并发性上的做出权衡。另外,不同的隔离级别也有不同的问题存在。
读未提交(Read Uncommitted)
读未提交允许读取未提交的数据,这实际上并没有提供隔离性–事务之间可以完全可以看到彼此的修改。读取未提交的数据也称为脏读。
读已提交(Read Committed)
读已提交只允许读已提交的数据,避免了脏读现象。但是存在以下情况:在事务T1内,读取了数据D值为D1,然后T1执行其他SQL,事务T2更新了D为D2并提交,此时T1再次读取D时值为D2。这种情况称为不可重复读。
可重复读(Read Repeatable)
可重复读只允许读已提交数据,而且在一个事务两次读取一个数据项期间,其他事务不得更新该数据,避免了不可重复读现象。虽然其他事务不得更新该数据,但是并未禁止不得插入新数据,这使得在一个事务内,两次同样的SELECT语句读取的记录数量是不一致的,这种情况称为幻读。
可串行化(Serializable)
可串行化保证事务的调度都是可串行化调度。可以避免幻读现象。
简要说明一些可串行化的概念:事务按顺序一个一个执行是串行调度,但是串行调度效率较低(比如读事务可以并发执行),可以对调度中的指令重排序形成新的调度,新的调度执行结果与串行执行结果是一致的,效率也更高。两个指令中有一个为写指令时两者是冲突的,顺序不能打乱,在一个串行调度中,保证冲突的指令顺序不打乱,其他指令的顺序可以调整以提高效率,即为可串行化调度。
隔离性级别与问题表:Y-可能,N-不可能
隔离性级别 | 丢失更新 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
RU | N | Y | Y | Y |
RC | N | N | Y | Y |
RR | N | N | N | Y |
S | N | N | N | N |
事务的隔离性主要靠锁和MVCC支持。
事务中的锁-基础理论
确保隔离性的方法之一就是要求对数据项以互斥的方式访问,当一个事务访问一个数据项时,其他任何事务都不能修改该数据项。最常见的方法就是只允许事务访问其持有锁的数据项。
锁的类型
排它锁与共享锁
排他锁与共享锁的相容函数:
相容性 | S | X |
---|---|---|
S | true | false |
X | false | false |
多粒度
上文所描述的并发控制机制均是基于单个的数据项的,但是在数据库中有时需要更大的控制粒度(如DDL中的ALTER TABLE操作),如果此时还是按照数据项的粒度去挨个加锁,效率将是极低的,引入多粒度加锁以解决这个问题。
如上图所示,有的事务需要锁住整个数据库,有的只需要锁住表,有的只需要锁住一条记录即可。
多粒度封锁协议引入新的锁类型–意向锁(Intension Lock)来实现多粒度加锁,意向锁用来表示事务在加锁的数据项(各种粒度)上的操作意向。意向锁分为共享意向锁和排他意向锁,其与共享锁、排他锁的相容性矩阵如下表所示:
相容性 | IS | IX | S | X |
---|---|---|---|---|
IS | true | true | true | false |
IX | true | true | false | false |
S | true | false | true | false |
X | false | false | false | false |
多粒度封锁协议要求加锁按自顶向下的顺序(根到叶),解锁按照自底向上的顺序(叶到根)。如对某个记录加共享锁,需要先对根(数据库)加共享意向锁,再对表加共享意向锁,最后对该记录加共享锁;释放时,先释放该记录的共享锁,再释放在表上的共享意向锁,最后释放数据库上的共享意向锁。
多粒度封锁协议增强了并发性,减小了锁的开销。
封锁的实现
封锁通过锁管理器实现事务加解锁的管理,锁管理器通过维护一个锁表实现该功能。锁表如下图所示,图中I表示数据项,T表示事务。
锁管理器为目前所有已经加锁的数据项I维护一个链表,每个请求T为链表中的一条记录,按请求到达的顺序排序(防止饿死)。数据项通过散列表来维护。
在InnoDB中的锁是行级锁(但其实际锁住的不是记录本身,而是索引),并支持多粒度封锁协议。InnoDB即时锁信息可以在Information_schema中的INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS三张表中查询。
Two Parse Locking
TPL将事务的锁操作分为加锁和解锁两个阶段,加锁阶段事务只能加锁而不能解锁,解锁阶段事务只能解锁而不能加新锁。
通常TPL配合锁转换以避免级联回滚问题。即加锁阶段读锁可以升级为写锁,解锁阶段写锁降级为读锁而不是直接释放。
TPL进一步提高了并发效率,事务在将数据库由一个一致性状态转换至新的一致状态之间,将部分一致的状态对其他事务可见。
InnoDB三种加锁方式
- Record Lock:记录锁,锁住记录的索引
- Gap Lock:间隙锁,锁住一个范围,但不包含记录本身
- Next-key Lock:Record Lock+Gap Lock,锁定一个记录范围,并且锁定记录本身
当sql语句通过唯一性索引查询某唯一行时,不会产生Gap Lock。此唯一性索引包括主键、普通唯一索引、联合唯一索引。
在查询中字段没有索引或者不是唯一索引时,都会产生Gap Lock。
因为有Next-key lock的存在,在InnoDB中的RR隔离级别下,事务中的锁定读可能存在的幻读问题不再存在。Next-key lock锁定了记录周围的域,使得插入操作阻塞,保证了查询范围内的一致性。
PS:这里陈述的查询语句都是锁定读,手动加锁如SELECT .. FOR UPDATE,SELECT .. LOCK IN SHARED MODE。不涉及下文将要提到的MVCC非锁定一致性读
死锁
目前的封锁协议无法完全避免死锁的发生。处理死锁主要有两种方式:一是在申请资源前进行死锁检测;而是在发生死锁后,进行回滚。一适用于死锁发生频繁的场景,二适用于死锁发生概率较小的场景。
丢失更新问题
前面的事务隔离性级别问题表中,丢失更新问题在任何一个隔离级别下的单纯更新操作事务中都不都可能发生,即使是在RU隔离级别下,对行数据的DML操作都会加X锁,最多降级为S锁,直到事务提交,另外的事务才能更新新的数据,所以不存在在一个事务内部,尚未提交的更新数据被另外一个事务的更新操作所覆盖。
但是如果一个事务是先读后更新,分为两步操作,在操作间隙可能被另外的事务读取数据再更新,情况就不一样了。
例如:业务逻辑A,T1读取数据D,然后更新D为D1。业务逻辑B,T2读取数据D,然后T2更新D为D2。
如果T2读取D的时机在T1更新D之前,那么A和B更新操作都是基于数据D,D1的值将被D2覆盖。如果D代表了银行余额,A和B都是转账操作,那么明显数据库将不会处于一致状态。
这时的丢失更新问题需要将读写操作作为一个整体来解决,即在SELECT时就要手动加锁SELECT….FOR UPDATE,在读的时候直接加X锁。
MVCC(Multi-version Concurrency Control)
在业务场景中,不能读到数据与读到数据后但是更新失败给用户的体验是不一样的,大多数业务中读数据的请求都远远多于写数据的请求。在仅有锁控制的并发控制下,读事务仅在共享锁时可以并发读,而在排他锁时被阻塞,MVCC使得数据库在排他锁时,读事务依然不会阻塞,极大的提高了数据读并发处理能力。
MVCC,多版本并发控制,提供了数据库非锁定读的能力,在InnoDB中,MVCC由Undo Log支持。
在查询事务查询时如果数据项被加排他锁,则查询事务不会被阻塞而是读取该数据项的快照数据。不同的隔离级别对快照数据的轻易不一样,在RR级别下,事务总是读取事务刚开始读取的那个版本;在RC级别下,事务总是读取数据项最新的那个版本。