在 Postgres 中,FOR UPDATE 锁定父行,而非子行
在 `READ COMMITTED` 隔离级别下,使用 `SELECT FOR UPDATE` 锁定父行并不能保护其子行。这会造成三个陷阱,以及针对每个陷阱的修复方法。
在 PostgreSQL 中,行锁保护的是单行。它并不保护指向该行的那组行。在默认的 READ COMMITTED 隔离级别下,对父行执行 SELECT ... FOR UPDATE 并不能说明哪些子行存在,另一个会话将要插入哪些子行,或者哪些子行正在改变状态。本文将探讨三个因将行锁误读为关系锁而引发的并发错误,以及修复这些错误的查询结构、事务边界和超时设置。
行锁不是谓词锁
SELECT ... FOR UPDATE 会标记该语句返回的行。另一个试图更新、删除或锁定这些相同行的事务将会等待,直到你提交或回滚。这就是全部的保证。它没有提及你的语句运行时不存在的行,也没有提及其他表中引用你锁定的行的行。
这种误解源于我们的谈论方式。“我锁定了父级,所以没人能动它”悄悄地变成了“没人能动任何属于它的东西”。在文档模型中,这种理解是成立的,因为子级存在于父记录内部。在关系型模式中,它们是拥有各自锁的独立行。
PostgreSQL 确实有一种可以对集合进行推理的模式:在 SERIALIZABLE 隔离级别下,它会跟踪谓词读,并在两个事务的读写集发生冲突时中止其中一个,这包括通过尚不存在的行产生的冲突。默认级别是 READ COMMITTED,在该级别下,每个语句都会获取一个新的快照,并且不会跟踪任何谓词。在这种情况下,覆盖一组行的不变量必须通过一个触及整个集合的语句来强制执行,或者通过将每个写入者序列化到它们都锁定的单个行之后来强制执行。
陷阱 1:两次状态范围更新之间的间隙
场景设置:父行可以被停用(软删除)。当父行被停用时,其下的每个子行都会变为 rejected 状态,并且调用方希望得到按先前状态统计的数量,用于生成一条审计记录。直接的代码实现会为每种状态编写一条语句。
-- Two statements, one logical intent. The gap between them is the bug.
UPDATE child SET status = 'rejected'
WHERE parent_id = $1 AND status = 'confirmed'; -- count A
UPDATE child SET status = 'rejected'
WHERE parent_id = $1 AND status = 'candidate'; -- count B
这两条语句的作用域都限定于父级,它们自身都是原子的,并且有一个事务将它们包裹起来,所以这看起来是安全的。事实并非如此。在这两条语句之间,另一个会话可以提交一个子行,将其状态从 candidate 变为 confirmed。第一条语句已经运行,没有看到该行为 confirmed 状态;第二条语句按 candidate 状态进行筛选,也就不再能看到该行为 candidate 状态。该行不匹配任何一个筛选条件,并以 confirmed 状态留在一个已停用的父级下。
在顶部添加 SELECT ... FROM parent WHERE id = $1 FOR UPDATE 并不能弥补这个漏洞。该锁只覆盖 parent 表中的一行。被提升的子行存在于 child 表中,其写入者从不需要父级锁,因为外键检查在插入和键列更新时触发,而不是在状态变更时触发。两个计数也都是错误的,因为该行在 A 和 B 中都缺失了。
此修复方案是一条单独的语句,它会锁定目标集,然后精确地写入该集合。
WITH target AS MATERIALIZED (
SELECT id, status AS old_status FROM child
WHERE parent_id = $1 AND status IN ('confirmed','candidate') FOR UPDATE
), upd AS (
UPDATE child c SET status = 'rejected'
FROM target t WHERE c.id = t.id RETURNING t.id, t.old_status
) SELECT id, old_status FROM upd
target 中的 FOR UPDATE 会锁定每个匹配的子行。如果另一个会话在该语句的快照之后对其中某一行提交了更改,PostgreSQL 会等待该锁,然后根据新提交的版本重新评估 WHERE 子句。这种机制就是 EvalPlanQual。刚刚变为 confirmed 状态的行仍然满足 status IN ('confirmed','candidate') 条件,并会和其余行一起被降级。刚刚移至 rejected 状态的行则会被正确地排除,因为它已经处于目标状态。
出于同样的原因,计数结果是正确的:它们描述的是语句实际锁定和更改的行,而不是在不同时刻采样的两个计数。MATERIALIZED 在这里并不会改变 CTE 的运行频率,因为它只被引用了一次;它声明了其意图,即固定目标集并精确地对该集合进行操作。
陷阱 2:无锁读取的守卫并非真正的守卫
同一功能需要相反的防御措施:一旦父节点被停用,就不能在其下插入新的子节点,也不能确认任何现有的子节点。进行该检查最自然的位置是写入路径的顶端。
// Broken: the read runs in its own implicit transaction, so any lock it
// takes is released before the write begins.
func AddChild(ctx context.Context, pool *pgxpool.Pool, parentID int64, label string) error {
status, err := parentStatus(ctx, pool, parentID) // own connection, own snapshot
if err != nil {
return err
}
if status == statusRejected {
return ErrParentRejected
}
return insertChild(ctx, pool, parentID, label) // new transaction, new snapshot
}
这可以编译通过,也能通过审查,但什么都没有强制执行。其原因在于一个代码从未请求过的锁。
向 child 中插入数据必须验证其外键,而该验证会在被引用的父行上获取一个 FOR KEY SHARE 锁。FOR KEY SHARE 与 FOR UPDATE 冲突,因此当级联操作持有父行时,插入操作会在外键检查中被阻塞并等待它。这部分正是你所期望的。
问题在于防护性读取相对于该等待所处的位置。它在早些时候、在另一个连接上、在其自己的单语句事务中运行,并且看到了 candidate,因为级联操作尚未提交。然后插入操作被阻塞,级联操作提交,接着插入操作被唤醒并成功执行。外键锁并不会使这种交错发生变得不可能。它反而使其成为首选的交错方式,因为它恰好将插入操作暂停到级联操作完成为止。
修复方法是在与写入操作相同的事务中运行防护性读取,并持有一个与级联操作冲突的锁。
SELECT status FROM parent WHERE id = $1 FOR SHARE
FOR SHARE 与 FOR UPDATE 冲突,因此守卫会等待级联操作完成,而不是绕过它进行读取,并且一旦锁被授予,EvalPlanQual 就会返回新提交的版本。守卫看到 rejected 后便会拒绝。FOR SHARE 也与 FOR NO KEY UPDATE 冲突,这是一个普通的 UPDATE parent SET status = ... 语句隐式采用的模式,因此即使对于从未写入锁定子句的级联操作,它也会等待。两个守卫同时存在仍然不会相互阻塞,因为共享锁是兼容的。
// Fixed: guard and write share one transaction, and the guard holds a lock
// that conflicts with the cascade until that transaction commits.
func AddChildTx(ctx context.Context, tx pgx.Tx, parentID int64, label string) error {
var status string
err := tx.QueryRow(ctx,
`SELECT status FROM parent WHERE id = $1 FOR SHARE`, parentID).Scan(&status)
if err != nil {
return err
}
if status == statusRejected {
return ErrParentRejected
}
return insertChildTx(ctx, tx, parentID, label)
}
总的教训是:函数签名决定了卫兵(guard)是否起作用。接收事务句柄的函数会继承调用者的事务,因此卫兵的锁会一直存在,直到该事务提交。接收连接池的函数会开启自己的事务,并在返回前释放锁。两者都能通过类型检查,在差异(diff)对比中看起来也一样,但只有一个能起到强制约束作用。
陷阱 3:每把新锁都会增加一个新的等待点
一个获取锁的守卫会改变之前没有锁的路径的时序,而它的对手不仅仅是你刚刚编写的级联操作。任何长时间持有父行的事务现在都会延迟该父行下的每一次写入。一个锁定父行、运行缓慢并在结束时提交的夜间作业,会在其整个运行期间卡住用户的确认点击。行锁等待没有默认超时。
因此,要将锁和它的边界一起引入。
BEGIN;
SET LOCAL lock_timeout = '3s';
SELECT status FROM parent WHERE id = $1 FOR SHARE;
SET LOCAL 将设置的作用域限定在当前事务,因此它不会泄漏到该池化连接的下一个用户。当等待时间超过限制时,PostgreSQL 会引发 SQLSTATE 55P03 (lock_not_available),这需要被转换为调用者可以据此操作的内容。
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "55P03" { // lock_not_available
return ErrBusyRetryLater
}
对于用户正在等待的请求,55P03 意味着“此记录正忙,请稍后重试”。对于后台遍历许多父项的操作,它意味着“跳过这一个,在下次运行时再处理”,而不是“中止批处理”。弄错这一点会将一次点击变成一次失败的夜间运行:某人确认了一个子项,批处理触及了 3 秒的限制,将其视为致命错误,并在剩下数百个父项未处理的情况下停止。NOWAIT 和 SKIP LOCKED 是同一思想的更精确版本。
锁顺序、时间戳和用于固定行为的测试
有三个细节决定了上述修复在实际代码库中能否保持正确。
首先是锁顺序。死锁需要两条路径以相反的顺序获取相同的两行。如果每条路径都在获取子行之前先获取父行,就不会形成循环,而新的防护措施也会遵循这个顺序,因为它会先读取父行。只要有一条路径在锁定父行之前先锁定了子行,就足以产生循环,所以要通过搜索而非凭记忆来验证:在写入路径中用 grep 查找对子表的锁定读和更新操作,并检查每条路径已持有哪些锁。
其次是时间戳,它引发的错误是无声的。像这样的批量状态转换通常会触及一个类似 reviewed_at 的列,该列旨在记录某人判定该行的时间。如果某个仪表盘按该列对计数进行分组,那么在级联操作期间更新此时间戳会重写历史记录:昨天判定的行现在会被计为今天的拒绝,而上周的数字在没有任何人编辑任何东西的情况下发生了变动。批量操作的后果并非人工判定,所以不要改动那个时间戳。
第三是测试。状态转换的各个分支是否互斥,是当前状态和请求状态的一个纯函数,因此应将其从 SQL 路径中提取出来,并用表驱动测试来固定其行为。在这次工作中,一个停用已停用父项的请求进入了恢复分支,因为分支条件是独立的、非互斥的条件判断。它通过了编译,现有的测试也通过了,而手动检查却漏掉了它,因为当前的客户端使用的是较新的端点,只有一条较旧的路径会进入那个分支。
tests := []struct {
name string
current string
request string
want action
}{
{"retire a live parent", statusActive, statusRejected, actionCascade},
{"retire an already retired parent", statusRejected, statusRejected, actionNoop},
{"revive a retired parent", statusRejected, statusActive, actionRevive},
{"revive a live parent", statusActive, statusActive, actionNoop},
}
常见问题
我应该改用 SERIALIZABLE 吗?
它解决了根本原因:谓词冲突(包括插入尚不存在的行)会被检测到,其中一个事务会因 SQLSTATE 40001 而中止。代价是每个事务都变得可重试,因此每个写入路径都需要一个重试循环,并且每个副作用都必须能够容忍重放。谓词锁跟踪还会消耗内存,并且可能从行粒度升级到页粒度,这表现为从未重叠的事务之间发生冲突。将一个热点路径设为可串行化,而其余部分保持 READ COMMITTED 几乎没有好处,因为检查仅在都以该级别运行的事务之间生效。
使用咨询锁会更简单吗?
对派生自父 ID 的键使用 pg_advisory_xact_lock 可以串行化该父 ID 下的所有写入者,即使没有行可锁,它也能工作。有两个缺点:锁是一种约定而非约束,因此忘记它的新代码路径不会收到错误也不会等待,只会产生一个无人察觉的竞争条件;并且键空间是全局的,因此不相关的功能可能会发生冲突,除非你使用带有命名空间的两参数形式。
FOR NO KEY UPDATE 怎么样?
这是一个普通 UPDATE 非键列时所采用的较弱模式,它的存在是为了让外键检查不必等待那些不可能破坏键的更新。这正是关键所在:FOR NO KEY UPDATE 与 FOR KEY SHARE 不冲突,因此它不会让子项插入等待。要阻止插入,请使用 FOR UPDATE 锁定父项。对于防护,FOR SHARE 就足够了,因为它与两种写入模式都冲突。
数据库应该用触发器或约束来强制执行此操作吗?
在 child 上设置一个读取父项状态的 BEFORE INSERT 触发器,与应用程序防护有相同的缺陷,原因也相同:它的读取需要 FOR SHARE,而本可以阻塞操作的外键检查在语句的稍后阶段才会运行。声明式版本是可行的:在子项的外键中携带父项的状态,即一个带有 ON UPDATE CASCADE 的对 (id, status) 的复合引用,外加一个禁止子项上出现已停用值的 CHECK 约束。任何代码路径都无法绕过它,代价是键更宽,并且故障被反转了:现在是停用父项的操作会失败。
有一个边界值得明确说明。这里的每个修复方案都串行化了对单个父行的写入者,这使其成本低廉,因为对不同父项的两个操作永远不会产生竞争。一个跨越多个父项的不变量,例如对已确认子项的全局上限,没有单一的行可以锁定,需要一个所有写入者都获取的专用行、一个针对该规则的咨询锁,或者使用 SERIALIZABLE。大多数时候,父行是正确的单元,唯一的问题是你的语句和事务边界是否使用了它。