Design Data-Intensive Applications 读书笔记二十二 第七章:两步加锁和SSI

本文介绍了两步加锁(2PL)和序列化快照隔离(SSI)两种数据库并发控制机制。2PL通过严格锁机制确保序列化,但可能导致性能下降和死锁。SSI提供序列化隔离,使用乐观并发控制,通过检测过期前提避免冲突,性能优于2PL。
摘要由CSDN通过智能技术生成

两步加锁(2PL)

之前30多年,数据库中只有一种序列化算法: two-phase locking,2PL,两步加锁。

之前我们使用锁来防止脏写:如果两个事务同时写入相同对象,加锁会确保一个事务写入完成前,其他事务不会写入相同对象。

两步加锁类似,但是锁更加严格。多个事务想同时读取相同对象,如果没有事务写入这个对象,那么同时读取时允许的。但是只要有任何事务想写入对象,那么就需要排他性:

1、如果一个对象,事务A正在读取,事务B想写入,那么事务B必须等到事务A结束(提交或者丢弃),这确保B不会在A背后修改对象。

2、事务A正在写入对象,事务B想读取,那么事务B必须等事务A结束。两步加锁中不能读取一个旧版本的数据。

在2PL中写入器不仅排斥其他写入器,也排斥读取器,反之亦然。快照隔离的规则是写入器不屏蔽读取器,读取器也不屏蔽写入器;这是它与2PL的主要区别。因为2PL提供序列化,能够更早的阻止竞态条件,防止更新丢失和写偏斜。

 

实现两步加锁

2PL在MySQL,SQL Server用于实现序列化隔离等级,DB2的可重复读。都是使用锁来屏蔽写入器和读取器。锁可以处于共享模式和排他模式。如下使用方法:

1、如果一个事务想读取一个对象,需要先获得对象的共享模式的锁。多个事务允许同时获得对象的共享锁。但是只要对象有一个排它锁,其他事务就必须等待。

2、如果一个事务想写入一个对象,需要先获取对象的排他锁。同时其他事务不能获得对象的锁,不论是共享锁还是排他锁。如果对象已经加上排他锁,其他事务必须等待。

3、如果事务先读取然后写入,它会将共享锁升级为排他锁,升级过程等同于直接获得排他锁。

4、事务获得锁之后,必须持有锁直到事务结束,这就是两步:事务执行时获得锁,事务结束时释放锁。

因为有很多锁,很容易出现事务A等待事务B释放锁,事务B等待事务A释放锁,这就成了“死锁”。数据库会自动检测死锁,然后丢弃其中一个,被丢弃的需要重试。

 

两步加锁的性能

两步加锁没有广泛使用的主要原因就是性能。它的吞吐和相应时间明显弱于弱隔离等级。这部分是因为加锁和释放锁操作的工作量,更多的是因为它移除了并发。设计上,如果两个事务可能产生竞态条件,那么其中一个必须等待另一个完成。

传统关系型数据库没有限制事务的执行时间,因为它们被设计为交互式应用,与用户交互等待用户输入。结果就是一个事务可以一直等待另一个事务。即便你设计成短的事务,也可能形成事务的等待队列,这时一个事务必须等待其他事务完成。

因此使用2PL的数据库运行很不稳定,如果工作负载中有竞争,大概率会很慢。它可能会执行一个慢的事务,或者是一个事务获取大量数据获得大量的锁,导致系统其他部分停摆。一个稳健的应用不能接受这个缺陷。

尽管使用锁的读取提交隔离等级中也会发生死锁,但是使用2PL的序列隔离等级发生死锁的情况更加频繁。这导致额外的性能问题:如果事务因为死锁而被丢弃,需要重新执行,如果有大量的死锁,那么很多工作都是无用的。

 

谓语锁

之前我们已经讨论过,幻读会导致写偏斜。使用序列化可以防止写偏斜。

