在一个数据系统中,许多事情都可能出错,但是在一系列操作中,中间某个操作出错,这将会很麻烦,已经执行的需要回滚,代码层面完成很麻烦。所以事务 成为这些问题的首选机制。事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(提交(commit))要么失败(中止(abort)回滚(rollback))。

本文将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入并发控制的领域,讨论各种可能发生的竞争条件,以及数据库如何实现读已提交快照隔离可串行化等隔离级别。

深入理解事务

目前的几乎所有关系型数据库和一些非关系型数据库都支持事务。

ACID的含义

事务所提供的安全保证,通常由众所周知的首字母缩略词ACID来描述,ACID代表原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)

原子性

能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。 也就是一个操作只有两个状态,即只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。

一致性

ACID的一致性主要是指对数据有特定的预期状态,任何数据更改都要满足状态约束,比如一个账单系统,贷款和借款要保持平衡。

如果一个事务从一个有效的状态开始,中间没有违背约束,那最终结果依然是有效状态。

一致性本质上要求应用层来维护状态一致,它不是数据库可以保证的事情:即如果提供的数据修改违背了恒等条件,数据库也难以检测。

原子性、隔离性和持久性是数据库自身属性,而一致性更多取决于应用。C其实不应该属于ACID。

隔离性

在没有隔离性的情况下,两个客户端同时操作一个数据,可能结果是错的。ACID语义中的隔离性意味着并发执行是多个事务相互隔离。但由于性能,基本很少用串行化隔离,比如oracle使用的快照隔离,快照隔离比串行化更弱。

持久性

一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的数据也不会丢失。

单对象和多对象事务操作

多对象事务要求确定事务包含了那些读写操作。对于关系数据库,客户端通常与数据库服务器建立TCP网络连接,因而对于特定的某个连接,SQL语句BEGIN TRANSACTIONCOMMIT 语句之间的所有操作都属于同一个事务。

由于数据库操作会出现很多问题,所以存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。

某些数据库还提供一些复杂的原子操作,例如自增操作,还有cas等。这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,但和事务有一定的区别,事务通常被理解为,将多个对象上的多个操作合并为一个执行单元的机制

多对象事务的必要性

许多分布式数据存储系统不支持多对象事务,主要是跨分区时,多对象事务难以正确实现,对高可用或极致性能的场景有影响。但并不是不可以实现。

但是否有可能只用键值数据模型和单对象操作来实现任何应用程序?

有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:

  • 关系数据模型中,表中的行有另外表的外键,需要同步更新。
  • 带有二级索引的数据库,更新值时一般需要同步索引。事务角度看,这些索引就是不同的数据库对象。

弱隔离级别

数据库使用隔离来隐藏数据库内部的各种并发问题,但可串行化的隔离会严重影响性能,许多数据库不愿牺牲性能,更多采用较弱的隔离级别。

读-提交

读-提交是最基本的事务隔离级别,只提供以下两个保证:

  1. 读数据库时,只能看到已经成功提交的数据。
  2. 写数据库时,只会覆盖已成功提交的数据。

防止脏读

事务如果更新多个对象,脏读就是另一个事务可能看到部分更新。事务如果中止,那么写入操作回滚,这时候发生脏读,则将已经回滚的数据读取,发生难以预料的后果。

防止脏写

两个事务同时尝试更新相同的对象,后写的操作会覆盖较早的写入。如果先写的操作是尚未提交的事务的一部分,后写的事务如果覆盖的话,就是脏写。一般通过推迟第二个写请求,直到前面的事务提交或中止。

如果存在脏写,不同事务的并发写入会混杂在一起。

但是,读提交不能防止对计数器增加这种情况(a对计数器+1,b对计数器+1,最后计数器只加1),第二次写入确实是第一次提交后写入,不属于脏写,但结果错误。

实现读-提交

数据库通常使用行锁来防止脏写:当事务想修改某个对象,必须先拿到对象的锁,然后持有到事务提交或中止。只有一个事务能拿到锁。

但是,这种读锁的方式在实际中并不可行,因为运行时间较长的写事务会导致许多只读事务等待太长时间,大多数数据库对每个待更新的对象,事务提交前,所有读取只会读到旧值。

快照级别隔离与可重复读

表面上看读已提交,可能会以为它满足了事务的一切特征:支持中止(原子性),防止读取不完整的结果,并防止并发写的混乱。但还有很多场景可能造成并发错误。

这种现象称为不可重复读,或读倾斜。快照隔离是这个问题的最常见解决方案。想法是,每个事务都从数据库的一致快照(consistent snapshot) 中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。

