《数据密集型应用系统设计》整理06

第七章 事务

Some authors have claimed that general two-phase commit is too expensive to support, because of the performance or availability problems that it brings. We believe it is better to have application programmers deal with performance problems due to overuse of transac‐ tions as bottlenecks arise, rather than always coding around the lack of transactions.
—James Corbett et al., Spanner: Google’s Globally-Distributed Database (2012)

事务将应用程序的多个读、写操作捆绑在一起成为一个逻辑操作单元,整个事务要么成功,要么失败。失败的时候可以安全重试。

深入理解事务

ACID的准确含义

  • 原子性Atomicity,如果某线程执行一个原子操作,则其他线程无法看到该操作的中间结果。如果执行的时候发生了错误,则将部分完成的写入全部丢弃。
  • 一致性Consistency,数据库处于应用程序所期待的“预期状态”,任何数据更改必须满足恒等条件。
  • 隔离性Isolation,并发执行的多个事务互相隔离,不能互相交叉。
  • 持久性Durability,一旦事务提交成功,即使硬件故障或者数据库崩溃,事务所写入的任何数据也不会丢失。在单节点数据库中,意味着数据已经被写入非易失性存储设备。而对于支持远程复制等数据库,则意味着数据已经成功复制到多个节点。

弱隔离级别

可串行化的隔离会严重影响性能,而许多数据库却不愿意牺牲性能,因而更多倾向于采用较弱的隔离级别,来防止某些但非全部的并发问题。

读已提交

问题:需要保证操作的时候满足以下两个条件

  • 读数据库只能读到成功提交的数据
  • 写数据库只能覆盖成功提交的数据

解决方法
通常使用行锁来防止脏写。

但是对于脏读的场景,运行较长时间的写事务会导致许多只读的事务等待太长时间,所以大多数采取下图的方式来防止脏读:
对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本,在事务提交之前返回旧值,提交之后返回新值。
WechatIMG51

读倾斜

问题:客户在不同的时间点看到了不同值,短时间的不一致对于大多数应用是可以接受的,但是对于比如备份这样的场景是不能容忍的,因为得到的镜像中可能包含部分旧数据和部分新版本数据。

解决方法:快照级别隔离,每个事务都从数据库的一致性快照中读取。

快照级别隔离的实现通常采用写锁来防止脏写,这意味着正在进行写操作的事务会阻止同一对象上的其他事务,但是读不需要加锁。从性能上快,快照级别隔离的一个关键点是读操作不会阻止写操作。通常采用多版本并发控制(Multi-Version Concurrenct Control)。数据库可能必须保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同时间点的状态。

一致性快照的可见性规则:

  • 忽略所以正在进行的事务的部分写入
  • 所有中止事务所做的修改不可见
  • 晚于当前事务所做的修改不可见
  • 其他所有写入都可见
    可以简练为以下两点
  • 事务开始时,创建该对象的事务已经提交
  • 对象没有被标记删除,或者标记了但是删除事务在当前事务开始时还没完成提交

更新丢失

问题:两个客户端同时执行读-修改-写入,其中一个覆盖了另一个,但又不包含对方最新值。

解决方法

  • 原子写操作:采用对读取对象加独占锁的方式,原子执行类似以下的操作:
    UPDATE conters SET value=value+1 WHERE key='foo';

但是ORM可以很容易产生出非“读-修改-写回”的应用层代码,导致无法使用数据库的原子操作。

  • 显式加锁:在应用层加锁,比如对SQL加上FOR UPDATE就会对所有返回结果行加锁。
  • 自动检测更新丢失:另一种思路是先让他们并发执行,如果事务管理器检测到了更新丢失的风险,则会中止事务,并强制回退到“读-修改-写回”方式。
  • 原子比较和设置(CAS):只有在上一次读取的数据没有发生变化时才允许更新,如果变化了则回退到“读-修改-写回”方式。
  • 冲突解决与复制:加锁和原子修改都是前提是只有一个最新的数据副本。多副本的情况下,通常支持多个并发写,然后保留多个冲突版本,之后由应用层逻辑或依靠特定的数据结构来解决。

写倾斜与幻读

问题:写倾斜是一种更广义的更新丢失问题:事务首先查询数据,根据数据来做出决定,修改数据库。在事务提交的时候,前提条件可能已经不再成立。