在会议室案例中,一个事务想在一个时间段内预订会议室,那么其他事务没法在相同时间段内预订相同会议室。这是如何实现的?概念上说,我们需要谓语锁, predicate lock。它类似于之前提到的共享/排他锁,但是它不属于特定对象(一行数据),而是属于符合搜索条件的所有对象。

1、如果事务A想读取符合查询条件的对象(SELECT 语句),那么需要获得查询条件的共享谓语锁,如果这是事务B有任一个符合这些条件的对象的排他锁,那么A就必须等待B。

2、如果事务A想插入,更新,删除某个对象,需要检测新的或旧的对象符合现存的谓语锁,如果事务B持有一个符合条件的谓语锁,那么A必须等待B。

关键就是谓语锁适用于数据库中不存在的数据,可能在未来添加的数据(幻读)。如果两步加锁包含谓语锁,那么就可以实现序列化。

 

索引区间锁

但是谓语锁性能不好:如果同时有多个锁,检测是否符合锁过程很花时间。因此大部分数据库使用索引区间锁来实现2PL加锁,它类似于谓语锁的简化版本。

可以通过扩大谓语锁的范围来安全的地简化它。如果你有一个谓语锁,在正午至下午1点订阅房间123。那么你可以将锁近似为正午至下午1点的所有房间。这很安全,因为符合原始的谓语锁就符合它的泛化版本。

在预定会议室时,你可能在room_id或者是start_time/end_time上有索引。

1、如果你在room_id上有索引,数据库就是用这个索引搜索现有的房间123的订单。现在数据库可以给这个索引加上锁,表明事务已经搜索过房间123的订单。

2、也可以使用时间列上的索引,可以将一个时间范围加上锁,表明一个事务已经搜索过这个时间范围内的订单。

哪种方法都是将锁加到搜索条件对应的索引上。如果同时有其他事务想增加、删除和修改相同房间的,时间上有重叠的订单,那么需要更新索引,这时就会遇到共享模式的锁,那么它不得不等待。

这就能防止幻读和写偏斜。虽然没有谓语锁那么精确,但是能提高性能。如果不能复杂合适的范围锁,那么数据库就会锁上整张表,这会降低性能,但是安全。

 

序列化快照隔离(SSI)

这章描绘了并发控制的灰暗图景。序列化隔离的实现要么性能差(两步加锁),要么扩展性差(顺序执行)。另一方面,弱隔离等级性能好但是容易产生问题(更新丢失,写偏斜,幻读)。难道序列化隔离和高性能本质上是互斥的?

或许不是,一个算法,称为序列化快照隔离,serializable snapshot isolation (SSI)很有希望。它提供完整的序列化,但是比起快照隔离,性能上有一些损失。它是个2008年才提出来的新算法。

今天SSI既用于单节点数据库(9.1版本以后的Postgresql的序列化隔离等级)和分布式数据库( FoundationDB使用类似算法)。SSI比起其他并发控制机制,很年轻,待实践验证,但是有前途。

 

乐观与悲观并发控制

两步加锁被称为悲观并发控制机制,如果事情可能会出错,那就等到安全后再执行。类似于用来在多事务程序中保护数据的互斥锁。序列化执行就是完全悲观的:它等价于给每个事务都加上了整个数据库范围内的互斥锁。我们让事务执行速度快来作为补偿,因此它只需要短时间内持有“锁”。

对比序列化隔离,就是乐观并发控制技术。乐观意味着如果即便某些事情可能会出错,还是继续执行,期望结果是好的。当事务提交时,数据库会检测是否有错误(隔离被破坏);如果是,事务被废弃然后重试。只有事务序列化执行才能提交。

乐观并发控制是个老概念,关于它的优缺点争议了很长时间。如果有很多冲突,那么它的性能很差,因为这会导致相当比例的事务废弃然后重试。如果系统的吞吐已经接近最大值,那么重试事务会让性能更糟。但是如果有足够的空间,事务间的冲突并不高,那么乐观并发控制技术比悲观的技术有更好的性能。例如:多事务并发增长计数器,并无规定执行顺序(只要不重复读取计数器),所以并发增长可以无冲突。

