# 数据库事务

# 事务

# 特性 ACID

# 原子性

一个事务(transaction)中的所有操作,要么全部完成,要么全部失败,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。也就是说事务执行成功全部会应用到数据库,失败的话对数据库没用任何影响

# 一致性

在事务开始之前和事务结束以后,数据库的完整性没有被破坏

比如说A账户有1000块,B账户有100块,A转账500给B,那么A变成500,B变成600,也就是说两者之间无论如何转账都好,在事务结束之后加起来都是1100,这就是一致性

# 隔离性

数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

事务隔离分为不同级别,包括

  • 读未提交(Read uncommitted)
  • 读提交(read committed)
  • 可重复读(repeatable read)
  • 串行化(Serializable)

# 持久性

事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失

# 事务隔离级别

事务隔离级别,主要保障关系数据库ACID特性的I(Isolation),既针对存在冲突的并发事务,提供一定程度的安全保证。隔离性是如何防止多个事务并发执行时由于交叉执行而导致数据的不一致的安全问题的呢?答案是通过设置隔离级别来解决的

SQL标准中有对于各个隔离级别所允许出现的问题作出规定

打X表示在该隔离级别下,不会出现这类问题

db-001

# 数据库并发执行导致的问题

# 脏写(Dirty Write)

还未提交的事务写了另一个未提交事务所写过的数据,称为脏写。 比如: 两个并发执行的事务A、B,A写了x,在A还未提交前,B也写了x,然后A提交,此时虽然B还没有提交,但是A也会发现自己写的x不见了。

db-002

很多地方用“覆盖”去形容脏写,但是我觉得不太适合,因为覆盖暗示了一种先后链条,某个事务写了数据,在昨天就提交了,今天有事务来写同一个数据,可以称之为覆盖,昨天的数据成为历史,但这不是脏写,所以更适合的形容可能是“擦除”,事务发现自己的提交被别人擦除,好像不存在。 脏写是事务一定不允许发生的,所以不管是哪个隔离级别都一定不允许脏写

# 脏读(Dirty Read)

由于事务的可回滚特性,因此commit前的任何读写,都有被撤销的可能,假如某个事物读取了还未commit事务的写数据,后来对方回滚了,那么读到的就是脏数据,因为它已经不存在了

db-003

避免脏读可以采用加锁或者快照读的解决方案。在已提交读Read Committed级别就可以避免脏读,因为读到的一定是已经Commit的数据。在业务开发中,虽然有未提交读(Read Uncommitted),但是几乎是没有人会用的,读到脏数据一般对业务是很大的伤害,所以有的数据库干脆都不支持未提交读,比如PostgreSQL

# 不可重复读(Non-Repeatable Read)

事务A读取一个值,但是没有对它进行任何修改,另一个并发事务B修改了这个值并且提交了,事务A再去读,发现已经不是自己第一次读到的值了,是B修改后的值,就是不可重复读。 简单来说就是第一次读的值,啥都没做,下次读它也有可能发生变化。

db-004

一般数据库使用MVCC,在事务的第一条语句开始时生成Read View,事务之后的所有读取,都是基于同一个Read View,以此避免不可重复读问题。

# 幻读(Phantom)

与不可重复读非常类似,事务A查询一个范围的值,另一个并发事务B往这个范围中插入了数据并提交,然后事务A再查询相同范围,发现多了一条记录,或者某条记录被别的事务删除,事务A发现少了一条记录

db-005

幻读容易与不可重复读混淆,区别它们只需要记住不可重复读面向的是“同一条记录”,而幻读面向的是“同一个范围”。 MVCC虽然使用快照的方式解决了不可重复读,但是还是不能避免幻读,幻读需要通过范围锁解决,可能大家会觉得很奇怪,为什么快照读无法避免幻读,这个会在下一篇文章中详细讲。

除了以上4个问题外,下面还有3个问题,更偏向业务层面,不过也是由于隔离不足引起的:

# 读偏差(Read Skew)