幻读:在一个事务中的写入改变了另一个事务查询结果的现象。
WechatIMG51-1
解决方法

  • 查询结果中有对象可以加锁,用FOR UPDATE加锁
  • 查询结果中没有对象可以加锁,使用实体化冲突,把问题转换为针对数据库中一组具体行的锁冲突问题。比如对于会议室预定,构造一个时间-房间表,表的每一行对应于特定时间段的特定房间

上面的方式不够优化,在大多数情况下,可串行化隔离方案更为可行。

串行化

可串行化隔离通常被认为是最强的隔离级别,它保证及时事务可能会并发执行,但最终的结果与每次一个即串行执行结果相同。

可串行化隔离使用以下三种技术之一:

实际串行执行

每个事务执行非常快,且单个CPU可以满足事务的吞吐量要求的情况下可行。

  • 事务所需数据都在内存中
  • OLTP执行非常快,而OLAP通常只要在一致性快照上运行

为了扩展到多个CPU和多节点,还可以对数据进行分区,但是必须确保每个事务只在单个分区内读写数据。给每个CPU核分配一个分区。
对于跨分区的事务,需要对所有分区去加锁执行,以确保整个系统的可串行化。

两阶段加锁

近三十年来唯一广泛使用的串行化算法。
只要没有写入,就允许多个事务同时读取同一对象。如果有写入,就必须独占加锁:

  • A读取某对象,B要写入,则需要等A提交或中止。
  • A修改了对象,B要读取,则需要等A提交或中止。

实现方式:

  • 事务要读取对象,必须以共享模式获取锁。 可以有很多事务同时获得一个对象的共享锁,但是如果有一个事务已经获得独占锁,则其他事务必须等待。
  • 事务要修改对象,必须以独占模式获取锁。 不允许多个对象同时持有该锁。
  • 事务读取后写入,则需要从共享锁升级为独占锁。 升级的流程等价于直接获取独占锁。
  • 获得锁后,一直持有知道事务结束。

出现死锁时,系统必须自动检测,并强制打破。

2PL模式下的数据库访问延迟有很大的不确定性。

谓词锁

需要解决满足某些查询条件的所有查询对象,比如

SELECT * FROM bookings 
    WHERE room_id = 123 AND
    end_time > 'xxx' AND start_time < 'xxx';
  • 事务A想要读取某些满足匹配条件的对象,例如SELECT查询,它必须以共享模式获取查询条件的谓词锁。如果B正持有任何一个匹配对象的互斥锁,则A必须等B释放锁
  • 事务A想要插入、更新、删除任何对象,都必须检查所有旧值和新值是否与现有的任何谓词锁匹配。如果B持有这样子的谓词锁,则A必须等到B提交或中止后才能继续

索引区间锁

谓词锁性能不佳:如果事务中存在许多锁,则检查这些匹配就变得非常耗时。
所以通常使用2PL的数据库实际上使用的是索引区间锁,将其保护的对象扩大化。

可串行化的快照隔离(Serializable Snapshot Isolation, SSI)

提供了完整的可串行化保证,而性能相比于快照隔离损失很小。

SSI是一种乐观并发控制。如果可能产生冲突,事务会继续执行,寄希望一切相安无事。而当事务提交(只有可串行化的事务被允许提交)时,数据库会检查是否确实发生了冲突。如果是的话,事务会中止并重试。

SSI基于快照隔离,读和写都是基于一致性快照。新增加了相关算法来检测写入之间的串行化冲突从而决定中止哪些事务。

基于过期条件做决定

数据库假定对查询结果的任何变化都应使写事务失效。

检测是否读取了过期的MVCC(多版本并发控制)

需要跟踪那些由于MVCC可见性规则而被忽略的写操作。当事务提交时,数据库会检查是否存在一些当初被忽略的写操作现在已经完成了提交。

检测写是否影响了之前的读

当另一个事务尝试修改时,首先检查索引,确定是否最近存在一些读目标数据的其他事务。这个过程类似于在受影响的字段范围内获取写锁,但是不阻塞读取,而是直到读事务提交才进一步通知他们:所读到的数据现在已经发生了变化。

优点是事务不需要等待其他事务所持有的锁。只读查询通常不需要任何锁。

但是事务种植的比例会显著影响SSI的性能表现。比如一个运行时间很长的事务,读取和写入了大量数据,因而产生冲突并中止的概率就会增大。所以SSI要求读-写型事务要简短。

comments powered by Disqus