文章目录

  • 第7章 事务
    • 1 深入理解事务
      • 1.1 ACID的含义
        • 1.1.1 原子性
        • 1.1.2 一致性
        • 1.1.3 隔离性
        • 1.1.4 持久性
        • 复制与持久性
      • 1.2 单对象与多对象事务操作
        • 1.2.1 单对象写入
        • 1.2.2 多对象事务的必要性
        • 1.2.3 处理错误与中止
    • 2 弱隔离级别
      • 2.1 读-提交
        • 2.1.1 防止脏读
        • 2.1.2 防止脏写
        • 2.1.3 实现读-提交
      • 2.2 快照级别隔离与可重复读
        • 2.2.1 实现快照级别隔离
        • 2.2.2 一致性快照的可见性规则
        • 2.2.3 索引与快照级别隔离
        • 2.2.4 可重复读与命名混淆
      • 2.3 防止更新丢失
        • 2.3.1 原子写操作
        • 2.3.2 显式加锁
        • 2.3.3 自动检测更新丢失
        • 2.3.4 原子比较和设置
        • 2.3.5 冲突解决与复制
      • 2.4 写倾斜与幻读
        • 2.4.1 定义写倾斜
        • 2.4.2 更多写倾斜的例子
        • 2.4.3 为何产生写倾斜
        • 2.4.4 实体化冲突
    • 3 串行化
      • 3.1 实际串行执行
        • 3.1.1 采用存储过程封装事务
        • 3.1.2 存储过程的优缺点
        • 3.1.3 分区
        • 3.1.4 串行执行小结
      • 3.2 两阶段加锁
        • 3.2.1 实现两阶段加锁
        • 3.2.2 两阶段加锁的性能
        • 3.2.3 谓词锁
        • 3.2.4 索引区间锁
      • 3.3 可串行化的快照隔离(SSI)
        • 3.3.1 悲观与乐观的并发控制
        • 3.3.2 基于过期的条件做决定
        • 3.3.3 检测是否读取了过期的MVCC对象
        • 3.3.4 检测写是否影响了之前的读
        • 3.3.5 可串行化快照隔离的性能
    • 小结

信息是激发创新的力量

本章目标: 事务。

第7章 事务

在一个苛刻的数据存储环境中,会有许多可能出错的情况。例如:

  • 数据库软件或硬件可能会随时失效(包括正在执行写操作的过程中)
  • 应用程序可能随时崩溃(包括一系列操作执行到中间某一步)
  • 应用与数据库节点之间的链接可能会随时中断,数据库节点之间也存在同样问题
  • 多个客户端可能同时写入数据库,导致数据覆盖
  • 客户端可能读到一些无意义的、部分更新的数据
  • 客户端之间由于边界条件竞争所引入的各种奇怪问题

近十年来,事务技术一直是简化这些问题的首选机制。事务将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元。即事务中的所有读写是一个执行的整体,整个事务要么成功(提交)、要么失败(中止或回滚)。如果失败,应用程序可以安全地重试。这样,由于不需要担心部分失败的情况(无论出于何种原因),应用层的错误处理就变得简单很多。
        事务不是一个天然存在的东西,它是被人为创造出来,目的是简化应用层的编程模型。

1 深入理解事务

目前几乎所有的关系数据库和一些非关系数据库都支持事务处理。然而21世纪末,非关系(NoSQL)数据库开始兴起。然而事务却随之成为了受害者:很多新一代的数据库完全放弃了事务支持,或者将其重新定义,即替换为比以前弱得多的保证。
        过分夸大事务或者贬低事务都是不正确的,与其他技术一样,事务有其优势,也有其自身的局限性。 为了更好地理解事务设计的权衡之道,让我们考虑正常运行和各种极端(但确实存在)情况,详细分析事务可以为我们提供哪些保障。

1.1 ACID的含义

原子性、一致性、隔离性与持久性是为了精确描述数据库的容错机制而定的,搞清楚这写概念是为了建立对事务思想的清晰而牢固的认识。
        但实际上,各家数据库所实现的ACID并不尽相同。想法非常美好,细节方见真章。

1.1.1 原子性

通常,原子是指不可分解为更小粒度的东西。这个术语在计算机的不同领域里有着相似但却微妙的差异。
        在出错时中止事务,并将部分完成的写入全部丢弃。也许可中止性比原子性更为准确,不过我们还是沿用原子性这个惯用术语。

1.1.2 一致性

一致性非常重要,但它在不同场景有着不同的含义,例如:

  • 讨论副本一致性以及异步复制模型时,引出了最终一致性问题
  • 一致性哈希则是某些系统用于动态分区再平衡的方法
  • CAP理论中,一致性一词用来标识线性化
  • 而在ACID中,一致性主要指数据库处于应用程序所期待的“预期状态”

ACID中的一致性主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。这种一致性本质上是要求应用程序来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。
        原子性,隔离性和持久性是数据库自身的属性,而ACID中的一致性更多是应用层的属性。应用程序可能借助数据库提供的原子性和隔离性,以达到一致性,但一致性本身并不源于数据库。因此,字母C其实并不应该属于ACID。

1.1.3 隔离性

