事务,隔离级别和并发控制

  • 事务
    • ACID 特性
    • 实现方式
  • 并发一致性问题
  • 隔离级别
  • 一致性非锁定读
    • MVCC
    • 一个例子
    • 产生问题
    • RR 级别下,MVCC 解决不可重复读和幻读的合理解释
  • 一致性锁定读
    • Next-Key Lock
  • 共享锁和排他锁
    • 意向锁
  • 乐观锁和悲观锁
    • 使用场景
    • 乐观锁的实现
  • 参考

 

事务


ACID 特性

“事务是满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,可以通过 Rollback 进行回滚。”

原子性(Atomicity):事务是不可分割的工作单位。
一致性(Consistency):事务将数据库从一种状态变为下一种一致的状态。在事务开始之前和结束以后,数据库的完整性约束没有被破坏。(例如银行扣款数目等于进款数目就属于自定义状态。)
隔离性(Isolation):每个读写事务的对象对其他事务的操作对象能互相分离,该事务提交前对其他事务不可见,通常使用锁来实现。(多事务互相影响导致脏读等问题)
持久性(Durability):事务一旦提交,其结果是永久性的。

其中一致性是目的,原子性、持久性、隔离性是手段。


实现方式

undo log:回滚日志,记录了数据被修改前的信息,发生错误时可以 rollback 到原始状态。回滚日志用来保证事务的 原子性

redo log:重做日志,用来保证事务的 持久性。重做日志由两部分组成,分别是重做日志缓冲(在内存中)和重做日志文件(在磁盘中)。MySQL 为了提升性能不会把每次的修改都实时同步到磁盘,而是先存到缓冲池里,然后使用后台线程同步到磁盘。而 redo log 会记录已成功提交事务的修改信息,并将日志写入磁盘,因此可以在系统宕机重启之后恢复数据库(如果没来得及记录 undo log,需要使用 redo log 回滚)。相比于整个事务,redo log 只是记录哪一页修改了什么,体积小刷盘快,而且是一直往末尾添加,属于顺序I/O,速度较快。

undo log 并不是 redo log 的逆过程,它们的作用都可以视为一种恢复操作,redo 恢复提交事务修改的页操作,undo 回滚行记录到某个特定版本。除此之外,redo 通常是物理日志,记录页的物理修改操作,undo 是逻辑日志。

隔离性 由锁和多版本并发控制机制保证,详见下文。

 

并发一致性问题


丢失更新

T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,但是未提交,T2 随后修改,且未提交,T1 提交,然后 T2 提交,T2 的结果覆盖 T1 的结果。

脏读

T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。

不可重复读

一个事务范围内两个相同的查询却返回了不同数据。T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。

不可重复读和脏读的区别是,脏读是读到未提交的数据,而不可重复读读到的是已经提交的数据。

幻读

T1 读取某个范围的数据,T2 在这个范围内插入新的数据,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。

不可重复读对应的是修改,即 UPDATE 操作,幻读对应的是 INSERT 操作。

 

隔离级别


通过提高事务的隔离级别,可以解决并发一致性问题。

SQL 标准定义的四个隔离级别为:

隔离级别脏读不可重复读幻读
读未提交(READ UNCOMMITTED)
读已提交(READ COMMITTED)×
可重复读(REPEATABLE READ)××
串行化(SERIALIZABLE)×××

√ 表示存在该问题,× 表示不存在该问题。RR 隔离级别中可以加锁避免幻读,但是默认的 MVCC 快照读方式是存在幻读的问题的。

读未提交(READ UNCOMMITTED)

允许一个事务读取另一个事务未提交的数据。

读已提交(READ COMMITTED)

一个事务要等另一个事务提交后才能读取数据。若有事务对数据进行更新(UPDATE)操作时,读操作事务要等待这个更新操作事务提交后才能读取数据。

该等级解决了脏读的问题,读取的数据永远是事务提交之后的数据。但是如果一个事务 T1 包含两次查询,而在两次查询之间执行了一次修改数据的事务 T2,那么事务 T1 读到的两次数据不一致,即此隔离级别依然存在不可重复读的问题。

大多数数据库默认的隔离级别是读已提交,例如 SQL Server 和 Oracle。

可重复读(REPEATABLE READ)

在开始读取数据(事务开启)时,不再允许修改操作。

