值锁定
注释:包含插入 ... 冲突更新/忽略补丁的进行中的讨论中的一些问题:https://commitfest.postgresql.org/action/patch_view?id=1564
另请参阅:UPSERT以了解更广泛的讨论。
对“值锁定”的途径
术语“值锁定”指的是抽象地锁定一个值的想法。无需锁定存在的对象 - 抽象地锁定值“5”可能是必须的(也许可以创建在逻辑上表示这种状态的对象,但这只是一个实现细节)。(几乎?)总是,此机制涉及唯一索引。此页面考虑了为了实施 UPSERT 而对“值锁定”的各种途径。值锁定是一个尚未建立良好的术语,但它是一个出现在不同语境中的想法。
在 Goetz Graefe 的“现代 B-Tree 技术”中(http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.219.7269&rep=rep1&type=pdf),Graefe 写道:
术语键值锁定和键范围锁定通常可以互换使用。锁定键范围而不是只锁定键值的目的,是保护一个事务不受另一个事务插入的影响。
“值锁定”的经典示例是 2PL 系统中存在的键范围锁定,例如 DB2 和 SQL Server。范围锁定有着悠久历史;自上世纪 70 年代以来一直存在,因为它形成了实现可串行化隔离级别的传统方法的基础。键范围锁定可以被认为是对“值锁定”的一种具体实现,它旨在成为一个更为通用的术语。
InnoDB 增加“记录、间隙和下一个键锁”[1],尽管它首先且最为重要的 MVCC 系统(显然在 READ COMMITTED 模式中,间隙锁定仅影响唯一索引执行和外键等内容 [2]),并且 SQL Server 将它们用于可序列化的交易之外(Graefe 称之为“即时锁”)。很明显,它们的职能不仅限于实施基于 2PL 的 SERIALIZABLE 隔离级别。
有人认为 PostgreSQL 已经具备非常有限形式的“值锁定”,这是唯一索引实施所必需的。nbtree AM 以这样一种方式小心锁定缓冲区,即在确定插入权限之前检查是否存在副本。这需要在向唯一索引中插入期间的常规过程中发生。一旦对“一个值可能所在的第一个树叶页”采取“值锁定”排他缓冲锁,则该后端将成功插入或引发错误:其他插入相同值的后台将被拒绝,直到第一个后台完成[3],才能借由获取该锁来自身检查副本。
缓冲锁从概念上讲是“值锁定”。任何随后插入的行实际上都不是值锁定;它仅仅刚好包含一个值的某一行。从高层级上来看,效果差不多:其他后端必须立即阻止缓冲锁(概念上的“值锁定”),或在潜在冲突的其他事务仍在进行时阻止行锁。但是,值锁与行锁之间的区别对于描述 UPSERT 必须如何整体起作用很重要。这种区别就在于某些此处所述值锁定的方法(#2 和 #3)以各种方式使用特定索引/堆元组这个事实带来了一些混乱。
UPSERT
值锁定对于UPSERT非常重要,因为它对于使该功能正常工作是必需的。必须有可能锁定一个值来达成共识,以便继续插入堆元组(或将提前插入的堆元组标记为逻辑上*真正*插入,或将 nbtree 索引元组标记为实际上指向某些真实的堆元组,甚至可能是其他等效项)。针对针对 UPSERT 讨论的值锁定方法至少有 3 种。
各种方法的比较
请注意,方法 #1 和 #2 以同步的方式维护,作为 INSERT...ON CONFLICT UPDATE/IGNORE 补丁的持续开发的一部分。
更新:从 V2.0 开始,仅维护方法 #2。
主要提交派对条目:https://commitfest.postgresql.org/4/35/
性能
方案 1 和方案 2 的性能在这里进行了直接比较:https://postgresql.ac.cn/message-id/CAM3SWZQvSf+UWpt1YfTqudc4Z2j5dwyBX2fQQfDR-1k-CB6eog@mail.gmail.com
性能是通过使用压力测试套件进行衡量的(套件锻炼的只是一个性能方面):https://github.com/petergeoghegan/upsert
#1. 重量级页面锁定(Peter Geoghegan)
从本质上讲,这是现有机制的概括:页面缓冲区锁已扩展为页面重量级锁。它们会被保留,直到 UPSERT 转到行锁...但在行锁期间不会保留,因为这会导致死锁(见下文)。有一些玩弄页面级标志(页面特殊区域中的私有 B 树标志位)的技巧,使大多数插入不必担心除检查标志以外的页面上存在重量级锁的可能性。
优点
- 现有机制的概括。
- 全部在内存中(但使用 B 树叶页面上的单个标志除外,该标志无需在宕机时保留)。在未来,它有可能在不对磁盘兼容性造成限制的情况下进行优化。
- 已存在,按评审质量进行形式。当叶页面上的锁争用不是一个重要问题时,可能会执行得最好。
- 已经过压力测试的广泛测试。
- 不会膨胀。不认为这是一个主要优势(至少与 #2 相比)。
- 保留不变,即有些索引元组始终指向某个堆元组(与 #3 不同)。
- 不会发生死锁。有一种策略可以使便宜/容易地*释放*值锁,以避免死锁(见下文)。
缺点
- 仅适用于 nbtree AM。可能会让 ON CONFLICT IGNORE 与排除约束一起使用(但不是 ON CONFLICT UPDATE,这几乎没有意义)。
- 必要将一些东西放在普通插入的关键路径中。现在,必须检查叶页面以找出重量级锁指示标志。这可能不会造成性能问题,但尚未证实且未经测试。
- 具有产生错误的可能性。这似乎并不是批评的重点,因为目前尚不清楚这是否比备选方法更正确或更不正确。
海基·图奥米宁(Heikki Tuominen)将值锁定概括应用于各个方面(这种方法明显无法做到这一点)
https://postgresql.ac.cn/message-id/[email protected]
这有些像格雷夫在以下内容中描述的内容
微软的 SQL Server 产品采用了基于 Lomet 设计[85]的关键范围锁,该设计建立在 ARIES/IM 和 ARIES/KVL [93, 97]之上。与 ARIES 一样,这种设计需要“即时锁”,即持续时间极短的锁。
请注意,假定“值锁”永远不能有用地持有超过一瞬间,因为它们必须在行锁*之前*释放,如下所述。
显然,这不是对缓冲区锁的引用 - 格雷夫(以及 B 树论文通常)始终将它们称为“闩锁”。
#2. "Promise" 堆元组(Heikki Linnakangas)
Heikki 在 2013 年末对 #1 作出回应,提出了此方案(稍后进行细化)。与 #3 类似,此方法作为一种粗略的想法已存在一段时间 [4]。它基本的工作原理是检查现有具有脏快照的元组,然后乐观地插入堆元组,最后在需要时插入关联的唯一索引元组。但是,在两个更新程序之间存在竞争且两者都决定插入时,可能会产生冲突。当需要在行锁定之前释放值锁,并且存在冲突时,我们必须“彻底删除”推测堆元组。如果不彻底删除推测堆元组,则简单案例将死锁,无法防御(请参见下文)。
优点
- 广义方法 — INSERT IGNORE 也可以配合排他约束工作。
- 与 #1 和 #3 相比,它对 nbtree 代码的影响更小。它是非常复杂和微妙的代码,至关重要,通常情况下避免修改它是一件好事。
可能的话,我们可以在此过程中修复以下现有问题:两个插入排他约束的插入程序可能会同时失败。更新:现在认为这不是事实 — 在排他约束的现有实现中不存在此问题(只有在一方或另一方的事务被杀死时可能发生并发死锁,但由于至少一个插入者可以继续,并且队列排序或多或少等效于随机排序,因此这几乎不像是一个主要问题)。
- 在后续的修订中,有针对释放值锁以避免死锁的一项策略,使其廉价/容易(请参见下文)。这对任何在 READ COMMITTED 中避免不合理的死锁错误的设计都是至关重要的。
- 与 #1 相比,更接近 UPSERT 子事务循环模式,因为我们几乎中止子事务以释放值锁(基本上,使用特殊的释放早期锁,其中最后一直到事务结束的锁是在之前使用的)。
- 存在;在与新语法、测试等集成方面已经与 #1 放到了同等基础上。
- 保留不变量,即某些索引元组总是指向某些堆元组(与 #3 不同)
缺点
- 不基于已知在某些其他系统中运行良好的设计……与现有唯一索引/排他约束强制执行的相似性可能并非很有用。
- 似乎需要对多个现有位置进行很多影响很大的更改(与 #1 形成对比,它仅对 btree 代码有影响)。这些位置包括 HeapTupleSatisfiesDirty()(#1 不会接触 tqual.c,除了可重复读/可序列化隔离级别的一个狭义案例需求)。另一个示例是它添加了一个使用共享锁锁定 procarray 的新原因。
- 必须“超级删除”不可用的元组。我们可能无法交换 xmin 中的 InvalidTransactionId,原因与我们无法使用 relfrozenxid 相同(请参阅 37484ad2 的提交消息)。相反,可能会找到一些空闲的 infomask 位,但它们非常有价值,而且从未真正调查过。需要思考。
- 由于预检查,性能可能不如 #1,当插入是结果时,预检查总是浪费。可能可以改善这种情况。但是,当 B-Tree 的一个区域中有很多值时,与 #1 相比,#1 没有那么多锁定争用的问题。
- 可能导致膨胀(尽管并不多...预检查往往效果很好)。膨胀并不是选择任何特定设计的强有力动机,因为它似乎并不是一个非常重要的因素,因此 “无膨胀” 并不是 #1 的优势。
- 并非完全未经测试,但不如 #1 测试得充分。
- 一般来说,将处理这些问题的责任放在堆中可能会使代码暴露于大量的其他代码中,这些代码的复杂交互可能会导致 bug。也许坚持总是将元组 xmin 设置为 InvalidTransactionId 的补丁是不公平的,但如果我们假设必须这样做,那么它会*与*相当多的其他地方发生不良交互。此分析尚未完成,但必须完成。
#3.“Promise”索引元组(Andres Freund、Simon Riggs)
重要提示:请注意,没有以任何形式实现此方法的实际补丁,因此这些要点基于有限的信息,并且比 #1 和 #2 的要点更易于更改。
优点
- 与“幽灵记录”有些相似,有时在 2PL 系统中以与范围锁定交互的方式使用。
- 有关在堆元组中放置此类知识的 #2 异议不太可能在此处适用。
缺点
- 目前并不存在。完全未经验证。
- 违反了每个索引元组都具有对应堆元组的现有不变量,这可以追溯到伯克利时代。目前,我们总是首先创建堆元组,最后物理删除它们。
- 与此相关的是,担心会中断 VACUUM。btree 的 VACUUM 会附带一个要删除的 TID 列表,通过堆创建。无法将当前的批量删除回调用于此目的。需要超独占锁来删除 btree 页面中的元组(即 VACUUM)。通常在堆中跳过 LockBufferForCleanup()(由 bbb6e559 添加)效果不错,因为它往往意味着不会在索引中批量删除 tid 列表,而目前我们在无法快速获取超独占锁的情况下无法跳过该列表。因此,这可能会使 bbb6e559 解决的问题在一定程度上再次出现。
- “索引专用”膨胀成为一种可能性。影响尚不十分清楚。
- 我们必须使用普通索引扫描(而不是 tid)重新查找任何项目。必须将我们的 xid 与此匹配。
- 目前还没有处理不讲理死锁的策略。大概可以采用一些关于 #2 的方面。
“不讲理死锁”和值锁
UPSERT 在 Postgres 中的目标包括实现避免“不讲理死锁”——用户没有合理方式避免的死锁 [5]。为了让死锁场景被考虑为“合法的”,它必须是由于用户可见的相互依存。它不能仅仅是因为相互依赖于非显式的实现级别锁(如值锁),这是用户无法对其代码进行重构以避免相互依存的。仍应该将永远不会死锁的客户端应用程序视为现实的目标;应用程序开发人员需要能够确定事务中的查询以一致的顺序获取锁,而不存在让一般情况下无法实现的实现级别障碍。
“不讲理死锁”是一个问题,#1 始终避免此问题,#2 后来在一些工作后也避免出现此问题。Peter Geoghegan 指出了方法 #2 的早期原型抛出的“不讲理死锁”中的问题 [6]。Geoghegan 投诉的结果基本上是,不能合理地持久保留值锁跨行锁。否则,即使并发很高,死锁也是可能的甚至是不可避免的,并且用户没有什么合理的办法可以避免这种情况。
我们不能保持值锁,因为这样做会造成不讲理死锁的风险这一观点似乎已经获得接受。Heikki 从早期接受了这一原则 [7]。Heikki 后来修改了他的原型(实现方法 #2),于 2013 年底修复了“不讲理死锁”。不久后(2014 年 9 月),Robert Haas 确认“不讲理死锁”是不可接受的 [8]。
示例
直观上,“不能合理地持久保留值锁跨行锁”并不是这种情况。要理解为什么这是真的,考虑 #2 的早期版本的一个示例,以及如何将它展示为以被认为不可接受的方式进行死锁。Peter Geoghegan 写道 [9]
如果行锁在插入更新时发现冲突更新改变了唯一约束属性,会发生什么情况?当然,没有 HOT 的简单更新在插入唯一索引元组时会失败,但*它*仍然可能导致行级冲突,*它*只能在我们提交/中止时失败(出现重复违例)。但是现在,我们必须等待它获取行锁,它必须等待我们获取 promise 元组锁或任何其他在我们获取行锁时尚未释放的“值锁”。死锁。通常情况下,成功锁定一个值并不意味着将锁定该行。该实现必须循环。在值锁定阶段和行锁定阶段可能会有需要重新启动的冲突,但实际上该实现往往表现得非常公平。
Geoghegan 在 pgCon 的讨论中谈到了从值锁升级到行锁的问题,以及这如何造成死锁 [10]。