实现快照级别隔离

考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保存了对象的多个不同的提交版本,这种技术也叫做多版本并发控制(MVCC, multi-version concurrency control)

支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别。典型的做法是,在读-提交级别下,对每一个不同的查询单独创建一个快照;而快照级别隔离则是使用一个快照来运行整个事务。

实现基于MVCC的快照级别隔离:

当事务开始时,首先赋予一个唯一的、单调递增的事务ID。当事务向数据库写入新内容时,所写的数据都会被标记写入者的事务ID。

mysql实现MVCC主要通过依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。具体见08 | 事务到底是隔离的还是不隔离的?

一致性快照的可见性原则

当事务读取数据库时,通过事务ID可以决定那些对象可见,那些不可见。

  1. 每笔事务开始时,数据库列出所有正在进行中的其他事务,然后忽略这些事务完成的部分写入(尽管以后可能提交),即不可见。
  2. 所有中止事务所做的修改全部不可见。
  3. 在当前事务开始只会开始的事务所做的任何修改不可见,不管是否完成的提交。
  4. 除此之外,其他所有写入都对应用查询可见。

这些是针对于读来说的,但更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

索引与快照级别隔离

多版本数据库如何支持索引呢?一种方案是索引之间指向对象的所有版本,然后想办法过滤对当前事务不可见的那些版本。当后台垃圾回收进程决定删除某个旧对象版本时,对应的索引条目也需要随之删除。

MySQL是通过undolog来实现的多版本,通过最新版本加undolog来将老版本计算出来,也就是版本对应的记录只有一条,不需要处理索引关系。

在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用B树,但它们使用的是一种仅追加/写时拷贝(append-only/copy-on-write) 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变。

使用仅追加的B树,每个写入事务(或一批事务)都会创建一颗新的B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务ID过滤掉对象,因为后续写入不能修改现有的B树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。

可重复读与命名混淆

快照级别隔离对于只读事务特别有效。但是,具体到实现,许多数据库对它有不同的命名。Oracle称之为可串行化,PostgreSQL和MySQL仍称为可重复读。

这个命名混淆的原因是SQL标准没有定义快照级别隔离,因为当时还没出现快照隔离。标准定义的是可重复读,看起来比较接近于快照级别隔离。所以MySQL称快照级别隔离叫可重复读,符合标准。

防止更新丢失

更新丢失可能发生在这样的场景中:应用从数据库读某些值,然后修改后写回新值。当两个事务在同样的对象执行类似操作时,第二个写操作不包括第一个事务修改的值,最终导致第一个事务修改的值可能会丢失。

很多场景都会发生:递增计数器,复杂对象的一部分进行修改,两个用户同时编辑Wiki界面。

虽然并发写冲突是一个普遍的问题,但也有很多解决方案。

原子写操作

许多数据库提供了原子更新操作,在应用层面避免了“读-修改-写”操作。

原子操作通常采用对读取对象加独占锁的方式实现,这样更新被提交之前不会有其他事务读它。

显示加锁

应用可以显示锁定待更新的对象。

1
2
3
4
5
6
7
8
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;

-- 检查玩家的操作是否有效,然后更新先前SELECT返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;

FOR UPDATE子句告诉数据库应该对该查询返回的所有结果加锁。

自动检测丢失的更新

原子操作和锁是通过强制读取-修改-写入序列按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列

这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL的可重复读,Oracle的可串行化和SQL Server的快照隔离级别,都会自动检测到丢失更新,并中止违规的事务。但是,MySQL/InnoDB的可重复读并不会检测丢失更新。一些作者认为,数据库必须能防止丢失更新才称得上是提供了快照隔离,所以在这个定义下,MySQL下没有完全提供快照隔离。

原子比较和设置(CAS)

也就是常说的乐观锁,此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。

1
2
3
-- 根据数据库的实现情况,这可能也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';

但也有ABA问题,使用上见仁见智。

冲突解决与复制

对于支持多副本的数据库,防止丢失更新还需要考虑另一方面:由于多节点上的数据副本,不同的节点可能并发的修改数据,因此必须采取一些额外的措施来防止丢失更新。

加锁和原子修改的前提是只有一个最新的数据副本。在多主节点或无主节点的数据库不适用。但多副本数据库也通过保留多个冲突版本,来进行合并解决。

更新丢失和脏写区别

更新丢失:应用从数据库读某些值,然后修改后写回新值。当两个事务在同样的对象执行类似操作时,第二个写操作不包括第一个事务修改的值,最终导致第一个事务修改的值可能会丢失。