从名字看出,SSI是基于快照隔离等级。事务都是从数据库的一个隔离性等级中读取数据。这是与之前的乐观并发控制主要区别。在快照隔离之上,SSI增加了一个算法来检测写入间的序列化冲突,和决定要废弃哪个事务。

 

使用过期前提做决策

之前我们讨论快照隔离条件下的写偏斜情况时,提到了一个循环模式:事务从数据库中读数据,检验查询结果,然后决定执行下一步;但是同时原始查询结果在事务提交时不是最新的了,因为它已经被其他事务改变了。换句话说,决定事务进行下一步的是前提,premise(在事务开始时是正确的,例如现在有两个医生值班)。后来在事务提交时,原始数据已经被改变了,前提不再正确了。

当应用做出一个查询的时候,数据库不知道应用会怎么使用查询的数据。为了安全,数据库只能假设任何查询结果的改变都会导致事务的写入不可用。换句话说,事务的查询结果和写入之间有逻辑依赖关系。为了提供序列化隔离等级,数据库需要检测事务在错误前提下行动而导致需要废弃事务的场景。数据库如何知道查询结果会改变?两个场景:

1、检测在旧的MVCC对象版本号上的读取(未提交的写入先于读取)。

2、检测到写入影响之前的读取(读取先于写入)。

 

检测旧的MVCC读取

实现快照隔离使用的是多版本并发控制,MVCC。当事务从数据库的一致性快照中读取数据时,会忽略掉其他事务在获取快照时还没提交的写入。在图7-10中,事务43看到Alice的数据 on_call = true,因为事务42(修改了Alice的on_call状态)还未提交。但是同时,事务43想提交时,事务42已经提交了,这意味着之前从快照中读取而忽略的内容已经生效了,事务43的前提已经不正确了。

为了防止异常,数据库需要跟踪事务之前因为MVCC可见性规则而忽略的部分。当事务要提交时,数据库需要检测之前任何忽略的写入是否提交,如果是,那么需要废弃事务。为什么直到提交时才检测?为什么不在检测到读取旧版本的时候直接废弃?一是因为如果事务是只读事务,就不需要废弃。二是事务43读取时,数据库不知道事务43后续会不会写入。而且事务43未提交时,事务42也未提交,所以之前的读取并未过时。为了避免不必要的废弃,SSI提供的快照隔离支持长时间从一致性快照中读取。

 

检测影响之前读取的写入

第二个场景就是数据读取后,另一个事务修改了读取的数据,如图7-11:

在两步加锁中,我们提到了索引区间加锁,它允许数据库将符合查询条件的行都加锁,例如 WHERE shift_id = 1234;这里我们可以使用类似的技术,但是SSI不会屏蔽其他事务。

在图7-11中,事务42和43都在搜索在shift 1234上值班的医生,如果shift列有索引,那么数据库可以使用shift=1234来记录事务42,43读取过这条数据,如果没有索引则要跟踪到这张表。信息只需要保存到一小会,事务结束后,数据库就可以不管这些信息。当事务写入数据库时,它必须查看其它事务是否读取过相关数据。这个过程类似于在受影响的键区间上加上锁,但是没有屏蔽功能,这个锁类似绊网(tripware),它只是通知事务,它们读取的数据可能过时。

图7-11中,事务43通知事务42,之前的读取可能已经过时,反过来也一样。事务42首先成功提交:虽然事务43也做了写入,会影响到事务42,但是43还未提交,它的写入没有影响。但是事务43想提交时,与事务42的写入冲突,43必须丢弃。

 

SSI的性能