ACID语义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉。经典的数据库教材把隔离定义为可串行化,这意味着可以假装它是数据库上运行的唯一事务。虽然实际上它们可能同时运行,但数据库系统要确保当事务提交时,其结果与串行执行(一个接一个执行)完全相同。

1.1.4 持久性

数据库系统本质上是提供一个安全可靠的地方来存储数据而不用担心数据丢失等。
        对于单节点数据库,持久性通常意味着数据已被写入非易失性存储设备;而对于支持远程复制的数据库,持久性则意味着数据已成功复制到多个节点。

复制与持久性

从历史上看,持久性最早意味着是写入磁带来存档,之后演变为写入磁盘或SSD。最近,它又代表着多节点间复制。对比下来,事实上是没有哪一项技术可以提供绝对的持久性保证。这些都是帮助降低风险的手段,应该组合适用它们,包括写入磁盘、复制到远程机器以及备份等。因此对任何理论上的“保证”一定要谨慎对待。

1.2 单对象与多对象事务操作

ACID中的原子性和隔离性主要针对客户端在同一事务中包含多个写操作时做出一些保证,这些定义假定在一个事务中会修改多个对象(如行,文档,记录等)。这种多对象事务目的通常是为了在多个数据对象之间保持同步。

  • 原子性
            如果一系列写操作中间发生了错误,则事务必须中止,并且事务中已完成的写入应该被丢弃。换言之,不用担心数据库的部分失败,他总是保证要么全部成功,要么全部失败
  • 隔离性
            同时运行的事务不应相互干扰。例如,如果某个事务进行多次写入,则另一个事务应该观察到的是其全部完成(或者一个都没完成)的结果,而不应该看到中间的部分结果

多对象事务要求确定知道事务包含了哪些读写操作。关系数据库使用BEGINTRANSACTION和COMMIT来确定一个事务,而许多非关系数据库则不会将这些操作组合在一起。

1.2.1 单对象写入

原子性和隔离性也同样适用于单个对象的更新。例如,假设向数据库写入20KB的JSON文档:

  • 如果发送了第一个10KB之后网络连接中断,数据库是否只存储了无法完整解析的10KB JSON片段呢?
  • 如果数据库在覆盖磁盘现有数据时发生电源故障,最终是否是新旧值混杂在一起?
  • 如果另一个客户端在写入的过程中读取该文档,是否会看到部分更新的文档内容?

这些问题着实让人头疼,因此存储引擎几乎必备的设计就是在单节点、单个对象层面上提供原子性和隔离性(比如key-value对)。例如,出现宕机时,基于日志恢复来实现原子性,对每个对象采用加锁的方式(每次只允许一个线程访问对象)来实现隔离。
        单对象操作(高级的原子操作)可以有效防止多个客户端并发修改同一对象时的更新丢失问题。但需要注意,它们不是通常意义上的事务。通常意义上的事务针对的是多个对象,将多个操作聚合为一个逻辑执行单元。

1.2.2 多对象事务的必要性

许多分布式数据系统不支持多对象事务,主要是因为当出现跨分区时,多对象事务非常难以正确实现,同时在高可用或者极致性能的场景下也会带来很多负面影响。
        但是否所有应用都需要多对象事务呢?是否可能只用键-值数据模型和单对象操作就可以满足应用需求?
        的确有一些情况,只进行单个对象的插入、更新和删除就足够了。但是,还有许多其他情况要求写入多个不同的对象并进行协调:

  • 对于关系数据模型,表中的某行可能是另一个表中的外键。类似地,在图数据模型中,顶点具有多个边链接到其他的顶点。多对象事务用以确保这些外键引用的有效性,即当插入多个相互引用的记录时,保证外键总是最新、正确的,否则数据更新就变得毫无意义
  • 对于文档数据模型,如果待更新的字段都在同一个文档中,则可视为单个对象,此时不需要多对象事务。但是,缺少join支持的文档数据库往往会滋生反规范化,当更新这种非规范化数据时,就需要一次更新多个文档。此时多对象事务就可以有效防止非规范化数据之间出现不同步
  • 对于带有二级索引的数据库(除了纯粹键-值存储以外几乎所有其他系统都支持二级索引),每次更改值时都需要同步更新索引。从事务角度来看,这些索引是不同的数据库对象:如果没有事务隔离,就会出现部分索引更新

即使没有事务支持,或许上层应用依然可以工作,然而在没有原子性保证时,错误处理就会异常复杂,而缺乏隔离性则容易出现并发性方面的各种奇怪问题。

1.2.3 处理错误与中止

ACID数据库基于这样的一个理念:如果存在违反原子性、隔离性或持久性的风险,则完全放弃整个事务,而不是部分放弃。 并不是所有数据库系统都遵循这个理念,无主节点的数据存储会在”尽力而为“的基础上尝试多做些工作:数据库已经尽其所能,但万一遇到错误,系统并不会撤销已完成的操作,此时需要应用程序来负责从错误中进行恢复。

