浅析数据库之事务隔离级别与锁机制
写在前面,写这篇博文的主要原因是因为前几天阿里面试的时候,面试官问了数据库的事务隔离级别,虽然答上来了:未提交读(Read uncommitted)、提交读(Read committed),可重复读(Repeatable reads)和可串行化(Serializable)这几个,其实当时是想深入的讲解一下,因为之前看沈询前辈的海量数据系列博文的时候,讲到的数据库的隔离级别是通过数据库锁机制来实现的,不过当时记的不太清楚,担心把自己挖坑给埋了,就没有深入回答,现在就趁着是周日,把知识点复习一遍,把知识网络理清楚。
二阶段封锁
在并发环境下,我们往往需要进行并发控制,如果不进行并发控制的话,数据会出现一系列的问题:脏读,不可重复读和幻读。
然后数据库在事务执行过程中,需要维护事务的ACID特性,往往需要通过加锁来进行控制。
在高并发的情况之下,使用锁的话,如果加锁过度,可能会导致并发的性能降低,为了提供共享资源的并发性,往往要让锁定的对象具有选择性,即尽量只锁定要修改部分的数据资源,而不是所有的资源。
一般情况下,我们可以通过一次封锁法来对资源进行锁定,即就是在方法执行开始阶段,通过知道需要使用哪些数据资源,就将对应的资源进行锁定,减少锁的粗粒度,在执行方法完成之后,在进行解锁,避免了死锁的发生。
然而在数据库事务中,这却是不可行,因为在事务执行的时候,我们不清楚我们需要对哪些资源进行操作。
数据库事务是使用二阶段协议(2PL)来完成的,把事务分为两个阶段:加锁阶段和解锁阶段。
加锁阶段:在该阶段,我们可以进行加锁操作,在对数据进行读操作的时候,我们需要申请并获得读锁(s锁,共享锁,在事务获得锁的情况下,其他事务也可以加锁,但是不可以加排他锁);如果要进行写操作的话,则需要申请和获得写锁(x锁,排他锁,在有事务获得写锁的情况下,其他事务不能获的其他锁,包括读锁),如果在申请期间,事务没有获得锁,则事务就会进入等待阶段,直到加锁的成功才会继续进行。
解锁阶段 当事务释放一个封锁之后,事务就不能够获得其他封锁了,只能进行解锁操作而不能进行加锁操作。
在事务开启后,执行update、insert、delete操作会获得对应的锁,在事务commit之后就会释放对应的锁。
二阶段封锁虽然无法避免封锁,但是它可以保证事务的并发调度是串行化的。
事务加锁机制
数据库锁分类
MySQL中锁的种类很多,比较常见的有表锁和行锁。
表锁即锁住整张表,虽然也分为读锁和写锁,但是锁住了整张表,并发性能也会导致降低,一般用在ddl处理时候进行使用。
行锁是指锁住数据行,它只会锁住对应的数据行,也不会锁住整张表,并行性能比较高,MySQL一般使用表锁来处理并发事务。
事务隔离级别
隔离级别 | 脏读(Dirty Read) | 不可重复读(NonRepeatable Read) | 幻读(Phantom Read) |
---|---|---|---|
未提交读(Read uncommitted) | 可能 | 可能 | 可能 |
已提交读(Read committed) | 不可能 | 可能 | 可能 |
可重复读(Repeatable read) | 不可能 | 不可能 | 可能 |
可串行化(Serializable ) | 不可能 | 不可能 | 不可能 |
数据库操作中,使用事务隔离级别来保证并发读取数据的正确性。
未提交读: 允许脏读,就是可以读取其他会话中未提交事务修改的数据。
已提交读: 只能读取已经提交的数据,清除了脏读问题。
可重复读: 在同一个事务内的进行的查询都与事务开始时刻一致,它是Innodb的默认级别,它清除了不可重复读,但是还是存在幻象读。
可串行化: 完全串行话的读,每次读取的时候都需要重新获取表级共享锁,读写相互都会发生阻塞。
未提交读
未提交读隔离级别,任何操作都不会加锁,一般都不会使用。
已提交读
在已提交读级别中,对于update、insert、delete操作进行的所加的锁会一直持续到事务结束才释放,而对于select操作所加的锁则是在select操作完成之后就会进行释放,不会持续到事务结束才释放。因此它能避免读取在事务中间没有提交的数据,它能保证读取到读取的数据都是已提交的,避免了脏读,但是它不能保证在事务中两次读取的结果集一致。
1 | SET session transaction isolation level read committed; |
事务A | 事务B |
---|---|
set autocommit = 0 | set autocommit = 0 |
update user set education=’暨南大学’ where userId=30000; | |
update user set education=’暨南大学’ where userId=30000; | |
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction | |
commit |
为了防止并发过程中发生修改冲突,在事务A中对userId=30000的数据行进行加锁,如果一直不进行commit的话,事务B一直在申请得不到锁,会一直等待超时。
不过这处需要注意,因为userId是索引列,在进行加锁的只是对索引列值为30000的数据行进行加锁,而不会对其他数据行进行加锁,但是如果要加锁的数据列是没有索引的话,他会对所有的数据行进行加锁,因为在执行SQL的时候,数据库并不知道哪些数据行对应的数据行是需要进行加锁的,因为如果where条件无法通过索引进行快速过滤的话,存储引擎会将所有的记录加锁之后返回,然后再在服务层进行过滤。(Innodb只有在访问数据行的时候才会进行加锁,索引能够减少Innodb访问的行数,从而减少锁的数量,但这是只有Innodb能够在存储引擎层能够过滤掉所有不需要的行的时候才有效,如果索引无法过滤掉无效的行的话,只有Innodb访问数据行检索到数据并返回到服务层的时候,where子句才有进行过滤)
不过实际上,MySQL5.1版本之后在这进行了优化,Innodb可以在服务层过滤掉数据行,对这些过滤掉的数据行进行释放锁,不需要等到事务提交之后才释放锁。
可重复读
可重复读的事务隔离级别是Innodb的默认隔离级别。可重复度事务隔离级别对于select所加的读锁和insert、update、delete操作所加的写锁都会一直持续到事务结束才会进行释放。
读
读即是可重复读,即在一个事务中对同一个查询进行多次读取,能够看到同样的数据行。
事务A | 事务B |
---|---|
set autocommit = 0 | set autocommit = 0 |
select * from user where userId=30000 | |
update user set education=’暨南大学’ where userId=30000; | |
commit | |
select * from user where userId=30000 | |
commit |
如果在读已提交事务隔离级别的情况下,在这事务A的两次查询当中,两次查询得到的结果集是不相同的,即不可重复读的。
如果在可重复读的隔离级别下:
事务A | 事务B | 事务C |
---|---|---|
set autocommit = 0 | set autocommit = 0 | set autocommit = 0 |
select * from user where roleId=300 | ||
update user set education=’暨南大学’ where roleId=300; | ||
commit | ||
insert into user values(null,”西安大学”,300) | ||
commit | ||
select * from user where roleId=300 | ||
commit |
在这并发事务中,我们可以看到事务A先进行了一次读取,在事务B中进行了一次数据的修改,在事务C中进行了一次数据的插入,不过在事务A中第二次读取得到的数据与第一次完全相同,即它是可以进行重读的。
不过在这里我们首先需要对幻读和不可重复读这两种并发导致的问题进行区分
幻读与不可重复读
不可重复读主要是针对与delete和update操作来讲的,而幻读主要是针对insert操作来讲的,即不可重复读是针对数据库中已有的数据,而幻读是对原不在数据库中的数据来讲的。
如果是使用锁机制来实现这两种隔离级别的话(可串行化和可重复读)的话。在重复读中,查询语句在第一次读取数据后,就会对读取的数据进行加锁,那么其他事务就无法对加锁的数据进行修改,就可以实现重复读取,但是这些的话无法对insert进来新的数据进行加锁,这样的话,在事务A读取数据或者修改数据之后,事务B还是可以进行insert操作的,这样的话,在事务A就会发现多了一条新的记录,即出现了幻读,不可以通过行锁来避免,这需要可串行化隔离级别,读用读锁,写用写锁,读写互斥,来避免幻读。
不过以上讲的是使用悲观锁机制来处理这两种问题,不过在MySQL中,都是使用以乐观锁为理论基础的MVCC(多版本并发控制)来避免问题。
悲观锁与乐观锁
- 悲观锁
悲观锁是指数据对外部环境(本系统当前的其他事务或者其他外部系统的事务处理)保持保守悲观态度,即在整个数据处理的过程中,将数据处于锁定状态,其他事务无法进行修改。悲观锁的实现,往往是依靠数据库提供的锁机制(只有通过数据库层面才能保证数据访问的排他性)来实现的。
在悲观锁的情况下,为了保证事务的隔离性,通过一致性锁进行读写锁定,在读取数据的时候进行加锁,其他事务无法修改这些数据。 在修改删除数据时候也要加锁,其他事务无法读取数据。
- 乐观锁
乐观锁相对于悲观锁而已,乐观锁机制采取比较宽松的加锁机制,悲观锁是通过依靠数据库的锁机制实现的,认为在大部分情况存在着并发访问,需要加锁来保证操作的独占性。而乐观锁则认为在大部分情况下是不存在并发访问的,不需要加锁来保证数据的独占性,而是通过数据版本(Version)记录机制来为数据作一个版本标识,即在基于数据库表的版本解决方案则是通过在数据库表增加一个version字段来实现,在进行数据读取的时候,将版本号进行一同读取,在对数据进行更新的时候,对版本号进行加一操作,同时,在提交的时候会对提交数据的版本号与数据库表对应记录的当前版本号进行对比,如果当前提交数据的版本对在数据库表中记录的版本号大的话,就进行更新,否则就认为是过期数据。
Innodb中的乐观锁实现(MVCC)
在MVCC的Innodb实现中,它是通过在每行数据后添加两个额外的隐藏值来实现MVCC,这两个值一个是用于记录该行数据是在什么时候被创建的,另外一个是用来记录该行数据什么时候过期或者删除,不过它存储的不是具体的时间戳,而是事务的版本号,每开启一个事务,事务的版本号就会递增。
在可重复读的事务隔离级别的时候,进行以下操作的时候,事务版本号的修改如下:
- Select的时候,Innodb只会查找版本早于当前事务版本的数据行,可以保证事务读取的行,要么是事务开始之前就已经存在的,要么是事务自身插入或者修改过的。还有可以读取行的删除版本没有定义或者大于当前事务版本号的,用来确保事务读取到的行,在事务开始之前未被删除。
- Insert的时候会为新插入的每一行保存当前系统版本号作为行版本号。
- Delete的时候为删除的每一行保存当前系统版本号作为行删除标志。
- Update的时候会新插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标志。
通过保存这两个额外系统版本号,使得大多数读操作不需要进行加锁,使得读性能得到提高,也能保证只会读取到符合标准的行,不过每行记录都需要额外的存储空间。MVCC只有在REPEATABLE READ和READ COMMITTED这两个隔离级别下工作,其他两个隔离界别与MVCC不兼容,因为READ UNCOMMITTED总是读取最新的数据行,而不是符合事务版本的数据行,而SERUAKIZABLE则会对所有读取的行加锁。
在MySQL的innodb MVCC实现中,能在在可重复读的事务隔离界别中能够保证对读取的数据行进行加锁,而且在读取数据的时候会有获取一个范围锁(range-locks),保证读取的范围加锁,新的满足满足条件的记录不能插入(间隙锁),避免了幻读现象。
当前读和快照读
在Innodb的MVCC并发控制的系统,读操作可以分为两种:当前读和快照读。
- 快照读:快照读是简单的select操作,不加锁。
- 当前读:特殊的读操作,insert、update、delete操作,属于当前读,需要进行加锁。
当前读会读取记录的最新版本,在读取之后,同时会对记录加锁,保证其他并发事务不能修改当前记录。
在进行update/delete操作的时候,当MySQL Server接收到SQL之后,会根据where条件,读取第一条满足条件的数据,然后Innodb引擎会将第一条记录返回,并进行加锁,等MySQL Server接收到记录之后,会再放起一个Update/Delete请求,对数据进行更新或者删除操作,一条记录操作完成之后,再去读取下一条记录,直到没有符合条件的记录位置。
Insert操作的话会为可能突发Unique Key的冲突检查,也会进行一个当前读的操作。
Next Key锁
上面我们讲到MySQL Innodb MVCC实现,可以避免当前读的幻读问题,它是通过Next Key锁来实现的。
Next Key锁是指行锁与GAP(间隙锁)的合并。
行锁可以防止不同事务版本的数据修改提交时造成数据冲突的情况,但是它不能避免其他事务插入数据的问题。
在可重复读的事务隔离级别下,在事务A进行了Update/delete操作的操作之后进行加锁(Gap锁),事务B无法插入新数据,事务A能够保持操作前后数据保持一致,避免了幻读。
如果在检索的时候使用了索引列,Innodb存储引擎使用的是B+树索引,会在最外层节点将数据分为一段段的区间,在事务进行Update/delete操作的时候,不仅仅会锁住对应的数据行,同时也会锁住对应数据行附近的区间,加入GAP锁,这样的话事务B的话就无法对附近区间进行insert操作,这样的话Innodb在很多时候锁住不需要锁的区间。
如果检索的列不是索引列,那么就会给全表加入GAP锁,不过GAP锁不会向行锁一样在MySQL Server层过滤不满足条件的锁,因为没有索引,也没有排序,就没有区别,这样的话除非事务提交,否则其他事务无法插入任何数据。
这样的话,通过行锁防止事务的删除和修改,通过GAP防止其他事务的新增,行锁和GAP锁结合形成的Next-key锁防止了在可重复读隔离级别下写数据时的幻读问题。
可串行化
在可串行化的事务隔离级别中,会从MVCC并发控制退化成基于锁的并发控制,不对当前读和快照度进行区分,所有的读操作都是当前读,读加读锁(S锁),写加写锁(x锁),读写冲突,导致并发性能下降,一般不建议使用可串行化隔离级别。