脏写:两个事务同时尝试更新相同的对象,后写的操作会覆盖较早的写入。如果先写的操作是尚未提交的事务的一部分,后写的事务如果覆盖的话,就是脏写。

具体取决于时间窗口。

写倾斜和幻读

多个事务并发写入同一对象引发了脏写和更新丢失,但还有一些更微妙的问题。

比如这个,就是一个问题。

写倾斜的特征

这种异常情况称为写倾斜。既不是脏写,也不是丢失更新,两个事务更新的是两个对象。

广义上:如果两个事务读取相同的一组对象,然后更新其中一部分,不同的事务可能更新不同的对象,则可能发生写倾斜;而不同的事务如果更新的是一个对象,则可能发生脏写或丢失更新。

对于写倾斜,可选的方案有很多限制:

  • 由于涉及多个对象,单对象的原子操作不起作用。

  • 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在PostgreSQL的可重复读,MySQL/InnoDB的可重复读,Oracle可序列化或SQL Server的快照隔离级别中,都不会自动检测写入偏差。自动防止写倾斜需要真正的可串行化隔离。

  • 某些数据库允许配置约束,然后由数据库代为检查、执行约束(如外键约束,唯一性)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库。

  • 如果不能使用串行化隔离,可以使用for update的方式来显示的加锁。

实体化冲突

将幻读问题转变为针对数据库中一组具体行的锁冲突问题,就是实体化冲突。但是弄清楚如何实体化也很难,并且容易出错。不到万不得已,不推荐实体化冲突。大多数情况下,可串行化隔离方案更加可行。

串行化

对于应对并发问题。读-提交和快照隔离虽然可以防止其中一部分,但也有写倾斜和幻读导致的问题。最后会面临如下挑战:

  • 隔离级别难以理解,并且在不同的数据库中实现的不尽一致(例如,“可重复读”的含义有区别)。
  • 光检查应用代码很难判断在特定的隔离级别运行是否安全。 特别是在大型应用程序中,难以预测所有可能的并发情况。
  • 没有检测竞争条件的好工具。原则上来说,静态分析可能会有帮助,但研究中的技术还没法实际应用。并发问题的测试是很难的,因为它们通常是非确定性的 —— 只有在倒霉的时机下才会出现问题。

这些问题的答案也很简单:采用可串行化隔离级别。

可串行化(Serializability)隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果与串行执行结果相同。这意味着,数据库可以防止所有可能的竞争条件。

目前大多数可串行化的数据库都使用以下技术之一:

  • 严格按照串行顺序执行。
  • 两阶段锁定,几十年来几乎是唯一可行的选择。
  • 乐观并发控制技术,如可串行化的快照隔离。

实际串行执行

解决并发问题的方式就是避免并发,即一个线程按顺序每次只执行一个事务。

虽然很直白的想法,但数据库设计人员到2007年前后才认为它是可行的。因为内存越来越便宜,并且OLTP事务执行很快,只产生少量的读写操作。相比之下,运行时间较长的分析查询通常是只读的,可以在一致性快照上运行。

H-Store、Redis等采用串行方式执行事务。单线程执行有时可能比支持并发的系统效率更高,并避免锁开销。但是其吞吐量仅限于单个CPU核的吞吐量。为了充分利用单线程,需要与传统形式不同的结构的事务。

在存储过程中封装事务

采用事务的机制是希望将用户的所有操作都囊括,但人回应的速度会很慢,数据库总是等待人的回应,就会浪费大量资源,所以应用要提交整个事务代码作为存储过程打包发送到数据库。差异如图所示:

如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。

存储过程的优缺点

存储过程存在有一段时间了,1999年以来就一直是SQL标准的一部分。但由于各种原因,名声不太好:

  • 每个数据库厂商都有自己的存储过程语言(Oracle有PL/SQL,SQL Server有T-SQL,PostgreSQL有PL/pgSQL等)。这些语言并没有跟上通用编程语言的发展,所以从今天的角度来看,它们看起来相当丑陋和陈旧,而且缺乏大多数编程语言中能找到的库的生态系统。
  • 与应用服务器相比,在数据库中运行的管理困难,调试困难,版本控制和部署起来也更为尴尬,更难测试,更难和用于监控的指标收集系统相集成。
  • 数据库通常比应用服务器对性能敏感的多,因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程(例如,占用大量内存或CPU时间)会比在应用服务器中相同的代码造成更多的麻烦。

但是这些问题都是可以克服的。现代的存储过程实现放弃了PL/SQL,而是使用现有的通用编程语言:VoltDB使用Java或Groovy,而Redis使用Lua。