Skew可以理解为不一致,因此读偏差可以理解为读结果违反业务一致性,比如X、Y两个账户余额都为50,他们总和为100,事务A读X余额为50,然后事务B从X转账50到Y然后提交,事务A在B提交后读Y发现余额为100,那么它们总和变成了150,此时违反业务一致性。

db-006

# 写偏差(Write Skew)

写偏差可以理解为事务commit之前写前提被破坏,导致写入了违反业务一致性的数据,网上有个很好的简称为写前提困境,也就是读出某些数据,作为另一些写入的前提条件,但是在提交前,读入的数据就已被别的事务修改并提交,这个事务并不知道,然后commit了自己的另一些写入,写前提在commit前就被修改,导致写入结果违反业务一致性。 写偏差发生在写前提与写入目标不相同的情境下。 这是业务开发中最容易出错地方,如果开发者不太理解隔离级别,也不知道目前使用的是哪个隔离级别,很可能写出有写偏差的代码,造成业务不一致。 举个例子: 信用卡系统对不同等级的会员有积分加成,3级会员则每次都3倍积分,同时,会有定时任务检查当积分不满足要求时,就会降级。 首先,会员进行了刷卡消费,此时要计算积分,开启了事务A,读到会员等级为3,与此同时定时任务也开始了,读到会员积分为2800,已经不满足3000分应该降级为2级,然后将会员等级降级为2并且commit,由于事务A读到的等级为3,它还是按照3倍积分为会员增加了积分,会员赚了,多亏那个程序员不理解他使用的事务隔离级别,出现了业务不一致

db-007

# 丢失更新(Lost Updates)

由于未提交事务之间看不到对方的修改,因此都以一个旧前提去更新同一个数据,导致最后的提交结果是错误值。 假设有支付宝账户X,余额100元,事务A、B同时向X分别充值10元、20元,最后结果应该为130元,但是由于丢失更新,最后是110元。

db-008

丢失更新与写偏差很相似,都是由于写前提被改变,他们区别是,丢失更新是在同一个数据的最终不一致,而写偏差的冲突不在同一个数据,是在不同数据中的最终不一致

# 总结

  • 脏写(Dirty Write)
  • 脏读(Dirty Read)
  • 不可重复读(Unrepeatable Read)
  • 幻读(Phantom)
  • 读偏差(Read Skew)
  • 写偏差(Write Skew)
  • 丢失更新(Lost Updates)

db-009

# 数据库并发控制解决方案

数据库并发问题本质上都是由写造成的,根源都是数据的改变。 读是不改变数据的,因此无论多少读并发,都不会出现冲突,如果所有的事务都只由读组成,那么无论如何调度它们,它们都是可串行化的,因为它们的执行结果,都与某个串行执行的结果相同,但是写会造成数据的改变,稍有不慎,这个并发调度的结果就会与串行调度的结果不符合

数据库如何避免这些问题,也就是如何实现隔离,主要是讲两种主流技术方案:

  • MVCC

# 锁的分类

从数据库系统角度分为三种:排他锁(x)、共享锁(s)、更新锁(u)。 从程序员角度分为两种:一种是悲观锁,一种乐观锁

# 悲观锁

需要数据库本身的支持,通过SQL语句“select for update”锁住select出的那批数据,总是假设最坏的情况,每次取数据时都认为其他线程会修改,当其他线程想要访问数据时,都需要阻塞挂起。悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;另外,在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

# 乐观锁

乐观锁,虽然名字中带“锁”,但是乐观锁并不锁住任何东西,而是在提交事务时检查这条记录是否被其他事务进行了修改:如果没有,则提交;否则,进行回滚。相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。如果并发的可能性并不大,那么乐观锁定策略带来的性能消耗是非常小的。乐观锁采用的实现方式一般是记录数据版本

具体方式为:

数据版本是为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。一般地,实现数据版本有两种方式,一种是使用版本号,另一种是使用时间戳。

# 参考

  1. 从0到1理解数据库事务(上):并发问题与隔离级别 (opens new window)
  2. 从0到1理解数据库事务(下):隔离级别实现——MVCC与锁 (opens new window)
陕ICP备20004732号-3