了解数据库事务

192 阅读16分钟

事务理论

事务将应用程序的多个读写操作合并成一个逻辑操作单元,以保证操作的整体性,而不用再去关系部分失败的复杂情况。事务处理几乎在每一个信息系统中都会涉及,作为一种编程模型,它存在的意义是提供一种简化的模型用于保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。

ACID与实现

ACID作为为了精确描述事务特性而出现,但实际上不同数据库对ACID的实现都不尽相同。不仅是实现的方式,甚至于声明保证ACID的数据库,实际上可能并不能提供符合ACID概念规定的效果。

以隔离性为例:事实上不同数据库对隔离性概念都不一致,实现手段也不同。 以下将从概念角度介绍ACID:

原子性

在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。在多线程编程中,原子性保证操作的中间状态不得暂停和暴露给其他线程,与多线程编程原子性略微不同,ACID的原子性并不关注多个操作的并发,而是专注于保证:在出错时中止,并将部分完成的写入丢弃。

在这样的保证下,使用者不再需要关注部分完成、部分中止的复杂情况。

一致性

一致性在不同场景下有不同含义:

  • CAP中,一致性用于表示线性化
  • 在ACID中,一致性主要指数据库处于应用程序所期待的预期状态

事实上ACD属于是数据库自身的特性,一致性更多是应用层的要求。前者是保证后者的手段。

隔离性

在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。一些数据库教材把隔离定义为可串行化,即使多个事务并发运行,但结果也必须与串行运行的结果相同。

持久性

事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。

实现

ARIES 是现代数据库的基础理论,就算不能称所有的数据库都实现了 ARIES,至少也可以称现代的主流关系型数据库(Oracle、MS SQLServer、MySQL/InnoDB、IBM DB2、PostgreSQL,等等)在事务实现上都深受该理论的影响。在 20 世纪 90 年代,IBM Almaden 研究院总结了研发原型数据库系统“IBM System R”的经验,发表了 ARIES 理论中最主要的三篇论文,其中《ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging》着重解决了 ACID 的其中两个属性:原子性(A)和持久性(D)在算法层面上应当如何实现。而另一篇《ARIES/KVL: A Key-Value Locking Method for Concurrency Control of Multiaction Transactions Operating on B-Tree Indexes》则是现代数据库隔离性(I)奠基式的文章,下面,我们先从原子性和持久性说起。

原子性与隔离性

原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。

现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子的,不仅有“写入”与“未写入”状态,还客观地存在着“正在写”的中间状态。正因为写入中间状态与崩溃都不可能消除,所以如果不做额外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持久性。

由于写入存在中间状态,所以可能发生以下情形:

  • 未提交事务,写入后崩溃:此时,数据库必须要记录崩溃前发生过一次不完整数据操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。
  • 已提交事务,写入前崩溃:此时数据库需要记录还没来得及写入磁盘的那部分数据,重新写入,以保证持久性。

Commit Logging

采用“Commit Logging”(提交日志)可以实现原子性和持久性。

“Commit Logging”将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化。

日志一旦成功写入 Commit Record,那整个事务就是成功的,即使真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性;其次,如果日志没有成功写入 Commit Record 就发生崩溃,那整个事务就是失败的,系统重启后会看到一部分没有 Commit Record 的日志,那将这部分日志标记为回滚状态即可,整个事务就像完全没好有发生过一样,这保证了原子性。

但由于Commit Logging使用一份日志,一个Commit Record标记位,作为提交与未提交的边界。崩溃恢复和持久化写入都依靠这一份日志,因此所有对数据的真实修改都必须发生在事务提交以后,即日志写入了 Commit Record 之后。

这导致了,即使磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据。

Write-Ahead Logging改进

ARIES 提出了“Write-Ahead Logging”的日志改进方案,所谓“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。

Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,划分为 FORCE 和 STEAL 两类情况。

  • FORCE:当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为 NO-FORCE。现实中绝大多数数据库采用的都是 NO-FORCE 策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行。
  • STEAL:在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。