存储过程与内存式数据存储,使得在单个线程上执行所有事务变得可行。由于不需要等待I/O,且避免了加锁开销,它们可以在单个线程上实现相当好的吞吐量。

分区

虽然串行执行使并发变简单,但单线程事务也会是很严重的瓶颈。

对于需要访问多个分区的任何事务,数据库必须在涉及的所有分区之间协调事务。存储过程需要跨越所有分区加锁执行,以确保整个系统的可串行性。

跨分区事务需要额外的协调开销,性能可能比单分区慢好几个数量级,而且很难通过加机器的方式来扩展性能。

事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个二级索引的数据可能需要大量的跨分区协调。

串行执行小结

在特定约束条件下,真的串行执行事务,已经成为一种实现可序列化隔离等级的可行办法。

  • 事务都必须简短而高效,只要有一个缓慢的事务,就会拖慢所有事务处理。
  • 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问磁盘,就会严重拖累性能(可以中止事务,异步提取数据,完了再重启事务,这种方法叫反高速缓存)。
  • 写入吞吐量必须足够低,才能在单个CPU核上处理,如若不然,事务需要采用分区。
  • 跨分区事务虽然可以支持,但占比必须很小。

两阶段加锁

多个事务可以同时读取一个对象。但只要出现任何写操作,就需要加锁以独占访问。相当于只有读读可以并发,读写-写写都不行。区别于快照隔离中读写可以并发。

实现两阶段锁(TWO-Phase Lock)

2PL 已经用于 MySQL 和 SQL Server 中的可串行化隔离。已经 DB2 中的“可重复读隔离”。

数据库中每个对象都有一个读写锁来隔离读写操作。锁可以处于共享模式或独占模式。用法如下:

  • 事务读取对象,先以共享模式获得锁。共享锁可以多个事务同时获得,独占锁只能一个事务获取。
  • 事务要修改对象,先以独占模式获得锁。
  • 事务先读取对象,后尝试写入对象,需要把共享锁升级为独占锁,相当于直接获取独占锁。
  • 事务获得锁后,持有锁到事务结束。

这也是两阶段锁的由来,第一阶段事务执行前要获得锁,第二阶段事务结束时要释放锁。

这也很容易死锁,A事务等待B事务的锁,B事务等待A事务的锁。但数据库会自动检测事务间的死锁,并强行中止其中一个来打破死锁。被中止的事务需要应用层来重试。

两阶段加锁的性能

两阶段加锁的性能:事务吞吐量和查询响应时间比其他弱隔离级别下降非常多。

锁的获取和释放本身就有开销,更重要的是降低了事务的并发性。

虽然基于加锁方式的读-提交隔离也可能发生死锁,但在2PL下,死锁可能会更为频繁。因此导致的另一个性能问题就是由于死锁解除,应用重复尝试也导致最后的性能和效率降低。

谓词锁

还有一个细节:上面的 “写倾斜与幻读” 中提到的一个事务改变另一个事务的查询结果的问题。

可串行化隔离是通过谓词锁来实现的,具体是在满足事务搜索条件的所有查询对象上加锁,例如:

1
2
3
4
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2018-01-01 12:00' AND
start_time < '2018-01-01 13:00';

谓词锁限制如下访问:

  • 如果事务A想要读取匹配某些条件的对象,它必须以共享模式获取查询条件的谓词锁。如果另一个事务B持有任何满足这一查询条件对象的互斥锁,那么A必须等到B释放锁之后才允许进行查询。
  • 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B完成后才能继续。

索引区间锁

可以看出,谓词锁性能不好,会锁很多行。因此大多数使用2PL的数据库实际上使用索引区间锁。

索引区间锁,顾名思义,就是对索引上加锁,索引位置要包含查询的行,所以可能会将保护的对象扩大化。因为几乎所有的查询都会经过索引,另一个事务如果修改这个表,肯定要更新有关的索引,就会与共享锁冲突,于是处于等待状态到共享锁释放。

索引区间锁虽然不像谓词锁那样精确,但开销低很多。

如果没有合适的索引加区间锁,数据库可以回退到对整个表加共享锁。性能虽然不好,但也可以保证安全性。

可串行化的快照隔离

2PL可以保证串行化,但性能差强人意而且无法扩展(由于串行执行);弱隔离级别虽然性能不出,但容易更新丢失,写倾斜和幻读。

可序列化快照隔离(SSI, serializable snapshot isolation)在2008年首次提出,提供完整的可串行化保证,性能相比于快照隔离损失很小。可以用在单节点和分布式数据库中。但还需在实践中证明其性能。

悲观与乐观的并发控制