重试中止的事务虽然是一个简单有效的错误处理机制,但它并不完美:

  • 如果事务实际已经执行成功,但返回给客户端的消息在网络传输时发生意外(所以在客户端看来事务是失败),那么重试就会导致重复执行,此时需要额外的应用级重复数据删除机制
  • 如果错误是由于系统超负荷所导致,则重试事务将使情况变得更糟。为此,可以设定一个重试次数上限,例如指数回退,同时要尝试解决系统过载本身的问题
  • 由临时性故障(例如死锁,隔离违例,网络闪断和节点切换等)所导致的错误需要重试。但如果出现了永久性故障(例如违反约束),则重试毫无意义
  • 如果在数据库之外,事务还产生其他副作用,即使事务被中止,这些副作用可能已事实生效。例如,假设更新操作还附带发送一封电子邮件,肯定不希望每次重试时都发送邮件。如果想要确保多个不同的系统同时提交或者放弃,可以考虑采用两阶段提交
  • 如果客户端进程在重试过程中也发生失败,没有其他人继续负责重试,则那些待写入的数据可能会因此而丢失

2 弱隔离级别

如果两个事务操作的是不同的数据,即不存在数据依赖关系,则它们可以安全地并行执行。只有出现某个事务修改数据而另一个事务同时要读取该数据,或者两个事务同时修改相同数据时,才会引发并发问题(引入了竞争条件)
        数据库一直试图通过事务隔离来对应用开发者隐藏内部的各种并发问题。从理论上讲,隔离是假装没有发生并发,让程序员的生活更轻松,而可串行化隔离意味着数据库保证事务的最终执行结果与串行(即一次一个,没有任何并发)执行结果相同。
        本节将分析几个实际中经常用到的弱级别(非串行化)隔离,并详细讨论可能(或者不可能)发生的竞争条件,有了这些认识之后,可以帮助判断自己的应用更适合什么样的隔离级别。

2.1 读-提交

读-提交是最基本的事务隔离级别,它只提供以下两个保证:

  1. 读数据库时,只能看到已成功提交的数据(防止”脏读“)
  2. 写数据库时,只会覆盖已成功提交的数据(防止”脏写“)

这两个保证更深入的介绍如下:

2.1.1 防止脏读

假定某个事务已经完成部分数据写入,但事务尚未提交(或中止),此时另一个事务是否可以看到尚未提交的数据呢?如果是的话,那就是脏读。

2.1.2 防止脏写

脏写问题导致不同事务的并发写入最终混杂在一起。
        如果两个对象同时尝试更新相同的对象,会发生什么情况呢?我们不清楚写入的顺序,但可以想象后写的操作会覆盖较早的写入。
        但是,如果先前的写入是尚未提交事务的一部分,是否还是被覆盖?如果是,那就是脏写。读-提交隔离级别下所提交的事务可以防止脏写,通常的方式是推迟第二个写请求,直到前面的事务完成提交(或者中止)

2.1.3 实现读-提交

读-提交隔离非常流行。数据库通常采用行级锁来防止脏写:当事务想修改某个对象(例如行或文档)时,它必须首先获得该对象的锁;然后一直持有锁直到事务提交(或中止) 这种锁定是由处于读-提交模式(或更强的隔离级别)数据库自动完成的。
        读锁的方式在实际中并不可行,因为运行时间较长的写事务会导致许多只读的事务等待太长时间,这会严重影响只读事务的响应延迟,且可操作性差:由于读锁,应用程序任何局部的性能问题会扩散进而影响整个应用,产生连锁反应。
        大多数数据库采用如下方法来防止脏读:对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。

2.2 快照级别隔离与可重复读

不能够容忍不可重复读取读倾斜的场景:

  • 备份场景
  • 分析查询与完整性检查场景

快照级别隔离是解决上述问题最常见的手段。其总体想法是,每个事务都从数据库的一致性快照中读取,事务一开始所看到的是最近提交的数据,即使数据随后可能被另一个事务更改,但保证每个事务都只能看到该特定时间点的旧数据。
        快照级别隔离对于长时间运行的只读查询(如备份和分析)非常有用。如果数据在执行查询的同时还在发生变化,那么查询结果对应的物理含义就难以理清。而如果查询的是数据库在某时刻点所冻结的一致性快照,则查询结果的含义非常明确。

2.2.1 实现快照级别隔离

与读-提交隔离类似,快照级别隔离的实现通常采用写锁来防止脏写,这意味着正在进行写操作的事务会组织同一对象上的其他事务。但是,读取则不需要加锁。考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,这种技术因此也被称为多版本并发控制(MVCC)
        如果只是为了提供读-提交隔离级别,而不是完整的快照级别隔离,则只保留对象的两个版本就足够了:一个已提交的旧版本和尚未提交的新版本。 所以,支持快照级别隔离的存储引擎往往直接采用MVCC来实现读-提交隔离。典型的做法是,在读-提交级别下,对每一个不同的查询单独创建一个快照;而快照级别隔离则是使用一个快照来运行整个事务。
        当事务开始时,首先赋予一个唯一的、单调递增的事务ID,每当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务ID。如果事务要删除某行,该行实际上并未从数据库中删除,而只是将deleted_by字段设置为请求删除的事务ID(仅仅标记为删除)。事后,当确定没有其他事务引用该标记删除的行时,数据库的垃圾回收进程才去真正删除并释放存储空间。

