在数据系统中,很多事情都可能出错。为了实现可靠性,系统必须处理这些故障,确保它们不会大致整个系统的灾难性故障。但实现容错机制的工作量巨大。数十年来,事务(transaction) 一直是简化这些问题的首选机制。
事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功 提交(commit),要么失败 中止(abort)或 回滚(rollback)。如果失败,应用程序可以安全地重试。对于事务来说,应用程序的错误处理变得简单多了,因为它不用再担心部分失败的情况了,即某些操作成功,某些失败(无论出于何种原因)。
事物不是天然存在的;它们是为了 简化应用编程模型 而创建的。通过使用事务,应用程序可以自由地忽略某些潜在的错误情况和并发问题,因为数据库会替应用处理好这些。(我们称之为 安全保证,即 safety guarantees)。
并不是所有的应用都需要事务,有时候弱化事务保证、或完全放弃事务也是有好处的(例如,为了获得更高性能或更高可用性)。一些安全属性也可以在没有事务的情况下实现。
事物的棘手概念
ACID的含义
ACID 代表 原子性(Atomicity) ,一致性(Consistency) ,隔离性(Isolation) 和 持久性(Durability) 。它由 Theo Härder 和 Andreas Reuter 于 1983 年提出,旨在为数据库中的容错机制建立精确的术语。
但实际上,不同数据库的 ACID 实现并不相同。
原子性
将一组数据库操作看成一个操作,这一组操作要么全部成功,要么全局失败。通过提供 “宁为玉碎,不为瓦全(all-or-nothing) ” 的保证,免去了用户对部分失败的担忧。如果事务被 中止(abort) ,应用程序可以确定它没有改变任何东西,所以可以安全地重试。
ACID 原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。 或许 可中止性(abortability) 是更好的术语。
一致性
ACID 一致性的概念是,对数据的一组特定约束必须始终成立。即 不变式(invariants) 。
但是,一致性的这种概念取决于应用程序对不变式的理解,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情,如果写入违反不变式的脏数据,数据库也无法阻止你(数据库只管存储)。
原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID。
💡 乔・海勒斯坦(Joe Hellerstein)指出,在 Härder 与 Reuter 的论文中,“ACID 中的 C” 是被 “扔进去凑缩写单词的”【7】,而且那时候大家都不怎么在乎一致性。隔离性
多个事务同时访问相同的数据库记录,可能会遇到并发问题(竞争条件,即 race conditions)。
ACID 意义上的隔离性意味着,同时执行的事务是相互隔离的:它们不能相互冒犯。
实现隔离的方式有:
- 串行化:事务一个一个执行
- 快照隔离(snapshot isolation) :比串行化更弱的保证
持久性
持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。
- 在单节点数据库中,持久性意味着数据已经被写入非易失性存储设备。如硬盘或SSD、预写日志或类似文件。
- 在带复制的数据库中,持久性意味着数据已经成功复制到一些节点。
为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
完美的持久性是不存在的 :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。
单对象操作和多对象操作
回顾以下,,在 ACID 中,原子性和隔离性描述了客户端在同一事务中执行多次写入时,数据库应该做的事情:
-
原子性
提供 “宁为玉碎,不为瓦全(all-or-nothing) ” 的保证。
-
隔离性
同时运行的事务不应该互相干扰。
这些定义假设你想同时修改多个对象。这通常需要**多对象事务(multi-object transaction)**来保持多块数据同步。
多对象事务通常需要某种方式来确定哪些读写操作属于一个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,BEGIN TRANSACTION 和 COMMIT 语句之间的所有内容,被认为是同一事务的一部分
单对象写入
当单个对象发生改变时,原子性和隔离性也是适用的。存储引擎普遍的目标是:对单节点上的单个对象上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复,并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程对象访问)。
💡 单对象操作也需要原子性和隔离性:例如,假设你正在向数据库写入一个 20 KB 的 JSON 文档:
- 如果在发送第一个 10 KB 之后网络连接中断,数据库是否存储了不可解析的 10KB JSON 片段?
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
单对象操作,可以防止在多个客户端尝试同时写入同一个对象时丢失更新,但它们不是通常意义上的事务。事务通常被理解为,将多个对象上的多个操作合并为一个执行单元的机制。
多对象事务的需求
我们是否需要多对象事务?是否有可能只用键值数据模型和单对象操作来实现任何应用程序?
有些场景需要协调写入几个不同的对象:
- 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确保这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的和最新的,不然数据就没有意义。
- 在具有次级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
这些应用仍然可以在没有事务的情况下实现。然而,没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。
处理错误和中止
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。 ACID 数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。
并不是所有的系统都会遵循这个哲学。特别是无主复制数据库,运行遇到错误时,它不会撤销它已经完成的事情——所以,从错误中恢复是程序的责任。
重试一个中止的事务虽然是一个简单而有效的错误处理机制,但并不完美:
- 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次 —— 除非你有一个额外的应用级去重机制。
- 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,两阶段提交(2PC, two-phase commit) 可以提供帮助(“原子提交与两阶段提交” 中将讨论这个问题)。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
弱隔离级别
比起盲目地依赖工具,我们需要对存在的各种并发问题,以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序。
读已提交
最基本的事务隔离级别是 读已提交(Read Committed) [^v],它提供了两个保证:
- 从数据库读时,只能看到已提交的数据(没有 脏读,即 dirty reads)。
- 写入数据库时,只会覆盖已经写入的数据(没有 脏写,即 dirty writes)。
没有脏读
脏读(dirty reads): 读到了另一个事务没有提交的数据**。**
读已提交 需要防止脏读,意味着事务的任何写入操作只有在该事务提交时才能被其他事务看到。
为什么要防止脏读,有几个原因:
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。
- 如果事务中止,则所有写入操作都需要回滚。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。
没有脏写
**脏写(dirty write):**一个事务的写入覆盖了另一个事务尚未提交的写入。
在 读已提交 的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。
通过防止脏写,这个隔离级别避免了一些并发问题:
- 如果事务更新多个对象,脏写会导致不好的结果。
实现读已提交
如何防止脏写?
数据库通常使用**行锁(row-level lock)**来防止脏写:修改数据之前需要先获取行锁,事务提交或中止之后才释放行锁;同时只能有一个事务持有行锁。
如何防止脏读?
-
方法1:使用行锁,读取时需要获取行锁,读取到之后立即释放行锁。(写入的时候持有锁,这时无法读取,因此可以避免脏读)
- 效率差:慢写入事务会影响只读事务的响应时间。
-
方法2:对于每个写入的对象,数据库会记录已提交的值,和由当前持有写入锁事务设置的性质。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
数据库需要保留一个对象的两个版本:提价的版本和被覆盖但尚未提交的版本。
快照隔离和可重复读
读已提交可能会产生的问题:不可重复读(nonrepeatable read) 或 读取偏差(read skew)
图 7-6 读取偏差:Alice 观察数据库处于不一致的状态
有些情况下,暂时的不可重复读是可接受的。但有些情况下,不能容忍这种暂时的不一致:
-
备份
进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。
-
分析查询和完整性检查
有时,你可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见,也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
**快照隔离(snapshot isolation)**是这个问题最常见的解决方案。想法是,每个事务都从数据库的 一致快照(consistent snapshot) 中读取 —— 也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照,理解起来就很容易了。
实现快照隔离
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写,这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。
为了实现快照隔离,数据库使用了用于防止的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为 多版本并发控制(MVCC, multi-version concurrency control) 。
如果一个数据库只需要提供 读已提交 的隔离级别,而不提供 快照隔离 ,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用 MVCC 来实现 读已提交 隔离级别。一种典型的方法是 读已提交 为每个查询使用单独的快照,而 快照隔离 对整个事务使用相同的快照。
💡 在 PostgreSQL 中实现基于 MVCC 的快照隔离: 事务开始时,被赋予一个唯一的,单调增长的事务ID(`txid`)。每当事务向数据写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。一致性快照的可见性规则
当一个事务从数据库中读取时,事务 ID 用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照。工作如下:
- 在每次事务开始时,数据库列出当时所有其他(尚未提交或尚未中止)的事务清单,即使之后提交了,这些事务已执行的任何写入也都会被忽略。
- 被中止事务所执行的任何写入都将被忽略。
- 由具有较晚事务 ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
- 所有其他写入,对应用都是可见的。
这些规则适用于创建和删除对象。
换句话说,如果以下两个条件都成立,则可见一个对象:
- 读事务开始时,创建该对象的事务已经提交。
- 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。由于从来不原地更新值,而是每次值改变时创建一个新的版本,数据库可以在提供一致快照的同时只产生很小的额外开销。
索引和快照隔离
索引如何在多版本数据库中工作?
-
方法1:使索引简单地指向对象的所有版本,同一对象的不同版本可以放入同一个页面中,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。
当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
-
方法2:**仅追加 / 写时拷贝(append-only/copy-on-write):**每个写入事务(或一批事务)都会创建一颗新的 B 树,该特定树根生长的树就是数据库的一个一致性快照。 没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。这种方法也需要一个负责压缩和垃圾收集的后台进程。
可重复度与命名混淆
快照隔离是一个有用的隔离级别,特别对于只读事务而言。但是,许多数据库实现了它,却用不同的名字来称呼。在 Oracle 中称为 可串行化(Serializable) 的,在 PostgreSQL 和 MySQL 中称为 可重复读(repeatable read)。
这种命名混淆的原因是 SQL 标准没有 快照隔离 的概念,因为标准是基于 System R 1975 年定义的隔离级别【2】,那时候 快照隔离 尚未发明。相反,它定义了 可重复读,表面上看起来与快照隔离很相似。 PostgreSQL 和 MySQL 称其 快照隔离 级别为 可重复读(repeatable read) ,因为这样符合标准要求,所以它们可以声称自己 “标准兼容”。
防止丢失更新
读已提交 和 快照隔离 级别,主要保证了 只读事务在并发写入时 可以看到什么。却忽略了两个事务并发写入的问题 —— 我们只讨论了脏写,一种特定类型的写 - 写冲突是可能出现的。
并发写入可能出现的问题:
-
脏写
-
丢失更新(lost update) :如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改
两个客户之间的竞争状态同时递增计数器
丢失更新的解决方案如下:
原子写
在数据库层面提供原子更新操作,消除了在应用程序代码中执行读取-修改-写入数据的需要。
对于以下操作,原子写通常是最好的解决方案:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
原子写的几种实现方式:
- 排它锁。读取对象时,获取其上的排它锁,以便更新完成之前没有其他事务可以读取它。这种技术有时被称为 游标稳定性(cursor stability)
- 强制所有的原子操作在单一线程上执行
显示锁定
显示锁定:让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取 - 修改 - 写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个 读取 - 修改 - 写入序列 完成。
自动检测丢失的更新
原子操作和锁是通过强制 读取 - 修改 - 写入序列 按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其 读取 - 修改 - 写入序列。
💡 这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB 的可重复读并不会检测 **丢失更新** 【23】。一些作者【28,30】认为,数据库必须能防止丢失更新才称得上是提供了 **快照隔离**,所以在这个定义下,MySQL 下不提供快照隔离。 丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入错误;但丢失更新的检测是自动发生的,因此不太容易出错。比较并设置(CAS)
在不提供事务的数据库中,有一种原子操作:比较并设置(CAS,即 Compare And Set)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取 - 修改 - 写入序列
冲突解决和复制
在复制数据库中,防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
锁和 CAS 操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证只有一个最新数据的副本。所以基于锁或 CAS 操作的技术不适用于这种情况。
复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
最后写入胜利(LWW)的冲突解决方法很容易丢失更新。不幸的是,LWW 是许多复制数据库中的默认方案。
写入偏斜和幻读
并发写入间可能发生的另一种竞争条件:写入偏差。
场景:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作。现在想象一下,Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。图 7-8 说明了接下来的事情。
图 7-8 写入偏差导致应用程序错误的示例
在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 ,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了,而 Bob 也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。
写偏差的特征
这种异常称为 **写偏差。**它既不是 脏写,也不是 丢失更新,因为这两个事务正在更新两个不同的对象(Alice 和 Bob 各自的待命记录)。在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能。
可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时序)。
对于写偏差,防止选择受限:
- 由于涉及多个对象,单对象的原子操作不起作用
- 在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。自动防止写入偏差需要真正的可串行化隔离
- 某些数据库允许配置约束,然后由数据库强制执行。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库。
- 如果无法使用可串行化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。
写偏差的更多例子
-
会议室预定系统
BEGIN TRANSACTION; -- 检查所有现存的与 12:00~13:00 重叠的预定 SELECT COUNT(*) FROM bookings WHERE room_id = 123 AND end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00'; -- 如果之前的查询返回 0,则预定会议 INSERT INTO bookings(room_id, start_time, end_time, user_id) VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666); COMMIT;快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可串行化的隔离级别了。
-
多人游戏
我们可以使用一个锁来防止丢失更新(也就是确保两个完结不能同时移动一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照你正在执行的规则类型,也许可以使用唯一约束(unique constraint),否则你很容易发生写入偏差。
-
抢注用户名
在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。
-
防止双重开支
允许用户花钱或积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值。有了写入偏差,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。
导致写入偏差的幻读
以上所以例子都遵循类似的模式:
-
一个
SELECT查询找出符合条件的行,并检查是否符合一些要求。 -
按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
-
如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
这个写入的效果改变了步骤 2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤 1 的 SELECT 查询,将会得到不同的结果。因为写入改变了符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行 SELECT 查询,最后根据查询结果决定是放弃还是提交。
在医生值班的例子中,在步骤 3 中修改的行,是步骤 1 中返回的行之一,所以我们可以通过锁定步骤 1 中的行(SELECT FOR UPDATE)来使事务安全并避免写入偏差。但是其他四个例子是不同的:它们检查是否 不存在 某些满足条件的行,写入会 添加 一个匹配相同条件的行。如果步骤 1 中的查询没有返回任何行,则 SELECT FOR UPDATE 锁不了任何东西。
这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为 **幻读。**快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻读会导致特别棘手的写入偏差情况。
物化冲突
如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?
**物化冲突(materializing conflicts):**将幻读变为数据库中一组具体行上的锁冲突。弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。可串行化(Serializable) 的隔离级别是更可取的。
可串行化
可串行化(Serializability) 隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止 所有 可能的竞争条件。
可串行的三种实现方法:
- 字面意义上地串行顺序执行事物
- 两阶段锁定(2PL, two-phase locking)
- 乐观并发控制技术,例如 可串行化快照隔离(serializable snapshot isolation)
真的串行化
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测 / 防止事务间冲突的问题,由此产生的隔离,正是可串行化的定义。
多线程被认为是获得良好性能的关键所在,那么是什么让改变至单线程执行成为可能呢?
- RAM足够便宜了,许多场景都可以将活跃数据集保存在内存中。事物处理的执行速度要比等待数据从磁盘加载时快得多。
- OLTP是通常很短,而且只进行少量的读写操作。
串行事物的优点:
- 避免锁的协调开销
在存储过程中封装事物
在交互式的事物方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许并发处理,则吞吐量会非常低,因为数据库大部分的时间都花费在等待应用程序发出当前事物的下一个查询。
出于这个原因,单线程串行事物处理的系统不允许交互式的多语句事物。应用程序必须提前将整个事物代码作为存储过程提交给数据库。
存储过程的优点和缺点
缺点:
- 每个数据库都有自己的存储过程语言。
- 在数据库中运行的代码难以管理、调试
优点:
- 不需要等待 I/O,且避免了并发控制机制的开销。它们可以在单个线程上实现相当好的吞吐量。】
分区
对于写入吞吐量较高的应用,单线程事物处理器可能成为一个严重的瓶颈。
为了伸缩至多个CPU核心和多个节点,可以对数据库进行分区。如果每个事物只需要在的单个分区中进行读写,那么每个分区就可以拥有自己独立运行的事物处理线程。这种情况下可以为每个分区指派一个独立的CPU核,事物吞吐量就可以与CPU数保持线性伸缩。
但对于跨分区的事物,具有额外的协调开销,会大大降低吞吐量。
串行执行小结
在特定约束条件下,真的串行执行事务,已经成为一种实现可串行化隔离等级的可行办法。
-
每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
-
仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢
💡 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为 **反缓存(anti-caching)** -
写入吞吐量必须低到能在单个 CPU 核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
-
跨分区事务是可能的,但是它们能被使用的程度有很大的限制。
两阶段锁定
💡 大约 30 年来,在数据库中只有一种广泛使用的串行化算法:**两阶段锁定(2PL,two-phase locking).** 有时也称为 **严格两阶段锁定(SS2PL, strong strict two-phase locking)** ,以便和其他 2PL 变体区分。 💡 **[2PL不是2PC](http://ddia.vonng.com/#/ch7?id=_2pl%e4%b8%8d%e6%98%af2pc)**请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。
锁通常用于防止脏写:如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。两阶段锁定类似,但是锁的要求要强很多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 独占访问(exclusive access) 权限:
- 如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续(这确保 B 不能在 A 底下意外地改变对象)。
- 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续
在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得 **读不阻塞写,写也不阻塞读,这是 2PL 和快照隔离之间的关键区别。**因为 2PL 提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
实现两阶段锁
2PL 用于 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于 共享模式(shared mode) 或 独占模式(exclusive mode) 。锁使用如下:
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是 “两阶段” 这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务 A 等待事务 B 释放它的锁,反之亦然。这种情况叫做 死锁(Deadlock) 。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
两阶段锁定的性能
缺点:
- 由于并发性降低,以及获取和释放锁的开销,导致吞吐量下降、响应时间增加。
- 延迟不稳定。大事物会拖慢整个系统。
- 频繁死锁会产生巨大的浪费
谓词锁
**谓词锁(predicate lock)**类似于共享 / 排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象。
谓词锁适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
索引范围锁
谓词锁有性能问题:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。
因此,大多数使用 2PL 的数据库实际上实现了索引范围锁(index-range locking,也称为 next-key locking),这是一个简化的近似版谓词锁。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
如果没有可以挂载范围锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
可串行化快照隔离
**可串行化快照隔离(SSI, serializable snapshot isolation)**算法提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。
悲观与乐观的并发控制
两阶段锁是一种所谓的 悲观并发控制机制(pessimistic) :它是基于这样的原则:如果有事情可能出错,最好等到情况安全后再做任何事情。这就像互斥,用于保护多线程编程中的数据结构。
从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,我们让每笔事务执行得非常快,所以只需要短时间持有 “锁”。
相比之下,串行化快照隔离 是一种 乐观(optimistic) 的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。
如果存在很多 争用(contention,即很多事务试图访问相同的对象),则表现不佳,因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量,来自重试事务的额外负载可能会使性能变差。
但是,如果有足够的备用容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的要好。
SSI 基于快照隔离 —— 也就是说,事务中的所有读取都是来自数据库的一致性快照。在快照隔离的基础上,SSI 添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。
基于过时前提的决策
事务基于一个 前提(premise) 采取行动。当事务要提交时,原始数据可能已经改变 —— 前提可能不再成立。
事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
- 检测对旧 MVCC 对象版本的读取(读之前存在未提交的写入)
- 检测影响先前读取的写入(读之后发生写入)
检测旧MVCC读取
数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
检测影响之前读取的写入
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务直到其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
可串行化快照隔离的性能
- 与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。这种设计原则使得查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
- 与串行执行相比,可串行化快照隔离并不局限于单个 CPU 核的吞吐量。即使数据可能跨多台机器进行分区,事务也可以在保证可串行化隔离等级的同时读写多个分区中的数据
- 中止率显著影响 SSI 的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此 SSI 要求同时读写的事务尽量短(只读的长事务可能没问题)。对于慢事务,SSI 可能比两阶段锁定或串行执行更不敏感。