《数据密集型应用系统设计》第七章《事务》笔记
事务提供了一种机制,应用程序可以把一组读和写操作放在一个逻辑单元里,所有在一个事务的读和写操作会被视为一个操作:要么全部失败,要么全部成功,因此应用程序不需要担心部分失败(partial failure)问题,可以安全的重试。
深入理解事务
事务提供的安全性保证即所谓的ACID
,它包括以下四个要求:
ACID
原子性(Atomicity)
A(Atomicity,原子性):在一个事务中的所有操作,要么全部成功,要么全部失败,不存在部分成功或者部分失败的情况。在出错时中断事务,前面成功的操作都会被丢弃。
一致性(consistency)
C(Consistency,一致性):对数据有特定的预期状态,任何数据修改必须满足这些状态约束,比如针对一个账号,账号上的款项必须保持平衡。
隔离性(isolation)
I(Isolation,隔离性):并发执行的多个事务,不会相互影响。
如上图中所示,两个客户端同时增加数据库的计时器,由于没有做好隔离,导致最终的结果是43而不是正确的44。
ACID语义中的隔离性意味着并发执行的多个事务相互隔离,不能交叉运行。经典的数据库教材将隔离性定义为可串行化(serializability),这就意味着可以假装它是数据库上运行的唯一事务。
然而实践中,由于性能问题很少使用串行化隔离。
持久性(Durability)
D(Durability,持久性):一旦事务提交,数据将被持久化存储起来。
弱隔离级别
可串行化的隔离会影响性能,而很多业务不愿意牺牲性能,因而倾向于使用更弱的隔离级别。
以下介绍几个常见的弱隔离级别(非串行化)。
读提交(read committed)
读提交是最基本的事务级别,提供两个保证:
- 读数据库时,只能读到被提交成功的数据(不会读到脏数据)。
- 写数据库时,只会覆盖已被提交成功的数据(不会脏写)。
防止脏读
如果一个事务被中断或者没有提交成功,而另一个事务能读取到这部分没有提交成功的数据,这就是“脏读”。
如上图,用户2仅在用户1的事务提交成功之后,才能读取到这次事务修改的新值x=3。
防止脏写
如果先前写入的数据是尚未提交事务的一部分,而被另一个事务的写操作覆盖了,这就是脏写。通常防止脏写的办法是推迟第二个写请求,等到前面的事务操作提交。
如上图,alice和bob两人试图购买同一辆车。购买时需要两次数据库写入:网站需要更新买主为新买家,而同时发票也需要随之更新。 但是在上图中,车主被改成了bob,但是发票上面写的却是alice。
实现读提交
实现防脏写:数据库通常使用行级锁来防止脏写,事务想修改某个对象,必须首先获得该对象的锁,直到事务结束。
实现防脏读:也可以使用前面的防脏写来实现防脏读,但是这样代价太大了。一般的方式是保存这个值的两个版本,事务没有提交之前返回旧的值,提交之后才返回新的值。
然而,读锁在实际中并不可行,原因在于运行时间较长的事务导致了许多只读事务等待太长的时间。
因此,大部分数据库使用7-4中的方式来防止脏读:对于每个待更新的对象,数据库都会维护其旧值和当前持有锁事务将要设置的新值两个版本。在事务提交之前返回的是旧值;仅当事务提交之后,才会切换到新的值。
快照隔离级别(Snapshot isolation)和重复读
尽管上面的读提交已经能解决一部分问题,但是还是有一些问题不能解决的,如下图:
上图中,alice有两个账号,但是如果alice在转账过程中去查看账户,会发现少了100美元。
原因在于:alice对两个账户的两次读操作是同一个事务,而在这两次读操作之间,还有两次写操作,在这两次写操作完成之后才进行的第二次读操作,这样读出来的数据就不一致了。
这种异常现象称为”不可重复读取(nonrepeatable read)“或者”读倾斜(read skew)“问题。
以上问题,并不是一个永久性问题,因为alice的账号最终会一致,然而某些场景下这种过程中的不一致现象不能接受,比如:
- 备份数据:如果备份过程中的数据不一致,就会导致永久的不一致。
- 分析查询与完整性检查场景:如果这些查询在不同的时间点返回不一致的结果,则结果也无意义。
快照隔离级别是解决以上问题的常见手段。每个事物都从数据库的一致性快照中读取,事务一开始看到的是最近所提交的数据,即使数据随后可能被另一个事务修改,但保证事务都只能看到该特定时间点的旧数据。
快照隔离级别对于长时间运行的只读查询(如备份和分析)非常有用。如果数据在查询的同时还在发生变化,那么查询结果对应的物理含义就难以理清。而如果查询的是数据库在某时刻点所冻结的一致性快照,则查询结果非常明确。
实现快照隔离级别
快照级隔离的实现通常采用写锁来防止脏写,这意味着正在进行写操作的事务会阻止同一对象上其他事务。而读锁则不需要加锁了。从性能角度,快照级别隔离的关键点就是读操作不会阻止写操作,反之亦然。这使得数据库可以在处理正常写入的同时,在一致性快照上执行长时间的只读查询,且两者之间没有锁的竞争。
考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象的多个不同的提交版本,称为MVCC(Multi Version Concurrency Control)。如下图所示:
给每个事务一个唯一的、单调递增的事务ID(txid),每当事务写入新数据的时候,所写的数据都会带上写入者的事务ID。表中的每一行的created_by字段,用于保存创建该行的事务ID;deleted_by初始为空,用于保存请求删除该行的事务ID,仅用于标记为删除。事后,仅当确认没有其他事务引用该删除行的时候,才执行真正的删除操作。
上图中,账号1的最后一次写操作由事务ID 3完成,账号2的最后一次写操作由事务ID 5完成。事务ID 13的操作修改两个账号数据的时候,会同时设置上这一行的旧数据被事务13删除,同时新的数据由事务13创建。这样,事务12的针对账号2的读操作,返回的就是两个版本的数据:由事务5创建而由事务13删除的数据500,和由事务13创建的数据400,这样修改和未完成的读事务两者就被隔离而不会互相影响。
一致性快照的可见性原则
当事务读数据库时,通过事务ID可以决定哪些对象可见和不可见。要想对上层应用维护良好的快照一致性,需要静心考虑数据的可见性规则。如:
- 每笔事务开始时,数据库列出所有当时尚在进行中的其他事务,然后忽略这些事务完成的部分写入,即不可见。
- 所有中止事务所做的修改全部不可见。
- 较晚事务ID(即晚于当前事务)所做的任何修改不可见。
- 除此之外,其他所有的写入都对应用查询可见。
换言之,仅当以下两个条件都成立,则该数据对象对事务可见:
- 事务开始的时刻,创建该对象的事务已经完成了提交。
- 对象还没有被标记为删除,或者即使标记了,但是删除事务在当前事务开始时还没有完成提交。
防止更新丢失
更新丢失的典型场景:应用从数据库读取某些值,根据逻辑进行修改,然后写入新值(read-modify-write)。当有两个事务在同样的数据对象上执行类似操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一个事务修改后写入的值丢失。
比如:
- 递增计数器,或更新账户余额。
- 对某个复杂对象的一部分内容进行修改。
- 两个用户同时编辑一个文档。
有以下几种解决方案:
原子写操作
使用数据库提供的原子操作,比如:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
显示加锁
自动检测更新丢失
上面的原子写操作和锁都是通过强制“读-修改-写回”操作序列化串行执行来防止丢失更新,另一种思路是先让它们并发执行,但是如果检测到更新丢失,则会终止当前事务,强制回退到安全的“读-修改-写回”方式。
原子比较和设置
采用类似CAS(Compare-And-Swap)方式,只有在上次读取的数据没有发生变化时才允许更新,如果发生了变化,则回退到“读-修改-写回方式”。
比如为了避免两人同时编辑文档,采用以下的sql更新语句:
UPDATE wiki_pages SET content = 'new content' where id = 1234 AND content = 'old content';
写倾斜和幻读
当多个事务同时写入同一对象时引发了两种竞争条件,然而这些并不是并发写所引起的所有问题。
如下图所示,开发一个医院轮班系统,在保证至少有一个医生在值班的情况下,可以申请休假,但是这还是会出现问题:
如上图中,alice和bob是两位值班医生,两人碰巧都遇到身体不适决定请假,但是几乎同一个时刻点击了调班按钮,于是发生了上图的事情。
每笔事务总是首先检查是否至少有两名医生在值班。而由于数据库使用的是快照级隔离,因此两个事务的检查都返回了两名医生,这样两个事务都得以继续执行。接着两人都更新自己的值班记录离开,两个事务都成功提交,最后的结果就是没有任何一个医生在岗。
定义写倾斜
这种情况称为”写倾斜“,既不是脏写,也没有导致数据丢失。两次事务更新的是不同的对象(alice和bob的值班记录),写冲突并不那么直接。
可以将写倾斜问题视为一种更加广泛的更新丢失问题:即如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务可能更新不同的对象,则可能发生写倾斜;而如果不同的事务更新的是同一个对象,则可能发生脏写或更新丢失(具体取决于事件窗口)。
更多写倾斜例子
为何产生写倾斜
写倾斜都有类似的模式:
- 输入一些条件,按照条件查询出满足条件的行。
- 根据查询结果,应用层决定下一步操作。
- 应用程序需要更新一部分数据,而这个更新操作会改变步骤2的做出决定的前提条件,即写入之后再执行步骤1的查询操作将得到不同的结果。
这种在一个事务中的写入改变了另一个事务查询结果的现象,称为幻读(phantom)。
实体化冲突
串行化
可串行化的快照隔离(serializability Snapshot Isolation,简称SSI)
悲观与乐观的并发控制
两阶段加锁是典型的悲观并发控制机制。基于这样的设计原则:如果某些操作可能出错,那么直接放弃,采用等待方式直到绝对安全。这与多线程编程中的互斥锁一样。
某种意义上来说,串行执行是极端悲观的选择:事务执行期间,等价于事务对整个数据库持有互斥锁。
相比之下,可串行化的快照隔离则是一种乐观并发控制。在这种情况下,如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望于一切相安无事;而当事务提交时,数据库会检查是否确实发生了冲突,如果是的话中止事务再进行重试。
基于过期的条件做决定
事务是基于某些前提条件而决定采取行动,在事务开始时条件成立。
那么数据库如何知道查询结果是否发生了变化?可以分为以下两种情况:
- 读取是否作用于一个(即将)过期的MVCC对象。
- 检查写入是否影响即将完成的读取。