2.2.2 一致性快照的可见性规则

以下规则适用于创建操作删除操作

  1. 每笔事务开始时,数据库列出所有当时尚在进行中的其他事务(即尚未提交或中止),然后忽略这些事务完成的部分写入(尽管之后可能会被提交),即不可见
  2. 所有中止事务所做的修改全部不可见
  3. 较晚事务ID(即晚于当前事务)所做的任何修改不可见,不管这些事务是否完成了提交
  4. 除此之外,其他所有的写入都对应用查询可见

换句话说,仅当以下两个条件都成立,则该数据对象对事务可见:

  • 事务开始的时刻,创建该对象的事务已经完成了提交
  • 对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有完成提交

长时间运行的事务可能会适用快照很长时间,从其他事务的角度来看,它可能在持续访问正在被覆盖或者删除的内容。由于没有就地更新,而是每次修改总创建一个新版本,因此数据库可以以较小的运行代价来维护一致性快照。

2.2.3 索引与快照级别隔离

多版本数据库该如何支持索引呢?

  1. 索引直接指向对象的所有版本
            想办法过滤对当前事务不可见的那些版本。当后台的垃圾回收进程决定删除某个旧对象版本时,对应的索引条目也需要随之删除
  2. 追加式的B-tree
            当需要更新时,不会修改现有的页面,而总是创建一个新的修改副本,拷贝必要的内容,然后让父节点,或者递归向上直到树的root节点都指向新创建的节点。那些不受更新影响的页面都不需要复制,保持不变并被父节点所指向

采用追加式的B-tree,每个写入事务(或一批事务)都会创建一个新的B-tree root,代表该时刻数据库的一致性快照。这时就没有必要根据事务ID再去过滤掉某些对象,每笔写入都会修改现有的B-tree,因为之后的查询可以直接作用于特定快照B-tree(有利于查询性能)。采用这种方法依然需要后台进程来执行压缩和垃圾回收。

2.2.4 可重复读与命名混淆

快照级别隔离对于只读事务特别有效。但是,具体到实现,许多数据库却对它有着不同的命名。Oracle称之为可串行化,PostgreSQL和MySQL则称为可重复读。
        SQL标准对隔离级别的定义还是存在一些缺陷,结果是我们已经搞不清楚“可重复读”究竟代表什么了。

2.3 防止更新丢失

读-提交快照级别隔离主要都是为了解决只读事务遇到并发写时可以看到什么的问题。两个写事务并发的情况还没有触及,脏写只是写并发的一个特例。
        发生更新丢失冲突的场景:

  • 递增计数器,或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
  • 对某复杂对象的一部分内容执行修改,例如对JSON文档中一个列表添加新元素(需要读取并解析文档,执行更改并写回修改后的文档)
  • 两个用户同时编辑wiki页面,且每个用户都尝试将整个页面发送到服务器,覆盖数据库中现有内容以使更改生效

并发写事务冲突是一个普遍的问题,目前存在多种可行的解决方案。

2.3.1 原子写操作

许多数据库提供了原子更新操作,以避免在应用层代码完成“读-修改-写回”操作,如果支持的话,通常这就是最好的解决方案。
        原子操作通常采用对象读取对象加独占锁的方式来实现,这样在更新被提交之前不会有其他事务可以读它。这种技术有时被称为游标稳定性。另一种实现方式是强制所有的原子操作都在单线程上执行。

2.3.2 显式加锁

如果数据库不支持内置原子操作,另一种防止更新丢失的方法是由应用程序显式锁定待更新的对象。

2.3.3 自动检测更新丢失

原子操作和锁都是通过强制“读-修改-写回”操作序列串行执行来防止丢失更新。另外一种思路则是先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的“读-修改-写回”方式。
        更新丢失检测是一个非常赞的功能,应用层代码因此不用依赖于某些特殊的数据库功能。开发者可能会不小心忘记使用锁或原子操作,但更新丢失检测会自动生效,有效地避免这类错误。

2.3.4 原子比较和设置

在不提供事务支持的数据库中,有时你会发现它们支持原子“比较和设置”操作。使用该操作可以避免丢失更新,即只有在上次读取的数据没有发生变化时才允许更新;如果已经发生了变化,则回退到“读-修改-写回”方式。

2.3.5 冲突解决与复制