此级别不再存在不可重复读的问题,但是不能控制幻读,因为这时候事务不能修改数据,但是可以增加数据。

MySQL 默认的隔离级别是可重复读。

MySQL 的 InnoDB 存储引擎在可重复读的隔离级别下,对于一致性非锁定读和一致性锁定读两种情况,分别使用了多版本并发控制(MVCC)和 Next-Key Lock 算法来避免幻读的产生。

串行化(SERIALIZABLE)

所有事务串行化顺序执行,这样可以避免幻读,对于基于锁来实现并发控制的数据库来说,串行化要求在执行范围查询的时候,需要获取范围锁,如果不是基于锁实现并发控制的数据库,则检查到有违反串行操作的事务时,需回滚事务。

一般情况下,事务隔离越严格,并发副作用越小,但相应的,事务请求的锁越多或保持锁的时间就越长,付出的代价也越大。

 

一致性非锁定读


一致性非锁定读也称为快照读。在执行 SELECT 操作的时候,读取的是该行之前版本的数据,不需要等待访问的行上 X 锁的释放。该实现是通过 undo 段来完成的,而 undo 用来在事务中回滚数据,所以快照数据本身是没有额外的开销的。此外,读取快照数据不需要上锁,因为没有事务需要对历史数据进行修改操作。在 InnoDB 存储引擎下,一致性非锁定读是默认的读取方式,其读取不会占用和等待表上的锁。但是在并发的情境下,一个行记录可能有不止一个快照数据。在 READ COMMITTED 事务隔离级别下,非一致性读总是读取被锁定行的最新一份快照数据。而在 REPEATABLE READ 事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本。由快照读带来的并发控制,称为多版本并发控制(Multi Version Concurrency Control,MVCC)。


MVCC

MVCC 是行级锁的一个变种,但它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。不同存储引擎的 MVCC 实现是不同的。

在InnoDB中,对于 MVCC 的实现方式,一种常见的解释是:

存储引擎会在每行数据后添加两个额外的隐藏的列来实现MVCC:

创建版本号:保存行的“创建时间”,是创建一个数据行的快照时的系统版本号,注意,不是当前事务的版本号。
删除版本号:删除时的系统版本号。如果快照的删除版本号大于当前事务的版本号,表示该快照有效,否则表示快照已经被删除了。

其中:

系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
事务版本号:事务开始时的系统版本号。

当开启一个新事务的时候,该事务的版本号肯定会大于当前所有数据行快照的创建版本号。

SELECT

对于一个执行 SELECT 操作的事务 T,其所要读取的数据行必须满足两个条件:

  1. 数据行快照的创建版本号必须小于等于 T 的事务版本号,因为如果大于 T 的版本号,说明该数据行快照是其他事务在事务 T 开启后插入的数据行。如果等于说明该数据行是由当前事务插入的。
  2. 数据行快照的删除版本号必须不存在或者大于 T 的版本号,如果小于等于 T 的版本号,表示该数据行快照已经被删除了。

INSERT

将当前系统版本号作为当前数据行快照的创建版本号。

DELETE

将当前系统版本号作为当前数据行快照的删除版本号。

UPDATE

先执行 DELETE,后执行 INSERT。将当前系统版本号作为更新前数据行快照的删除版本号,并将当前系统版本号作为更新后数据行快照的创建版本号。


一个例子

数据库中存在表 student。假设系统版本号从 1 开始,执行版本号为 1 的事务:

begin;
insert into student values(1, 'Emma');
insert into student values(2, 'Jack');
commit;

事务执行完后,student 表中的数据如下所示(创建版本号列和删除版本号列不可见):

idname创建版本号删除版本号
1Emma1undefined
2Jack1undefined

此时,系统版本号为 2,开启版本号为 2 的事务:

begin;
select * from student; //s1
select * from student; //s2
commit;

当事务 2 执行完步骤 s1 之后,获取到的表 sudent 如下:

idname创建版本号删除版本号
1Emma1undefined
2Jack1undefined

版本号为 3 的事务开始执行并在事务 2 执行步骤 s2 之前提交:

begin;
insert into student values (3, 'Tom');
commit;

此时虽然表中的数据行变成如下形式:

idname创建版本号删除版本号
1Emma1undefined
2Jack1undefined
3Tom3undefined

但是执行事务 2 中步骤 s2 之后得到的查询结果和步骤 s1 得到的结果完全一致(使用 MySQL 8.0 版本测试)。


