说说数据库事务开发(上)

Posted 技术闲谈

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了说说数据库事务开发(上)相关的知识,希望对你有一定的参考价值。

前言

关系数据库事务的概念已经深入人心,这方面的经验介绍五花八门,主要原因是在事务的功能和实现机制上ORACLE/SQLServer/mysql也不尽相同。同时应用在不同类型数据库之间迁移时要小心其中的“陷阱”。随着分布式数据库技术的推广,分布式事务的场景也越来越多,对于分布式事务中间件或者分布式数据库原生支持的分布式事务,传统应用在使用的时候也多有顾虑。

本文分上下两篇,主要系统总结常用关系数据库ORACLESQL ServerMySQL的事务特点原理以及场景,并跟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 IsolationSerializable之间。后面单独介绍一下。

锁和快照的关系

快照读和当前读

MySQL把读分为两种类型,一是一致性读(也叫快照读),就是上面说的根据SCN去读取合适的版本的记录。一是当前读,就是加锁读取最新的数据。这个最新的数据如果是本事务修改的,直接读取;如果是其他事务修改还没提交的,本事务就会被阻塞等待。 发出当前读的SQL通常有UPDATE/DELETE/INSERT/MERGE/SELECT...FOR UPDATE/LOCK IN SHARE/EXCLUSIVE MODE 等。写SQL之所以要当前读是因为要在最新数据上修改。

索引的多版本

在MySQL里会发现,即使是在RR隔离级别下,SQL加锁的时候,是通过对索引记录加锁,也会尝试对其他事务未提交的新增记录进行加锁(因为它符合当前写SQL的条件)。似乎索引里有其他事务未提交的版本记录,这个有点脏读的意思。

在Oracle里即使是当前读锁,也只会对本事务可见的记录所在数据块上置标记。

丢失更新Write Skew常见问题解决方案

从上面分析看出隔离级别RCRR不能规避丢失更新Write Skew问题。隔离级别SERIALIZABLE可以,但是并发太低应用也不会用。 

  • 悲观锁——两阶段锁定(2PL)

一是两阶段锁定T1在读的时候加锁(用SELECT...FOR UPDATE,当前读),然后再修改。那么并发事务T2在读的时候申请加锁就会等待直至T1提交,然后T2才修改。这是通常说的悲观锁方法。

这个方案可以规避丢失更新write skew。有个影响就是有阻塞,应用其他事务设计如果不合理,有可能会引发死锁。不过这个也是大部分业务常用的选择。

  • 乐观锁——compare-and-set

二是compare-and-setT1在读出数据后,再次更新的时候,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里锁的特点。

后记

  1. 本文提到的Oracle主要是Oracle 8i/9i/10g/11g,SQL Server是97/2000/2008R2, MySQL是5.5/5.6/5.7。由于时间比较久,加上其他新版本接触不多,这里说的可能有不准确的地方,欢迎大家指出。此外DB2和PostgreSQL里也有自己特点的实现,了解不多这里也没有总结。

  2. 事务的隔离跟分布式开发中有很多概念相通,背后的理论研究也非常多。关于一致性就有很多解释以及很多相关的概念,目前感觉不好说清楚,所以本文就故意回避了这些概念。

  3. MySQL的锁类型用法实在太多,跟隔离级别的定义有关。MySQL隔离级别的特点跟ANSI定义又有细节上的不同,其原因又是跟MySQL锁和多版本实现机制有关。这两个概念互相引用,这里不一定说清楚了,可以查阅后面查看文章或者官方文档说明。Oracle的多版本设计实在非常精妙,请不要用MySQL的MVCC设计去思考Oracle的设计原因。

  4. 事务就是一个编程框架。应用跟数据库应该是协作的关系。数据库功能强,应用可以少做一些;数据库功能弱,应用就要多做一些。当然这里说的是某个功能的强弱。所以不同的场景造就了不同的数据库功能。理解了这点,后面在了解分布式事务中间件的实现原理后就更容易接受些。更大的意义在于开发和DBA也应该是协作的关系(好CP!扯远了)。

  5. 单实例的事务特点和场景问题是大家都熟知的,本文主要是总结这个然后引出对分布式数据库下这些事务和问题的思考,以及为下篇介绍分布式事务解决方案做铺垫。

参考

  • Martin Kleppmann, Designing.Data-Intensive.Applications

  • MySQL索引背后的数据结构及算法原理

  • 何登成,InnoDB多版本(MVCC)实现简要分析


以上是关于说说数据库事务开发(上)的主要内容,如果未能解决你的问题,请参考以下文章

说说你遇到哪些数据库事务引起的奇葩Bug?

拼多多面试官:说说数据库事务隔离级别

spring事务-说说Propagation及其实现原理

如何绕过将数据模型传递给片段参数以避免事务太大异常?

片段事务中的实例化错误

Android中的片段事务问题