本系列文章描述了DB并发控制的黯淡:
- 2PL虽保证了串行化,但性能和扩展不好
- 性能良好的弱隔离级别,但易出现各种竞争条件(丢失更新,写倾斜,幻读
串行化的隔离级别和高性能就是相互矛盾的吗?也许不是,一个称为可串行化快照隔离(SSI, serializable snapshot isolation)算法很有前途。提供完整的可串行化保证,而性能与快照隔离相比只有很小性能损失。 SSI在 2008 年首次被提出,如今既用于单节点DB(PostgreSQL9.1后的可串行化)和分布式DB(FoundationDB)。由于 SSI 与其他并发控制机制相比还很年轻,还在实践中证明自己。
3.3.1 悲观锁、乐观锁
两阶段锁是一种 悲观锁机制(pessimistic) ,其设计原则:若有操作可能出错(如与其他事务发生锁冲突),则直接放弃,等待直到绝对安全。和多线程编程中的互斥锁一致。
某种意义上,串行执行是很悲观的:事务期间,每个事务对整个DB(或DB的一个分区)持有互斥锁,我们只能假定每笔事务执行够快、短时持锁,来稍微弥补悲观色彩
相比之下,串行化快照隔离 是一种 乐观锁。如若存在潜在冲突,也不阻止事务,而是继续执行事务,寄希望于一切平安。而当事务想提交时(只有可串行化的事务才被允许提交。),DB会检查是否冲突(即违反隔离性原则):若是,则中止事务并重试。
乐观锁是古老的想法,其优缺点争论已久。若存在很多冲突,则性能不佳,大量事务需中止。若系统已接近最大吞吐量,重试的额外负载会使系统性能更差。
但若系统有足够性能提升空间,且事务之间争用不大,乐观锁比悲观锁更高效。可交换的原子操作能减少争用:如若多个事务同时增加某计数器,则应用增量的顺序(只要计数器不在同一个事务中读取)就无关紧要,所以并发增量可全部应用且无需冲突。
SSI基于快照隔离,即事务中的所有读取都基于DB的一致性快照(参阅本文的快照隔离、可重复读),这和早期乐观锁的主要区别。在快照隔离基础上,SSI新增一种算法检测写入之间的串行化冲突,并确定要中止哪些事务。
3.3.2 基于过期条件来决策
讨论写倾斜时,有一种场景:事务先从DB读一些数据,根据查询结果决定采取后续操作,如修改数据。但快照隔离下,数据可能在查询期间就已被其他事务修改,导致原事务在提交时决策的依据信息已变。
即事务基于某些前提而行动,事务开始时条件成立,如目前有两名医生正在值班,当事务提交时,数据可能已改变,前提已不再成立。
当应用执行查询时(如当前有多少医生在值班),DB本身不知道应用会如何使用该查询结果。为了安全,DB假定对该结果集的变更都可能会使该事务中的写无效。 即事务中的查询与写可能存在因果依赖关系。为提供可串行化隔离,DB必须检测事务是否会修改其它事务的查询结果,并在此情况下中止写事务。
DB如何知道查询结果是否已变?可分为如下case:
- 读取是否作用于一个(即将)过期的MVCC对象(读取之前已经有未提交的写入)
- 检查写是否影响即将完成的读取(读取后,又有新写入)
3.3.3 检测旧MVCC读取
快照隔离通常采用MVCC实现。当事务从 MVCC DB的一致性快照读时,会忽略创建快照时还没提交的事务写入。如图-10:
- 事务43认为 Alice
on_call = true
,因事务 42(修改 Alice 值班状态)还没提交 - 然而,事务43提交时,事务42已提交
即从快照读取时,被忽略的写已生效,直接导致事务43做决定的前提不再成立。
为防止这种异常,DB需跟踪一个事务由于MVCC可见性规则而被忽略的其它事务写。当事务提交时,DB会检查是否存在被忽略的写现在已被提交的,若是,则当前事务必须中止。
为何要等到提交?当检测到读旧值,为何不立即中止事务43,考虑如下场景:
- 若事务43是只读事务,则无需中止,因为无写倾斜风险
- 当事务43读DB 时,DB还不知道事务是否要稍后执行写操作
- 此外,事务42可能在事务43提交时,被中止或仍处于未被提交,因此读取的并非旧值
通过避免不必要的中止,SSI可高效支持那些需在一致性快照中运行很长时间的读事务。
3.3.4 检测写是否影响之前的读
读取数据后,另一个事务修改了数据:
2PL下讨论了索引范围锁,允许DB锁定和某查询匹配的所有行,如WHERE shift_id = 1234
。可在此使用类似技术,只有一点差异:SSI锁不阻塞其他事务。
图-11中,事务42、43 都在班次1234查找值班医生。若 shift_id
有索引,则DB能使用索引项1234记录事务42、43读取这个数据的事实。若无索引,可在表级别跟踪此信息。该信息只需保留很小一段时间:当所有并发事务完成后,就能丢弃。
当另一事务写时,先检查索引,从而确定是否在最近存在一些读目标数据的其它事务。这过程类似在受影响字段范围上获取写锁,但锁不会阻塞其它事务读取,而是直到读事务提交时才进一步通知它们:所读到的数据已变化。
图-11中,事务43通知事务42其先前读已过时,反之亦然。事务42先提交并成功,尽管事务 43写影响了42 ,但因43没提交,所以写还没生效。当43提交时,来自42的冲突写入已被提交,所以43必须中止。
3.3.5 性能
许多工程细节会影响算法实际效果。如一个需权衡考虑的是跟踪事务的读、写的粒度:
- 若DB详细跟踪每个事务的操作(细粒度),确实能准确确定哪些事务需中止,但记录元数据的开销可能也很大
- 而跟踪速度更快时(粗粒度),可能导致更多不必要的事务中止
有的case读过期数据不会造成太大影响:这还是完全取决于具体场景,有时可确信执行结果都是可串行化的,PostgreSQL 使用该理论减少不必要的中止。
相比于2PL,可串行化快照隔离最大优点:事务无需阻塞等待其它事务所持有的锁。这和快照隔离一样,读写不互相阻塞。这使查询延迟更稳定、可预测。尤其是只读查询可运行在一致快照,无需任何锁,对读密集系统友好。
相比于串行执行,可串行化快照隔可突破单CPU核吞吐量限制:FoundationDB将检测到的串行化冲突分布在多台机器,从而提高吞吐量。即使数据可能跨多台机器分区,事务也能在保证可串行化隔离等级同时,读写多个分区中的数据。
事务中止率会显著影响SSI性能。如长时间读、写数据的事务很可能会发生冲突并中止,因此SSI要求读写型事务尽量短(但只读的长事务则没问题)。总体上,对慢事务,SSI比2PL或串行执行更能容忍。