产生问题

产生幻读
对于图中场景,如果按照前述逻辑,事务 2 第一次查询不能读取到事务 1 插入的两行数据,但是由于在第二次查询之前,事务 1 已经提交,且两行数据的创建版本号均小于等于事务 2 的事务版本号,此时应该能读取这两行数据,于是意外地产生了幻读的问题。

难道 MVCC 依然没能解决幻读的问题吗?

然而在实际的测试中,事务 2 的两次查询得到的结果中,都不包含事务 1 插入的两行数据,即数据库中并不存在幻读的问题(使用 MySQL 8.0 版本测试)。说明前述逻辑,并非 InnoDB 存储引擎中 MVCC 的合理解释**(实际上是完全错误的)**。


RR 级别下,MVCC 解决不可重复读和幻读的合理解释

在《MySQL 技术内幕:InnoDB 存储引擎(第 2 版)》一书中,对于 MVCC 的描述是:“对于 REPEATABLE READ 的事务隔离级别,总是读取事务开始时的行数据。”

MVCC主要是由隐藏字段、undo日志和 Read View(读视图) 来实现的。

隐藏字段

对于每一行数据,除了数据库中原有的字段外,还有隐藏的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID 字段。

DB_TRX_ID:创建这行数据或者最后一次修改这行数据的事务 ID。
DB_ROLL_PTR:回滚指针,指向这条数据的上一个版本,通过这个指针可以在 undo log 中找到之前的版本,这个指针将数据的多个版本连接在一起构成一个undo log 版本链。
DB_ROW_ID:隐含的自增ID(隐藏主键),如果数据表未定义主键,InnoDB 会默认由 DB_ROW_ID 产生一个聚簇索引。这个字段和 MVCC 关系不大。

undo 日志

undo 日志记录了事务的行为,在事务回滚时可以对数据进行“重做”。对于 INSERT 操作,undo log 的作用是在事务回滚时还原数据到原始状态,对于 UPDATE 和 DELETE 操作,undo log 中保存了事务进行过程中,数据行的副本。不仅在事务回滚时需要 undo 日志,在快照读时也需要,因为需要获取到事务开启之前数据的状态(不一定是数据当前在数据库中的状态)。

回滚指针和 undo log 中数据行的关系如下图所示:

在这里插入图片描述
Read View(读视图)

Read View 是构造快照读的前提或者依据,主要用来作可见性判断,一个快照所呈现的数据是什么样子(版本)的完全依赖于 Read View 中所存储的数据。当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,Read View 表示事务的可见范围。其中主要使用了三个字段来实现这一功能:

trx_ids:创建该 Read View 时,记录正活跃(但未提交)的其他事务的 ID 集合。
up_limit_id:当前活跃事务的 ID 中的最小值。
low_limit_id:创建该 Read View 时系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值 + 1。(注意:low_limit_id 并不一定是活跃事务列表 trx_ids 中最大的事务 ID + 1。)

通过这三个字段来判断数据行对事务是否可见:

  • 首先比较 DB_TRX_ID 和 up_limit_id,当 DB_TRX_ID 小于 up_limit_id 时,说明当前数据行的最迟修改时间在事务开始之前,则当前数据行对事务可见。否则进入下一个判断。
  • 然后比较 DB_TRX_ID 和 low_limit_id,当 DB_TRX_ID 大于等于 low_limit_id 时,说明当前数据行创建或修改于当前事务之后,则数据行对事务一定不可见。若 DB_TRX_ID 小于 low_limit_id,说明 DB_TRX_ID 在 up_limit_id 和 low_limit_id 之间。
  • 此时需要判断的是数据行是否是由当前活跃的事务所创建,如果 DB_TRX_ID 包含在 trx_ids 里,说明 Read View 生成时,数据行正在被活跃事务更新或添加,且还没有 commit,所以数据行对于当前事务是不可见的(通过 undo log 寻找更新之前的版本,插入操作没有旧版本,则不存在该数据行)。如果不在,说明数据行并没有被活跃事务操作,即操作数据行的事务已经 commit,则数据行是可见的。

对于上一节提到的问题,由于事务 1 在事务 2 第一次执行 SELECT 即创建 Read View 时,是活跃事务状态,所以第一次 SELECT 的结果不包含事务 1 的插入数据,第二次 SELECT 使用的 Read View 和第一次 SELECT 相同,所以第二次 SELECT 的结果和第一次完全相同。

