MySQL 事务一致性的实现

文章目录

  • 一、事务特性
    • 1. 并发控制
      • ① 乐观并发控制(OCC)
      • ② 悲观并发控制(PCC)
      • ③ 多版本并发控制(MVCC)
    • 2. 预写日志
      • ① 数据日志
      • ② 操作日志
  • 二、InnoDB 引擎中的预写日志
    • 1. redo 日志
    • 2. undo 日志
  • 三、InnoDB 引擎的并发控制
    • 1. 锁
      • ① 两阶段锁协议
      • ② InnoDB 中锁的类型
        • 行/表 级锁
        • 意向锁(表级锁)
      • ③ 加锁规则
    • 2. 隔离性问题
      • ① 脏读
      • ② 不可重复读
      • ③ 幻读
      • ④ 丢失更新
    • 3. 隔离级别
      • ① 读未提交 READ UNCOMMITTED
      • ② 读已提交 READ COMMITTED
      • ③ 可重复读 REPEATABLE READ
      • ④ 序列化 SERIALIZABLE
    • 4. InnoDB 如何实现隔离性
      • ① 一致性非锁定读(快照读)
      • ② Next-Key Lock 算法
      • ③ 一致性锁定读(当前读)
      • ④ InnoDB 隔离级别总结
    • 5. InnoDB 中关于锁的其他知识
      • ① 自增长与锁
      • ② 外键与锁
      • ③ 死锁检测
      • ④ 锁升级

事务 指数据库中一个不可分的逻辑工作单元,它是数据库区别于文件系统的重要特征。


一、事务特性

首先,来看看事务的四大特性(ACID):

  • 原子性(Actomicity):事务中的操作要么全部执行,要么都不执行;

  • 一致性(Consistency):事务只能将数据库状态从一个一致性状态转变为另一个一致性状态(一致状态的含义是数据库中的数据应满足完整性约束);

  • 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行(即多个事务并发执行的结果与这些事务串行执行的结果一致);

  • 持久性(Durability):一个事务一旦提交,其对数据库的修改应被持久化。

虽然理论上定义了严格的事务特性要求,但数据库厂商出于各种目的,可能并没有严格实现事务的 ACID 特性。

本文中的数据库以使用 InnoDB 存储引擎的 MySQL 为例,InnoDB 存储引擎默认的READ REPEATABLE隔离级别则完全遵循事务的 ACID 特性。

通常,事务的隔离性通过并发控制实现;而原子性、一致性、持久性则通过预写日志实现


1. 并发控制

存储引擎中的锁管理器通过给数据库对象加锁,以处理并发事务间的交互,从而达到隔离性。

某一组事务的并发调度的结果等效于该组事务串行执行,则称该调度是 可串行化 的。

常见的并发控制技术有以下几类:

① 乐观并发控制(OCC)

允许多个事务并发执行,最后确定其执行结果能否被串行化。

并发控制基于以下假设:大多数事务可以在互不干扰的情况下完成。当事务获取资源时,不需要申请资源的锁,而是保留其操作历史,并在提交前检查是否存在冲突(如其他事务更新了本事务读取的数据),若有,则回滚事务并重新开始执行。

这种方法之所以称为乐观并发控制,是因为其假设事务冲突很少发生,其主要应用于数据竞争少且偶尔回滚事务的成本低于维护锁资源的成本的场景。

② 悲观并发控制(PCC)

悲观并发控制中,存储引擎对事务冲突持悲观的态度,在事务执行过程中维护数据库对象的锁,以防止竞争问题。

悲观并发控制由于使用了锁,故可能带来死锁的问题。

③ 多版本并发控制(MVCC)

多版本并发控制为一条记录创建多个时间戳的版本,确保事务能够读取到数据库过去某个时刻的一致视图,此时读操作会选择一个版本作为当前数据库状态的视图。