加锁和原子修改都有个前提即只有一个最新的数据副本。 然而,对于多主节点或者无主节点的多副本数据库,由于支持多个并发写,且通常以异步方式来同步更新,所以会出现多个最新的数据副本。此时加锁和原子比较将不在适用。(第九章讨论“线性化”
        多副本数据库通常支持多个并发写,然后保留多个冲突版本(互称为兄弟),之后由应用层逻辑或依靠特定的数据结构来解决、合并多版本。

2.4 写倾斜与幻读

当多个事务同时写入同一对象时引发了两种竞争条件,即前面章节所讨论的脏写和更新丢失。为了避免数据不一致,需要借助数据库的一些内置机制,或者采取手动加锁、执行原子操作等。

2.4.1 定义写倾斜

写倾斜既不是一种脏写,也不是更新丢失,两笔事务更新的是两个不同的对象。 这里的写冲突并不那么直接,但很显然这的确是某种竞争状态;
        可以将写倾斜视为一种更广义的更新丢失问题。即如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务可能更新不同的对象,则可能发生写倾斜;而不同的事务如果更新的是同一个对象,则可能发生脏写或更新丢失(具体取决于时间窗口)
        已经有了多种防范更新丢失的手段。然而对于写倾斜,可选的方案则有很多限制:

  • 由于涉及多个对象,单对象的原子操作不起作用
  • 基于快照级别隔离来实现更新丢失自动检测也有问题,自动防止写倾斜要求真正的可串行化隔离
  • 目前大多数数据库不支持对多个对象的约束,开发者可能可以采用触发器或物化视图来自己实现类似约束
  • 如果不能使用可串行化级别隔离,一个次优的选择是对事务依赖的行来显式的加锁

2.4.2 更多写倾斜的例子

  • 会议室预订系统
  • 多人游戏
  • 声明一个用户名
  • 防止双重开支

2.4.3 为何产生写倾斜

上述所有写倾斜的例子都遵循以下类似的模式;

  1. 首先输入一些匹配条件,即采用SELECT查询所有满足条件的行
  2. 根据查询的结果,应用层代码来决定下一步的操作
  3. 如果应用程序决定继续执行,它将发起数据库写入(INSERT,UPDATE或DELETE)并提交事务

而这个写操作会改变步骤2做出决定的前提条件。 上述步骤可能有不同的执行顺序,例如,可以先写入,然后是SELECT查询,最后根据查询来据欸的那个是否提交或者放弃。
        这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读。快照级别隔离可以避免只读查询时的幻读,但是对于我们上面讨论那些读-写事务,它却无法解决棘手的写倾斜问题。

2.4.4 实体化冲突

如果问题的关键是查询结果中没有对象(空)可以加锁,或许可以人为引入一些可加锁的对象?
        例如,对于会议室预定的例子,构造一个时间-房间表,表的每一行对应于特定时间段(例如最小15分钟间隔)的特定房间。现在,预定事务可以查询并锁定(SELECT FOR UPDATE)表中与查询房间和时间段所对应的行。加锁之后,即可检查是否有重叠,然后像之前一样插入新的预定。注意,这种附加表格并不存储预定相关的信息,它仅仅用于方便加锁,防止同一房间和时间范围的重复预定。
        这种方法称为实体化冲突(或物化冲突),它把幻读问题转变为针对数据库中一组具体行的锁冲突问题。然而,弄清楚如何实现实体化往往也具有挑战性,实现过程也容易出错,这种把一个并发机制降级为数据模型的思路总是不够优雅。 出于这些原因,除非万不得已,没有其他可选方案,我们不推荐采用实体化冲突。而在大多数情况下,可串行化隔离方案更为可行。

3 串行化

面临以下挑战:

  • 隔离级别通常难以理解,而且不同的数据库的实现不尽一致
  • 如果去检查应用层代码,往往很难判断它在特定的隔离级别下是否安全,特别是对于大型应用系统,几乎无法预测所有可能并发情况
  • 同时,还缺乏好的工具来帮助检测竞争状况。理论上,静态分析可能有所帮助,当更多的还只是学术研究缺乏实用性。测试并发性问题往往效率很低,一切取决于时机,它只有在特定的情景下才会出现,存在很大的不确定性

目前大多数提供可串行化的数据库都使用了以下三种技术之一:

  • 严格按照串行顺序执行
  • 两阶段锁定,几十年来这几乎是唯一可行的选择
  • 乐观并发控制技术,例如可串行化的快照隔离

3.1 实际串行执行

解决并发问题最直接的方法是避免并发:即在一个线程上按顺序方式每次只执行一个事务。 这样我们完全回避了诸如检测、防止事务冲突等问题,其对应的隔离级别一定是严格串行化的。
        以下两方面的进展使得单线程的性能能够和多线程相提并论:

  • 内存越来越便宜,现在许多应用可以将整个活动数据集都加载到内存中。当事务所需的所有数据都在内存中时,事务的执行速度要比等待磁盘I/O快得多
  • 数据库设计人员意识到OLTP事务通常执行很快,只产生少量的读写操作。相比之下,运行时间较长的分析查询则通常是只读的,可以在一致性快照(使用快照隔离)上运行,而不需要运行在串行主循环里

单线程的吞吐量上限是单个CPU核的吞吐量。为了充分利用单线程,相比于传统形式,事务也需要做出相应调整。

3.1.1 采用存储过程封装事务

交互式的事务处理,大量时间花费在应用程序与数据库之间的网络通信。采用单线程串行执行的系统往往不支持交互式的多语句事务。应用程序必须提交整个事务代码作为存储过程打包发送到数据库。把事务所需的所有数据全部加载在内存中,使存储过程高效执行,而无需等待网络或磁盘I/O。

3.1.2 存储过程的优缺点

缺点: 存储过程语言发展缓慢,语义丑陋,缺乏常见的函数库;在数据库中运行代码难以管理;性能瓶颈。
        存储过程与内存式数据存储使得单线程上执行所有事务变得可行。它们不需要等待I/O,避免加锁开销等复杂的并发控制机制,可以得到相当不错的吞吐量。
        VoltDB还借助存储过程 来执行复制:它并非将事务的执行结果从一个节点复制到另一个节点,而是在每个副本上都执行相同的过程。因此,VoltDB要求存储过程必须式确定性的(即不同的节点上运行时,结果必须完全相同)。如果事务需要获得当前的日期和时间等,必须通过专门的确定性API来实现。

3.1.3 分区

如果你能找到一个方法来对数据集进行分区,使得每个事务只在单个分区内读写数据,这样每个分区都可以有自己的事务处理线程且独立运行。此时为每个CPU核分配一个分区,则数据库的总体事务吞吐量可以达到与CPU核的数量成线性比例关系。
        但是,对于跨分区的事务,数据库必须在涉及的所有分区之间协调事务。存储过程需要跨越所有分区加锁执行,以保证整个系统的可串行化。、
        事务是否只能在单分区上执行很大程度上取决于应用层的数据结构。简单的键-值数据比较容易切分,而带有多个二级索引的数据则需要大量的跨区协调,因此不太合适。

3.1.4 串行执行小结

当满足以下约束条件时,串行执行事务可以实现串行化隔离:

  • 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行性能
  • 仅限于活动数据集完全可以加载到内存的场景。有些很少访问的数据可能会被转移到磁盘,但万一单线程事务需要访问它,就会严重拖累性能
  • 写入吞吐量必须足够低,才能在单个CPU核上处理,否则就需要采用分区,最好没有跨分区事务
  • 跨分区事务虽然也可以支持,但是占比必须很小

3.2 两阶段加锁

近三十年来,可以说数据库只有一种被广泛使用的串行化算法,那就是两阶段加锁(two-phase locking,2PL)
        2PL不是2PC 最然两阶段加锁(2PL)听起来和两阶段提交(two-phase commit,2PC)很相近,但是它们是完全不同的东西。
        两阶段加锁方式锁的强制性更高。多个事务可以同时读取同一对象,但只要出现任何写操作(包括修改或删除),则必须加锁以独占访问:

  • 如果事务A已经读取了某个对象,此时事务B想要写入该对象,那么B必须等到A提交或中止才能继续。以确保B不会在事务A执行的过程中间去修改对象
  • 如果事务A已经修改了对象,此时事务B想要读取该对象,则B必须等到A提交或中止之后才能继续。对于2PL,不会出现读到旧值得情况

3.2.1 实现两阶段加锁

数据库的每个对象都有一个读写锁来隔离读写操作。即锁可以处于共享模式或独占模式。基本用法如下:

  • 如果事务要读取对象,必须先以共享模式获得锁。可以有多个事务同时获得一个对象的共享锁,但是如果某个事务已经获得了对象的独占锁,则所有其他事务必须等待
  • 如果事务要修改对象,必须以独占模式获得锁。不允许多个事务同时持有该锁(包括共享或独占模式),换言之,如果对象上已被加锁,则修改事务必须等待
  • 如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁。升级锁的流程等价于直接获得独占锁
  • 事务获得锁之后,一直持有锁直到事务结束(包括提交或中止)。这也是名字“两阶段”的来由,在第一阶段即事务执行之前要获取锁,第二阶段(即事务结束时)则释放锁

3.2.2 两阶段加锁的性能

事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。

3.2.3 谓词锁

可串行化隔离也必须防止幻读问题。谓词锁(属性谓词锁) 的作用类似于之前描述的共享/独占锁,而区别在于,它并不属于某个特定的对象(如表的某一行),而是作用于满足某些搜索条件的所有查询对象。
        谓词锁会限制如下访问:

  • 如果事务A想要读取某些满足匹配条件的对象,例如采用SELECT查询,它必须以共享模式获得查询条件的谓词锁。如果另一个事务B正持有任何一个匹配对象的互斥锁,那么A必须等到B释放锁之后才能继续执行查询
  • 如果事务A想要插入、更新或删除任何对象,则必须首先检查所有旧值和新值是否与现有的任何谓词锁匹配(即冲突)。如果事务B持有这样的谓词锁,那么A必须等到B完成提交(或中止)后才能继续

这里的关键点在于,谓词锁甚至可以保护数据库中那些尚不存在但可能马上会被插入的对象(幻读)。将两阶段加锁与谓词锁结合使用,数据库可以防止所有形式的写倾斜以及其他竞争条件,隔离变得真正可串行化。

3.2.4 索引区间锁

索引区间锁本质上就是对谓词锁的简化或者近似。 简化谓词锁的方式是将其保护的对象扩大化,首先这肯定是安全的。如果没有合适的索引可以施加区间锁,则数据库可以退化到锁定整个表。 但的确可以保证安全性。
        举例:对于房间预定数据库,可以通过扩大时间段或者扩大房间来扩大对象。通常会在room_id列上创建索引,和/或在start_time和end_time上有索引(否则前面的查询在大型数据库上会很慢):

  • 假设索引位于room_id上,数据库使用此索引查找123号方将的当前预定情况。现在,数据库可以简单地将共享锁附加到此索引条目,表明事务已搜索了123号房间的所有时间段预定
  • 或者,如果数据库使用基于时间的索引来查找预订,则可以将共享锁附加到该索引中的一系列值,表示事务已经搜索了该时间段内的所有值

3.3 可串行化的快照隔离(SSI)

它提供了完整的可串行性保证,而性能相比于快照隔离损失很小。SSI可用于单节点数据库或者分布式数据库,很有可能成为未来数据库的标配。

3.3.1 悲观与乐观的并发控制

两阶段加锁是一种典型的悲观并发控制机制。它基于这样的设计原则:如果某些操作可能出错(例如与其他并发事务出现了所冲突),那么直接放弃,采用等待方式直到绝对安全。这和多线程编程中互斥锁是一致的。
        相比之下,可串行化的快照隔离则是一种乐观并发控制。在这种情况下,如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望一切相安无事;而事务提交时(只有可串行化的事务被允许提交),数据库会检查是否确实发生了冲突(即违反了隔离性原则),如果是的话,中止事务并接下来重试。
        顾名思义,SSI基于快照隔离,也就是说,事务中的所有读取操作都是居于数据库的一致性快照。这是与早期的乐观并发控制主要区别。在快照隔离的基础上,SSI新增加了相关算法来检测写入之间的串行化冲突从而决定中止哪些事务。

3.3.2 基于过期的条件做决定

数据库如何知道查询结果是否发生了改变呢?可以分为以下两种情况:

  • 读取是否作用于一个(即将)过期的MVCC对象(读取之前已经有未提交的写入)
  • 检查写入是否影响即将完成的读取(读取之后,又有新的写入)

3.3.3 检测是否读取了过期的MVCC对象

数据库需要跟踪那些由于MVCC可见性规则而被忽略的写操作。当事务提交时,数据库会检查是否存在一些当初被忽略的写操作现在已经完成了提交,如果是则必须中止当前事务。至于为什么要等到提交:当检测到读旧值,为何不立即中止事务? 是因为这样可以最大成的提高性能,只读事务没有任何写倾斜风险不必中止。通过减少不必要的中止,SSI可以高效支持那些需要在一致性快照中运行很长时间的读事务。

3.3.4 检测写是否影响了之前的读

SSI锁和索引区间锁的差异:SSI锁不会阻塞其他事务。 当另一个事务尝试修改时,他首先检查索引,从而确定是否最近存在一些读目标数据的其他事务。这个过程类似于在受影响的字段范围上获取写锁,但它并不会阻塞读取,而是直到读事务提交时才进一步通知他们:所读到的数据现在已经发生了变化。

3.3.5 可串行化快照隔离的性能

总体讲,相比于两阶段加锁与串行执行,SSI更能忍受那些执行缓慢的事务。

小结

事务作为一个抽象层,使得应用程序可以忽略数据库内部一些复杂的并发问题,以及某些硬件、软件故障,从而简化应用层的处理逻辑,大量的错误可以转化为简单的事务中止和应用层重试。
        本章,我们深入探讨了并发控制这一主题。介绍了多个广泛使用的隔离级别,特别是读-提交,快照隔离(或可重复读取)与可串行化。通过分析如何处理边界条件来阐述这些隔离级别的要点:

  • 脏读
            客户端读到了其他客户端尚未提交的写入。读-提交以及以及更强的隔离级别可以防止脏读
  • 脏写
            客户端覆盖了另一个客户端尚未提交的写入。几乎所有的数据库实现都可以防止脏写
  • 读倾斜(不可重复读)
            客户在不同的时间点看到了不同值。快照隔离是最有用的防范手段,即事务总是在某个时间点的一致性快照中读取数据。通常采用多版本并发版本(MVCC)来实现快照隔离
  • 更新丢失
            两个客户端同时执行读-修改-写入操作序列,出现了其中一个覆盖了另一个的写入,但又没有包含对方最新值得情况,最终导致了部分修改数据发生了丢失。快照隔离得一些实现可以自动防止这种异常,而另一些则需要手动锁定查询结果(SELECT FOR UPDATE
  • 写倾斜
            事务首先查询数据,根据返回的结果而做出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。只有可串行化的隔离才能防止这种异常
  • 幻读
            事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。快照隔离可以防止简单的幻读,但写倾斜情况则需要特殊处理,例如采用区间范围锁

弱隔离级别可以防止上面的某些异常,但还需要应用开发人员手动处理其他复杂情况(例如,显示加锁)。只有可串行化的隔离可以防止所有这些问题。我们主要讨论了实现可串行化隔离的三种不同方法:

  • 严格串行执行事务
            如果每个事务的执行速度非常快,且单个CPU核可以满足事务的吞吐量要求,严格串行执行是一个非常简单有效的方案
  • 两阶段加锁
            几十年来,这一直是实现可串行化的标准方式,但还是有很多系统出于性能原因而放弃使用它
  • 可串行化的快照隔离(SSI)
            一种最新的算法,可以避免前面方法的大部分缺点。他秉持乐观预期的原则,允许多个事务并发执行而不互相阻塞;仅当事务尝试提交时,才检查可能的冲突,如果发现违反了串行化,则某些事务会被中止

无论对哪种数据模型,事务都是非常有用的数据库功能。

DDIA - 第7章 事务相关推荐

  1. mySQL教程 第10章 事务和锁

    第10章 事务和锁 数据库事务(Database Transaction) ,是指作为单个逻辑工作单元执行的一系列操作. 事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据 ...

  2. Spring - Java/J2EE Application Framework 应用框架 第 7 章 事务管理

    第 7 章 事务管理 7.1. Spring事务抽象 Spring提供了一致的事务管理抽象.这个抽象是Spring最重要的抽象之一, 它有如下的优点: 为不同的事务API提供一致的编程模型,如JTA. ...

  3. 数据库系统概念总结:第十四章 事务

    周末无事水文章,期末备考的总结资料 第十四章 事务 14.1 事务概念 事务是访问并可能更新各种数据项的一个程序执行单元 ACID特性 –原子性(Atomicity):事务的所有操作都在数据库中正确反 ...

  4. mysql 事物状态有几种_mysql第三章 事务以及日志

    mysql第三章 事务以及日志 一. 事物简介 每条DDL DCL语句都是事务. 每个begin 到coomit语句是一个事务 二. 事物特性ACID以及开启方式 1. 原子性(A),部成功执行或全部 ...

  5. 《深入理解分布式事务》第一章 事务的基本概念

    <深入理解分布式事务>第一章 事务的基本概念 文章目录 <深入理解分布式事务>第一章 事务的基本概念 一.事务的特性 1.原子性 2.一致性 3.隔离性 4.持久性 二.事务的 ...

  6. 【数据库】第三章 事务、索引和SQL优化

    [数据库]第三章 事务.索引和SQL优化 文章目录 [数据库]第三章 事务.索引和SQL优化 一.事务 1.原子性 2.持久性 3.隔离性 4.一致性 二.索引 1.介绍 2.分类 3.底层实现 4. ...

  7. DDIA - 第8章 分布式系统的挑战

    文章目录 第8章 分布式系统的挑战 1 故障与部分失效 1.1 云计算和超算 2 不可靠的网络 基于不可靠的组件构建可靠的系统 2.1 现实中的网络故障 2.2 检测故障 2.3 超时与无限期的延迟 ...

  8. 【数据库系统概念】第14章 事务 知识总结

    <数据库系统概念>第14章知识点总结 事务 事务是访问并可能更新各种数据项的一个程序执行单元.事务通常由高级数据操作语言或编程语言通过JDBC或ODBC嵌入式数据库访问书写的用户程序的执行 ...

  9. mysql第五章事务_mysql 第五章 备份恢复

    mysql 第五章 备份恢复 一.备份策略***** 1.每周一次全备,每天一次增量备 2.每天检查备份是否成功 3.每季度进行备份恢复演练 4.设置 -master-data=2 (记录备份的GTI ...

最新文章

  1. 怎么看b树是几阶_看我在B站上怎么学习的
  2. 不使用框架的web项目中配置log4j
  3. 找出重复的那个数字的异或算法
  4. Android全屏与透明状态栏
  5. 如何判断序列是不是堆_备胎是什么意思?如何判断自己是不是感情中的备胎?...
  6. hive初识.docx
  7. python动态生成数据库表_Python-Flask:动态创建表的示例详解
  8. python自动化常用模块_Python自动化 【第五篇】:Python基础-常用模块
  9. 一个flash网页图片播放器
  10. html瀑布流下拉刷新,瀑布流下拉刷新 - osc_1wnye1so的个人空间 - OSCHINA - 中文开源技术交流社区...
  11. DOM(四)——事件、事件模型(冒泡)与事件对象的功能
  12. linux端口混杂模式,linux端口混杂模式简介~
  13. 计蒜客NOIP2017提高组模拟赛(三)day2-小区划分
  14. DRL实战 : 强化学习在广告点击业务中的应用
  15. 神经网络软件有哪些,神经网络分析软件
  16. 如何让excel说话,vba的speak功能
  17. 项目踩坑随记 —— getTime() is not a function
  18. 汉信码(Hanxin Code)与QR码(QR Code)的终极对决
  19. Pinta--一个画图软件
  20. 49个学习Python的国外资源

热门文章

  1. 统计拉人进入微信群数量的方法
  2. 修改eclipse皮肤护眼
  3. Android 11新增分区,可读写,恢复出厂设置不清除数据
  4. 译文:Android中糟糕的AsyncTask之停止AsyncTask操作
  5. 智慧人生 仁者见仁 与君共勉
  6. xcode怎么打flutter的release包啊_傲娇撩人表情包:小哥哥,请问这两个字怎么念?老婆!...
  7. Linux下套接字详解(二)----套接字Socket
  8. 美通社企业新闻汇总 | 2019.3.6 | 庞巴迪创公务机航空最远距离直飞纪录;欢聚时代14.5亿美元收购直播平台Bigo...
  9. c语言 return两个值,c语言return能不能有2个以上返回值
  10. 何如才能做好新闻软文营销?