MySQL 事务一致性的实现

文章目录2. 预写日志二、 引擎中的预写日志 三、 引擎的并发控制 ③ 加锁规则 2. 隔离性问题 3. 隔离级别 4.如何实现隔离性 5.中关于锁的其他知识
事务 指数据库中一个不可分的逻辑工作单元,它是数据库区别于文件系统的重要特征 。
一、事务特性
首先,来看看事务的四大特性(ACID):
虽然理论上定义了严格的事务特性要求,但数据库厂商出于各种目的,可能并没有严格实现事务的 ACID 特性 。
本文中的数据库以使用存储引擎的 MySQL 为例,存储引擎默认的READ 隔离级别则完全遵循事务的 ACID 特性 。
通常,事务的隔离性通过并发控制实现;而原子性、一致性、持久性则通过预写日志实现 。
1. 并发控制
存储引擎中的锁管理器通过给数据库对象加锁 , 以处理并发事务间的交互,从而达到隔离性 。
某一组事务的并发调度的结果等效于该组事务串行执行,则称该调度是 可串行化 的 。
常见的并发控制技术有以下几类:
① 乐观并发控制(OCC)
允许多个事务并发执行,最后确定其执行结果能否被串行化 。
并发控制基于以下假设:大多数事务可以在互不干扰的情况下完成 。当事务获取资源时 , 不需要申请资源的锁,而是保留其操作历史,并在提交前检查是否存在冲突(如其他事务更新了本事务读取的数据),若有,则回滚事务并重新开始执行 。
这种方法之所以称为乐观并发控制,是因为其假设事务冲突很少发生,其主要应用于数据竞争少且偶尔回滚事务的成本低于维护锁资源的成本的场景 。
② 悲观并发控制(PCC)
悲观并发控制中 , 存储引擎对事务冲突持悲观的态度,在事务执行过程中维护数据库对象的锁,以防止竞争问题 。
悲观并发控制由于使用了锁,故可能带来死锁的问题 。
③ 多版本并发控制(MVCC)
多版本并发控制为一条记录创建多个时间戳的版本,确保事务能够读取到数据库过去某个时刻的一致视图,此时读操作会选择一个版本作为当前数据库状态的视图 。
OCC 与 PCC 都通过延迟或中止相应的事务来解决事务之间的冲突 , 从而保证并发事务的可串行化 。但是在实际环境中,数据库的事务大多是只读的,数据的读取请求远多于写请求,即使对于事务的调度不是可串行化的,最坏的情况也是读请求读到了之前已经写入的数据,这对于很多应用而言是可接受的 。
MVCC 可以与 OCC 和 PCC 共存,它能与两者很好地结合以增加事务的并发量 。
2. 预写日志
为了减少对磁盘的访问次数 , 数据库会将页面缓存在内存中,此间被修改过的页面称为脏页,脏页需要刷写回磁盘才能保证数据的持久性 。
若在未将脏页刷写到磁盘时发生了宕机,则将带来数据丢失问题 。为了避免该问题,当前数据库普遍采用了预写日志(WAL,Write Ahead Log)机制,即 事务提交时,只有先将有关数据库状态改变的信息写入预写日志、持久化到磁盘,才能对页面进行修改 。在将缓存的数据刷写回磁盘之前,预写日志是保留操作历史的唯一磁盘副本 。
预写日志的功能可以概括为:
预写日志的类型分为两种:
① 数据日志
数据日志记录了不同版本的数据库状态,受操作影响的整个页都会被记录下来 。用于回滚行记录到某个特定版本,对事务进行撤销(事务失败时)或回滚() , 以保障事务的原子性与一致性 。
② 操作日志
操作日志记录了要对页应用的操作 。一般用于执行重做操作(恢复数据库状态) , 以保障事务的持久性 。
二、 引擎中的预写日志
InnDB 是面向事务的存储引擎,其 使用 Force Log at策略实现事务的持久性,即当事务提交时 , 必须先将该事务的所有操作写入到 WAL 进行持久化 , 才能完成事务的提交 。
中的 WAL 由两部分组成:
1. redo 日志
redo log 称为重做日志 , 记录了对数据页的物理修改,用来恢复提交后的物理数据页 。
2. undo 日志
undo log 称为回滚日志,保存了被修改的页 。
undo 不仅用于帮助事务回滚,还能用于 MVCC 。
在物理格式上,redo log 存放在日志文件中,而 undo log 存放在数据库内部的一个特殊段中(这是由这两种日志的访问方式决定的,redo log 基本上是顺序写的,数据库运行时不需要对 redo log 进行读取操作;而 undo log 是需要被随机读写的) 。
三、 引擎的并发控制
并发控制用于实现事务的隔离性,的并发控制策略为:在基于锁的并发控制(PCC)基础上,提供 MVCC 机制,以提高事务调度的并发性 。
存储引擎提供不同粒度的锁(表级锁、行级锁) , 且为了更好的协调表级锁与行级锁 , 还增加了意向锁 。
1. 锁
的行锁是加在索引上的 , 通过给主键索引加锁实现记录的上锁 。
① 两阶段锁协议
两阶段锁协议(2PL)是一种当前广泛使用的锁管理协议,它将锁管理分为两个阶段:
②中锁的类型 行/表 级锁
实现了如下两种标准的行/表级锁:
排他锁与任何的锁都不兼容,而共享锁仅与共享锁兼容 。兼容性表示为:
XS
不兼容
不兼容
不兼容
兼容
意向锁(表级锁)
此外, 为了支持多粒度锁定,为了更好的协调表级锁与行级锁,还支持称为意向锁的额外加锁方式 。
将锁定的对象分为多个层次,一个意向锁意味着事务希望在更细粒度上进行加锁 。若将上锁的对象看成一棵树,那么对下层的对象上锁,也就是对细粒度的对象进行上锁,则首先需要对粗粒度的对象上意向锁 。
仅支持表级别的意向锁,设计的目的在于在一个事务中揭示下一行将被请求的锁类型 。支持的两种意向锁为:
存储引擎在表级别的锁的兼容性为:
IS
兼容
兼容
兼容
不兼容
IX
兼容
兼容
不兼容
不兼容
兼容
不兼容
兼容
不兼容
不兼容
不兼容
不兼容
不兼容
引入表级的意向锁的用意在于:解决表锁与之前可能存在的行锁冲突 , 在加表锁的时候,避免为了判断表是否存在行锁而去扫描全表 。
③ 加锁规则
基于以上介绍的锁类型,中访问一行记录时 , 加锁的规则为:
2. 隔离性问题
首先先来认识一下,当事务的隔离性不能得到完全满足时,执行并发事务可能带来的异常:
① 脏读
脏读指一个事务可以读取到另一个未提交事务对数据的更改 。
② 不可重复读
指同一事务两次读取同一记录,却得到不一样的数据 。
不可重复读与脏读的区别:
脏读是读到其他事务未提交的数据;而不可重复读读到的是已提交的数据,但其违反了事务的一致性要求 。
③ 幻读
一个事务中两次查询同样的范围查询,得到不同的行集合 。
幻读与不可重复读的区别:
不可重复读是对于某一行记录而言的,即针对同一事务的两次读取,记录的内容发生了改变(操作);而幻读是针对一个行集合的,即满足条件的行集合发生了变化(、操作) 。
即幻读侧重“行的数量发生了变化”,而不可重复读侧重“某一行数据发生了变化” 。
④ 丢失更新
指事务对数据的更新结果被另一个事务所覆盖 。
3. 隔离级别
为了划分上述问题,SQL 标准定义了事务隔离级别,由低到高分别为:
【MySQL 事务一致性的实现】① 读未提交 READ
在这种隔离级别下 , 允许一个事务观察到其他并发事务的未提交更改 。
即脏读、不可重复读与幻读都是被允许的 。
② 读已提交 READ
在这种隔离级别下 , 确保一个事务只能读到已提交的更改,但是不能保证事务再次读取同一数据记录时还能看到相同的值 。
脏读是不允许的 , 但幻读和不可重复读是允许的 。
③ 可重复读READ
若进一步禁止不可重复读,则会得到可重复读的隔离级别 。
在此隔离级别下,脏读、不可重复读都是不被允许的,但仍会出现幻读的问题 。
禁止不可重复读解决的是操作的问题 , 但是可能还有幻读问题,因为带来幻读问题对应的是、操作 。
④ 序列化
最高的隔离级别是序列化,这种级别下,事务一个一个顺序执行 。
此时,脏读、可重复读、幻读都可以被避免,但是将对数据库性能产生很大的负面影响 。
4.如何实现隔离性
的并发控制策略为:在悲观并发控制的基础上,使用多版本并发控制 , 以寻求隔离性与并发性的平衡 。
先给出结论: 通过一致性非锁定读来避免脏读与不可重复读(达到READ / READ标准,但在这两种隔离级别下,一致性非锁定读的行为不一样),通过 Next-Key Lock 锁算法来避免幻读(达到标准) 。此外 , 还可以通过一致性锁定读读取数据,以保证数据逻辑的一致性,实现真正的可串行化调度 。
与其他数据库引擎不同,在默认的 READ的事务隔离级别下,即可使用 Next-Key Lock 锁算法来避免幻读,即此时已能完全保证事务的隔离性要求,达到标准要求的标准 。
对于丢失更新的问题,在任何隔离级别下都不会发生,因为即使使用READ,也有最基础的 PCC 机制,当一个事务获取 X 锁对记录进行更新的时,由于 X 锁的排他性质,其他事务不能对该记录进行更新 。
引擎的 READ级别即达到了标准,而在此基础上,还可以使用一致性锁定读读取数据,以满足事务真正意义上的隔离性,此时事务读取的数据为当前时间最新的版本,能够满足数据逻辑的一致性,为真正意义上的可串行化调度 。将的事务隔离级别设为,则每次查询操作都默认使用一致性锁定读 。
SQL 标准的含义是:解决了幻读问题,但实际上解决了幻读问题的事务调度还不能算作真正意义上的可串行化调度 。
① 一致性非锁定读(快照读)
一致性非锁定读(也称快照读)是指存储引擎通过 行多版本控制 的方式读取数据 。快照数据指的是该行的之前版本的数据 , 该实现是通过undo段完成的 。而undo用来在事务中回滚数据,因此快照数据本身没有额外的开销 。
一次非锁定读的具体流程为:读取操作尝试获取行上的 S 锁,若成功获?。蚨寥⌒械淖钚率荩蝗舸耸逼渌挛窕袢×诵械?X 锁(正在执行或操作),则读操作不会等待行上锁的释放,而是回去读取行的一个快照数据 。
可见,这种方式之所以称之为非锁定读 , 是因为不需要等待访问的行上的 X 锁释放 。非锁定读机制提高了数据库的并发性,因为读取操作不会获取和等待表上的锁,对快照数据的读取也不需要上锁,因为事务不会历史数据进行修改操作 。
在READ 与 READ隔离级别下,都默认通过一致性非锁定读进行读操作,但是快照数据的定义不同:
② Next-Key Lock 算法
存储引擎有 3 种行锁的算法,分别是:
的加锁机制是 给索引加锁 从而实现给记录上锁的,若存储引擎表在建立的时候没有设置任何一个索引,则会使用隐式的主键来进行锁定 。在 READ隔离级别下, 对于行的范围查询都是采用 Next-Key Lock 锁定算法 。
在 Next-Key Lock 锁定算法下,不仅锁定单个记录上的索引( Lock),还会锁定索引两边的间隙(对左或右索引区间加上 Gap 锁),这种情况下加的锁是共享锁 。
如,表中有三条记录,某一列 a 的值为 (2,5,9),当通过索引 a 查询记录 a>5 时,Next-Key Lock 算法不仅会给 a 的索引 5 加上 SLock , 还会分别添加 S Gap Lock 在区间(5,9)上 , 若有其他事务想插入一条 a 的值位于区间 [5,9)的记录 , 当需要插入 a 这一列时,需要获取索引的 X 锁,而此时索引 a 的该区间都被加了 S 锁,于是其他事务的插入请求会被阻塞(删除请求同理);而对于其他的读取事务,则能够正常读?。ɑ袢?S 锁) 。
通过给每个查询使用 Next-Key Lock 算法 , 保证当事务通过某个索引查询数据时,给对应的索引区间加锁,保证在该区间的值都不会被插入新的记录(或删除记录),从而解决了幻读问题 。
通过 Next-Key Lock 算法解决幻读问题仅适用于索引不唯一的查询 , 当查询的索引为唯一索引时,Next-Key Lock 降级为Lock,即仅锁住索引本身,不是范围,以提高并发性 。
③ 一致性锁定读(当前读)
在 READ隔离级别下 ,  就通过 Next-Key Lock 算法解决了幻读问题 。但是,用户也可以显式地对数据读取操作加锁以保证数据逻辑的一致性 , 追求更高的一致性 。
引擎对于语句支持两种一致性锁定读操作:
当将存储引擎的事务隔离级别设置为时,数据库的每次读取操作都会自动自动加上LOCK IN SHARE MODE 。
④隔离级别总结
5.中关于锁的其他知识 ① 自增长与锁
在引擎的内存结构中 , 对每个含有自增长值的表都有一个自增长计数器 。当对含有自增长值的表进行插入操作时,会获取表锁,以初始化计数器(获取当前表中最大的值) , 插入操作会根据这个自增长的计数器值加 1 赋予自增长列,这个实现方式称作 AUTO-INC。而这种锁其实采用了一种特殊的表锁机制 , 为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的 SQL 语句后立即释放 。
② 外键与锁
外键主要用于完整性的约束检查 。中,对于一个外键列,若没有显式地为这个列加索引,存储引擎会自动对其加一个索引,因为这样可以避免父表上的表锁 。
对于外键值的插入或更新,需要首先查询父表中的记录,该查询操作使用的是一致性锁定读(若使用一致性非锁定读,则会带来数据不一致的问题),主动获取父表中的记录上的 S 锁 。
③ 死锁检测
死锁是指两个或以上的事务在执行过程中,因争夺锁资源而造成的相互等待现象 。
死锁检测可以用超时的方法解决,即当两个事务相互等待时,当一个事务的等待时间超过某一设定的阈值时 , 进行回滚,以使另一个事务得以继续执行 。但这种方法存在弊端:该方法是通过 FIFO 的方式选择事务进行回滚,若超时的事务所占权重较大 , 如更新了较多undo log,此时回滚该事务不是很好的选择 。
除了超时之外,当前数据库还普遍使用 等待图 来解决死锁问题,这是一种更为主动的死锁检测机制,它要求数据库保存以下两种信息:
通过上述链表可以构造出一张图,若图中存在回路,则代表存在死锁 。
④ 锁升级
锁升级指降低当前锁的粒度,如数据库库将 1000 个行锁升级为页锁 , 或将页锁升级为表锁,从而降低使用锁的开销,这保护了系统资源,防止使用太多内存来维护锁 。
而 不存在锁升级的问题,因为其不是根据每个记录来产生行锁的 , 相反 , 其根据每个事务访问的每个页对锁进行管理,采用的是位图的方式 。因此不管一个事务锁住页中一个记录还是多个纪录,其开销通常都是一致的 。