OCC 与 PCC 都通过延迟或中止相应的事务来解决事务之间的冲突,从而保证并发事务的可串行化。但是在实际环境中,数据库的事务大多是只读的,数据的读取请求远多于写请求,即使对于事务的调度不是可串行化的,最坏的情况也是读请求读到了之前已经写入的数据,这对于很多应用而言是可接受的。

MVCC 可以与 OCC 和 PCC 共存,它能与两者很好地结合以增加事务的并发量。


2. 预写日志

为了减少对磁盘的访问次数,数据库会将页面缓存在内存中,此间被修改过的页面称为脏页,脏页需要刷写回磁盘才能保证数据的持久性。

若在未将脏页刷写到磁盘时发生了宕机,则将带来数据丢失问题。为了避免该问题,当前数据库普遍采用了预写日志(WAL,Write Ahead Log)机制,即 事务提交时,只有先将有关数据库状态改变的信息写入预写日志、持久化到磁盘,才能对页面进行修改。在将缓存的数据刷写回磁盘之前,预写日志是保留操作历史的唯一磁盘副本。

预写日志的功能可以概括为:

  • 在页缓存的基础上,保证数据库仍具有持久性语义

  • 当发生崩溃时,使系统能从操作日志重建内存中丢失的更改

预写日志的类型分为两种:

① 数据日志

 数据日志记录了不同版本的数据库状态,受操作影响的整个页都会被记录下来。用于回滚行记录到某个特定版本,对事务进行撤销(事务失败时)或回滚(ROLLBACK),以保障事务的原子性与一致性。

② 操作日志

 操作日志记录了要对页应用的操作。一般用于执行重做操作(恢复数据库状态),以保障事务的持久性。



二、InnoDB 引擎中的预写日志

InnDB 是面向事务的存储引擎,其 使用 Force Log at Commit 策略实现事务的持久性,即当事务提交时,必须先将该事务的所有操作写入到 WAL 进行持久化,才能完成事务的提交

InnoDB 中的 WAL 由两部分组成:

  • redo日志:重做日志,对应操作日志,保证事务的持久性

  • undo日志:回滚日志,对应数据日志,保证事务的原子性与一致性

1. redo 日志

redo log 称为重做日志,记录了对数据页的物理修改,用来恢复提交后的物理数据页。

2. undo 日志

undo log 称为回滚日志,保存了被修改的页。

undo 不仅用于帮助事务回滚,还能用于 MVCC。

在物理格式上,redo log 存放在日志文件中,而 undo log 存放在数据库内部的一个特殊段中(这是由这两种日志的访问方式决定的,redo log 基本上是顺序写的,数据库运行时不需要对 redo log 进行读取操作;而 undo log 是需要被随机读写的)。



三、InnoDB 引擎的并发控制

并发控制用于实现事务的隔离性,InnoDB 的并发控制策略为:在基于锁的并发控制(PCC)基础上,提供 MVCC 机制,以提高事务调度的并发性。

InnoDB 存储引擎提供不同粒度的锁(表级锁、行级锁),且为了更好的协调表级锁与行级锁,还增加了意向锁。

1. 锁

InnoDB 的行锁是加在索引上的,通过给主键索引加锁实现记录的上锁。

① 两阶段锁协议

 两阶段锁协议(2PL)是一种当前广泛使用的锁管理协议,它将锁管理分为两个阶段:

  • 增长阶段:获取事务所有的锁,且不释放任何锁

  • 收缩阶段:释放增长阶段获得的所有锁,且不能获得新的锁

② InnoDB 中锁的类型

行/表 级锁

 InnoDB 实现了如下两种标准的行/表级锁:

  • 共享锁(S Lock),允许事务读一行数据

  • 排他锁(X Lock),允许事务更新或删除一行数据

 排他锁与任何的锁都不兼容,而共享锁仅与共享锁兼容。兼容性表示为:

X S
X 不兼容 不兼容
S 不兼容 兼容

意向锁(表级锁)

此外,InnoDB 为了支持多粒度锁定,为了更好的协调表级锁与行级锁,还支持称为意向锁的额外加锁方式。

