弱隔离等级
如果两个事务没有触及到相同数据,那么他们可以安全地并行,因为它们没有依赖关系。并发问题(竞态条件)只在相同数据一个事务读,另一个事务写或者两个事务同时写入相同数据时才会发生。
并发问题很难在测试中发现,因为那些问题只有在不走运的时候才会触发,但是那种时候很少出现并且很难复现。并发问题也很难归因,特别是在大型系统里面,你不知道那段代码就访问了数据库。在一个时间只有一个用户的情况下,应用开发已经很难了,有多个并发用户难上加难,因为任何数据都可能在意想不到的时候发生改变。
因此,数据库一直在视图通过提供事务隔离来隐藏并发问题。理论上,隔离可以防止并发发生来简化工作:序列化隔离意味着数据库保证事务能有顺序执行的效果(一次执行一个,无并发)。
实际中,隔离没有那么简单。序列化隔离对性能有损耗,很多数据库不想支付这种成本。因此很多系统都使用了弱等级的隔离。这些隔离等级很难理解,并且会导致潜在的问题,但是还是在实践中被使用。
弱隔离事务会造成很多问题。它们会造成经济损失,损坏客户数据。一个相关问题的评论就是:“如果你要处理金融数据,就使用ACID数据库”,这并不准确。很多流行的关系型数据库(被认为是符合“ACID”)使用弱隔离,它们并不能防止发生bug。
我们不能只是盲目地依赖工具,而是需要理解存在的并发问题,以及如何预防它们。然后才能构建可靠和正确的应用,正确地使用工具。
这节我们会看到实际中使用的多种弱隔离事务,详细讨论哪种竞态条件会发生,哪种不会,如此我们才能决定哪种等级适合我们的应用。然后我们会讨论顺序化。
read committed
最基本的隔离等级是已提交读。它做了两个保障:
1、从数据库读取时,只看到已经提交的数据(无脏读)。
2、写入数据库时,只会覆写已经提交的数据(无脏写)。
无脏读
设想一个事务已经写入了一些数据,但是事务没有提交或者丢弃。如果另一个事务能看到未提交的数据,就是脏读。
已提交读隔离等级必须放置脏读。任何事务写入的数据必须在提交后才能被其他事务看到。如图7-4
阻止脏读有以下用处:
1、脏读意味着事务会看到部分更新。如图7-2,用户只看到新的未读邮件,但是没有更新计数。部分更新状态给用户的体验很不好,可能导致其他事务做出错误决策。
2、如果事务被丢弃,它的写入都会被回滚(图7-3)。如果数据库允许脏读,这意味着事务可能看到后续会回滚的数据。后续的逻辑会出错。
无脏写
如果两个事务同时更新相同对象,会发生什么?我们不知道写入顺序,但是我们可以认为后续的写入会覆盖先前的写入。
但是如果早一些执行的事务还没有提交,后续的事务覆写的未提交的值,这会发生什么?这称为“脏写”。已提交读隔离等级需要防止脏写,一般是延后第二个写入,直到第一个事务写入提交或者丢弃。
防止脏读可以防止以下问题:
1、如果事务更新多个对象,脏读会产生糟糕的输出。如图7-5,在一个二手汽车交易网站,Alice和Bob同时购买同一辆汽车。购买汽车需要两步:网站列表需要更新来反映买家,购物发票需要送到买家。在图7-5的例子中,Bob买到了汽车(列表显示购买成功,更新listings表格),但是发票却送到了Alice(更新了invoices表)。已提交读会防止这类问题。
2、但是,已提交读不会防止如图7-1所示的两个计数器增长的竞态条件。这种情况下,第二个写入发生在第一个写入提交之后,这不是脏读。这依然是错的,但是原因不是脏读。
实现提交后读
大多数数据库使用行一级的锁来防止脏写:当一个事务项修改特定对象(行或者文档),它必须先获得该对象的锁,在事务提交或者丢弃之前必须一直持有锁。一个对象的锁在一个时间只能被一个事务持有;如果另一个事务想写入相同的对象必须等前一个事务提交或者丢弃后才能获得锁。加锁是数据库在读取提交模式下自动完成的(或者是更高的隔离等级)。
如何防止脏读?一个选择是使用相同的锁,任何需要读取对象的事务都需要先获得锁,然后读取后立刻释放锁。这可以确保不会读取到有未提交的值的对象(因为在此期间,锁会被做出写入操作的事务持有)。
但是在实践中,读取锁不是很好用,因为一个运行时间长的事务会强制很多读取事务等待直到写入事务完成。这延长了只读事务的响应时间,对操作性很不利:应用的一部分的延迟会掣肘应用的其他部分。
因此大部分数据库使用图7-4的方法来防止脏读:系统同时记录旧值和持有锁的事务设置的新值,当事务正在进行时,任何其他事务只能读取到旧值;只有当新值提交后,事务才开始读取新值。
快照隔离和可重复读
使用读取提交隔离,比起不使用保障强很多。但是使用这个等级的隔离时,还是有一些场景会遇到并发问题。如图7-6.
Alice在银行中存有1000元,分配在两个账号上,每个账号500元。现在将一个账户的100元转移到另一个账户下面。如果Alice在事务进行中在查看账户余额,可能看到错误的结果。她可能在账户1转入前看到账号1,在账号2转出后看到账号2;在Alice看来,100元消失来。
这就是不可重复读或者是读取偏斜( nonrepeatable read 或者 read skew):如果Alice在事务结束后再次读取,会看到账户1余额为600。读取偏斜在提交读取隔离中被认为是可以接受的:Alice看到的余额确实是提交后的值。
在Alice的案例中,问题不会持久,只要几秒后重新加载网页就行。但是一些场景无法容忍短暂的不一致。
备份:备份需要拷贝整个数据库,如果数据库很大,这可能需要几个小时。这备份的时间里,数据库会持续接收写入。因此你得到的拷贝可能部分数据是旧的。如果想从这个拷贝中恢复数据库,不一致性会一直存在。
分析查询和完整性检查:如果一个查询需要扫描大部分数据库,如分析查询或者是定期的完整性检测。如果它们观察到的数据库的不同部分处在不同时间,返回的结果是无效的。
快照隔离就是用来解决这类问题。方法是每个事物从数据库的一个一致性快照读取数据,即事物看到的数据都是事物开始时提交的。即便后续数据被其他事务修改,每个事务也只是看到特定时间点的旧数据。
快照隔离对于备份和分析这种长时间运行、只读的查询很有用。如果查询会修改数据,同时有其他查询在执行,那么很难从结果中看到合理性。当事务看到数据库的某个时间点的一致性快照,结果就很容易理解了。
实现快照隔离
类似提交读取隔离,快照隔离使用写入锁来防止脏写,一个事务写入一个对象时会阻止其他事务写入。但是读取过程没有加锁。快照隔离的一个核心原则就是:读取与写入之间不能相互屏蔽,读取不能阻止写入,写入不能阻止读取。这样才能在运行长时间的查询的同时能处理写入。
为了实现快照隔离,数据库使用了图7-4的机制。数据库会保存一个对象的数个提交版本,因为程序中不同事物需要在不同的时间看到对象的状态。因为这种机制维护了一个对象的多个版本,称为“多版本并发控制” multiversion concurrency control (MVCC)。
如果数据库只需要提供提交读取隔离,不需要提供快照隔离,那么一个对象维护两个版本就足够了:已经提交的版本和已经覆写但是未提交的版本。但是支持快照隔离的数据库也是通过MVCC来实现提交读取隔离。一个方法就是为每个查询提供单独的快照隔离来实现读取提交隔离,为每个事务提供快照隔离来实现快照隔离。
图7-7显示了Postgresql如何使用MVCC实现快照隔离的。当事务开始时,会被分配给一个唯一的、只增的事务ID(txid)。当事务写入数据的时候,数据就会被打上事务的ID。
表格中每一行都有一个 created_by 字段,包含将这条记录插入数据库的事务的id。并且每一行都有deleted_by字段,初始是空的。如果事务删除记录,那么那条记录实际上并不会被删除,而是在 deleted_by字段里填上执行删除操作的事务id。过些时候,如果确定没有事务能够访问到标记删除的数据,那么后台的垃圾处理器会移除标记删除的数据,释放空间。
更新操作通常翻译为一个删除和增加操作。如图7-7,事务13减少了账户2的100元余额,从500改为400。账号2的account表格实际上包含了两行,一行有500余额并且标记为事务13删除了它,一行有400余额标记为事务13添加了它。
一致性快照的可见性规则
当事务从数据库中读取时,事务ID决定了哪些对象可见,哪些不可见。一个一致性快照有以下可见性规则:
1、在每个事务开始的时候,数据库建立了程序中的其他事务的列表,排除已经提交的和丢弃的。事务列表中事务的任何写入都忽略,即便是随后就会提交的事务。
2、废弃事务的任何写入都忽略
3、任何后续事务(当前事务开始后开始的事务)做出的写入都忽略,不管那些事务是否会提交。
4、所有的写入对于查询都可见。
这些规则适用于删除和新建的对象。在图7-7中,当事务12读取账号2时,它看到余额为500,因为根据规则3,事务13作出的删除余额500,增加余额400这些操作不可见。
换种说法,一个对象只有满足下列条件后才可见:
1、在读取事务开始时,创建对象的事务已经提交了。
2、对象没有标记为删除;如果标记删除,那么作出删除操作的事务在读取事务开始时还没有提交。
长时间运行的事务可能长时间使用一个快照,持续读取那些在其他事务看来已经覆写或者删除的值。数据库可以不使用原地更新,而是每次值改变时新建一个值来提供一致性快照。
索引和快照隔离
索引如何在多版本数据库上工作?一个选择是索引只是指向一个对象的所有版本,然后再需要一个查询索引过滤掉对当前事务的所有不可见的对象。当垃圾收集器移除掉任何事务都不可见的旧版本对象时,相应的索引条目也可以删除。在应用中,很多优化细节决定了多版本并发控制的细节。如PostgreSQL做了如下优化,如果不同版本的相同对象可以适用于相同页,那么索引不会更新。
另外一个方法就是 CouchDB, Datomic, 和LMDB使用的。它们也使用B-trees,但是使用的是只增/写入时复制(copy-on-write)这种性质的树;在更新时不会覆写page,而是会给每个修改的page建立一个新的副本。包括修改过的page的父page,直至树的根节点都会被复制,然后指向新版本的子page。不受写入影响的page不需要复制,保持不变。
使用只增的B-tree,每个写入事务(或者是批量事务)都会创建一个新的B-tree根节点,一个根节点就是数据库在根节点被创建的那个时间点的一个一致性快照。因为后续写入不会修改已经存在的B-tree,所以不需要依照事务id过滤掉对象。但是,这种方法需要额外的后台压缩程序和垃圾收集。
可重复读和命名混乱
因为SQL标准没有 统一,快照隔离的标准不一,不同数据库里的实现有不同的名字。Oracle里称为序列化,PostgreSQL和MySQL里称为可重复读。
防止更新丢失
目前讨论的提交后读取和快照隔离等级都是保证在有写入时,只读事务该获得什么。我们忽略掉了两个事务的并发写入问题,我们只讨论了“脏写”这个特定的并发问题。还有其他类型的并发写入事务间冲突产生的问题。最著名的就是如图7-1的“更新丢失”(lost update)问题。
如果一个程序从数据库读取某值,修改、写入修改后的值(读取-修改-写入的循环),就会发生更新丢失的问题。如果两个事务并发执行,其中一个的修改就会丢失,因为第二个写入没有包含第一个写入的修改。
什么场景下回出现“写入丢失”:
1、计数或者增加账户余额,需要读取现在的数据,计算新数据,然后写回去。
2、局部修改一个复杂数据,如在一个JSON格式的list数据中添加元素;需要解析数据,修改,然后写回去。
3、两个用户同时修改一个维基页面。每个人都会将整个页面发送至服务器,覆盖掉数据库中的当前内容。
因为这是个常见问题,有不少解决方法。
原子写入操作
很多数据库提供原子写操作,因而应用不需要实现“读取- 修改-写入”循环。如果代码能够使用相关操作,那么它就是最好的选择。例如下面操作在大多数关系型数据库中就是安全的:
原子写操作,一般使用排他性锁来实现,在读取的时候就加锁直到更新结束。另一个实现方法就是强制操作单线程执行。
问题是对象-关系映射框架经常误使用不安全的读取修改写入循环,而不是使用原子操作,这会造成难以排查的隐藏bug。
显式加锁
如果数据库内置的原子操作方法没法满足需求,那么可用的方法就是在代码中显式加锁。如此,在代码进行读取-修改-写入循环时,如果有其他事务要读取相同对象,需要等到正在进行的循环结束。
例如在一个多人游戏中,多个玩家移动同一个对象,只靠数据库的原子操作是不够的,因为应用需要保证玩家的移动操作没有违反游戏规则,而这些游戏规则很难用基本的查询操作来实现。因此用锁来防止两个玩家同时移动对象,如例7-1:
FOR UPDATE子句表明要在所有返回的行上面加锁。
自动探测写入丢失
原子操作和加锁强制读取-修改-写入操作顺序执行来防止写入丢失。如果事务管理能够探测到写入丢失,丢弃事务然后强制重试,就能允许它们并行执行。
这个方法的一个优势就是数据库能够联合快照隔离来快速检测。Postgresql的可重复读,Oracle的序列化,SQL server的快照隔离都能够自动检测更新丢失,然后丢弃正在进行的事务。但是MySQ/InnoDB的可重复读不能自动检测。
更新丢失检测是个很棒的特性,它允许应用不需要特意使用特定的数据库特性,你可能忘记加锁或者原子操作然后引入bug,但是这个特性能减少错误。
Compare and set,CAS
如果数据库没有提供事务,你会发现一个原子操作,CAS(比较设置)操作。这个操作是在更新前确保值与上次读取的值相等,如果不相等则重试读取-修改-写入循环,以此来避免丢失更新。
例如:为防止两个人同时编辑相同的维基页面,可以规定只有在当前内容等于开始编辑时的内容时,才可以提交更新。
如果当前读取的内容不等于‘old content’,更新就是无效的,你可能需要重试。但是如果数据库允许WHERE子句从旧快照读取,那就不能放在丢失更新了,因为在写入并发时,条件判断为真。所以使用CAS操作前检测其是否安全。
冲突解决和备份
在分布式数据库中,要解决更新丢失,需要额外考虑一个维度:因为在多个节点上都有数据备份,所以数据可能同时在多个节点上被修改,需要额外的措施。
加锁和CAS操作都是默认有且只有一个最新的数据备份。但是多主节点和无主节点数据库运行多个写入同时写入数据库,然后异步备份它们,所以无法保证只有一个最新的数据备份;这种情况下使用加锁和CAS操作的技术就不适用了。
作为替代,一个普遍的方法就是允许并发写入,并同时记录一个值的多个有冲突的版本(同辈值),然后使用应用代码和特定的数据结构来解决冲突。
原子操作也适用于分布式环境,尤其是它们没有特定执行顺序的时候,也就是说可以在不同备份上按不同顺序执行,得到的结果是一样的。例如计数就是可交换操作。
另一方面需要注意, last write wins (LWW)冲突解决方案很容易导致更新丢失,但是很多分布式数据库默认使用LWW。
写偏斜和幻像
之前的章节中,如果两个事务并发,会导致脏写和更新丢失的问题。但是事务间的并发问题不止如此,下面是一些更复杂的冲突问题。
设想一下:现在要实现一个应用用于安排医生的排班。医院需要几个医生值班,一个时间至少有一个医生在值班。医生可以在有其他同事值班的情况下选择放弃排班。
现在设想Alice和Bob都是在一个时间段内值班的医生,现在他们的身体都不适,所以都想请假。不幸的是,他们同时点击按钮作出申请,图7-8表示发生了什么:
在每个事务,应用首先检测现在至少有两名医生在值班,如果是,那么一名医生请假是安全的。因为数据库使用快照隔离,现在检测到有两名医生在值班,检测通过,都进入下一步。Alice和Bob都请假成功了,现在没有医生在值班了。
写偏斜
上述例子的情况称为 write skew,写偏斜。既不是脏写,也不是更新丢失,因为它是两个事务更新不同对象。这个冲突不明显,但它确实是竞态条件:如果两个事务顺序执行,那么第二个医生没法请假,出现异常情况是因为两个事务并发执行。
可以将写偏斜看成是一般的写入丢失问题。如果两个事务读取相同对象,然后更新不同的对象,就可能发生写偏斜。特殊情况下,两个事务更新相同对象就发生了脏写或者特殊的丢失更新。
要防止写偏斜,方法需要更对限制:
1、原子操作无效,不适用多对象的场景
2、使用快照隔离来进行的自动检测更新丢失无效:Postgresql的可重复读,MySQL的可重复读,Oracle的序列化,SQL server的快照隔离等级,不会自动检测写偏斜。自动防止写偏斜需要真正的序列化隔离。
3、一些数据库允许你配置一些约束,如独特性,外键约束或者是限定一些特定的值。但是你需要保证至少一名医生值班,需要在限制中包含多个对象。大多数数据库没有实现这类限制,你可以使用诸如触发器,物化视图这些内置工具来实现。
4、如果你不能使用序列化隔离,第二个方法就是显示加锁,将事务相关的那些行都加锁。在医生例子中,可以这么写:
FOR UPDATE字句锁住了查询返回的所有行。
写入偏斜的更多示例
1、预订会议室
你需要确保一个会议室在一个时间段不会被两个人预订。需要先查询一个时间段有没有其他人已经预订了,然后才能继续。下面例7-2显示了预订过程
但是在快照隔离条件下,如果在事务执行过程中,其他在相同时间段要预订会议室的人已经执行完并发事务了,那么相同时间段就重复预订会议室了。因为两个并发事务同时执行的时候,查询得到的都是目标时间段是空闲的。
2、多人游戏
例7-1中,我们使用锁来防止更新丢失(确保两个玩家不会在同一时间移动相同角色)。但是这没法防止多个玩家移动不同角色至相同位置,或者是违反其他游戏规则。要符合之前的规则,需要使用唯一性约束,否则会导致写偏斜。
3、声明用户名
当你在网上注册一个新用户名的时候,需要检测用户名是否已经存在。如果两个用户想同时注册相同用户名,那么事务并发同时开始检测用户名是否已经存在,如果使用快照隔离,检测都会通过,两个用户就会注册到相同用户名。这里使用唯一性约束就能解决问题。
4、防止两次消费
服务需要用户账户能正确扣费。可以临时将物品输入到账户中,列出所有物品,检测余额是否正确。写偏斜的情况下,两个消费品可能同时插入账户,导致余额错误,但是没有任何事务能注意到。
幻像引起的写偏斜
所有例子遵循类似的模式:
1、SELECT语句查询得到某些信息,作为先决条件决定。
2、依赖第一步的查询结果,应用决定如何进行下一步。
3、如果决定继续,就作出写入(INSERT,UPDATE,DELETE)。写入的结果会改变第二步的先决条件。如果从第一步开始,那么会得到不同的结果,因为查询结果改变了(值班医生数量减少了;在一个时间段,会议室被预定了;地图上的某个点位已经被一个角色占据了;用户名已经被使用了;账户余额减少了)。
步骤可能有变,首先会做出写入过程,然后做检测判断是否要废弃事务。
在值班医生案例,第三步中会修改第一步返回的行,因此可以使用SEARCH FOR UPDATE语句来加锁防止写偏斜;但是其他案例中,第一步是检测没有某些行,第三步会添加行,所以不能通过加锁来防止写偏斜。
这种现象:一个事务的写入改变了其他事务的查询结果,称为幻读。快照隔离可以防止只读查询的幻读。但是之前案例中的读写事务中的幻读会导致写偏斜。
物化冲突
如果没有对象可以加锁会导致幻像问题,那么是否可以人为地在数据库中引入对象锁。
在会议室案例中,可以在数据库中建立一张表时间段对应会议室,每一行表示一个时间段对应一个会议室(比如15min);你可以预测未来6个月的会议室的使用。这样在检查某个时间段会议室是否被占用时,可以从建立的对照表格中查询并给返回的行加锁(SEARCH FOR UPDATE),然后插入新的预订计划。时间会议室表格中不存储有关预订的信息,只是用来提供锁,防止相同会议室在相同时间被重复预订。
这种方法被称为物化冲突,将冲突转化为数据库中的行数据。但是这种方法很难而且容易出问题,而且将并发控制机制延伸到应用层代码,很怪异。因此,物化冲突时最后选择,一般情况下优先选择序列化隔离。