2PL是典型的悲观并发控制,设计原则是:如果某些操作可能出错(与其他事务发生锁冲突),那就直接放弃。

相比,可串行化的快照隔离是乐观并发控制。如果可能发生潜在冲突,那么事务会继续执行而不是中止,事务提交时,数据库检测是否确实发生冲突,如果发生了,中止事务并重试。

如果系统还有足够的性能提升空间,且事务竞争不大,乐观并发控制高效的多。如果存在很多冲突,则性能不佳,系统已经接近最大吞吐量,反复重试事务可能会使性能更差。

SSI基于快照隔离,与早期的乐观并发控制区别是,快照隔离基础上,SSI新增了相关算法来检测写入之间的串行化冲突从而决定中止那些事务。

基于过期的条件做决定

写倾斜是,事务先查询某些数据,查询期间数据如果被修改,那事务在提交时决策的依据信息就会变化。

所以为了安全起见,查询结果会与写事务间存在因果依赖关系。为了提供可串行化的隔离,数据库必须检测事务是否会修改其他事务的查询结果,并在此情况下中止事务。

数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:

  • 读取是否作用于一个即将过期的MVCC对象。
  • 检测写入是否影响即将完成的读取。

检测是否读取了过期的MVCC对象

如图,虽然由于MVCC机制,事务忽略在创建快照时尚未提交的事务写入,事务43不知道42已经修改了Alice的状态,但事务管理器可以发现这个值已经变化。当事务提交时,数据库会检查是否存在一些当初被忽略的写操作是否完成提交,如果是,则必须中止当前事务。

检测写是否影响之前的读

另一种是读取数据后,另一个事务修改了数据。

如果shift_id上有索引,数据库可以通过索引条目1234来记录事务42和43都查询了相同的结果。如果没有索引,可以在表级别跟踪此信息。该额外记录只要保存一小段时间,并发的事务处理完后就丢弃。

另一个事务尝试修改时,首先检查索引,从而确定是否存在一些读目标数据的其他事务。在事务提交时,会提示其他事务,所读的数据已经发生变化。

图中,43的修改影响了42,但43还未提交,42首先尝试提交,可以成功,然后43试图提交时,来自42的冲突写已经提交生效,43中止。

可串行化快照隔离的性能

与2PL相比,可串行化快照隔离优点事务不需要等待其他事务所持有的锁。读写通常不会阻塞。这样设计使查询延迟更加稳定、可预测。一致性快照执行只读查询不需要任何锁。

与串行执行相比,可串行化快照隔离可以突破单个CPU核的限制。FoundationDB将冲突检测分布在多台机器上,从而提高总体吞吐量。数据即使跨多台机器分区,事务也可以在多个分区上读、写数据并保证可串行化隔离。

另外,事务中止比例会显著影响SSI的性能表现。SSI要求读-写型事务要简短(只读事务没有限制)。总体上讲,SSI比2PL和串行执行更能容忍执行缓慢的事务。

小结

并发控制的问题

脏读

客户端读到了其他客户端尚未提交的写入。读-提交以及更强的隔离级别可以防止脏读。

脏写

客户端覆盖了另一个客户端尚未提交的写入。几乎所有的数据库实现都可以防止脏写。

读倾斜(不可重复读)

客户在不同的时间点看到了不同值。快照隔离是最常用的防范手段,即事务总是在某个时间点的一致性快照中读取数据。通常采用多版本并发控制(MVCC) 来实现快照隔离。

更新丢失

两个客户端同时执行读-修改-写入操作序列,出现了其中一个覆盖 了另一个的写入,但又没有包含对方最新值的情况,最终导致了部分修改数据发生了丢失。快照隔离的一些实现可以自动防止这种异常,而另一些则需要手动锁定查询结果(SELECT FOR UPDATE)。

写倾斜

事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。只有可串行化的隔离才能防止这种异常。

幻读

事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。快照隔离可以防止简单的幻读,但写倾斜情况则需要特殊处理,例如采用区间范围锁。

可串行化隔离的三种不同方法

严格串行执行事务

如果每个事务的执行速度非常快,且单个CPU核可以满足事务的吞吐量要求,严格串行执行是一个非常简单有效的方案。

两阶段加锁

几十年来,一直是实现可串行化的标准方式,但还是很多系统出于性能原因放弃使用它。

可串行化的快照隔离(SSI)

新算法,避免前面方法的大部分缺点。它秉持乐观预期的原则,允许多个事务并发执行而不互相阻塞;仅当事务尝试提交时,才检查可能的冲突,如果发现违背了串行化,某些事务会中止重试。