数据库事务
在数据库技术中,事务将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元。即事务中的所有读写是一个执行的整体,整个事务要么成功(提交)、要么失败(中止或回滚)。如果失败,应用程序可以安全地重试。这样,由于不需要担心部分失败的情况(无论出于何种原因),应用层的错误处理就变得简单得多。
1. ACID的含义
事务提供了四个方面的安全保证,即ACID,分别代表原子性(Atomicity),一致性(Consistency),隔离性(Isolation)与持久性(Durability)。
1.1 原子性
ACID原子性描述了客户端发起一个包含多个写操作的请求时可能发生的情况,例如在完成了一部分写入后,系统发生了故障,包括进程崩溃,网络中断,磁盘变满或者违反了某种完整性约束等;把多个写操作纳入到一个原子事务,万一出现了上述故障而导致没法完成最终提交时,则事务会中止,并且数据库丢弃或撤销那些局部完成的操作。
ACID原子性所定义的特征是:在出错时中止事务,并将部分完成的写入全部丢弃。它强调一个可中止性的概念。
1.2 一致性
ACID中的一致性主要是指对数据有特定的预期状态,任何数据更改必须满足这些状态约束(或者恒等条件)。例如,对于一个账单系统,账户的贷款余额应和借款余额保持平衡。如果某事务从一个有效的状态开始,并且事务中任何更新操作都没有违背约束,那么最后的结果依然符合有效状态。
这种一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情:即如果提供的数据违背了恒等条件,数据库很难检测进而阻止该操作(数据库可以完成针对某些特定类型的恒等约束检查,例如使用外键约束或唯一性约束。但通常主要靠应用程序定义数据的有效/无效状态,数据库主要用于存储)。
原子性,隔离性和持久性是数据库自身的属性,而ACID中的一致性更多是应用层的属性。应用程序可能借助数据库提供的原子性和隔离性,以达到一致性,但一致性本身并不源于数据库。因此,也有一种说法,字母C其实并不应该属于ACID。
1.3 隔离性
ACID语义中的隔离性意味着并发执行的多个事务相互隔离,它们不能互相交叉,主要是指多个事务中对相同记录读写操作进行隔离。
1.4 持久性
数据库系统本质上是提供一个安全可靠的地方来存储数据而不用担心数据丢失等。持久性它保证一旦事务提交成功,即使存在硬件故障或数据库崩溃,事务所写入的任何数据也不会消失。
在ACID中,原子性,隔离性和持久性是数据库自身的属性,其中原子性和持久性,我们能修改的地方不多,所以后面的内容主要关注隔离性相关的内容。
2. 隔离性
隔离性主要是解决多个事务对相同数据或关联数据同时进行读写引发的问题,这些问题包括脏读、脏写、不可重复读及幻读。
2.1 脏读
定义:一个事务读取了另外一个事务未提交的数据,主要是并发读的问题。
假定某个事务已经完成部分数据写入,但事务尚未提交(或中止),此时另一个事务可以看到尚未提交的数据,如图所示:
事务1设置了x=3,在事务1未提交之前,事务2的get x操作返回了3。没有脏读时,事务2只有在事务1的事务提交之后才能看到x的新值。
当有以下需求时,需要防止脏读:
- 如果事务需要更新多个对象,脏读意味着另一个事务可能会看到部分更新,而非全部。
- 如果事务发生中止,则所有写入操作都需要回滚。如果发生了脏读,这意味着它可能会看到一些稍后被回滚的数据,而这些数据并未实际提交到数据库中。之后所引发的后果可能会变得难以预测。
2.2 脏写
定义:一个事务覆盖了另外一个事务未提交的数据更新,主要是对同一份数据进行并发更新的问题。
如果两个事务同时尝试更新相同的对象,会发生什么情况?我们不清楚写入的顺序,但可以想象后写的操作会覆盖较早的写入。如果先前的写入是尚未提交事务的一部分,是否还会被覆盖?如果是,那就是脏写。
如果事务需要更新多个对象,脏写会带来非预期的错误结果,例如Alice和Bob两个人试图购买同一辆车,而购买洗车需要两次数据的写入:商品买主需要更新,同时发票也要更新。如下图所示,车主被改为Bob(他成功更新了商品数据),而Alice成功更新了发票信息,导致了业务数据的不一致。
2.3 隔离级别-读提交
读-提交是数据库中比较流行的事务隔离级别,它提供以下两个保证:
- 读数据库时,只能看到已成功提交的数据,防止脏读;
- 写数据库时,只会覆盖已成功提交的数据,防止脏写。
数据库通常使用行级锁来防止脏写:当事务想修改某个对象(例如行或文档)时,它必须首先获得该对象的锁;然后一直持有锁直到事务提交(或中止)。给定时刻,只有一个事务可以拿到特定对象的锁,如果有另一个事务尝试更新同一个对象,则必须等待,直到前面的事务完成了提交(或中止)后,才能获得锁并继续。
数据库了为防止脏读,一般使用读锁,或者对于每一个待更新的对象,数据库都会维护数据的两个版本:1)旧值的版本;2)当前事务最新的版本。在事务提交之前,所有其它读操作都读取旧值;仅当写事务提交之后,才会切换到读取最新的值。
2.4 不可重复读
定义:在同一个事务中,同一个查询操作重复多次执行,返回结果不一样,主要是并发读的问题。
不可重复读出现在一个事务中,在同一个查询操作执行多次操作的期间,另外一个事务对相同数据对象进行更新操作并成功提交事务,导致提交前后的数据不同。如下图所示,在读提交隔离级别下不能解决不可重复读的问题。
假设Alice在银行有1000美元的存款,分为两个账户,每个500美元。现在有这样一笔转账交易从账户1转账户2。如果在她提交转账请求之后而数据库系统执行转账的过程中间,来查看两个账户的余额,她有可能会看到账户1在收到转账之前的余额(500美元),和账户2在完成转账之后的敌众余额(400美元)。对于Alice来说,貌似她的账户总共有900美元,而不是1000美元。
在上面的场景中,主要是一个事务跨越了另外一个事务,读取到了另外一个事务前后更新的数据,导致了数据的不一致性。为了解决这个问题,数据库引入了多版本并发控制(Multivesion Concurrency Control,MVCC),这种技术保留了数据对象多个不同的提交版本。
提供MVCC技术的隔离级别称为快照隔离级别,在MYSQL中也叫可重复读隔离级别,我们在这里统一叫快照隔离级别。在快照隔离级别中,脏写也是通过行锁来实现的,而脏读的实现也比较简单,直接基于MVCC来实现。
以PostgreSQL(或Mysql)中的MVCC实现为例。当事务开始时,首先赋予一个唯一的、单调递增的事务ID(txid)。每当事务向数据库写入新内部时,所写的数据都会被标记写入者的事务ID,如下图所示:
表中的每一行都有一个created_by字段,其中包含了创建该行的事务ID。每一行还有一个deleted_ty字段,初始为空。如果事务要删除某行,该行实际上并未从数据库中删除,而只是将deleted_ty字段设置为请求删除的事务ID(仅仅标记为删除)。事后,当确定没有其它事务引用该标记删除的行时,数据库的垃圾回收进程才去真正删除并释放存储空间。
一次更新操作在内部会转换为一个删除操作加一个创建操作。例如,事务13从账户2中扣除100元,余额从500美元减为400美元,在account表里会出现现两行:一个余额为500但标记为删除的行(由事务13删除),另一个余额为400,由事务13创建。
当事务读数据库时,通过事务ID可以决定哪些对象可见,哪些不可见。通常情况下,仅当以下两个条件都成立,则该数据对象对事务可见:
- 事务开始的时刻,创建该对象的事务已经完成了提交;
- 对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有完成提交。
如事务12只能看到12之前提交的数据,事务13的更改对于事务12来说是不可见的。
2.5 当前读
在快照隔离级别下,以下的场景会产生问题:
- 首先输入一些匹配条件,即采用SELECT查询所有满足条件的行(例如,至少有两名医生正在值班,同一时刻房间没有预订)。
- 根据查询的结果,应用层代码来决定下一步的操作(有可能继续,或者报告错误并中止)。
- 如果应用程序决定继续执行,它将发起数据库写入(INSERT,UPDATE或DELETE)并提交事务。
假定在一个医生管理系统中,医院会安排多个医生值班,医生也可以申请调整班次,但前提是确保至少一个医生还在该班次中值班。现在的情况是,Alice和Bob是两位值班医生,两个人碰巧都感到身体不适,因而都决定请假。如果他们几乎同一个时刻执行了调班的操作,如下图所示:
在数据库使用快照级别隔离,两个检查都返回有两名医生,所以两个事务都安全地进入下一下阶段。接下来,两个事务执行更新操作,调班成功。两个事务都成功提交,最后的结果却是没有任何医生在值班,显然违背了至少一个医生值班的业务需求。
为了解决这个问题,一种可选的方案是对查询的数据行显示加锁,加上for update,如下所示:
1 | select count(*) from doctors where on_call=true and shift_id=1234 for update; |
在上面的查询语句中,加入了for update,表示对数据行进行加锁,采用的是“当前读”的模式(Mysql数据库),即当前读会读取当前最新提交的数据,即使当前事务落后于最新事务,也能看到最新事务提交后的数据。
2.6 幻读
定义:在一个事务中的写入(插入)操作改变了另外一个事务查询的结果。
在Mysql中,在快照读(可重复读)隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。幻读只会在“当前读”下才会出现,同时幻读专指“新插入的行”。
假定有如下的表及初始数据:
1 |
|
现在对该表执行三个事务,如下图所示:
可以看到,事务A里执行了三次查询,分别是 Q1、Q2 和 Q3。它们的 SQL 语句相同,都是 select * from t where d=5 for update。这个语句查询所有d=5的行,使用当前读,并且加上锁,现在来看它们的返回结果:
- Q1只返回id=5这一行数据;
- 在T2时刻,事务B修改了id=0这行数据,所以Q2返回id=0和id=5这两行数据;
- 在T4时刻,事务C插入了新的一行数据,所以Q3返回id=0,id=1及id=5这三行数据。
其中,Q3 读到 id=1 这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
2.6.1 幻读引发的问题
幻读会引入两个问题,一是语义上的问题,事务A已经对d=5的行加了锁,其它事务仍然还可以对d=5的行进行操作,如将其它行的d字段改为5或新插入d=5的行;另外一个问题,会导致数据库数据和日志的不一致,如下图所示:
在数据库中id=5的行,d字段修改为100;id=1的行,d=5,c=5。我们再来看binlog的日志:
- T2时刻,事务C提交之后,写入两行:
1
2insert into t values(1,1,5);
update t set c=5 where id=1; - T4时刻,事务A提交之后,写入三行:合在一起之后,日志内容如下:
1
2
3select * from t where id=5 for update;
update t set d=100 where d=5
select * from t where id=5 for update从执行的语句来看,事务A的update最后执行,导致所有d=5的行,d字段都修改为100,与数据库中的数据不一致。如果使用这个binlog日志进行恢复数据或进行主从备份,会导致数据的前后不一致。1
2
3
4
5
6insert into t values(1,1,5);
update t set c=5 where id=1;
select * from t where id=5 for update;
update t set d=100 where d=5
select * from t where id=5 for update
2.6.2 解决的办法
事务B中的update操作可以通过行锁来解决,但对于事务C的插入操作,由于该行在插入之前根本不存在,不能使用行锁来解决,因此,为了解决幻读问题,InnoDB 引入新的锁,也就是间隙锁 (Gap Lock)。顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。如下图所示:
这样,当执行 select * from t where d=5 for update
的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。
间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update
要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum],其中supremum是InnoDB为每个索引加的一个不存在的最大值。
3. 总结
除了上面提到的两种隔离级别:读-提交和可重复读(快照读),数据库还提供了另外两种隔离级别:读未提交和串行化,其中读未提交只解决了脏写,没有解决脏读,而串行化则要求事务串行化执行,由于性能的问题,大多数据库一般不会使用该隔离级别。
参考: