本文节选自:关系型数据库理论 https://url.wx-coder.cn/DJNQn ,涉及引用/整理的文章列举在了 Database-List。
关系型数据库中的事务管理详解:并发控制与事务日志
数据库系统的萌芽出现于 60 年代。当时计算机开始广泛地应用于数据管理,对数据的共享提出了越来越高的要求。传统的文件系统已经不能满足人们的需要。能够统一管理和共享数据的数据库管理系统(DBMS)应运而生。1961 年通用电气公司(General ElectricCo.)的 Charles Bachman 成功地开发出世界上第一个网状 DBMS 也是第一个数据库管理系统—— 集成数据存储(Integrated DataStore IDS),奠定了网状数据库的基础。
1970 年,IBM 的研究员 E.F.Codd 博士在刊物 Communication of the ACM 上发表了一篇名为“A Relational Modelof Data for Large Shared Data Banks”的论文,提出了关系模型的概念,奠定了关系模型的理论基础。1974 年,IBM 的 Ray Boyce 和 DonChamberlin 将 Codd 关系数据库的 12 条准则的数学定义以简单的关键字语法表现出来,里程碑式地提出了 SQL(Structured Query Language)语言。在很长的时间内,关系数据库(如 MySQL 和 Oracle)对于开发任何类型的应用程序都是首选,巨石型架构也是应用程序开发的标准架构。
本文即是对关系型数据库中的事务管理相关内容进行讨论。
事务基础
ACID
事务提供一种全做,或不做(All or Nothing)的机制,即将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。数据库事务具有 ACID 属性,即原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),在分布式事务 https://url.wx-coder.cn/7p8Xx 中我们也会讨论分布式系统中应该如何实现事务机制。
ACID 包含了描述事务操作的整体性的原子性,描述事务操作下数据的正确性的一致性,描述事务并发操作下数据的正确性的隔离性,描述事务对数据修改的可靠性的持久性。针对数据库的一系列操作提供了一种从失败状态恢复到正常状态的方法,使数据库在异常状态下也能够保持数据的一致性,且面对并发访问时,数据库能够提供一种隔离方法,避免彼此间的操作互相干扰。
-
原子性(Atomicity):整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。例如:银行转账,从 A 账户转 100 元至 B 账户,分为两个步骤:从 A 账户取 100 元;存入 100 元至 B 账户。这两步要么一起完成,要么一起不完成。
-
一致性(Consistency):在事务开始之前和事务结束以后,数据库数据的一致性约束没有被破坏;即当事务 A 与 B 同时运行,无论 A,B 两个事务的结束顺序如何,数据库都会达到统一的状态。
-
隔离性(Isolation):数据库允许多个并发事务同时对数据进行读写和修改的能力,如果一个事务要访问的数据正在被另外一个事务修改,只要另外一个事务未提交,它所访问的数据就不受未提交事务的影响。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。 例如:现有有个交易是从 A 账户转 100 元至 B 账户,在这个交易事务还未完成的情况下,如果此时 B 查询自己的账户,是看不到新增加的 100 元的。
-
持久性(Durability):当某个事务一旦提交,无论数据库崩溃还是其他未知情况,该事务的结果都能够被持久化保存下来。
隔离级别
SQL 标准定义了 4 类隔离级别,包括了一些具体规则,用来限定事务内外的哪些改变是可见的,哪些是不可见的。低级别的隔离级一般支持更高的并发处理,并拥有更低的系统开销。
隔离级别 | 脏读(Dirty Read ) | 不可重复读(NonRepeatable Read ) | 幻读(Phantom Read ) |
---|---|---|---|
未提交读(Read Uncommitted) | 可能 | 可能 | 可能 |
提交读(Read Committed ) | 不可能 | 可能 | 可能 |
可重复读(Repeatable Read ) | 不可能 | 不可能 | 可能 |
可串行化(Serializable ) | 不可能 | 不可能 | 不可能 |
Read Uncommitted | 未提交读
在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)。
Read Committed 提交读
这是大多数数据库系统的默认隔离级别比如 Sql Server, Oracle 等,但不是 MySQL 默认的。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别也支持所谓的不可重复读(Nonrepeatable Read),因为同一事务的其他实例在该实例处理其间可能会有新的 Commit,所以同一查询可能返回不同结果。
Repeatable Read | 重复读
当隔离级别设置为 Repeatable Read 时,可以避免不可重复读。不可重复读是指事务 T1 读取数据后,事务 T2 执行更新操作,使 T1 无法再现前一次读取结果。具体地讲,不可重复读包括三种情况:
- 事务 T1 读取某一数据后,事务 T2 对其做了修改,当事务 T1 再次读该数据时,得到与前一次不同的值。例如,T1 读取 B=100 进行运算,T2 读取同一数据 B,对其进行修改后将 B=200 写回数据库。T1 为了对读取值校对重读 B,B 已为 200,与第一次读取值不一致。
- 事务 T1 按一定条件从数据库中读取了某些数据记录后,事务 T2 删除了其中部分记录,当 T1 再次按相同条件读取数据时,发现某些记录神密地消失了。
- 事务 T1 按一定条件从数据库中读取某些数据记录后,事务 T2 插入了一些记录,当 T1 再次按相同条件读取数据时,发现多了一些记录,也就是幻读。
这是 MySQL 的默认事务隔离级别,它确保在一个事务内的相同查询条件的多次查询会看到同样的数据行,都是事务开始时的数据快照。虽然 Repeatable Read 避免了不可重复读,但还有可能出现幻读。简单说,就是当某个事务在读取某个范围内的记录时,另外的一个事务又在该范围内插入新的记录。在之前的事务在读取该范围的记录时,就会产生幻行,InnoDB 通过间隙锁(next-key locking)策略防止幻读的出现。
Serializable | 序列化
Serializable 是最高的事务隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。该隔离级别代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读。
并发控制
并发控制旨在针对数据库中对事务并行的场景,保证 ACID 中的一致性(Consistency)与隔离性(Isolation)。假如所有的事务都仅进行数据读取,那么事务之间并不会有冲突;而一旦某个事务读取了正在被其他事务修改的数据或者两个事务修改了相同的数据,那么数据库就必须来保证事务之间的隔离,来避免某个事务因为未见最新的数据而造成的误操作。解决并发控制问题最理想的方式就是能够每当某个事务被创建或者停止的时候,监控所有事务的所有操作,判断是否存在冲突的事务,然后对冲突事务中的操作进行重排序以尽可能少地减少冲突,而后以特定的顺序运行这些操作。绝大部分数据库会采用锁(Locks)或者数据版本控制(Data Versioning)的方式来处理并发控制问题。
数据库技术中主流的三种并发控制技术分别是: Multi-version Concurrency Control (MVCC), Strict Two-Phase Locking (S2PL), 以及 Optimistic Concurrency Control (OCC),每种技术也都有很多的变种。在 MVCC 中,每次写操作都会在旧的版本之上创建新的版本,并且会保留旧的版本。当某个事务需要读取数据时,数据库系统会从所有的版本中选取出符合该事务隔离级别要求的版本。MVCC 的最大优势在于读并不会阻塞写,写也不会阻塞读;而像 S2PL 这样的系统,写事务会事先获取到排他锁,从而会阻塞读事务。PostgreSQL 以及 Oracle 等 RDBMS 实际使用了所谓的 Snapshot Isolation(SI)这个 MVCC 技术的变种。Oracle 引入了额外的 Rollback Segments,当写入新的数据时,老版本的数据会被写入到 Rollback Segment 中,随后再被覆写到实际的数据块。PostgreSQL 则是使用了相对简单的实现方式,新的数据对象会被直接插入到关联的 Table Page 中;而在读取表数据的时候,PostgreSQL 会通过可见性检测规则(Visibility Check Rules)来选择合适的版本。
锁管理器(Lock Manager)
基于锁的方式基础理念为:如果某个事务需要数据,则对数据加锁,操作完毕后释放锁;如果过程中其他事务需要锁,则需要等到该事务释放数据锁,这种锁也就是所谓的排他锁(Exclusive Lock)。不过使用排他锁会带来极大的性能损耗,其会导致其他那些仅需要读取数据的事务也陷入等待。另一种加锁的方式称为共享锁(Shared Lock),当两个事务都声明读取数据 A 时,它们会分别给 A 添加共享锁;对于此事需要修改数据 A 的事务而言,它必须等待所有的共享锁释放完毕之后才能针对数据 A 添加排他锁。同样地,对于已经被设置了排他锁的数据,仅有读取请求的事务同样需要等到该排他锁被释放后才能添加共享锁。
从锁定的数据范围锁粒度(Lock Granularity)来看分为:
- 表锁:管理锁的开销最小,同时允许的并发量也最小的锁机制。MyIsam 存储引擎使用的锁机制。当要写入数据时,把整个表都锁上,此时其他读、写动作一律等待。在 MySql 中,除了 MyIsam 存储引擎使用这种锁策略外,MySql 本身也使用表锁来执行某些特定动作,比如 ALTER TABLE.
- 行锁:可以支持最大并发的锁策略。InnoDB 和 Falcon 两种存储引擎都采用这种策略。
锁管理器(Lock Manager)即负责分配与释放锁,大部分数据库是以哈希表的方式来存放持有锁以及等待锁的事务。在 MySQL 实战 https://url.wx-coder.cn/Tu5dq 中我们也讨论了如何触发锁机制,譬如查询加锁,select * from testlock where id=1 for update;
,即查询时不允许更改,该语句在自动提交为 off 或事务中生效,相当于更改操作,模拟加锁;而更像类操作 update testlock name=name;
则是会自动加锁。
同样的,参考并发编程导论 https://url.wx-coder.cn/Yagu8 中的讨论,只要存在锁的地方就会存在死锁(Deadlock)的可能性:
在发生死锁的时候,锁管理器会根据一定的规则来选取应该终止或者被回滚的事务:
- 根据是否能最小化需要被回滚的数据;
- 根据事务发生的先后顺序;
- 根据事务执行所需要的时间,以尽可能避免饥饿状态的出现;
- 根据需要回滚的关联事务的数目;
避免死锁,确保纯隔离的最简单方法是在事务开始时获取锁并在事务结束时释放锁。这意味着事务必须在启动之前等待其所有锁,并且在事务结束时释放事务持有的锁,这种方式会浪费很多时间来等待所有锁。实际的数据库,譬如 DB2 与 SQL Server 中往往采取两阶段锁协议(Two-Phase Locking Protocol),即将事务过程切分为两个阶段:
- Growing Phase: 该阶段仅可以获取锁,而不可以释放锁。
- Shrinking Phase: 该阶段仅可以释放锁,而不可以获取新的锁。
该策略能够减少其他事务等待锁的时间,并且避免某个事务在中途修改了并不是它初次申请的数据。
MVCC
在并发编程导论 https://url.wx-coder.cn/Yagu8 中我们讨论了两种不同类型的锁:乐观锁(Optimistic Lock)与悲观锁(Pessimistic Lock),前文介绍的各种锁即是悲观锁,而 MVCC(Multiple Version Concurrency Control) 这样的基于数据版本的锁则是乐观锁,它能够保证读写操作之间不会相互阻塞:
- 每个事务都可以在同一时间修改相同的数据;
- 每个事务会保有其需要的数据副本;
- 如果两个事务修改了相同的数据,那么仅有单个更改操作会被接收,另一个操作会被回滚或者重新执行。
乐观锁,大多是基于数据版本(Version)记录机制实现。数据版本即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 version
字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。而 PostgreSQL 中则是依赖于 txid 以及 Commit Log 结合而成的可见性检测机制来实现 MVCC,详情可以参考 PostgreSQL 架构机制 https://url.wx-coder.cn/SgRDQ 中关于并发控制相关的介绍。
日志管理器(Log Manager)
数据库事务由具体的 DBMS 系统来保障操作的原子性,同一个事务当中,如果有某个操作执行失败,则事务当中的所有操作都需要进行回滚,回到事务执行前的状态。导致事务失败的原因有很多,可能是因为修改不符合表的约束规则,也有可能是网络异常,甚至是存储介质故障等,而一旦事务失败,则需要对所有已作出的修改操作进行还原,使数据库的状态恢复到事务执行前的状态,以保障数据的一致性,使修改操作要么全部成功、要么全部失败,避免存在中间状态。
访问磁盘中的数据往往速度较慢,换言之,内存中数据的访问速度还是远快于 SSD 中的数据访问速度。基于这个考量,基本上所有数据库引擎都尽可能地避免访问磁盘数据。并且无论数据库表还是数据库索引都被划分为了固定大小的数据页(譬如 8 KB)。当我们需要读取表或者索引中的数据时,关系型数据库会将磁盘中的数据页映射入存储缓冲区。当我们需要修改数据时,关系型数据库首先会修改内存页中的数据,然后利用 fsync 这样的同步工具将改变同步回磁盘中。
不过一旦数据库突发崩溃,那么缓冲区中的数据也就丢失,最终打破了事务的持久性。另一个极端情况而言,我们也可以随时将数据写入到磁盘中,但是在崩溃的时候,很可能只写入了一半的数据,而打破了事务的原子性(Atomicity)。为了解决这个问题,我们可以采取以下两种方案:
- 影子拷贝(Shadow Copies/Pages):每个事务会创建数据库的部分拷贝,然后针对这些拷贝进行操作。在发生异常的时候,这些拷贝会被移除;正常的情况下,数据库则会立刻将这个拷贝写入到磁盘然后移除老的数据块。
- 事务日志(Transaction Log):所谓的事务日志即是独立的存储空间,在将数据写入真正的数据表之外,数据库都会将事务操作顺序写入到某个日志文件中。
在实际情况下,Shadow Copies/Pages 会受到极大的磁盘限制,因此绝大部分数据库还是选择了以事务日志的方式。
事务日志(Transaction Log)
为了实现数据库状态的恢复,DBMS 系统通常需要维护事务日志以追踪事务中所有影响数据库数据的操作,以便执行失败时进行事务的回滚。以 MySQL 的 InnoDB 存储引擎为例,InnoDB 存储引擎通过预写事务日志的方式,来保障事务的原子性、一致性以及持久性。它包含 Redo 日志和 Undo 日志,Redo 日志在系统需要的时候,对事务操作进行重做,如当系统宕机重启后,能够对内存中还没有持久化到磁盘的数据进行恢复,而 Undo 日志,则能够在事务执行失败的时候,利用这些 Undo 信息,将数据还原到事务执行前的状态。
事务日志可以提高事务执行的效率,存储引擎只需要将修改行为持久到事务日志当中,便可以只对该数据在内存中的拷贝进行修改,而不需要每次修改都将数据回写到磁盘。这样做的好处是,日志写入是一小块区域的顺序 I/O,而数据库数据的磁盘回写则是随机 I/O,磁头需要不停地移动来寻找需要更新数据的位置,无疑效率更低,通过事务日志的持久化,既保障了数据存储的可靠性,又提高了数据写入的效率。
当某个事务需要去更改数据表中某一行时,未提交的改变会被写入到内存数据中,而之前的数据会被追加写入到 Undo Log 文件中。Oracle 或者 MySQL 中使用了所谓 Undo Log 数据结构,而 SQL Server 中则是使用 Transaction Log 完成此项工作。PostgreSQL 并没有 Undo Log,不过其内建支持所谓多版本的表数据,即同一行的数据可能同时存在多个版本。总而言之,任何关系型数据库都采用的类似的数据结构都是为了允许回滚以及数据的原子性。
某个事务提交之后,内存中的改变就需要同步到磁盘中。不过并不是所有的事务提交都会立刻触发同步,过高频次的同步反而会对应用性能造成损伤。这里关系型数据库就是依靠 Redo Log 来达成这一点,它是一个仅允许追加写入的基于磁盘的数据结构,它会记录所有尚未执行同步的事务操作。相较于一次性写入固定数目的数据页到磁盘中,顺序地写入到 Redo Log 会比随机访问快上很多。因此,关于事务的 ACID 特性的保证与应用性能之间也就达成了较好的平衡。该数据结构在 Oracle 与 MySQL 中就是叫 Redo Log,而 SQL Server 中则是由 Transaction Log 执行,在 PostgreSQL 中则是使用 Write-Ahead Log(WAL)。下面我们继续回到上面的那个问题,应该在何时将内存中的数据写入到磁盘中。关系型数据库系统往往使用检查点来同步内存的脏数据页与磁盘中的对应部分。为了避免 IO 阻塞,同步过程往往需要等待较长的时间才能完成。因此,关系型数据库需要保证即使在所有内存脏页同步到磁盘之前引擎就崩溃的时候不会发生数据丢失。同样地,在每次数据库重启的时候,数据库引擎会基于 Redo Log 重构那些最后一次成功的检查点以来所有的内存数据页。
WAL(Write-Ahead Logging)
WAL 协议主要包含了以下三条规则:
- 每个数据库中的修改操作都会产生一条记录,该记录必须在数据被写入到数据库之前被写入到日志文件中;
- 所有的操作日志都必须严格按序记录,即如果 A 记录发生在 B 之前,那么 A 也必须在 B 之前被写入到日志中;
- 在事务被提交之后,必须在日志写入成功之后才能回复事务处理成功。
同样可以参考 PostgreSQL 架构机制 https://url.wx-coder.cn/SgRDQ 中有关于 WAL 的实例讨论。