将锁定的对象分为多个层次,一个意向锁意味着事务希望在更细粒度上进行加锁。若将上锁的对象看成一棵树,那么对下层的对象上锁,也就是对细粒度的对象进行上锁,则首先需要对粗粒度的对象上意向锁。

InnoDB 仅支持表级别的意向锁,设计的目的在于在一个事务中揭示下一行将被请求的锁类型。支持的两种意向锁为:

  • 意向共享锁(IS Lock),事务想要获得一张表中某几行记录的共享锁

  • 意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁

InnoDB 存储引擎在表级别的锁的兼容性为:

IS IX S X
IS 兼容 兼容 兼容 不兼容
IX 兼容 兼容 不兼容 不兼容
S 兼容 不兼容 兼容 不兼容
X 不兼容 不兼容 不兼容 不兼容

InnoDB 引入表级的意向锁的用意在于:解决表锁与之前可能存在的行锁冲突,在加表锁的时候,避免为了判断表是否存在行锁而去扫描全表

③ 加锁规则

基于以上介绍的锁类型,InnoDB 中访问一行记录时,加锁的规则为:

  • 事务在获取行级 S 锁之前,必须获取其对应表的 IS 锁

  • 事务在获取行级 X 锁之前,必须获取其对应表的 IX 锁


2. 隔离性问题

首先先来认识一下,当事务的隔离性不能得到完全满足时,执行并发事务可能带来的异常:

① 脏读

脏读指一个事务可以读取到另一个未提交事务对数据的更改。

② 不可重复读

指同一事务两次读取同一记录,却得到不一样的数据。

不可重复读与脏读的区别

 脏读是读到其他事务未提交的数据;而不可重复读读到的是已提交的数据,但其违反了事务的一致性要求。

③ 幻读

一个事务中两次查询同样的范围查询,得到不同的行集合。

幻读与不可重复读的区别

 不可重复读是对于某一行记录而言的,即针对同一事务的两次读取,记录的内容发生了改变(UPDATE操作);而幻读是针对一个行集合的,即满足条件的行集合发生了变化(INSERTDELETE操作)。

 即幻读侧重“行的数量发生了变化”,而不可重复读侧重“某一行数据发生了变化”。

④ 丢失更新

 指事务对数据的更新结果被另一个事务所覆盖。


3. 隔离级别

为了划分上述问题,SQL 标准定义了事务隔离级别,由低到高分别为:

① 读未提交 READ UNCOMMITTED

在这种隔离级别下,允许一个事务观察到其他并发事务的未提交更改。

即脏读、不可重复读与幻读都是被允许的。

② 读已提交 READ COMMITTED

在这种隔离级别下,确保一个事务只能读到已提交的更改,但是不能保证事务再次读取同一数据记录时还能看到相同的值。

脏读是不允许的,但幻读和不可重复读是允许的。

③ 可重复读 REPEATABLE READ

若进一步禁止不可重复读,则会得到可重复读的隔离级别。

在此隔离级别下,脏读、不可重复读都是不被允许的,但仍会出现幻读的问题。

禁止不可重复读解决的是UPDATE操作的问题,但是可能还有幻读问题,因为带来幻读问题对应的是INSERTDELETE操作。

④ 序列化 SERIALIZABLE

最高的隔离级别是序列化,这种级别下,事务一个一个顺序执行。

此时,脏读、可重复读、幻读都可以被避免,但是将对数据库性能产生很大的负面影响。


4. InnoDB 如何实现隔离性

InnoDB 的并发控制策略为:在悲观并发控制的基础上,使用多版本并发控制,以寻求隔离性与并发性的平衡。

先给出结论:InnoDB 通过一致性非锁定读来避免脏读与不可重复读(达到READ COMMITTED/REPEATABLE READ标准,但在这两种隔离级别下,一致性非锁定读的行为不一样),通过 Next-Key Lock 锁算法来避免幻读(达到SERIALIZABLE标准)。此外,还可以通过一致性锁定读读取数据,以保证数据逻辑的一致性,实现真正的可串行化调度。