Write-Ahead Logging 允许 NO-FORCE,也允许 STEAL,Write-Ahead Logging增加了另一种被称为 Undo Log 的日志类型,当变动数据写入磁盘前,必须先记录 Undo Log,注明修改了哪个位置的数据、从什么值改成什么值,等等。以便在事务回滚或者崩溃恢复时根据 Undo Log 对提前写入的数据变动进行擦除。Undo Log 现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志就相应被命名为 Redo Log,一般翻译为“重做日志”。

InnoDB实现

InnoDB中使用的就是Write-Ahead Logging的是Undo Log和Redo Log实现原子性和持久性。

Redo Log

重做日志用于实现事物的持久性。

redo日志中记录了对每个页的修改

如果直接将修改的数据页刷入磁盘

  • 可能修改的只是页中的一行数据,重新写入一整个数据页,开销很大
  • 一个语句可能跨越的多个数据页进行修改,全部刷入是随机IO,速度太慢
格式

image_1d36k7d3412oo1c0qcuuben12l79.png-31.3kB

  • type:该条redo日志的类型。

    MySQL 5.7.21这个版本中,InnoDB中一共为redo日志设计了53种不同的类型

  • space ID:表空间ID。

  • page number:页号。

  • data:该条redo日志的具体内容。

使用

InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么 总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写

write pos指向当前写入的记录,checkpoint指向刷新入磁盘的记录

两阶段提交: redo log 的写入拆成了两个步骤:prepare 和 commit。这是为了让redolog和binlog日志之间的逻辑一致。

rodo Log 先写入Buffer中,刷入、写入磁盘的实际与参数和操作系统调用写入的函数有关:

innodb_flush_log_at_trx_commit:用来控制redo log刷新到磁盘的策略。

默认值是1,表示每次事务提交的时候都调用fsync来写入到磁盘;\

0:表示事务在执行过程中,日志一直放在redo log buffer中,但是在事务commit的时候,不写入redo log file,而是通过master线程每秒操作一次,从redo log buffer写入到redo log file中。\

2: 表示事务提交时将redo log buffer刷入redo log file,也即刷入系统文件缓存中,不进行fsync操作,由系统来进行fsync操作。此时如果数据库层宕机,则不会丢失redo log,但是如果服务器宕机,这个时候文件系统中的缓存还没有fsync到磁盘文件中,这个时候就会丢失这一部分数据

undo日志

用于实现回滚操作。undo是逻辑日志,被储存于数据库内部的一个特殊段中。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。

Undo版本链

事实上InnoDB中的MVCC也是通过undo log来实现的:当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。

每条数据其实都有两个隐藏字段,一个是trx_id,一个是roll_pointer,这个trx_id就是最近一次更新这条数据的事务id,roll_pointer就是指向你了你更新这个事务之前生成的undo log。

image.png

更详细分析见MVCC部分

隔离性

由于串行化运行的底下效率,出现了更弱的隔离级别以适应对性能的要求。

事务隔离等级本质上是读写操作互斥程度的不同。

四大隔离等级(弱隔离级别)

可串行化
  • 写倾斜:事务先查询数据A,根据数据A进行判断,最后修改数据B,但事务提交时,支持修改B的判断已经不成立,B被错误地修改。
  • 幻读:事务按查询条件读取数据,同时另一个事务执行写入,改变了之前地查询结果。

只读查询的幻读可以才有快照隔离解决,但复杂情况需要以可串行化级别。

实体化冲突:为了方便加锁,提前创建好对应的数据。 这解决以上问题的一个方案:他把幻读问题转换位对数据库一组具体数据的冲突。 但如何设计对具体情况的实体化比较复杂同时也不够优雅。

可串行化有以下的实现方案:

  • 严格串行执行 : 采用储存过程封装事务、单线程执行等方案,但需要满足:
  1. 事务简短高效
  2. 大部分数据加载到内存
  3. 写入吞吐量低
  4. 只能非常有限地支持分区
  • 两阶段加锁(2PL) : 对于任何写操作都必须加锁独占访问

