近30年,DB只有一种广泛使用的串行化算法:两阶段加锁 1
2PL不是2PC
请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但是完全不同概念
之前我们知道,加锁可防止脏写:即若两个事务同时尝试写入同一对象,则锁可确保第二个写必须等第一个写完成事务(中止或提交)才能继续。
两阶段锁定类似,但锁的强制性更高。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写,就得加锁独占访问:
若事务 A 已读某对象,此时B想写该对象,则必须等A提交或中止才能继续,这确保 B 不能在 A 执行过程的中间意外改变对象
若事务 A 已写某对象,此时 B 想读该对象,则 B 必须等 A 提交或中止才能继续,像图-1读取旧版本的对象在 2PL 下不可接受
2PL不仅在并发写互斥,读写之间也互斥。快照级别隔离是读写不互斥,这是 2PL 和快照隔离的关键区别。且因 2PL 提供串行化,可防止前文讨论的所有竞争条件,包括丢失更新和写倾斜。
3.2.1 实现原理
2PL已在:
MySQL(InnoDB)和 SQL Server 实现可串行化
DB2 中的可重复读
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可处于 共享模式或独占模式,使用如下:
若事务要读对象,则须先共享模式获取锁。允许多事务同时持有一个对象的共享锁。但若某事务已持有对象的独占锁,则其它事务必须等待
若事务要写对象,须以独占模式获取锁。禁止其他事务同时持有锁(无论共享模式or独占模式),即若对象上存在锁,则写事务必须等待
若事务先读再写对象,则需将其共享锁升级为独占锁。升级锁的流程和直接获得独占锁相同
事务获得锁后,必须一直持有锁直到事务结束。这就是 “两阶段” 名字来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多锁,很容易死锁:如事务A等待B释放锁,而B等A释放锁。DB会自动检测事务之间死锁,并强行中止一个。被中止的事务需由应用层重试。
3.2.2 性能
其巨大缺点及1970s以来没有被广泛使用的原因还是其性能:事务吞吐量和查询响应时间比弱隔离级别下差太多。
部分因为获取、释放锁开销,但更重要是并发性降低。按设计,若两个并发事务试图做任何可能导致竞争条件的事情,则其一必须等待另一完成。
传统关系DB不限制事务的执行时间,因为它们是为等待人类输入的交互式应用而设计。结合2PL,最终结果是,当一个事务还需等待另一事务时,则最终等待时间几乎无上限。即使能保证所有事务都很短,若有多事务同时访问同一对象,会形成一个等待队列,事务需要等待前面事务完成后才能继续。
因此,2PL DB的访问延迟具有极大不确定性,若负载存在严重竞争,以百分比方式观察延迟指标会发现很缓慢。可能某缓慢事务或一个访问大量数据并获取许多锁的事务,就能把系统其他部分拖慢。当需要稳定操作时,这种不稳定性是致命的。
基于锁实现的RX也可能死锁,但 2PL 下取决于事务的访问模式,死锁更频繁。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,应用层就需从头重试。若死锁频繁,则最后性能和效率必然大打折扣。
谓词锁
对加锁,忽略了一个微妙但重要的细节。在写倾斜幻读中的幻读问题,即一个事务改变另一个事务的查询结果。可串行化隔离也必须防止幻读。
会议室预订案例,若事务在查询某时间段内一个房间的预订情况,则另一个事务不能同时插入或更新同一时间段内该房间的预订 (可同时插入其他房间的预订或在不影响另一个预定的条件下预定同一房间的其他时间段)。
要实现就需要谓词锁(predicate lock),类似共享/独占锁,但不属于特定对象(如表的某行),而是作用于所有符合某些搜索条件的对象,如:
SELECT * FROM bookings
WHERE room_id = 123
AND end_time > '2018-01-01 12:00'
AND start_time < '2018-01-01 13:00';
谓词锁会限制如下访问:
若事务A想读取某些满足匹配条件的对象,如SELECT 查询,必须获取查询条件上的 共享谓词锁(shared-mode predicate lock)。若事务B持有任何满足这一查询条件对象的独占锁,则A必须等到B释放锁后才能继续执行查询
若事务A想插入、更新或删除任何对象,须先检查所有旧值或新值是否和现有谓词锁匹配。若B持有匹配的谓词锁,则A须等B完成提交或中止后才能继续
关键在于,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
索引范围锁
但谓词锁性能不佳:若活跃事务持有很多锁,则检查匹配的锁很耗时。因此,大多2PL DB实际上实现的是索引范围锁(index-range locking,也称为 next-key locking),本质是对谓词锁的简化或近似。
简化谓词锁的方式是扩大其保护的对象,这肯定是安全的。如若你有12:00~13:00预订 123 号房间的谓词锁,则锁定123号房间的所有时间段或锁定12:00~13:00时间段的所有房间就是安全的近似。这样,任何与原始谓词锁冲突的操作肯定也和近似后的区间锁相冲突。
房间预订DB,一般在:
room_id 列建索引
并/或在 start_time 和 end_time 上有索引
否则前面的查询在大型DB上的速度会很慢。
假设索引位于 room_id 上,并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索 123 号房间用于预订。
或者,若DB使用基于时间的索引来查找预订,则可将共享锁附加到该索引中的一系列值,指示事务已搜索了该时间段内的所有值 (如直到2023年 1 月 1日)
无论哪种,查询条件的近似值都附加到某个索引上。若另一事务想插入、更新或删除同一房间和/或重叠时间段的预订,则须更新这些索引的相同部分,就一定会和共享锁冲突,将被迫等到共享锁被释放。
这有效防止了幻读和写倾斜。索引范围锁并不像谓词锁精确(会锁定更大范围的对象,超出维持可串行化所必需的范围),但由于开销低得多,是很好的折衷方案。
若无可挂载范围锁的索引,则DB可退化到使用整表的共享锁。这对性能不利,会阻止所有其他事务的写,但这是一个安全的回退位置。
有时称为 严格两阶段锁定(SS2PL, strong strict two-phase locking),以便和其他 2PL 变体区分。