与其他数据库引擎不同,InnoDB 在默认的REPEATABLE READ的事务隔离级别下,即可使用 Next-Key Lock 锁算法来避免幻读,即此时已能完全保证事务的隔离性要求,达到标准要求的SERIALIZABLE标准。

对于丢失更新的问题,在任何隔离级别下都不会发生,因为即使使用READ UNCOMMITTED,也有最基础的 PCC 机制,当一个事务获取 X 锁对记录进行更新的时,由于 X 锁的排他性质,其他事务不能对该记录进行更新。

InnoDB 引擎的REPEATABLE READ级别即达到了SERIALIZABLE标准,而在此基础上,还可以使用一致性锁定读读取数据,以满足事务真正意义上的隔离性,此时事务读取的数据为当前时间最新的版本,能够满足数据逻辑的一致性,为真正意义上的可串行化调度。将 InnoDB 的事务隔离级别设为SERIALIZABLE,则每次查询操作都默认使用一致性锁定读。

SQL 标准的SERIALIZABLE含义是:解决了幻读问题,但实际上解决了幻读问题的事务调度还不能算作真正意义上的可串行化调度。


① 一致性非锁定读(快照读)

一致性非锁定读(也称快照读)是指存储引擎通过 行多版本控制 的方式读取数据。快照数据指的是该行的之前版本的数据,该实现是通过undo段完成的。而undo用来在事务中回滚数据,因此快照数据本身没有额外的开销。

一次非锁定读的具体流程为:读取操作尝试获取行上的 S 锁,若成功获取,则读取行的最新数据;若此时其他事务获取了行的 X 锁(正在执行DELETEUPDATE操作),则读操作不会等待行上锁的释放,而是回去读取行的一个快照数据。

可见,这种方式之所以称之为非锁定读,是因为不需要等待访问的行上的 X 锁释放。非锁定读机制提高了数据库的并发性,因为读取操作不会获取和等待表上的锁,对快照数据的读取也不需要上锁,因为事务不会历史数据进行修改操作。

READ COMMITTEDREPEATABLE READ隔离级别下,InnoDB 都默认通过一致性非锁定读进行读操作,但是快照数据的定义不同:

  • READ COMMITTED:一致性非锁定总是读取被锁定行的最新一份数据快照(可能导致同一事务中两次查询操作读取不同的快照数据,带来不可重复读)

  • REPEATABLE READ:非一致性锁定读总是读取事务开始时的行数据版本

② Next-Key Lock 算法

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

  • Record Lock:用于单个记录上的锁

  • Gap Lock:间隙锁,锁定一个范围,但不包含记录本身

  • Next-key Lock:Record Lock + Gap Lock 的结合,锁定一个范围,且锁定记录本身

InnoDB 的加锁机制是 给索引加锁 从而实现给记录上锁的,若 InnoDB 存储引擎表在建立的时候没有设置任何一个索引,则 InnoDB 会使用隐式的主键来进行锁定。在REPEATABLE READ隔离级别下,InnoDB 对于行的范围查询都是采用 Next-Key Lock 锁定算法。

在 Next-Key Lock 锁定算法下,不仅锁定单个记录上的索引(Record Lock),还会锁定索引两边的间隙(对左或右索引区间加上 Gap 锁),这种情况下加的锁是共享锁。

如,表中有三条记录,某一列 a 的值为 (2,5,9),当通过索引 a 查询记录 a>5 时,Next-Key Lock 算法不仅会给 a 的索引 5 加上 S Record Lock,还会分别添加 S Gap Lock 在区间(5,9)上,若有其他事务想插入一条 a 的值位于区间 [5,9)的记录,当需要插入 a 这一列时,需要获取索引的 X 锁,而此时索引 a 的该区间都被加了 S 锁,于是其他事务的插入请求会被阻塞(删除请求同理);而对于其他的读取事务,则能够正常读取(获取 S 锁)。