特别注意:创建 Read View 的时间点是事务中首次出现 SELECT 快照读的时候。

对于 RC 隔离级别的数据库,也会涉及到快照读,但是和 RR 不同的地方是,在 RC 级别下,每一次 SELECT 快照读都会创建新的 Read View,而 RR 只在当前事务第一次执行 SELECT 的时候创建。


MVCC 不能完全避免幻读

考虑如下情况,事务 A 开启,进行 select 操作,此时事务 B 开启插入一行,如果事务 A 没有对该行进行写操作,当然符合 MVCC 的规则,不会出现幻读,但是如果事务 A 此时对该行进行了修改,那么下一次读取,可以读取到这一行的内容,因为该行隐藏字段中最后一次修改的事务变成了当前事务 A,当前事务 A 是可以读取到新插入的数据行的,即出现了幻读。

以上述场景为基础,进一步进行测试(MySQL 8.0),有如下结果:

场景结果
上述场景中事务 A 不修改插入的数据行不会出现幻读
上述场景中事务 A 修改插入的数据行出现幻读
上述场景中直接插入数据行,不使用事务出现幻读
上述场景中查询语句加 for update 锁直到事务 A 提交之后才允许插入,不会出现幻读
上述场景中查询语句加 for update 锁,插入数据行不使用事务直到事务 A 提交之后才允许插入,不会出现幻读

 

一致性锁定读


在默认的 REPEATABLE READ 隔离级别下,InnoDB 存储引擎的 SELECT 操作使用一致性非锁定读来查询数据,但是在某些特定情况下,用户在查询时需要显式地对数据库加锁来保证一致性。在读取到数据的时候会对这个数据加锁,防止别的事务更改。由于每次读取的都是当前数据行的最新版本,所以一致性锁定读也被称为当前读。

下列语句中的读取操作为当前读而非快照读:

select * from table where ? lock in share mode;  # 读锁
select * from table where ? for update;          # 写锁
insert into table values (); 
update table set ? where ?; 
delete from table where ?;

SELECT···FOR UPDATE,SELECT···LOCK IN SHARE MODE 必须在事务中,当事务提交了,锁也就释放了。


Next-Key Lock

InnoDB 存储引擎有三种行锁的算法,分别是:

Record Lock:单个行记录上的锁。
Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。
Next-Key Lock:Gap Lock + Record Lock,锁定一个范围,并且锁定记录本身。

例如一个索引有 10,11,13,20 这四个值,那么该索引可能被 Next-Key Lock 锁住的区间有:(-∞,10],(10,11],(11,13],(13,20],(20,∞)。
除此之外还有 Previous-Key Lock,为前闭后开区间。
当查询的索引含有唯一属性时,InnoDB 存储引擎会对 Next-Key Lock 优化,将其降级为 Record Lock,即锁住索引本身,而不是范围。例如下面的 SQL 语句:

select * from student where id = 1 for update

如果 id 是主键且唯一,那么锁定的仅仅是 1 这个值,而不是任何范围。若另外存在辅助索引 name,则对两个索引分别进行锁定,对聚集索引加上 Record Lock,对辅助索引锁定范围。

另外,对于唯一索引的锁定时,如果唯一索引是由多个列组成,而查询仅仅查询多个唯一索引列中的其中一个,这样的查询实际上还是范围查询,此时依然使用 Next-Key Lock 锁定,不会降级成 Record Lock。

注意:Record Lock 总是会锁住索引记录,如果存储引擎表没有设置任何一个索引,那么会使用隐式的主键来进行锁定。

 

共享锁和排他锁


行级锁主要分为两类:

共享锁(读锁):允许获得该锁的事务读取数据行,同时允许其他事务获得该数据行上的共享锁,并且阻止其他事务获得数据行上的共享锁。
排他锁(写锁):允许获得该锁的事务更新或删除数据行,同时阻止其他事务取得该数据行上的共享锁和排他锁。

除了共享锁之间是兼容的之外,其它的任意两者之间都不兼容。


意向锁

InnoDB 引擎支持表锁(LOCK TABLES … WRITE 在指定的表加上表级排他锁),行级锁和表级锁之间可能导致冲突。为此,InnoDB 引入了意向锁。

