文章目录
- 一、事物介绍
- 1.1 事物的目的是保证数据的一致性
- 1.2 事物的ACID A、I、D是为了实现 C
- 1.3 什么是本地事物(Local Transactions)
- 二、数据库系统如何实现ACID
- 2.1 影响深远的ARIES理论
- 2.2 本地事物如何实现原子性和持久性 A、D
- 2.2.1 实现原子性和持久性的Commit Logging方法
- 2.2.2 基于ARIES Commit Logging 的升级 Write-Ahead Logging
- 2.2.3 SQLite的 Shadow Paging (影子分页)方案实现的事物机制
- 2.3 本地事物如何实现隔离性(Isolation)
- 2.3.1 隔离性有什么用?
- 2.3.2 现代数据库都提供了三种锁
- 2.3.3 本地事物的四种隔离级别的实现
- 2.3.4 MVCC 无锁的隔离场景优化方案
- 2.3.5 乐观加锁和悲观加锁
内容总结
- 事物的目的是保证数据的一致性;事物的ACID中A、I、D都是为了实现C 一致性!
- 各家数据库事物都参考了ARIES理论;
- 原子性和持久性的实现方案:Commit Logging、Write-Ahead Logging、Shadow Paging;
- 隔离性保证了每个事物各自读、写的数据相互独立,不会彼此影响;
- 数据库通用的三种锁,写锁、读锁、范围锁;
- 不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。
- MVCC优化事物“读+写”的事物隔离场景;
一、事物介绍
1.1 事物的目的是保证数据的一致性
事务处理几乎是每一个信息系统中都会涉及到的问题,它存在的意义就是保证系统中的数据是正确的,不同数据间不会产生矛盾,也就是保证数据状态的一致(Consistency)。
1.2 事物的ACID A、I、D是为了实现 C
- 原子性(Atomic):在同一项业务处理过程中,事务保证了多个对数据的修改,要么同时成功,要么一起被撤销。
- 隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
- 持久性(Durability):事务应当保证所有被成功提交的数据修改都能够正确地被持久化,不丢失数据。
- 一致性(Consistency):是数据库处理前后结果应与其所抽象的客观世界中真实状况保持一致。这种一致性是一种需要管理员去定义的规则。管理员如何指定规则,数据库就严格按照这种规则去处理数据。
1.3 什么是本地事物(Local Transactions)
- 本地事物也称为局部事物
- 本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(通常是数据库系统) 本身的事务能力来工作的。
- 本地事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持分布式事物中的 XA、TCC、SAGA 等主要靠应用程序代码来实现的事务,有着十分明显的区别。
二、数据库系统如何实现ACID
2.1 影响深远的ARIES理论
现代的主流关系型数据库(Oracle、Microsoft SQLServer、MySQL-InnoDB、IBM DB2、PostgreSQL,等等)在事务实现上都深受该理论的影响
ARIES的全称 Algorithms for REcovery and Isolatiion Expliting Sematics 基于语义的恢复与隔离算法。
2.2 本地事物如何实现原子性和持久性 A、D
原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。
原子性和持久性的难度在于,写入持久化介质的动作不是原子的,除了已写入未写入,还有正在写入的状态。
数据写入数据库可能会发生崩溃,需要进行崩溃恢复的场景。
- 未提交事务时,数据库崩溃需要回滚不完整的事物
- 已提交事物数据未写入数据库, 数据库需要将数据重新写入。
2.2.1 实现原子性和持久性的Commit Logging方法
- 为了能完成崩溃恢复,数据库会将修改数据操作所需的全部信息,以日志追加的形式写入到磁盘日志文件中 一般称为 Redo Log。
- 事物提交日志新增一条CommitRecord后,数据库会根据日至上的信息进行数据修改。修改完成后在日志中加入一条End Logging记录。标识事物已经完成持久化。
- Commit Logging实现的持久性:日志一旦被写入 Commit Record 那么整个事物就是成功的,即使修改数据时崩溃,也可以根据日志恢复现场继续写入。
- Commit Logging实现的原子性:如果有Commit Record没有End Record那么就可根据日志回滚数据
- 阿里的OceanBase就是使用Commit Logging实现事物
缺陷 所有真实的修改,都发生在事物提交、日志写入Commit Record之后,这样的话磁盘IO就会集中在事物提交后,无法利用I/O空闲的时间。
2.2.2 基于ARIES Commit Logging 的升级 Write-Ahead Logging
Write-Ahead Logging的改进方案就是允许在事物提交之前提前写入变动数据。
Write-Ahead Logging将数据写入的时机,按照事物提前后分成了FORCE和STEAL
- FORCE 事物提交后,要求数据立即写入称为FORCE(即使日志文件没有写入也会提交)。 不强制数据同步写入称为NO-FORCE。大部分数据库都是采用了No-FORCE策略。因为有了日志可以随时持久化,没有必要立即进行数据写入。
- STEAL 在事物提交前,允许变动数据提前写入,则称为STEAL 不允许则称为 NO-STEAL 。 从优化I/O性能考虑,允许数据提前写入有利于利用空闲的I/O资源,也可以节省数据库缓冲区占用的内存。
Commit Logging机制允许NO-FORCE但是不允许STEAL
- 如果允许STEAL,当一个事务正在执行时,另一个事务可以直接读取该事务已经提交的数据,就会出现这种竞态条件。
- Commit Logging事务的实现一般都不允许STEAL,而是采用其他的方式来解决竞态条件,例如使用MVCC(多版本并发控制)机制来控制数据的访问。在MVCC机制中,每个事务都会有一个自己的数据视图,其他事务无法访问该事务未提交的数据,从而避免了竞态条件的发生。
Write-Ahead Logging 允许NO-FORCE 也允许STEAL(Undo Log实现)
- Write-Ahead Logging增加了一种 Undo Log日志。变动数据写入磁盘前,先记录Undo Log、记录了数据被修改的过程,包括新的值和原始值。事物崩溃或者回滚时,可以根据Undo log对提前写入的数据进行擦除。
Write-Ahead Logging 的崩溃恢复
- 分析 Analysis:从最后一次检查点CheckPoint(正常状态) 找出未结束的事物也就是没有end Record的日志。组成待恢复的集合
- 重做 Redo : 从第一步找出的历史事物中,如果事物已经包含Commit Record 将这些记录写入磁盘,然后增加End Record 这条事物,就从待恢复事物集合中移除。
- 回滚 Undo:该阶段将重做后剩余的需要回滚的事物,被称为Loser 根据Undo Log 中的信息回滚这些事物。
- 重做、回滚都需要设计为幂等的,这三个阶段的复杂度都非常高。
FORCE和STEAL的组合
数据库按照“是否允许 FORCE 和 STEAL”可以产生四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE 加 STEAL 组合的复杂度无疑是最高的。
2.2.3 SQLite的 Shadow Paging (影子分页)方案实现的事物机制
Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。在事务过程中,被修改的数据会同时存在两份,一份修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。
当事务成功提交,所有数据的修改都成功持久化之后,最后一步要修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,所以 Shadow Paging 也可以保证原子性和持久性。
缺陷: Shadow Paging 相对简单,但涉及到隔离性与锁时,Shadow Paging 实现的事务并发能力相对有限,因此在高性能的数据库中应用不多
2.3 本地事物如何实现隔离性(Isolation)
2.3.1 隔离性有什么用?
隔离性保证了每个事物各自读、写的数据相互独立,不会彼此影响。隔离性和并发密切相关,如果一切都是串行的那么这样的访问具有天然的隔离性。
2.3.2 现代数据库都提供了三种锁
这些是按照锁的功能划分的分类,如果按照粒度分类还有表锁、行锁、页锁等等
- 写锁 Write Lock:
- 也叫排它锁 eXclusive Lock 简写X-Lock只有持有写锁的事物才能对数据进行写入操作。
- 并且数据被加持写锁时其他事物不能写入数据,也不能施加读锁。但是其他不需要锁的事物可以正常读取到数据。
- 读锁 Read Lock:
- 也叫共享锁,Shared Lock 简称为 S-Lock 多个事物可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事物不能对该数据进行写入,但是仍然可以进行读取。
- 如果数据只有一个事物加了读锁,那可以直接将其升级为写锁,然后写入数据。
- 读锁 一个事物对数据加上了读锁,就表示这个数据不能被修改了。直到所有读锁被释放,或者是转化为了写锁。但是不影响其他事物读取被加锁的数据。
- 范围锁 Range Lock:
- 对于某个范围直接加排他锁,这个范围的数据不能被读取,也不能被写入。
- 范围锁不是一批排它锁,排它锁是针对一批已存在的数据,而范围锁对于不存在于数据库中的数据也有效。
范围锁案例:SELECT * FROM books WHERE price < 100 FOR UPDATE;
- 具体数据库可以选择自己可能的方式去实现范围锁,以达到进一步的细分功能或提升性能等目的。例如表锁、行锁、页锁、间隙锁、后码锁
2.3.3 本地事物的四种隔离级别的实现
利用如上述几种锁实现下面的隔离级别,不同的数据库会有自己不同的实现,甚至是自己不同的锁,这里只是以ARIES理论来介绍。如果所有数据库的实现都一样,那也没必要有市面上这么多种数据库了。
可串行化 Serializable
- 这是最高的隔离级别
- 不考虑性能的话,对事物所有读写的数据,都加上读锁、写锁、和范围锁 就能实现串行化。
- 隔离程度越高,并发能力就越低,所以厂商一定会提供串行化之外的隔离级别供用户选择。
可重复读 Repeatable Read
- 可重复读的意思就是对事物涉及的数据,整个周期都施加读锁和写锁。这样事物涉及的数据,在加锁期间就无法被修改。事物结束前,读取的数据都是一致的。
- 可重复读对比可串行化,弱化的地方在于**幻读问题,**因为没有范围锁,假设事物A在不同时间查询或修改相同的范围数据,如果此时事物B修改了范围内的数据 事物A的两次查询或就可能会有不同的结果。这就是事物A被事物B影响,隔离性遭到破坏的表现。
读已提交 Read Committed
- 读已提交是指在事物执行的过程中,对同一行数据的两次查询得到了不同的结果。
- 例如事物A 第一次查余额是90,事物B在此过程中修改了价格为100,事物A第二次查询发现数据发生了改变,变成了100。这也是隔离性被破坏的表现!
- 读已提交出现这种情况,就是事物A读取数据时未对数据施加全周期的读锁,事物A读取完数据就释放了读锁,此时事物B施加写锁修改完数据又释放了,事物A后续再施加读锁读取就发现数据已经发生了变化。
读未提交 Read Uncommitted
- 会对事物涉及的数据只加全周期的写锁,但是完全不加读锁。
- 读未提交比读已提交弱化的地方在于脏读问题,是指一个事物执行过程中读取到了另一个事物未提交的数据。
- 如果读取数据不加读锁的话,读取到的数据可能就是被别的事物施加了写锁的未提交的数据。
- 举例:事物A 修改价格 80->100 事物A未完成,写锁未释放。此时事物B尝试读取价格,隔离级别如果是读未提交那么事物B不加锁,直接读取到了事物A 未提交的数据100元! 此时发生了**脏读。**如果隔离级别是读已提交,那么事物B读取价格就需要先加写锁,此时写锁未释放所以事物B会被阻塞,在事物A提交后才能读取,就不会发生脏读。
更低的级别:完全不隔离,事物完全不加锁
- 读未提交会有脏读问题,不会有脏写 Dirty Write问题。但是完全不加锁,就会有脏写的问题。完全不隔离还会导致第一类丢失更新的问题,就是事物A回滚时,使得事物B已提交的数据被修改。
- 出现脏写问题,就已经连事物的原子性都无法保证了。所以一般隔离级别都不会包括他。
总结 不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。
2.3.4 MVCC 无锁的隔离场景优化方案
MVCC 全称是 Multi-Version Concurrency Control 多版本并发控制。
MVCC是一种无锁的隔离场景优化方案
在上面隔离性被破坏的场景中(幻读、可不重复度、脏读等) ,都是一个事物读取数据过程被另一个更新数据的事物影响而破坏了隔离性。针对这种 “一个事物读取,另一个事物写入” 的隔离场景,可以引入MVCC作为一种无锁的优化方案。主流的商业数据库都采用了这种方案。
MVCC的基本思路
- MVCC的"无锁"是特指读取数据时,不需要加锁。
- MVCC的基本思路是对数据库的任何修改都不会覆盖之前的数据。而是产生一个新版本副本与老版本共存,以此达到读取时可以完全不加锁的目的。
- 新版本和老版本,可以理解为每一行记录都有两个看不见的字段,CREATE_VERSION和DELETE_VERSION,这两个字段记录的都是事物的ID。这里事物的ID是一个全局严格递增的数值。
- 数据被插入时:CREATE_VERSION记录插入数据的事物ID
- 数据被删除时:DELETE_VERSION记录删除数据的事物ID
- 数据被修改时:将修改视为“旧数据删除、新数据新增”则原有数据的。DELETE_VERSION和新数据的CREATE_VERSION为本次修改数据的事物ID。
MVCC优化事物隔离的场景
- 隔离级别是可重复读:总是读取CREATE_VERSION 小于等于当前事物ID的记录,如果有多个就取事物ID最大的一个。这样事物A在读取数据时,事物B更新了数据产生了新的事物ID的CREATE_VERSION记录也不会被读取,事物A仍然读取的是其事物开始时的数据。这个数据不会被更改,天然不需要加锁。
- 隔离级别是读已提交:每次读取最新的版本即可,这样每次都是读取到最后Commit的数据。
可串行化和读未提交用不上MVCC
- 串行化的目标是阻塞其他事物的读取和写入,MVCC是做读取时无锁优化的,自然是用不上。
- 读未提交 是需要直接读取未提交的数据,这种场景下直接修改原始数据即可,无需版本字段。
写入+写入的场景下MVCC无能为力
MVCC针对的是“读+锁”场景的优化,对于“写+写”的场景加锁几乎是唯一可行的解决方案。
2.3.5 乐观加锁和悲观加锁
一般来说提到的锁都是悲观锁(Pessimistic Locking)数据库认为需要先加锁再访问数据,不然肯定会出问题。而乐观锁策略(Optimistic Locking)认为竞争是偶然情况,没有竞争是普遍情况,应该一开始不加锁出现竞争时在补救。这种思路是 (Optimistic Concurrency Control,OCC)“乐观并发控制”
乐观锁和悲观锁的性能优劣主要看并发竞争的剧烈程度,如果竞争剧烈乐观锁反而更慢。