通过给每个查询使用 Next-Key Lock 算法,保证当事务通过某个索引查询数据时,给对应的索引区间加锁,保证在该区间的值都不会被插入新的记录(或删除记录),从而解决了幻读问题。

通过 Next-Key Lock 算法解决幻读问题仅适用于索引不唯一的查询,当查询的索引为唯一索引时,Next-Key Lock 降级为 Record Lock,即仅锁住索引本身,不是范围,以提高并发性。


③ 一致性锁定读(当前读)

REPEATABLE READ隔离级别下,InnoDB 就通过 Next-Key Lock 算法解决了幻读问题。但是,用户也可以显式地对数据读取操作加锁以保证数据逻辑的一致性,追求更高的一致性。

InnoDB 引擎对于SELECT语句支持两种一致性锁定读操作:

  • SELECT ... FOR UPDATE:为读取的行记录加一个 X 锁

  • SELECT ... LOCK IN SHARE MODE:为读取的行记录加一个 S 锁

当将存储引擎的事务隔离级别设置为SERIALIZABLE时,数据库的每次读取操作都会自动自动加上LOCK IN SHARE MODE

④ InnoDB 隔离级别总结

  • READ UNCOMMITTED:提供最基本的 PCC 机制,即基于锁的并发控制

  • READ COMMITTED:在 PCC 的基础上使用 MVCC,默认查询使用非锁定一致性读,但读取的快照数据总是最新的;此外,对于范围查询不会使用 Next-Key Lock 算法;

  • REPEATABLE READ:默认查询使用非锁定一致性读,且总是读取事务开始时的快照数据版本;此外,对于范围查询使用了 Next-Key Lock 算法来避免幻读

  • SERIALIZABLE:默认使用一致性锁定读来查询数据


5. InnoDB 中关于锁的其他知识

① 自增长与锁

 在 InnoDB 引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器。当对含有自增长值的表进行插入操作时,会获取表锁,以初始化计数器(获取当前表中最大的值),插入操作会根据这个自增长的计数器值加 1 赋予自增长列,这个实现方式称作 AUTO-INC Locking。而这种锁其实采用了一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的 SQL 语句后立即释放。

② 外键与锁

外键主要用于完整性的约束检查。InnoDB 中,对于一个外键列,若没有显式地为这个列加索引,存储引擎会自动对其加一个索引,因为这样可以避免父表上的表锁。

对于外键值的插入或更新,需要首先查询父表中的记录,该查询操作使用的是一致性锁定读(若使用一致性非锁定读,则会带来数据不一致的问题),主动获取父表中的记录上的 S 锁。

③ 死锁检测

死锁是指两个或以上的事务在执行过程中,因争夺锁资源而造成的相互等待现象。

死锁检测可以用超时的方法解决,即当两个事务相互等待时,当一个事务的等待时间超过某一设定的阈值时,进行回滚,以使另一个事务得以继续执行。但这种方法存在弊端:该方法是通过 FIFO 的方式选择事务进行回滚,若超时的事务所占权重较大,如更新了较多undo log,此时回滚该事务不是很好的选择。

除了超时之外,当前数据库还普遍使用 等待图 来解决死锁问题,这是一种更为主动的死锁检测机制,它要求数据库保存以下两种信息:

  • 锁的信息链表

  • 事务等待链表

通过上述链表可以构造出一张图,若图中存在回路,则代表存在死锁。

MySQL 事务一致性的实现_第1张图片

④ 锁升级

锁升级指降低当前锁的粒度,如数据库库将 1000 个行锁升级为页锁,或将页锁升级为表锁,从而降低使用锁的开销,这保护了系统资源,防止使用太多内存来维护锁。

而 InnoDB不存在锁升级的问题,因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理,采用的是位图的方式。因此不管一个事务锁住页中一个记录还是多个纪录,其开销通常都是一致的。

你可能感兴趣的:(mysql,数据库,sql)