数据密集型应用系统设计-事务
date
Dec 10, 2021
slug
reading-notes-ddia-transaction
status
Published
tags
读书
系统和网络
summary
type
Page
事务中的所有读写是一个执行的整体,整个事务要么成功提交,要么失败中止或回滚。如果失败,应用程序可以安全地重试。这样由于不需要担心部分失败的情况,应用层的错误处理就简单很多。
事务的目的是简化应用层的编程模型。有了事务,应用可以不用考虑某些内部潜在的错误以及复杂的并发性问题,这些都交给数据库负责处理,称之为安全性保证。
深入理解事务
目前几乎所有的关系数据库和一些非关系数据库都支持事务处理。它们大多数都沿用了和 IBM 于 1975 年推出的第一个 SQL 数据库 System R 相似的总体设计。尽管在一些具体实现方面有些不同,但事务的概念在这四十年中几平没有发生变化,换句话说 MYSQL、Postgresql、Oracle、SQL Server 等系统实现的事务与当年 System R 非常相似
然而 21 世纪末,非关系(NOSQL)数据库开始兴起。它们的目标是通过提供新的数据模型以及内置的复制和分区等手段来改进传统的关系模型。然而事务却成了这场变革的受害者:很多新一代的数据库完全放弃了事务支持,或者将其重新定义,即替换为比以前弱得多的保证
ACID 的含义
原子性 Atomicity
指在出错时中止事务,并将部分完成的写入全部丢弃。也许可中止性比原子性更为准确,不过我们还是沿用原子性这个惯用术语。
一致性 Consistency
指对数据有特定预期状态,任何数据更改必须满足约束状态或恒等状态。
一致性是目的,借助数据库的原子性和隔离性和持久性来实现一致性。C 是目的,AID 是手段。
隔离性 Isolation
意味着并发执行的多个事务相互隔离,他们不能相互交叉。
持久性 Durability
一旦事务提交成功,即使存在硬件故障或数据库崩溃,事务所写入的任何数据
弱隔离级别
数据库一直试图通过事务隔离来对应用开发者隐藏内部的各种并发问题。从理论上讲,隔离是假装没有发生并发,让程序员的生活更轻松,而可串行化隔离意味着数据库保证事务的最终执行结果与串行(即一次一个,没有任何并发)执行结果相同。
读提交
读提交是最基本注的事务隔离级别,它只提供以下两个保证:
- 读数据库时,只能看到已成功提交的数据(防止“脏读" )
- 写数据库时,只会覆盖已成功提交的数据(防止“脏写” )
什么是脏读
假定某个事务已经完成部分数据写入,但事务尚未提交(或中止) ,此时另一个事务是否可以看到尚未提交的数据呢?如果是的话,那就是脏读。
什么是脏写
两个事务同时尝试更新相同的对象,如果先前的写入是尚未提交事务的一部分,是否还是被覆盖?如果是,那就是脏写。
注意脏写不等于更新丢失:脏写是未提交的数据被覆盖,而更新丢失则是已提交的数据被覆盖了。
脏读脏写发生流程示例,摘抄自 https://www.qtmuniao.com/2022/07/07/db-isolation/
实现读提交防止脏读脏写
数据库通常采用行级别锁来防止脏写。
脏读用读锁不现实,这样并发性能会很低。因此大多数系统采用新旧两个值来区分:对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本。在事务提交之前,所有其他读操作都读取旧值;仅当写事务提交之后,才会切换到读取新值。
快照级别隔离与可重复读
前面读提交级别虽然可以防止脏读脏写,但是会出现读倾斜问题:
账户1 2 一共应该是 1000 元,但是 Alice 的两次读取发现账户2是400元,原因是Alice第二次读取读到了转账者的结果。
这种情况在读提交隔离级别语义上是可接受的,因为 Alice 第二次读到的确实是转账者提交后的值。
读倾斜也称为不可重复读。
快照级别隔离这是阶级上述问题最常见的手段。其总体想法是,每个事务都从数据库的一致性快照中读取,事务一开始所看到是最近提交的数据,即使数据随后可能被另一个事务更改,但保证每个事务都只看到该特定时间点的旧数据。
实现快照级别隔离
写锁-防止脏写
与读-提交隔离类似,快照级别隔离的实现通常采用写锁来防止脏写,这意味着正在进行写操作的事务会阻止同一对象上的其他事务。
多版本并发控制MVCC-防止脏读
但是,读取则不需要加锁。从性能角度看,快照级别隔离的一个关键点是读操作不会阻止写操作,反之亦然。这使得数据库可以在处理正常写入的同时,在一致性快照上执行长时间的只读查询,且两者之间没有任何锁的竞争。
数据库保留了对象多个不同的提交版本,这种技术因此也被称为多版本并发控制(Multi-
Version Concurrency Control, Mvcc)
图中说明了基于 MVCC 的快照级别隔离。每个事物开始时首先赋予一个唯一的单调递增的唯一事物 ID txid。
一致性快照的可见性规则
仅当以下两个条件都成立,则该数据对象对事务可见:
- 事务开始的时刻,创建该对象的事务已经完成了提交。
- 对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有 完成提交。
索引与快照级别隔离
接下来一个问题是,这种多版本数据库该如何支持索引呢?一种方案是索引直接指向对象的所有版本,然后想办法过滤对当前事务不可见的那些版本。当后台的垃圾回收进程决定删除某个旧对象版本时,对应的索引条目也需要随之删除。
在实践中,有许多细节决定了多版本并发控制的实际性能表现。例如,可以把同一对象的不同版本放在一个内存页面上, PostgresQL采取这样的优化措施来避免更新索引 (31
CouchDB, Datomic和LMDB则使用另一种方法。它们主体结构是B-tree (参阅第3章"B-tree" ) ,但采用了一种追加/写时复制的技术,当需要更新时,不会修改现有的页面,而总是创建一个新的修改副本,拷贝必要的内容,然后让父结点,或者递归向上直到树的root结点都指向新创建的结点。那些不受更新影响的页面都不需要复制,保持不变并被父结点所指向
防止更新丢失
总结一下,我们所讨论的读-提交和快照级别隔离主要都是为了解决只读事务遇到并发写时可以看到什么(虽然中间也涉及脏写问题) ,总体而言我们还没有触及另一种情况,即两个写事务并发,而脏写只是写并发的一个特例。
写事务并发还会带来其他一些值得关注的冲突问题,最著名的就是更新丢失问题。
更新丢失可能发生在这样一个操作场景中:应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值(read-modify-write过程) 。当有两个事务在同样的数据对象上执行类似操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一个事务的修改值可能会丢失。
原子写操作
数据库提供原子更新操作,避免read-modify-write操作,如:
UPDATE XX SET a = a +1 WHERE key = 'foo';
类似的 MongoDB 支持对文档进行部分本地修改,Redis 对特定类型支持原子操作。
显式加锁
如
SELECT XXX FOR UPDATE
对读加锁自动检测更新丢失
原子写和显式加锁都属于强制将read-modify-write串行化,而另一种思路则是让事务并行执行,如果事务管理器检测到更新丢失风险则中止事务,退回到更安全的 read-modify-write方式。
原子比较和设置
即更新时条件限制数据没有变化才执行更新:
update xxx... where key = 'old'
冲突解决与复制
加锁和原子修改前提是只有一个最新的数据副本,然而对于多主节点和无主节点的多副本数据库,通常支持多个并发写和异步方式更新,所以会出现多个最新的数据副本,此时加锁和原子更新将不再适用。
写倾斜与幻读
定义写倾斜
写倾斜不是脏写,也不是更新丢失,两笔事务更新的是不同的两个对象,每个更新都正确,但放在一起最终结果却是不符合预期的。如图所示,要求是医生中至少有一位值班,但两个事务操作完之后结果却是没有医生值班。
可以将写倾斜视为一种更广义的更新丢失问题。即如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务可能更新不同的对象,则可能发生写倾斜,而不同的事务如果更新的是同一个对象,则可能发生脏写或更新丢失。
更多写倾斜例子
上面用户名的例子,就是一个事务先查询没有结果,当插入时发现报唯一冲突,再次查询发现还是没有结果。
为何产生写倾斜
重点:写操作改变了之前查询的结果是关键。
这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读。快照级别隔离可以避免只读查询的幻读,但是对于上面讨论的读-写事务,他却无法解决棘手的写倾斜问题。
读锁和串行化解决幻读
对于某些幻读问题可以通过加锁来解决,如两个事务想插入同一条记录,事务1先查询发现没有结果,事务2也查询发现没有结果,事务2马上插入一条,之后事务1也插入发现报错,可是事务1再次查询依旧没有结果。
可通过在事务1使用 select for update 加锁来解决幻读。或者使用下面的串行化。
读倾斜和写倾斜
倾斜可理解为两个事务相互影响的现象。
- 读倾斜:指事务1读到了事务2提交的数据
- 写倾斜:指事务1和事务2单独执行没问题,但执行完后最终结果不符合预期
串行化
采用读-提交和快照隔离可以防止其中一部分竞争条件,但并非对所有情况都有效。例如写倾斜和幻读所导致的棘手问题。
可串行化隔离通常被认为是最强的隔离级别。它保证即使事务可能会并行执行,但最终的结果与每次一个即串行执行结果相同。这意味着,如果事务在单独运行时表现正确,那么它们在并发运行时结果仍然正确。换句话说,数据库可以防止所有可能的竞争条件。
实现串行执行
解决并发问题最直接的办法是避免并发。像Redis单线程执行的模式,但是单线程无法利用多核CPU。
采用存储过程封装事务
用的较少暂时忽略
分区
串行执行简单有效,但是无法利用多核,可以扩展到多个 CPU和多节点,对数据分区,但这又引入跨分区事务问题。
串行执行小结
当满足以下约束条件时,串行执行事务可以实现串行化隔离:
- 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行性能
- 仅限于活动数据集完全可以加载到内存的场景。有些很少访问的数据可能会被移到磁盘,但万一单线程事务需要访问它,就会严重拖累性能注
- 写入吞吐量必须足够低,才能在单个CPU核上处理;否则就需要采用分区,最好没有跨分区事务。
- 跨分区事务虽然也可以支持,但是占比必须很小。
两阶段加锁 two-phase locking 2PL
2PL不是2PC,虽然两阶段加锁(2PL)听起来和两阶段提交(two-phase commit, 2PC)很相近,但它们是完全不同的东西。
之前我们看到,可以使用加锁的方法来防止脏写,即如果两个事务同时尝试写入同一个对象时,以加锁的方式来确保第二个写入等待前面事务完成(包括中止或提交)。
两阶段加锁方法类似,但锁的强制性更高。多个事务可以同时读取同一对象,但只要出现任何写操作(包括修改或删除) ,则必须加锁以独占访问,即2PL意味着只要出现写操作则必须等待前一个事务完成:
- 如果事务A已经读取了某个对象,此时事务B想要写入该对象,那么B必须等到A提交或中止之才能继续。以确保B不会在事务A执行的过程中间去修改对象。
- 如果事务A已经修改了对象。此时事务B想要读取该对象,则B必须等到A提交或中止之后才能继续。对于2PL,不会出现读到旧值的情况。
因此2PL不仅在并发写操作之间互斥,读取也会和修改产生互斥。快照级别隔离的口号“读写互不干扰” 非常准确地点明了它和两阶段加锁的关键区别。另一方面,因为2PL提供了串行化,所以它可以防止前面讨论的所有竞争条件,包括更新丢失和写倾斜。
实现两阶段加锁
目前,2PL已经用于MySQL (InnoDB)和SQL Server中的“可串行化隔离” ,以及DB2中的“可重复读隔离”。
此时数据库的每个对象都有一个读写锁来隔离读写操作。即锁可以处于共享模式或独占模式。基本用法如下:
- 如果事务要读取对象,必须先以共享模式获得锁。可以有多个事务同时获得一个对象的共享锁,但是如果某个事务已经获得了对象的独占锁,则所有其他事务必须等待。
- 如果事务要修改对象,必须以独占模式获取锁。不允许多个事务同时持有该锁 (包括共享或独占模式) ,换言之,如果对象上已被加锁,则修改事务必须等待。
- 如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁。升级锁的流程等价于直接获得独占锁。
- 事务获得锁之后,一直持有锁直到事务结束(包括提交或中止) 。这也是名字“两阶段”的来由:在第一阶段即事务执行之前要获取锁,第二阶段(即事务结束时)则释放锁。
由于使用了这么多的锁机制,所以很容易出现死锁现象。例如事务A可能在等待事务B释放它的锁,而事务B在等待事务A释放所持有的锁。数据库系统会自动检测事务之间的死锁情况,并强行中止其中的一个以打破僵局,这样另一个可以继续向前执行。而被中止的事务需要由应用层来重试。
两阶段加锁的性能
两阶段加锁的主要缺点,或者说自1970年以来并不被所有人接纳的主要原因在于性能:其事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。
部分原因在于锁的获取和释放本身的开销,但更重要的是其降低了事务的并发性。按2PL的设计,两个并发事务如果试图做任何可能导致竞争条件的事情,其中一个必须等待对方完成。
谓词锁
谓词锁作用类似于之前描述的共享/独占锁,而区别在于,它并不属于某个特定的对象(如表的某一行),而是作用于满足某些搜索条件的所有查询对象,如:
谓词锁会限制如下访问:
- 如果事务A想要读取某些满足匹配条件的对象,例如采用SELECT查询,它必须以共享模式获得查询条件的谓词锁。如果另一个事务B正持有任何一个匹配对象的互斥锁,那么A必须等到B释放锁之后才能继续执行查询。
- 如果事务A想要插入、更新或删除任何对象,则必须首先检查所有旧值和新值是否与现有的任何谓词锁匹配(即冲突)。如果事务B持有这样的谓词锁,那么A必须等到B完成提交(或中止)后才能继续。
这里的关键点在于,谓词锁甚至可以保护数据库中那些尚不存在但可能马上会被插入的对象(幻读) 。将两阶段加锁与谓词锁结合使用,数据库可以防止所有形式的写倾斜以及其他竞争条件,隔离变得真正可串行化。
索引区间锁 next-key-locking
不幸的是,谓词锁性能不佳:如果活动事务中存在许多锁,那么检查匹配这些锁就变得非常耗时。因此,大多数使用2PL的数据库实际上实现的是索引区间锁(next-key-locking) ,本质上它是对谓词锁的简化或者近似。
简化谓词锁的方式是将其保护的对象扩大化,首先这肯定是安全的。例如,如果一个调词锁保护的是查询条件是:房间123,时间段是中午至下午1点,则一种方式是通过扩大时间段来简化,即保护123房间的所有时间段;或者另一种方式是扩大房间,即保护中午至下午1点之间的所有房间(而不仅是123号房间) 。这样,任何与原始谓词锁冲突的操作肯定也和近似之后的区间锁相冲突。
对于房间预订数据库,通常会在room-id列上创建索引,和/或在start_time和end_time上有索引(否则前面的查询在大型数据库上会很慢) :
- 假设索引位于room-id上,数据库使用此索引查找123号房间的当前预订情况。现在,数据库可以简单地将共享锁附加到此索引条目,表明事务已搜索了123号房间的所有时间段预订。
- 或者,如果数据库使用基于时间的索引来查找预订,则可以将共享锁附加到该索引中的一系列值,表示事务已经搜索了该时间段内的所有值(例如直到2020年1月1日)。
无论哪种方式,查询条件的近似值都附加到某个索引上。接下来,如果另一个事务想要插入、更新或删除同一个房间和/或重叠时间段的预订,则肯定需要更新这些索引,一定就会与共享锁冲突,因此会自动处于等待状态直到共享锁释放。
这样就有效防止了写倾斜和幻读问题。的确,索引区间锁不像谓词锁那么精确(会锁定更大范围的对象,而超出了串行化所要求的部分) ,但由于开销低得多,可以认为是一种很好的折衷方案。
如果没有合适的索引可以施加区间锁,则数据库可以回退到对整个表施加共享锁。这种方式的性能肯定不好,它甚至会阻止所有其他事务的写操作。但的确可以保证安全性。
可串行化的快照隔离
SSI Serializable Snapshot Isolation 算法基于快照隔离并检测写入之间串行化冲突从而决定中止哪些事务。
基于过期的条件做决定
我们在讨论写倾斜时,介绍了这样一种使用场景:事务首先查询某些数据,根据查询的结果来决定采取后续操作,例如修改数据。而在快照隔离情况下,数据可能在查询期间就已经被其他事务修改,导致原事务在提交时决策的依据信息已出现变化。
换句话说,事务是基于某些前提条件而决定采取行动,在事务开始时条件成立,例如“目前有两名医生值班” ,而当事务要提交时,数据可能已经发生改变。条件已不再成立。
当应用程序执行查询时(例如“当前有多少医生在值班? " ) ,数据库本身无法预知应用层逻辑如何使用这些查询结果。安全起见。数据库假定对查询结果(决策的前提条件)的任何变化都应使写事务失效。换言之,查询与写事务之间可能存在因果依赖关系。为了提供可串行化的隔离,数据库必须检测事务是否会修改其他事务的查询结果,并在此情况下中止写事务。
数据库如何知道查询结果是否发生了改变呢?可以分以下两种情况:
- 读取是否作用于一个(即将)过期的MVCC对象(读取之前已经有未提交的写入) 。
- 检查写入是否影响即将完成的读取(读取之后,又有新的写入) 。
检测是否读取了过期的 MVCC 对象
当事务 43 读取时,事务 42 已经修改了数据,也就是说事务43 查询的数据是过期的 MVCC数据。此时事务管理器应该发现这个值发生变化,从而中止43后续的写入。
检测写是否影响了之前的读
小结
如果没有事务,各种错误情况(如进程崩溃、网络中断、停电、磁盘已满、并发竞争等)会导致数据可能出现各种不一致。例如,反规格化数据模式和相关操作会导致与源数据不同步。假如没有事务,处理那些复杂交互访问最后所导致的数据库混乱就会异常痛苦。
概念回顾
脏读
客户端读到了其他客户端尚未提交的写入。读提交以及更强的隔离级别可以防止脏读。在 MySQL RU 读未提交中会出现。
脏写
客户端覆盖了另一个客户端尚未提交的写入。几乎所有的数据库实现都可以防止脏写。
读倾斜(不可重复读、MySQL RC 读提交)
客户在不同的时间点看到了不同值。快照隔离是最用的防范手段,即事务总是在某个时间点的一致性快照中读取数据。通常采用多版本并发控制(MVCC)来实现快照隔离。
更新丢失
两个客户端同时执行读-修改-写入操作序列,出现了其中一个覆盖了另一个的写入,但又没有包含对方最新值的情况,最终导致了部分修改数据发生了丢失。快照隔离的一些实现可以自动防止这种异常,而另一些则需要手动锁定查询结果 (SELECT FOR UPDATE) 。
写倾斜
事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。只有可串行化的隔离才能防止这种异常。
幻读
事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。快照隔离可以防止简单的幻读,但写倾斜情况则需要特殊处理,例如采用区间范围锁。
弱隔离级别可以防止上面的某些异常,但还需要应用开发人员手动处理其他复杂情况(例如,显式加锁) 。只有可串行化的隔离可以防止所有这些问题。我们主要讨论了实现可串行化隔离的三种不同方法:
严格串行执行事务
如果每个事务的执行速度非常快,且单个CPU核可以满足事务的吞吐量要求,严格串行执行是一个非常简单有效的方案。
两阶段加锁
几十年来,这一直是实现可串行化的标准方式,但还是有很多系统出于性能原因而放弃使用它。
可串行化的快照隔离(SSI)
一种最新的算法,可以避免前面方法的大部分缺点。它秉持乐观预期的原则,允许多个事务并发执行而不互相阻塞;仅当事务尝试提交时,才检查可能的冲突,如果发现违背了串行化,则某些事务会被中止。
参考:
MySQL幻读及解决方法:https://zhuanlan.zhihu.com/p/360254683