2PL是InnoDB、SQL Server中的可串行化实现方式 在事务中完整应用读写锁,且获得锁之后会一直持有锁到事务结束。 同时为了解决幻读问题,采用谓词锁、索引区间锁对一定范围的数据进行锁定。

  • 可串行化的快照隔离
可重复读

对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。因此涉及范围操作可能出现幻读问题。InnoDB默认的事务隔离等级,InnoDB中增加间隙锁解决幻读问题。

MVCC也是该隔离等级常见的实现手段,在整个事务期间只对同一个版本(快照)上的数据进行读写。而读已提交只需要对单独的查询限制版本。

事实上使用多版本数据实现可重复读,这种隔离方案在不同的数据库有不一样的命名,Oracle成为可串行化,MySQL成为可重复读。

并发写冲突

MVCC能优化读写冲突,但对写冲突无能为力。写冲突会导致更新丢失问题:应用程序读取数据库,并对数据作出修改,然后写回新值。如果同时两个并发事务执行以上操作,由于隔离性,第二个写操作并不包括第一个事务修改后的指,最终覆盖了第一个事务修改的值。 解决方案包括:

  • 原子写操作 : 许多数据库支持原子更新,避免在应用层实现。

  • 显式加锁 :for update显式加锁

  • 自动检测更新丢失 : 有些数据库事务管理器支持自动检测更新丢失,并中止违规事务

  • 原子比较 : CAS是可行的解决方案

  • 冲突解决 : 由于加锁和原子修改的前提是只有一个最新的副本,多副本数据库可能会使用“最后更新获胜(LWW)”、合并冲突、保留不同冲突版本的方案。

读已提交

读已提交提供以下保证:

  • 只能读取已提交数据
  • 只能写入覆盖已提交数据

对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。因此不同的查询操作之间可能出现写操作,导致同一事务中两次查询结果不同。即不可重复读现象

在实际中,长时间的写锁,可能导致许多只读事务过久等待,InnoDB采用MVCC对读写的优化,读事务读取快照数据,不会被写锁阻塞。

读未提交

事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁。因为不加锁,因此能直接读取其他事务加了写锁的数据,导致脏读现象。

MVCC

MVCC 也就是多版本并发控制,数据库通过保留对象对各不同的提交版本,通过事务ID和数据的版本号,获取在事务ID开始前就存在且未被改动的数据集合,即事务开始瞬间的数据库快照。在隔离事务的同时,读写互不阻塞。也一致性非锁定读。

可见性规则

数据库中每行数据,在行结构中都隐藏了两个字段,CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID,事务 ID 是一个全局严格递增的数值,然后根据以下规则写入数据。

  • 插入数据时:CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。
  • 删除数据时:DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空。
  • 修改数据时:将修改数据视为“删除旧数据,插入新数据”的组合,即先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。

根据隔离级别来决定到底应该读取哪个版本的数据。

  • 隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

image.png

同时,不同版本的数据会通过Undo Log版本链连起。顺序遍历直到事务ID符合可见性规则。

索引与MVCC

多版本数据库支持索引的方案包括:

  • 索引执行所有版本的合集,再进行过滤,对旧版本回收时也要同步更新索引。

  • 追加/写时复制技术,需要更新时,拷贝旧页面,再进行追加更新,并更改索引指向。

索引区间锁

谓词锁会保护符合条件的所有查询对象,并规定:

  • 如果A事务读取某些满足匹配条件的对象,就必须以共享模式获得查询条件的谓词锁,如果有任何一个事务持有符合查询条件的这些对象的独占锁,A必须等待。

  • 如果A事务想对数据进行写入,必须检查新值与旧值是否与任何谓词锁冲突。

由于谓词锁的开销过大,实际上多采用索引区间锁。索引区间锁对谓词锁的简化,扩大了保护范围。以(id,A,B)为例:A = 1 AND B < 10 的条件下,索引区间锁锁定了A列索引,即满足A = 1,B的所有 的数据。与原谓词锁冲突的修改也会与索引区间锁冲突。区间锁的开销比谓词锁的开销低得多。

顺带一提:InnoDB的行级锁也是通过给索引上的索引项加锁来实现的,也就意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。