事务将应用程序多个读、写操作捆绑在一起成为一个逻辑单元。即事务中所有读写都是一个执行操作,整个事务要么成功(提交)、要么失败(中止或回滚)。但是不是所有应用程序都需要事务,有时可以弱化事务处理或者完全放弃事务。
事务
ACID的含义
分别是原子性(A)、一致性(C)、隔离性(I)与持久性(D)。
原子性
例如多线程编程中,如果某线程执行一个原子操作,意味着其他线程无法看到操作的中间结果,它只能处于操作前或者操作后状态。故,在出错时中止事务,并将部分完成的写入全部丢弃,也许中止性比原子性更为准确。
一致性
ACID中的一致性主要是指对数据有特定的预期状态,任何更改数据必须满足这些状态约束(或者恒等条件)。例如账户的贷款余额和借款余额保持平衡。一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。
隔离性
ACID中的隔离性意味着并发执行的多个事务互相隔离,它们不能互相交叉。虽然经典的数据库教材把隔离定义为可串行化,但是数据库系统要确保当事务提交时,结果与串行执行完全相同(虽然我们是并行执行)。
持久性
对于单节点数据库,持久性通常意味着数据已被写入非易失性存储设备,对于支持远程复制的数据量,持久性意味着数据已经成功复制到多个节点。
单对象与多对象事务操作
单对象写入
原子性和隔离性适用于单个对象的更新,例如存储引擎几乎必备的设计就是在单节点、单个对象层面提供原子性和隔离性,当出现宕机是,基于日志恢复来实现原子性(可靠的B-tree),对每个对象采用加锁的方式来实现隔离。
多对象事务的必要性
许多分布式数据存储系统不支持多对象事务,因为出现跨分区时,多对象事务非常难以实现,同时在高可用性或者极致性能的场景下,会有负面影响。但是也有一些情况只进行单个对象的插入、更新和删除就足够了。
处理错误与中止
事务的关键特性就是,如果发生了意外,所有操作被中止后,可以安全地重试,但不是所有数据库都这样,例如无主节点复制的数据存储,会在“尽力而为”的基础上尝试多做些工作,如果遇到错误,也不会撤销已完成的操作。
重试中止的事务虽然是一个简单有效的错误处理机制,但是有一些问题:
-如果事务已经执行成功,但是返回给客户端的消息在网络传输时发生意外,那么重试就会导致重复执行。
-如果错误是系统超负荷所导致,则重试事务将使情况变得更糟。
-由临时性故障(死锁、隔离违例、网络闪断)所导致的错误需要重试,但是永久性故障(违反约束)的重试毫无意义。
弱隔离级别
如果两个事务操作的是不同数据,则不存在数据依赖关系,可以安全地并行,只有出现某个事务修改数据而另一个事务读取,或者两个事务同时修改相同数据,才会出现并发问题(竞争条件)
并发性相关的错误很难发现,因此数据量一直试图通过事务隔离来对应用开发者隐藏内部的并发问题,但是实现隔离较难,会牺牲数据库的性能,更多倾向于采用较弱的隔离级别。
读-提交
防止脏读:只能看到已成功提交的数据。
防止脏写:只能覆盖已成功提交的数据。
防止脏读
有以下需求,需要防止脏读:
-如果事务需要更新多个对象,脏读意味着另一个事务可能看到部分更新,而非全部。
-如果发生事务中止,则所有写操作都需要回滚。如果发生了脏读,意味着它可能会看到稍后被回滚的数据,而数据并未实际提交到数据库中。
防止脏写
如果两个事务同时尝试更新相同对象,可以想象后写的操作会覆盖较早的写入,但是如果先前写入是尚未提交事务的一部分,那是否还是被覆盖?如果是,那就是脏写,读-提交隔离通常的方式是推辞第二个写请求,直到前面的事务完成。
实现读-提交
数据库采用行级锁来防止脏写。
脏读?一种选择是使用使用相同的锁,试图读取该对象的事务必须先申请锁,然而读锁的方式不太可行,运行时间较长的写事务会导致很多只读事务等待太长时间。且可操作性差。一般的方式都是:对于每个待更新的对象,数据库维护其旧值和当前持锁事务将要设置的新值两个版本,事务提交之前,所有其他操作读取旧值;仅当事务提交后,才会切换到读取新值。
快照级别隔离与可重复读
快照级别隔离的总体想法是,每个事务都从数据库的一致性快照中读取,事务一开始看到的是最新提交的数据,即使数据随后可能被另一个事务更改,但保证每个事务都只看到特定时间点的数据。
快照级别隔离对于长时间运行的只读查询非常有用。
实现快照级别隔离
类似于读-提交隔离,快照也采用写锁来防止脏写,但是读取不需要加锁。从性能来看,读操作不会影响写操作。
考虑到多个正在进行的事务会在不同时间点查看数据量,采用了多版本并发控制,也就是保留了对象多个不同的提交版本。
对比读-提交隔离,前者只保留对象两个版本,一个提交的旧版本和未提交的新版本,所以快照级别隔离往往直接采用MVCC来实现读-提交隔离,做法是:在读-提交隔离下,对每一个不同的查询单独创建一个快照,而快照级别则是使用一个快照来运行整个事务。
一致性快照的可见性规则
1.每笔事务开始时,数据库列出所有正在进行的其他事务,然后忽略这些事务完成的部分写入,即不可见;2.所有中止事务所做的修改全部不可见;3.较晚事务ID(晚于当前事务)所做的修改不可见,无论是否完成提交;4.除此之外,全部可见。
索引与快照级别隔离
一种索引方案是直接指向对象的所有版本,然后过滤对当前事务不可见的版本,当后台的垃圾回收进程决定删除某个旧对象版本时,对应索引条目也要删除。实践中,可以把同一对象的不同版本放在一个内存页面上。
另一个的主体结构是B-tree,但采用了追时/写时复制,当需要更新时,不会修改现有的页面,而是创建新的修改副本,拷贝必要内容,然后让父节点都指向新创建的结点。那些不受更新影响的页面都不需要复制,保持不变。
这种采用追加式的B-tree,每个写入事务都会创建新的B-treeroot,代表该时刻数据库的一致性快照。因为每一笔写入都会修改现有的B-tree,之后的查询可以直接作用于特定快照B-tree。
防止更新丢失
读-提交和快照级别隔离都是解决只读事务遇到并发写,当出现两个并发写?写并发带来的另一个值得关注的问题:更新丢失。
更新丢失可能出现在这样的场景:应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值(read-modify-write 过程)。当两个事务在同样的数据对象上执行操作,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终可能导致第一个事务的修改值丢失。解决方案有:
原子写操作
数据库提供原子更新操作,避免应用代码层完成“读-修改-写”操作。原子操作通常采用对读取对象加独占锁的方式,这也被称为游标稳定性,另一种方式是强制所有的原子操作都在单线程上执行。
显式加锁
由应用程序显式锁定待更新对象,然后应用程序执行“读-修改-写”操作,此时如果其他事务尝试读取对象,必须等待正在执行的序列完成。
自动检测更新丢失
原子操作和锁都是强制通过“读-修改-写”操作序列串行执行来防止丢失更新。另一思路就是让他们并发执行,如果检测到了更新丢失风险,则中止当前事务,强制回退到安全地“读-修改-写”。
原子比较和设置
仅当上次读取的数据没有变化才允许更新,如果已经发生了变化,则回退到“读-修改-写”方式。
注意,如果并发写是建立在一个旧的快照,另一个并发写正在运行写入,可能导致冲突。
冲突解决与复制
加锁和原子修改都是建立在只有一个最新的数据副本,然而对于多主节点,或者无主节点的多副本数据库,由于支持多个并发写,通常以异步方式同步更新,此时加锁和原子比较不适用。
在第五章中,多副本数据库通常支持多个并发写,然后保留多个冲突版本(互称为兄弟),之后由应用层逻辑来解决、合并版本。
或者LWW(最后写入获胜)
写倾斜与幻读
如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务可能更新不同的对象,则可能发生写倾斜;而不同的事务如果更新同一个对象,则可能发生脏写或者更新丢失。
对于写倾斜,有许多限制:
-由于设计多个对象,单对象的原子操作不起作用。
-自动防止写倾斜要求真正的可串行化隔离。
-如果不能使用可串行级别隔离,一个次优的选择是对事务依赖的行来显式的加锁。
幻读:一个事务中的写入改变了另一个事务查询结果的现象。快照级别隔离可以避免只读查询时的幻读。
串行化
串行化采用了以下三种技术之一:
-严格按照串行顺序执行
-两阶段锁定,几乎是唯一可行的选择
-乐观并发控制技术,例如可串行化的快照隔离
下面的都是限定在单节点背景,对于分布式系统,在第九章。
实际执行串行化
对于单线程循环来执行事务是可行的,吞吐量上限是单个CPU核的吞吐量。
采用存储过程封装事务
几乎所有OLTP应用程序都避免在事务执行过程中等待用户交互,从而使事务变得简洁,对于web,这意味着事务会在一个http请求中提交,而不会跨越多个请求,新的事务往往需要开启新的http。
存储式的单线程串行系统往往不支持交互式的多语句事务。应用程序提交整个事务代码作为存储过程打包发送到数据量,系统把事务所需的所有数据加载在内存中,使存储过程高效执行。
分区
串行执行所有事务会让并发控制更加简单,但是数据库的吞吐量限制在单机CPU。为了扩展到多个CPU核和多个节点,可以对数据分区,但是跨分区的事务,数据库必须在涉及的所有分区之间协调,跨所有分区加锁来确保系统串行化。
串行执行小结
串行执行条件:
-事务简短高效
-仅限于活动数据集可以完全加载到内存的场景
-写入吞吐量低,可以在单个CPU执行,否则分区
-跨分区占比要小。
两阶段加锁
2PL,两阶段加锁,多个事务可以同时读取同一对象,但只要出现写操作,必须加锁以独占访问?
快照可以“读写互不干扰”,2PL提供了串行化,可以防止前面讨论的所有竞争条件,包括更新丢失和写倾斜。
实现两阶段加锁
数据库每一个对象都有一个读写锁来隔离读写操作,即锁可以处于共享模式或独处模式。
对于死锁,数据库会自动检测事务之间的死锁情况,并强行中止其中的一个以打破僵局,被中止的事务需要由应用层重试。
两阶段加锁的性能
在2PL模式下,数据库的访问延迟具有非常大的不确定性。(锁就是这样)
谓词锁
它的作用类似于之前描述的共享/独占锁,但是它并不属于某个特定的对象,而是作用于满足某些搜索条件的所有查询对象。
谓词锁的限制如下:
-如果事务A想要读取某些满足匹配条件的对象,例如采用SELECT查询,它必须以共享模式获得查询条件的谓词锁,如果B正持有任何一个匹配对象的互斥锁,那么A必须等B释放锁后才能查询。
-如果A想要插入、更新对象,必须先检查所有旧值和新值是否与现有的任何谓词锁匹配(即冲突),如果事务B持有这样的谓词锁,A要等待。
关键在于谓词锁可以保护数据库中尚不存在但是可能马上会被插入的对象(幻读)。
但是谓词锁性能不好,过程要查询太多锁。
索引区块锁
2PL实际用的都是索引区间锁,本质是简化谓词锁。
简化谓词锁的方式是将保护对象扩大化。
可串行化的快照隔离
SSI,可串行化的快照隔离,可用于单节点数据库或者分布式数据库。
乐观的并发控制
SSI是乐观的,如果发生潜在冲突,事务会继续执行而不是中止,寄希望于一切相安无事;当事务提交时(只有可串行化的事务被允许提交),数据库会检测是否发生冲突(即违反了隔离性),如果是,中止事务并接下来重试。
事务中所有读取操作都是基于数据库的一致性快照。
基于过期的条件做决定
由于写倾斜,事务根据查询结果来进行行动,但是事务开始时条件成立,事务提交时可能条件不成立,so我们假定对查询结果(决策的前提条件)的任何变化都应使写事务失效,换言之,查询与写事务之间可能存在因果依赖关系。为了提供可串行化的隔离,数据库必须检测事务是否会修改其他事务的查询结果,并在此情况下中止写事务。
-读取是否作用于一个即将过期的MVCC对象(读取之前已经有未提交的写入)
-检查写入是否影响即将完成的读取(读取后,又有新的写入)
检测是否读取了过期的MVCC对象
隔离快照(MVCC),事务从MVCC读取时,会忽略那些在创建快照时尚未提交的事务写入,为了防止异常,数据库需要跟踪由于MVCC可见性规则而被忽略的写操作。当事务提交时,数据库检测是否存在一些当初被忽略的写操作现在已经完成了提交,如果是,必须中止当前事务。
检测写是否影响了之前的读
可串行化快照隔离的性能
与两阶段加锁相比,可串行快照的优点时事务不需要等待其他事务的锁,与快照隔离一样,读写通常不会阻塞,特别是,在一致性快照上执行只读查询不需要任何锁。
与串行执行相比,可串行快照可以突破单个CPU的限制,冲突检测分布在多台机器,从而提高吞吐量。
小结
脏读:客户端读取了其他客户端未提交的写入。读-提交以及更强的隔离级别可以防止脏读。
脏写:客户端覆盖了另一个客户端未提交的写入。几乎所有数据库都可以避免脏写。
读倾斜:客户在不同时间看到了不同值。快照隔离式最用的防范手段,即事务总是在某个时间点的一致性快照中读取数据。
更新丢失:两个客户端同时执行 “读取-修改-写入”操作,出现了一个覆盖另一个的写入,但又没有包含对方最新值的情况。快照隔离的一些失效可以防止,或者手动锁定查询结果。
写倾斜:事务首先查询数据,根据返回结果而作出决定,然后修改数据库。当事务提交时,支持决定的前提已经不成立,只有可串行化的隔离才能防止。
幻读:事务读取了某些符合条件的对象,同时另一个客户端写入改变了先前的查询结果。快照隔离可以防止简单的幻读,但是写倾斜需要特殊处理,例如采用区间范围锁。
实现可串行化隔离的三种方法:
严格串行执行事务
两阶段加锁:多个事务可以同时读取同一对象,但只要出现写操作,必须加锁以独占访问
可串行化的快照隔离(SSI):秉承乐观预期的原子,允许多个事务并发而不阻塞,仅当提交时,才检查可能冲突,如果阻塞,事务会被中止。