前言
关系数据库事务的概念已经深入人心,这方面的经验介绍五花八门,主要原因是在事务的功能和实现机制上ORACLE/SQLServer/MySQL也不尽相同。同时应用在不同类型数据库之间迁移时要小心其中的“陷阱”。随着分布式数据库技术的推广,分布式事务的场景也越来越多,对于分布式事务中间件或者分布式数据库原生支持的分布式事务,传统应用在使用的时候也多有顾虑。
本文分上下两篇,主要系统总结常用关系数据库ORACLE\SQL Server\MySQL的事务特点原理以及场景,并跟OceanBase以及GTS、DTX进行比较,分析其中原理和风险,方便传统应用开发评估使用分布式数据库和中间件产品的风险。
事务的ACID特性
通常事务会被认为是数据库特有的机制,这样理解会有两个弊端。一是业务使用了数据库事务,如果业务数据出现不正确的现象就是数据库事务的问题,应用可能甩锅给数据库。二是面临一些中间件也支持分布式事务时,会感觉不是很靠谱,应用一开始就容易有排斥心理。
不如换个角度理解事务。站应用角度,事务就是一个编程框架,两阶段提交(2PC)也是一个框架。数据库支持事务这种框架,减轻了应用开发并发处理数据的技术复杂度和风险。能做到这一点是因为事务的四个特性:ACID。不过各个厂商数据库在实现ACID的细节上并不完全相同。同样一个应用换了一个类型数据库,可能会有不同的表现。
关于原子性(Atomicity)
不同关系数据库的表现都是一样的,数据在事务前后各有一种确定的状态,不会有中间状态(部分成功部分失败)出现。严格说有脏读那个是违背这个特性的。
关于一致性(Consistency)
关于ACID,其中的C指Consistency,指的业务视角下某个数据或者相关数据满足某种业务规则约束。如果这个约束是主键、唯一键或者外键或者自定义CHECK约束,数据库还是可以理解并执行这个约束的。但是如果这个约束是业务规则。比如说转账业务,A转B钱。业务规则是在转账前后A和B的余额总和是不变的。这个规则就没有数据库表达式可以支持。 实际上数据库事务通过原子性(Atomicity)、隔离性(Isolation)和持久性(Durability)来保证业务数据的一致性。站业务角度,事务可以抽象为一个业务逻辑单元,业务需求就是它的规则。应用开发完全理解业务需求是正确使用事务的前提。
关于隔离性(Isolation)
事务隔离性的表现跟事务隔离级别有关。ANSI(92)标准定义了四种隔离级别:READ UNCOMMITTED
, READ COMMITTED
, REPEATABLE READ
or SERIALIZABLE
。ANSI定义的隔离级别是为了解决一些特别的现象。ANSI(92)标准比较老,对于采用多版本并发控制实现隔离性的级别不能够很好的描述,主要是用来参考,不代表全部。后面会详细描述各个事务隔离级别和相关场景。
关于持久性(Durability)
上篇文章就是专门分析不同关系数据库在持久化事务日志行为上的不同。这里就不赘述了。
数据库会话事务设置
数据库的默认设置以及会话的默认设置可能影响事务的表现。
首先,事务隔离级别是数据库级别的一个属性。每个数据库都有默认隔离级别的设置,一般默认读提交(READ COMMITTED
)。
其次,会话也有事务隔离级别属性,并且可以单独调整,影响范围是会话的活动期间。所以,如果要验证各个数据库在不同事务隔离级别下的表现,可以在会话层面设置。
第三,数据库都有一个autocommit
的设置,表示会话开启的时候是否开启自动提交。不同数据库的这个参数默认值是不一样的。比如说ORACLE默认就是不自动提交,SQL Server和MySQL默认都是自动提交。 OceanBase的租户默认是兼容MySQL的租户模式,其默认设置是事务自动提交。另外一种模式是ORACLE兼容模式,默认是不自动提交。后面示意图都忽略事务开始时间。
应用常见问题场景描述
隔离级别和场景总是密切相关的,无论先介绍哪个都容易牵扯到另外一个。这里先说场景问题,后提解决方案——事务隔离级别 下面的场景在某些事务隔离级别下会存在,在更严格的隔离级别下可能就不存在了。
场景:脏读(P1)
现象:事务T1读取到事务T2修改的但是还没有提交的数据。 影响:如果T1依赖读取的数据做业务判断而T2事后回滚了,则T1的做法导致业务数据错误。
场景:不可重复读(P2) 或
read skew
。
现象:事务T1先读取一行数据,然后其他会话事务T2也读取并修改了该行数据,并且在T1之前提交了。然后T1又读取了这行数据发现数据已经变了。 影响:取决于业务。
场景:幻读(P3)
现象:事务T1先读取若干行数据,然后其他会话事务T2又插入一行数据,并且在T1之前提交了。然后T1重新按原条件查询发现多出一行。 影响:取决于业务。
备注:幻读严格来说也算不可重复的一种,之所以区分开是因为数据库在应对这两个场景的方法细节有不同。
场景:丢失更新(P4)
现象:事务T1读取数据,同时有并发会话的事务T2同时也读取数据,做业务逻辑后写回数据先提交。然后T1做业务逻辑后写回数据提交。此时T2d修改丢失。 影响:业务数据跟实际情况不一致。
备注:这个场景很常见,如库存扣减或者显示计数等。它的特点就是“读出->修改->写回”。这里还是操作同一个对象(记录)的。如果是操作不同对象(记录),则隐藏的更深。
场景:
Write Skew
现象:事务T1读取一组记录作为判断条件,然后写回自己的记录(此时符合条件的记录数变了)并提交。同时有并发会话的事务T2也同样处理写回自己的记录并提交。T1和T2修改的记录并不是同一笔记录。 影响:业务数据跟实际情况不一致。
备注:这个跟丢失更新有点像,不同的是后面写的记录是不冲突的(会影响前面的查询结果),所以两个事务不会阻塞就完成了。贴一下原文的例子更好理解一些 。
可能还有其他场景这里没有提到。
事务隔离级别及实现原理
上面场景的共性都事务并发有关。并发的问题很难测试和排查,数据库提供事务隔离级别这个框架来减轻开发处理并发场景的困难。理论上数据库可以提供一种serializable
的隔离级别让所有并行的事务实际运行的结果跟串行运行一样(即串行化,消除并发)。但是这样的性能很差,极大的限制了数据库的使用场景。各个关系数据库都没有这么做,而是支持一些弱一些的事务隔离级别。并且虽然不同数据库的事务隔离级别名字一样,在实现细节和表现特征上也还会有些区别。
ANSI标准描述了几种问题场景,以及每个隔离级别能应对的场景。
Oracle、SQLServer和OceanBase默认隔离级别都是READ COMMITTED
(后面简称RC),MySQL默认是REPEATABLE READ
(后面简称RR)。
通过锁实现不同隔离级别
SQL Server和MySQL早期版本在RC隔离级别下都是用对修改的记录加锁的方法来规避脏读和脏写。
锁定范围描述
锁定范围首先跟锁的最小粒度有关。SQLServer早期版本锁的粒度有表锁、页锁、行锁。SQL Server在判断锁定记录范围时可能会为了减少锁的成本而对锁的粒度进行升级,表现为查询可能会锁定很多不必要的记录。因此读与写会互相阻塞,并发数很低。MySQL的InnoDB支持表锁和行级锁。
第二,锁定范围还跟数据和索引的存储格式有关。如MySQL的表是索引组织表,数据和索引是分开存储的。索引是 B+Tree,主键索引(也叫聚集索引)就是数据,其他索引的叶子节点存储的索引字段值和对应的主键值(没有主键的时候会是该记录的一个内部唯一标识)。所以MySQL的SQL如果走了普通索引加锁时,还会附带对主键索引里某些记录加锁。
第三, MySQL的锁还有一类特殊的锁。如间隙锁(
Gap Lock
,锁定一个开区间)、Next-Key Lock
(锁定一个半开-闭区间),后者用来实现唯一性约束(包括主键)。当隔离级别是RR
时,根据范围条件更新(UPDATE/DELETE)加锁时会使用Next-Key Lock
锁住一个范围。
Oracle的`锁`实现机制完全不一样,严格说它没有内存对象去管理锁,只是在记录所在数据块上置个标记。这也是Oracle设计精妙的一个地方,这里就不详细展开。
不可重复读的锁
解决方案
在RC
隔离级别下,对有写的记录加锁是不能规避不可重复读
这个场景问题。 要规避,常用方法是要把读的记录也加锁,如用SELECT...FOR UPDATE
或 SELECT...LOCK IN SHARE MODE
。(另外一种规避不可重复读
场景问题的方法就是设置事务隔离级别为RR
。) 这样表面上并发的会话实际上是串行化执行。对读加锁的方法也可以规避Write Skew
场景问题。对读加锁的弊端就是发生阻塞,降低了并发。
幻象读的锁
解决方案
在RC
隔离级别下,对读的记录加锁不能规避幻读
这个场景问题。因为它只会在扫描到的记录的索引叶节点上加锁,不会对范围加锁。但是如果把隔离级别提升为RR
时,对读加锁就可以彻底阻止幻读
源头的那个INSERT的发生。同样,这个弊端也是发生阻塞,降低了并发。
关于锁还有个细节不同。在Oracle里锁等待会一直等下去,直到源头事务回滚或提交,或者等DBA介入处理。在MySQL里锁等待会有个超时机制。超时后当前DML回滚。
通过多版本技术实现不同隔离级别
锁的问题就是形成阻塞,如果应用写的不好,还有可能发生死锁。同时有锁的时候应用整体吞吐能力也不高,这点在OLTP类型业务中是很难接受的。于是各个数据库分别支持多版本快照读功能。通常称为MVCC
。
快照版本和Undo方法
实际上Oracle是最早就支持多版本快照读的。Oracle里每个写都会先生成Redo(包括随后Undo的Redo),然后生成Undo,再然后才是修改记录所在的块。Undo块保存在Undo表空间里,只要Undo表空间足够大,Oracle是可以保留很长一段时间内的历史版本的。Oracle里的版本号叫SCN,是一个随着时间变化只会增加的数字,Oracle可以随时计算出一个不重复的SCN.
当事务隔离级别是RC
时,每个SQL执行时取会话当前SCN号为参考版本,然后去读取每笔记录里小于或等于参考版本的最大的已提交的版本的记录。其过程就是根据记录查找Undo列表。所以,Oracle不支持也没办法支持脏读
。不过在这个隔离级别下也是会有不可重复读
问题。应对办法也是通过读加锁
。如SELECT...FOR UPDATE
等。写操作同理。 当事务隔离级别是SERIALIZABLE
,这个参考版本就是事务开启时的SCN,即整个事务活动期间都参考同一个版本号。在这个隔离级别下,Oracle没有幻读
问题。
MySQL的InnoDB也有Undo,也支持多版本。相对于Oracle的浑然一体的设计,MySQL的设计显得不是那么精巧。每个记录有一个事务版本号,一个删除标记位。同时每个记录还有个指针指向Undo记录。当更新索引键值时,修改老版本删除标记,插入新版本;不更新索引键值时,直接在最新版本上更新,老版本通过Undo指针连接。
备注:上图是PostgreSQL的多版本控制示意图,在Update细节上跟MySQL有一点区别,MVCC表现是一样的。
也有的把这个快照读的特性称为Snapshot Isolation
(快照隔离级别)。并且为了解决它不能防止丢失更新
和Write Skew
问题,设计了一个新的串行化隔离级别Serializable Snapshot Isolation
(简称SSI)。它的性能和能力介入Snapshot Isolation
和Serializable
之间。后面单独介绍一下。
锁和快照的关系
快照读和当前读
MySQL把读分为两种类型,一是一致性读(也叫快照读),就是上面说的根据SCN去读取合适的版本的记录。一是当前读,就是加锁读取最新的数据。这个最新的数据如果是本事务修改的,直接读取;如果是其他事务修改还没提交的,本事务就会被阻塞等待。 发出当前读的SQL通常有UPDATE/DELETE/INSERT/MERGE/SELECT...FOR UPDATE
/LOCK IN SHARE/EXCLUSIVE MODE 等。写SQL之所以要当前读是因为要在最新数据上修改。
索引的多版本
在MySQL里会发现,即使是在RR
隔离级别下,SQL加锁的时候,是通过对索引记录加锁,也会尝试对其他事务未提交的新增记录进行加锁(因为它符合当前写SQL的条件)。似乎索引里有其他事务未提交的版本记录,这个有点脏读
的意思。
在Oracle里即使是当前读
的加锁,也只会对本事务可见的记录所在数据块上置标记。
丢失更新
和Write Skew
常见问题解决方案
从上面分析看出隔离级别RC
和RR
不能规避丢失更新
和Write Skew
问题。隔离级别SERIALIZABLE
可以,但是并发太低应用也不会用。
悲观锁
——两阶段锁定(2PL)
一是两阶段锁定
。T1在读的时候加锁(用SELECT...FOR UPDATE
,当前读),然后再修改。那么并发事务T2在读的时候申请加锁就会等待直至T1提交,然后T2才修改。这是通常说的悲观锁
方法。
这个方案可以规避丢失更新
和write skew
。有个影响就是有阻塞,应用其他事务设计如果不合理,有可能会引发死锁。不过这个也是大部分业务常用的选择。
乐观锁
——compare-and-set
二是compare-and-set
。T1在读出数据后,再次更新的时候,sql的条件里带上要更新的值的旧值。如果要更新的记录被其他会话修改了,则不满足条件,更新影响行数是0,也就没有丢失更新
问题。这个也是通常说的」乐观锁
方法。如果实际并发不低的话,这个会有一定概率失败。就需要应用重试。有些业务在表结构上设计一个时间戳或者数字列(版本列)来应用乐观锁
技术。
-- This may or may not be safe, depending on the database implementation
UPDATE wiki_pages SET content = 'new content' WHERE id = 1234 AND content = 'old content';
串行化快照隔离级别(SSI
)
SSI
依然是支持快照读的。它的特点在于数据库能检测到当前快照读取的数据是被其他事务修改但是未提交的,或者检测到当前修改操作会修改了此前其他事务的某个快照读读取的数据。然后两个事务在提交的时候往往就是先发出提交的那个最终成功,而后发出提交的则失败。这个跟前面乐观锁
的思想有点一致。
下面引用原书中的例子。
检测到陈旧的快照读
检测到写影响了之前的快照读
顺带提一下,SSI的这个乐观
的实现思想还用在多主写入时的冲突判断里。如PXC
的多主复制。
分布式数据库下的读写问题
分布式数据库能支持什么事务隔离级别
即使数据库换成分布式数据库,应用对它的要求还倾向于不变。只是不同分布式数据库的能力不同,可能能支持的事务隔离级别不一样。
分布式数据库通常有两种类型。一是分布式数据库中间件,也就是常说的分库分表。另外一种就是真正的分布式数据库。
事务隔离级别RC
,所有的分布式数据库都支持,因为难度不大。 可以用锁,也可以用快照读。这个快照读只需要每个节点都支持就行。
事务隔离级别RR
,这个分布式数据库支持有点难度。先看快照读
这个技术。 快照读
的关键在于有版本
的概念。版本能反映了记录被修改的先后顺序,它跟时间有关但不一定是时间,只能增长不能倒退。如Oracle的SCN。单个节点内部的事务的版本顺序好确定,不同节点之间的事务版本顺序就很难确定。它需要一个全局时钟
。Google的Spanner分布式数据库使用一个原子钟
的硬件提供了一个全局时间服务。TiDB和OceanBase数据库使用软件算法实现了一个全局时间服务。 分布式数据库中间件在这方面没有办法实现。
所以大部分应用对分布式数据库的事务隔离级别通常要求到RC
就可以了,然后用2PL
规避不可重复读
、`丢失更新
和Write Skew
问题。不过在分布式数据库里,如果实现了SSI 隔离级别,也是可以避免这些问题。
分布式数据库下新的问题
跨节点读问题
当一个SQL请求的数据跨分布式数据库的节点的时候,有个隐性要求是在不同节点读取的数据应当是小于或等于同一个版本(SQL发起时的那个版本)。说直白点就是如果在SQL执行过程中,某些节点的数据发生修改并迅速提交了,这个数据是不应该被读取到的。这个在单实例里是很好做的,在分布式数据库(有多个节点)则不好做。原因就是上面说的需要一个全局时间服务
。
分布式事务问题
当事务修改的数据跨越多个节点时,就产生了分布式事务。这个时候也要保证事务的ACID
特性。一般都是使用两阶段提交(2PC)
这个框架来实现。只不过不同分布式数据库(包括中间件)在这个2PC细节上做法也有不同,主要是基于性能考虑。这个在下篇文章中详细介绍。
死锁问题
死锁指每个事务都持有其他某个事务请求的锁,同时又在请求其他某个事务持有的锁,这种持有和请求的关系形成了一个闭环。
在单实例里,死锁可以通过算法瞬间检测到然后并自动处理。处理方法就是回滚掉其中一个事务解套。至于选择哪个事务跟事务的时间、回滚成本有关。
在分布式数据库里,不同事务锁定的对象可能分布在不同节点上,如果各个节点之间的锁信息不是共享的(分布式数据库通常是share nothing的,也就不会共享锁信息),那么就无法在算法上识别到死锁,也就不能自动解决。目前一个不是很完美的解决方案是靠锁等待超时
机制解决。这是MySQL里锁的特点。
后记
本文提到的Oracle主要是Oracle 8i/9i/10g/11g,SQL Server是97/2000/2008R2, MySQL是5.5/5.6/5.7。由于时间比较久,加上其他新版本接触不多,这里说的可能有不准确的地方,欢迎大家指出。此外DB2和PostgreSQL里也有自己特点的实现,了解不多这里也没有总结。
事务的隔离跟分布式开发中有很多概念相通,背后的理论研究也非常多。关于
一致性
就有很多解释以及很多相关的概念,目前感觉不好说清楚,所以本文就故意回避了这些概念。MySQL的锁类型用法实在太多,跟隔离级别的定义有关。MySQL隔离级别的特点跟ANSI定义又有细节上的不同,其原因又是跟MySQL锁和多版本实现机制有关。这两个概念互相引用,这里不一定说清楚了,可以查阅后面查看文章或者官方文档说明。Oracle的多版本设计实在非常精妙,请不要用MySQL的MVCC设计去思考Oracle的设计原因。
事务就是一个编程框架。应用跟数据库应该是协作的关系。数据库功能强,应用可以少做一些;数据库功能弱,应用就要多做一些。当然这里说的是某个功能的强弱。所以不同的场景造就了不同的数据库功能。理解了这点,后面在了解分布式事务中间件的实现原理后就更容易接受些。更大的意义在于开发和DBA也应该是协作的关系(好CP!扯远了)。
单实例的事务特点和场景问题是大家都熟知的,本文主要是总结这个然后引出对分布式数据库下这些事务和问题的思考,以及为下篇介绍分布式事务解决方案做铺垫。
参考
Martin Kleppmann, Designing.Data-Intensive.Applications
MySQL索引背后的数据结构及算法原理
何登成,InnoDB多版本(MVCC)实现简要分析