在数据系统的残酷现实中,很多事情都可能出错:
- 数据库软件、硬件可能在任意时刻发生故障(包括写操作进行到一半时)。
- 应用程序可能在任意时刻崩溃(包括一系列操作的中间)。
- 网络中断可能会意外切断数据库与应用的连接,或数据库之间的连接。
- 多个客户端可能会同时写入数据库,覆盖彼此的更改。
- 客户端可能读取到无意义的数据,因为数据只更新了一部分。
- 客户之间的竞争条件可能导致令人惊讶的错误。
数十年来,事务(transaction) 一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(提交(commit))要么失败(中止(abort),回滚(rollback))。如果失败,应用程序可以安全地重试。
事务的棘手概念
- 几乎所有的关系型数据库和一些非关系数据库都支持事务。
- 2000 年以后,非关系(NoSQL)数据库开始普及。很多新一代数据库放弃了或者弱化了事务。
- 事务是一种权衡
ACID的含义
ACID代表原子性(Atomicity),一致性(Consistency),隔离性(Isolation)和持久性(Durability)。它由TheoHärder和Andreas Reuter于1983年创建,旨在为数据库中的容错机制建立精确的术语。
不符合ACID标准的系统有时被称为BASE,它代表基本可用性(Basically Available),软状态(Soft State)和最终一致性(Eventual consistency),这比ACID的定义更加模糊,似乎BASE的唯一合理的定义是“不是ACID”,即它几乎可以代表任何你想要的东西。
- ACID原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。 或许 可中止性(abortability) 是更好的术语
- CID一致性的概念是,对数据的一组特定约束必须始终成立。即不变量(invariants):一致性不属于数据库的属性,这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。—— 数据库只管存储。原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID
- ACID意义上的隔离性意味着,同时执行的事务是相互隔离的:它们不能相互冒犯。同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。
- 持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”
单对象和多对象操作
用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。
如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚
原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致
多对象事务写入方法?
- 需要某种方式来确定哪些读写操作属于同一个事务。
- 在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,BEGIN TRANSACTION 和 COMMIT 语句之间的所有内容,被认为是同一事务的一部分(不完美)。
- 许多非关系数据库,并没有将这些操作组合在一起的方法。所以可能让数据库处于部分更新的状态。
单对象写入
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档:
- 如果在发送第一个10 KB之后网络连接中断,数据库是否存储了不可解析的10KB JSON片段?
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
实现方法
● 原子性可以通过使用日志来实现崩溃恢复
● 可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象)
更高级的原子操作
- 自增操作。
- 比较和设置(CAS, compare-and-set) 操作,仅当值没有被其他并发修改过时,才允许执行写操作。
上面两种对但对象操作有用,但不是通常意义上的事务。
事务通常被理解为,将多个对象上的多个操作合并为一个执行单元的机制。
多对象事务的需求
为什么许多分布式数据存储已经放弃了多对象事务?
● 多对象事务很难跨分区实现;
● 而且在需要高可用性或高性能的情况下,它们可能会碍事。
我们是否需要多对象事务?是否有可能只用键值数据模型和单对象操作来实现任何应用程序?
● 关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。
● 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,当需要更新非规范化的信息时,需要一次更新多个文档。比如上面邮件未读数目的例子。
● 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。
没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。
处理错误和中止
● 事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。
● ACID数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。
● 但并不是所有的系统都遵循这个哲学。特别是具有无主复制的数据存储,主要是在“尽力而为”的基础上进行工作。——所以,从错误中恢复是应用程序的责任。
重试一个中止的事务并不完美:
● 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障,那么重试事务导致事务执行了两次——除非有额外的应用级除重机制。
● 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。需要限制重试次数、采用指数退避算法。
● 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
● 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。比如更新操作后附带发送电子邮件。(如果想让多个不同的系统同时提交或者放弃,需要两阶段提交。)
● 如果客户端在重试过程中也失败了,并且没有其他人负责重试,那么数据就会丢失。
弱隔离级别
如果两个事务不触及相同的数据,它们可以安全地并行(parallel) 运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。
数据库一直试图通过提供事务隔离(transaction isolation) 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:可序列化(serializable) 的隔离等级意味着数据库保证事务的效果与连续运行(即一次一个,没有任何并发)是一样的。
读已提交
最基本的事务隔离级别是读已提交(Read Committed),它提供了两个保证:
- 从数据库读时,只能看到已提交的数据(没有脏读(dirty reads))。
- 写入数据库时,只会覆盖已经写入的数据(没有脏写(dirty writes))。
没有脏读
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做脏读(dirty reads)
没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值
为什么要防止脏读,有几个原因:
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如在上图中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。
- 如果事务中止,则所有写入操作都需要回滚。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。
没有脏写
如果两个事务同时尝试更新数据库中的相同对象,会发生什么情况?我们不知道写入的顺序是怎样的,但是我们通常认为后面的写入会覆盖前面的写入。
但是,如果先前的写入是尚未提交事务的一部分,又会发生什么情况,后面的写入会覆盖一个尚未提交的值?这被称作脏写(dirty write)。在读已提交的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。
在下图的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。
如果存在脏写,来自不同事务的冲突写入可能会混淆在一起但是,读已提交不能防止本章第一个图中的两个计数器增量之间的竞争状态。第二次写入在第一个事务提交后,所以它不是一个脏写。但结果仍然不正确。后文中“防止更新丢失”中将探讨如何使这种计数器安全递增
实现读已提交
读已提交是一个非常流行的隔离级别。这是Oracle 11g,PostgreSQL,SQL Server 2012,MemSQL和其他许多数据库的默认设置
最常见的情况是,数据库通过使用行锁(row-level lock) 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。这种锁定是读已提交模式(或更强的隔离级别)的数据库自动完成的。
如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保不会读取进行时,对象不会在脏的状态,有未提交的值(因为在那段时间锁会被写入该对象的事务持有)。
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
出于这个原因,大多数数据库对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。 当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
快照隔离和可重复读
如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,那是可以原谅的。它允许中止(原子性的要求);它防止读取不完整的事务结果,并排写入的并发写入。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。
读取偏差:Alice观察数据库处于不一致的状态
爱丽丝在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的一个账户中转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为500美元),而在转账完成后看到另一个转出账户(已经转出100美元,余额400美元)。对爱丽丝来说,现在她的账户似乎只有900美元——看起来100美元已经消失了。
这种异常被称为不可重复读(nonrepeatable read)或读取偏差(read skew):如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到的不同的值(600美元)。在读已提交的隔离条件下,不可重复读被认为是可接受的:Alice看到的帐户余额时确实在阅读时已经提交了。
有些情况不可以接受上述暂时的不一致:
备份
● 大型数据库备份会几个小时才能完成,如果备份时数据库仍然接受写入操作,那么备份就可能有一些新的部分和旧的部分。
● 从这样的备份中恢复,那么数据不一致会变成永久的。
分析查询和完整性检查
● 一个分析需要查询数据库的大部分内容,如果不同时间点的查询结果不一样,那就没意义。
快照隔离(snapshot isolation)
● 解决上述问题的最常见方案
● 想法是,每个事务都从数据库的一致快照(consistent snapshot) 中读取。——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。
● 即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
● 优点:
○ 快照隔离对长时间运行的只读查询(如备份和分析)非常有用。
○ 如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。有快照理解起来就容易了。
● 快照隔离是一个流行的功能:PostgreSQL,使用InnoDB引擎的MySQL,Oracle,SQL Server等都支持
实现快照隔离实现快照隔离
思路
● 与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写。
● 这意味着进行写入的事务会阻止另一个事务修改同一个对象。
● 但是读取不需要任何锁定。
● 从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。
● 这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。
实现
● 通常使用防止图 7-4 的脏读
● 数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。
● 因为它同时维护着单个对象的多个版本,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrency control)。
保留几个版本的快照?
● 如果一个数据库只需要提供读已提交的隔离级别,而不提供快照隔离,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。
● 支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别。一种典型的方法是读已提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。
举个例子
● 下图是 PostgreSQL中实现基于MVCC的快照隔离(其他实现类似)
● 当一个事务开始时,它被赋予一个唯一的,永远增长的事务ID(txid)。
● 每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。
● 说明:
○ 表中的每一行都有一个 created_by 字段,其中包含将该行插入到表中的的事务ID。
○ 此外,每行都有一个 deleted_by 字段,最初是空的。
○ 如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的ID来标记为删除。
○ 在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。
○ UPDATE 操作在内部翻译为 DELETE 和 INSERT 。
观察一致性快照的可见性规则
对于一个事务 ID,哪些对象时可见的?
● 当一个事务从数据库中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照。
● 工作如下:
○ 在每次事务开始时,数据库列出当时所有其他(尚未提交或尚未中止)的事务清单,即使之后提交了,这些事务已执行的任何写入也都会被忽略。
○ 被中止事务所执行的任何写入都将被忽略。
○ 由具有较晚事务ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
○ 所有其他写入,对应用都是可见的。
● 这些规则适用于创建和删除对象。
● 换句话说,如果以下两个条件都成立,则可见一个对象:
○ 读事务开始时,创建该对象的事务已经提交。
○ 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
长时间运行的事务看到的记录是新的还是旧的?
● 长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。
● 由于从来不原地更新值,而是每次值改变时创建一个新的版本,数据库可以在提供一致快照的同时只产生很小的额外开销。
索引和快照隔离
索引如何在多版本数据库中工作?
● 一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。
● 当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
实践中的索引实现细节决定了多版本并发控制的性能。
● 如果同一对象的不同版本可以放入同一个页面中,PostgreSQL的优化可以避免更新索引。
● 在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用B树,但它们使用的是一种仅追加/写时拷贝(append-only/copy-on-write) 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变。
● 使用仅追加的B树,每个写入事务(或一批事务)都会创建一颗新的B树。当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务ID过滤掉对象,因为后续写入不能修改现有的B树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
可重复读与命名混淆
● 快照隔离是一个有用的隔离级别,特别对于只读事务而言。
● 但是,许多数据库实现了它,却用不同的名字来称呼。
○ 在Oracle中称为可串行化(Serializable) 的
○ 在PostgreSQL和MySQL中称为可重复读(repeatable read)
● 这种命名混淆的原因是SQL标准没有快照隔离的概念,因为标准是基于System R 1975年定义的隔离级别,那时候快照隔离尚未发明。相反,它定义了可重复读,表面上看起来与快照隔离很相似。
● 后续虽然有可重复度的正式定义,但是结果现在命名混乱了。
防止丢失更新
到目前为止已经讨论的读已提交和快照隔离级别,主要保证了只读事务在并发写入时可以看到什么。却忽略了两个事务并发写入的问题——我们只讨论了脏写,一种特定类型的写-写冲突是可能出现的。
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改(有时会说后面写入狠揍(clobber) 了前面的写入)这种模式发生在各种不同的情况下:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到JSON文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
原子写
很多数据库提供了原子更新操作,从而消除在应用程序代码中执行读取-修改-写入序列的需要。
原子写的实现方法
● 原子操作通常通过在读取对象时,获取其上的排它锁来实现。
○ 使得更新完成之前,没有其他事务可以读取它。
○ 这种技术有时被称为游标稳定性(cursor stability)
● 另一个选择是简单地强制所有的原子操作在单一线程上执行
使用时请注意
● ORM框架很容易意外地执行不安全的读取-修改-写入序列,而不是使用数据库提供的原子操作。
● 经常产出很难测出来的微妙 bug
显式锁定
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取-修改-写入序列完成。
自动检测丢失的更新
● 原子操作和锁是通过强制读取-修改-写入序列按顺序发生,来防止丢失更新的方法。
● 还可以允许并发执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列。
比较并设置(CAS)
● 有些不提供事务的数据库中,提供了一种原子操作:比较并设置(CAS, Compare And Set)
● 此操作的目的是为了避免丢失更新:
○ 只有当前值从上次读取时一直未改变,才允许更新发生。
○ 如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
冲突解决和复制
在复制数据库中,防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
写入偏斜与幻读
你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作
写入偏差导致应用程序错误的示例在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 ,所以两个事务都进入下一个阶段。Alice更新自己的记录休班了,而Bob也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。
写偏差的特征
这种异常称为写偏差。它既不是脏写,也不是丢失更新,因为这两个事务正在更新两个不同的对象(Alice和Bob各自的待命记录)。
处理写偏差,我们能用的方法很少,原因:
● 由于涉及多个对象,单对象的原子操作不起作用。
● 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。目前的可重复读、快照隔离级别中,都不会自动检测写入偏差。自动防止写入偏差需要真正的可串行化隔离
● 某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库
导致写入偏差的幻读?理解不同场景下的幻读
● 一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读。
● 快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻读会导致特别棘手的写入偏差情况。
物化冲突
● 如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?
● 例如,在会议室预订的场景中,可以想象创建一个关于时间槽和房间的表。
● 要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。
● 这种方法被称为物化冲突(materializing conflicts),因为它将幻读变为数据库中一组具体行上的锁冲突
串行化
读已提交和快照隔离级别会阻止某些竞争条件,但并非对所有情况都有效。我们遇到了一些特别棘手的例子,写入偏差和幻读。面临的挑战:
● 隔离级别难以理解,并且在不同的数据库中实现的不一致
● 光检查应用代码很难判断在特定的隔离级别运行是否安全。 特别是在大型应用程序中,您可能并不知道并发发生的所有事情。
● 没有检测竞争条件的好工具。并发问题的测试是很难的,因为它们通常是非确定性的 —— 只有在倒霉的时序下才会出现问题。
真的串行执行
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可序列化的定义。
原因:
○ RAM足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。
○ 数据库设计人员意识到OLTP事务通常很短,而且只进行少量的读写操作。而长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
在存储过程中封装事务
交互式事务和存储过程之间的区别存储过程的优点和缺点
分区
串行执行小结
两阶段锁定
● 两阶段锁定类似,但是锁的要求更强得多。
○ 只要没有写入,就允许多个事务同时读取同一个对象。
○ 但对象只要有写入(修改或删除),就需要独占访问(exclusive access) 权限:
■ 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。 (这确保B不能在A底下意外地改变对象。)
■ 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。 (像图7-1那样读取旧版本的对象在2PL下是不可接受的。)
特点
● 在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得读不阻塞写,写也不阻塞读,这是2PL和快照隔离之间的关键区别。
● 另一方面,因为2PL提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
实现两阶段锁
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)或独占模式(exclusive mode)。锁使用如下:
若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务A等待事务B释放它的锁,反之亦然。这种情况叫做死锁(Deadlock)。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
两阶段锁定的性能
● 两阶段锁定的巨大缺点:性能问题
● 两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
谓词锁
谓词锁限制访问,如下所示:
如果事务A想要读取匹配某些条件的对象,就像在这个 SELECT 查询中那样,它必须获取查询条件上的共享谓词锁(shared-mode predicate lock)。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
索引范围锁
● 谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。
● 大多数使用2PL的数据库实际上实现了索引范围锁(也称为间隙锁(next-key locking)),这是一个简化的近似版谓词锁。
索引范围锁:
● 对查询对象的索引加锁。
● 比如,room_id列上有一个索引,并且/或者在start_time 和 end_time上有索引;那么在查询的时候,在查询时,将某个具体对象的索引加上锁,比如给 room_id =123的索引加锁,那么其他事务就没法获取到此索引的锁,导致无法插入、更新、删除。
优点:
● 这种方法能够有效防止幻读和写入偏差。
● 虽然索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
如果没有索引,怎么加索引范围锁?
● 退化到整个表上的共享锁
● 对性能不利,但是比较安全。
序列化快照隔离(SSI)
悲观与乐观的并发控制
两阶段锁是一种所谓的悲观并发控制机制(pessimistic) :它是基于这样的原则:如果有事情可能出错(如另一个事务所持有的锁所表示的),最好等到情况安全后再做任何事情。这就像互斥,用于保护多线程编程中的数据结构。
基于过时前提的决策
● 前文讨论的快照隔离中的写入偏差,是由于事务基于一个前提(premise) 采取行动,之后当事务要提交时,原始数据可能已经改变——前提可能不再成立。
● 更好的办法是由数据库进行判断,而不是应用程序来判断。
● 数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
○ 检测对旧MVCC对象版本的读取(读之前存在未提交的写入)
○ 检测影响先前读取的写入(读之后发生写入)
检测旧MVCC读取
检测事务何时从MVCC快照读取过时的值为了防止这种异常,数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务43 ?因为如果事务43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留快照隔离对从一致快照中长时间运行的读取的支持。
检测影响之前读取的写入
● SSI 采用和索引范围锁类似的技术,除了SSI锁不会阻塞其他事务。
● 事务42 和43 都在班次1234 查找值班医生。如果在shift_id上有索引,则数据库可以使用索引项1234 来记录事务42 和43 读取这个数据的事实。此信息保留到所有事务处理完成即可。
● 当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务指导其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
● 当事务 43 想要提交时,发现来自事务42 的冲突写入已经被提交,所以事务43 必须中止。
可序列化的快照隔离的性能
与两阶段锁定相比,可串行化快照隔离的优点:
● 一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。
● 这种设计原则使得查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
与串行执行相比,可串行化快照隔离的优点:
● 并不局限于单个CPU核的吞吐量:FoundationDB将检测到的串行化冲突分布在多台机器上,允许扩展到很高的吞吐量。
● 即使数据可能跨多台机器进行分区,事务也可以在保证可串行化隔离等级的同时读写多个分区中的数据。
使用场景
● 中止率显著影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(只读的长事务可能没问题)。
● SSI可能比两阶段锁定或串行执行更能容忍慢事务。
网友评论