意向锁属于表级锁,由 InnoDB 自动添加,不需要用户干预。意向锁也分为共享和排他两种方式:
意向共享锁(读锁):事务在给数据行加行级共享锁之前,必须先取得该表的意向共享锁。
意向排他锁(写锁):事务在给数据行加行级排他锁之前,必须先取得该表的意向排他锁。

InnoDB 表存在两种表级锁,一种是 LOCK TABLES 语句手动指定的锁,另一种是由 InnoDB 自动添加的意向锁。

意向锁的兼容情况如下所示:

锁类型共享锁排他锁意向共享锁意向排他锁
共享锁××
排他锁××××
意向共享锁×
意向排他锁××

 

乐观锁和悲观锁


乐观锁和悲观锁不是数据库中实际存在的锁,而是一种对锁分类的方法和思想。而乐观锁和悲观锁的思想并不仅仅存在于数据库中,在高并发和线程不安全的场景下,乐观悲观锁的思想普遍存在。

悲观锁:总是假设最坏的情况,认为竞争总是存在,每次拿数据的时候都认为会被修改,因此每次都会先上锁,其他线程阻塞等待释放锁。例如 MySQL 数据库中的排他锁(写锁)就是典型的悲观锁实现。
乐观锁:总是假设最好的情况,认为竞争总是不存在,每次拿数据的时候都认为不会被修改,因此不会先上锁,在最后更新的时候比较数据有无更新,可以通过版本号或CAS实现。


使用场景

悲观锁

保证同一时间,只有一个线程占有数据。通常情况下,悲观锁就是利用数据库本身提供的锁去实现的。具体的流程是线程先尝试取锁,如果成功取到,那么就可以对当前记录进行修改,如果没有取到,说明该记录已经被锁定,即被其它线程占有,则此线程被阻塞或者退出。由于对数据的完全占有,悲观锁保证了数据的完全一致和安全性,代价就是增加了系统的开销,一定程度上降低了性能。

上面提到的 InnoDB 引擎中的三种行锁都是悲观锁。

乐观锁

由于乐观锁总是假设最好的情况,不会对记录上锁,只是在更新的时候会判断一下在此期间别人有没有去更新这个数据,所以避免了数据库中加锁、阻塞、等待锁等一系列的开销,对于性能有较大提升,适合读操作较多的场景。

MVCC 属于乐观锁。


乐观锁的常见实现方式

乐观锁的常见实现方法中,通常有版本号和 CAS (Compare And Swap)两种方式(其实内在思想都是 CAS)。其中 CAS 思想是 Java 并发包中最重要的概念之一,在 Java 并发包中有非常广泛的应用。

版本号

对于数据维护一个数据版本号,每当数据被修改的时候,版本号的值加一。一个线程需要对数据更新,在读取数据的同时读取版本号,在提交更新的时候,再次读取版本号,若两次读取的版本号相等,那么执行更新操作,否则说明数据已被其他线程修改,则更新失败,重试更新操作。

例如,线程 A 和线程 B 同时对某一数据 data = 0 进行加 1 操作:

在这里插入图片描述
CAS

CAS 算法主要包括 3 个操作数:内存位置 V、旧的预期值 A 和需要写入的新值 B。整个CAS操作是一个原子操作,首先从内存位置 V 读取现值,如果读取的现值等于旧预期值 A,说明内存位置 V 的值没有被其他线程操作,此时可以将需要写入的新值 B 写入到内存位置 V,否则不执行更新。

以Java 语句 i++ 为例:

i = 0;
i++;

线程 A 执行上面的 Java 语句,首先从内存位置 V 读取并保留作为旧的预期值 A(A = 0),然后对 i 进行加 1 操作。在提交更新之前,检查内存位置 V 的现值,是否等于旧的预期值 A(A = 0),如果相等,新值 B(B = 0 + 1 = 1)将会写入内存位置 V,即 i 此时变成 1,否则说明其他线程在这个过程中对内存位置 V 进行了修改,那么操作失败,等待下一步操作(重试直到成功或者返回失败状态)。

CAS 的 ABA 问题

在线程 A 对内存位置 V 两次读取的过程中,实际上有线程 B 对 V 进行了操作,将值 A 变成了值 B,但是最后又变成了值 A,此时 CAS 依然会成功,线程 A 会认为并没有其它线程进行修改。线程 B 操作的中间多出的过程可能会引发问题。

 

参考


评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值