很多工程师都关注算法实际应用的效率。例如一个取舍就是跟踪事务的读写至什么程度(读写的粒度)。如果数据库很详细跟踪了事务的读写,那么可以准确的判断哪些事务需要丢弃,但是跟踪过程会造成大量的工作负载;如果是粗略的跟踪事务,则速度快,但是会造成额外的事务被丢弃。一些情况下,事务读取其他事务覆盖的信息是可行的:有些时候事务执行不需要是序列化的。Postgresql使用这个理论来减少不必要的事务丢弃。

对比两步加锁,SSI的优势就是一个事务获取锁后不会屏蔽其他事务。类似于快照隔离,写入器不会屏蔽读取器,读取器不会屏蔽写入器。这使得预测延迟更加准确了。并且只读事务可以运行在一致性快照上而不需要加锁,这利于读取量大的工作负载。

对比序列化执行,SSI不局限于一个CPU。 FoundationDB就将序列化冲突检测工作扩展到了多个机器,允许其扩展到很高的吞吐。即便是数据分区了,分布到多个机器上,多分区上的事务读写也可以确保序列化隔离。

丢弃比例显著影响了SSI的整体性能。例如,长时间的读写事务更容易遇到冲突而被丢弃,所以SSI需要读写事务运行时间短,长时间的只读事务是可行的。但是SSI对比两步加锁或者序列化隔离,对长时间事务不敏感。

 

总结

事务就是一个抽象层,运行应用忽略掉特定的并发问题和特定的硬件和软件问题。事务丢弃就可以简化大量的问题,而应用只需要重试。

没有事务,在很多错误场景下(进程出错,断电,网络故障,磁盘空间不足,不可期的并发问题等),意味着数据会不一致。例如不规范数据很容易与源数据有初入。没有事务,很难推断复杂交互式访问模式会给数据库带来什么影响。

这章我们深入探讨了并发控制。讨论了几个广泛使用的隔离等级:read committed, snapshot isolation (repeatable read), 和serializable。讨论了几个竞态条件来标注这几个隔离等级。

脏读:一个客户端读取另一个客户端未提交的写入。提交后读取或者更强的隔离等级可以防止这个问题。

脏写:一个客户端覆盖了另一个客户端的已经写入但是还未提交的数据。几乎所有的事务实现可以防止这个问题。

读偏斜(不可重复读):客户端在不同时间点看到的数据库的部分不同。一般使用快照隔离来防止这个问题,事务在一个时间点从一个一致性快那里读数据。一般使用多版本并发控制,MVCC来实现。

更新丢失:两个事务同时进行读取-修改-写入循环。一个覆盖了另一个的写入,但是并没有合并另一个的修改,等于是另一个事务的更新丢失了。有些时候可以通过快照隔离来防止,但是其他时候需要手动加锁( SELECT FOR UPDATE)。

写偏斜:一个事务读取一些信息,然后依据此信息来进行下一步,然后写入到数据库。但是如果同时有其他写入,导致之前事务做决定的前提出错了,那么决定就是错的。只有序列化隔离可以解决这个问题。

幻读:事务读取符合某些条件的对象,但是其他事务修改了数据,影响了搜索结果。快照隔离能直接放止幻读。但是某些场景下写偏斜导致的幻象需要特别对待,例如索引范围加锁。

弱隔离等级防止了一些问题,但是需要开发者处理其他问题,比如使用手动加锁。只有序列化隔离才能防止所有的问题。我们讨论了三种序列化事务的实现方式:

1、字面意义上的顺序执行事务。如果你可以让每个事务执行起来很快,并且但CPU足以支撑足够的负载,那么这就是简单有效的方法。

2、两步加锁:几十年来一直是实现序列化的标准方法,但是因为性能问题没有广泛使用。

3、序列化隔离等级(SSI):一个相当新的算法,能够避免之前方法的大多数缺陷。它是乐观算法,事务不会阻塞其他事务。但事务提交时会检测,如果事务不是序列化执行的,那就丢弃。

这章的例子都是关系型数据库里的,但是事务适用于其他数据模型。

下两章会讨论分布式数据库中事务带来的挑战。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值