数据库基础体系 · 第 97/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

PostgreSQL 锁与 Serializable SSI:谓词冲突、死锁和咨询锁


一、先区分三类“并发控制”

讨论 PostgreSQL 的锁、Serializable SSI 和咨询锁时,最容易混淆的是:它们都与并发有关,但解决的问题并不相同。

1. MVCC:决定“读到什么”

PostgreSQL 使用 MVCC。普通 SELECT 通常不会等待正在修改数据的事务,而是根据当前事务的快照判断某个行版本是否可见。

例如:

BEGIN;

SELECT balance
FROM accounts
WHERE id = 1;

这个查询主要依赖:

  • 当前事务的快照;
  • 行版本的事务可见性信息;
  • xminxmax 等元数据;
  • 已提交、未提交和事务快照的关系。

MVCC 解决的是:

当前事务应该看到哪个版本?

它并不自动保证多个查询组合起来具有串行化语义。

2. 锁:限制某些并发操作

锁解决的是:

某个资源当前是否允许另一个事务继续执行某种操作?

例如:

  • 表锁协调 ALTER TABLEVACUUMINSERT 等操作;
  • 行锁协调对同一行的更新和锁定;
  • 咨询锁协调应用自定义的业务资源。

锁可能让事务等待,也可能导致死锁。

3. Serializable SSI:检测不安全的读写依赖

SERIALIZABLE 隔离级别的目标是:

所有成功提交的事务,其结果应等价于某种串行执行顺序。

PostgreSQL 通过 Serializable Snapshot Isolation,简称 SSI,实现这一保证。SSI 通常不会把所有读操作都变成阻塞锁,而是记录读写依赖;如果发现当前并发执行不可能等价于串行执行,就让某个事务失败,返回序列化失败。

因此:

  • 普通锁主要通过等待来协调;
  • SSI 主要通过检测冲突并回滚事务来协调;
  • 咨询锁是应用自己定义的锁,不会自动改变 MVCC 或 SSI 的规则。

二、PostgreSQL 锁的基本模型

2.1 锁保护的不是“数据库”这么一个整体

PostgreSQL 中存在多个层次的锁资源,常见的包括:

  • 数据库对象锁,例如表、索引、模式;
  • 行锁,例如某一行被 UPDATESELECT FOR UPDATE 锁定;
  • 事务 ID、虚拟事务 ID 相关的等待;
  • 咨询锁;
  • Serializable 使用的谓词锁。

表锁并不意味着整个表永远只能由一个事务访问。关键在于不同锁模式之间是否冲突。

例如,普通的 INSERTUPDATEDELETE 会取得表级的 RowExclusiveLock。这种锁与其他普通数据修改通常可以并存,但会与某些需要更强表级排他性的操作冲突,例如部分 DDL。

可以用下面的语句观察表级锁:

SELECT
    pid,
    relation::regclass AS relation_name,
    mode,
    granted,
    waitstart
FROM pg_locks
WHERE relation IS NOT NULL
ORDER BY relation_name, pid, granted;

其中:

  • granted = true 表示锁已经获得;
  • granted = false 表示正在等待;
  • relation 是内部对象标识,转换成 regclass 后通常可以显示表或索引名称。

2.2 行锁不是 MVCC 可见性

考虑两个事务同时更新同一行:

UPDATE accounts
SET balance = balance - 10
WHERE id = 1;

第一个事务已经修改但尚未提交时,第二个事务通常会等待第一个事务对该行的锁。第一个事务提交后,第二个事务会重新检查目标行是否仍然满足条件,并根据当前规则继续或放弃更新。

这个过程中的“等待”是锁行为,而不是普通 SELECT 的可见性判断。

另一方面:

SELECT balance
FROM accounts
WHERE id = 1;

普通读取一般不会因为另一个事务正在更新该行而等待。它可能读取旧版本,前提是该版本对当前快照可见。

因此,以下两句话不能互换:

  • “我能读到这行”;
  • “我锁住了这行”。

如果读取结果将决定后续更新,通常需要明确选择合适的隔离级别、行锁或业务约束。


三、谓词冲突:冲突的不是一行,而是一个条件

3.1 什么是谓词

谓词是查询条件。例如:

SELECT *
FROM orders
WHERE customer_id = 10
  AND status = 'pending';

这里的谓词可以抽象为:

P(row)=(row.customer_id=10)(row.status=pending)P(row) = (row.customer\_id = 10) \land (row.status = 'pending')

查询读取的不是某个预先指定的行,而是满足 P(row) 的所有行。

这会产生一个重要问题:事务读取时结果为空,但另一个事务随后插入一行,使这行满足同一个条件。

这就是常说的幻读场景。

3.2 一个空集读取的例子

假设表中当前没有 sku = 'A' 的商品。

事务 T1:

BEGIN ISOLATION LEVEL SERIALIZABLE;

SELECT count(*)
FROM inventory
WHERE sku = 'A';
-- 返回 0

事务 T2:

BEGIN ISOLATION LEVEL SERIALIZABLE;

INSERT INTO inventory(sku, quantity)
VALUES ('A', 100);

COMMIT;

如果 T1 随后基于“没有 sku = 'A'”这一事实进行决策,那么 T2 的插入就可能破坏 T1 所依据的假设。

这里 T1 并没有读取某一行,因此不能只用“锁住已读行”解释这个冲突。冲突针对的是:

满足 sku = 'A' 这一条件的集合,或者说这个条件对应的搜索空间。

3.3 PostgreSQL 的谓词锁是 SIREAD

在 Serializable 模式下,PostgreSQL 使用谓词锁记录读取过的范围或谓词相关资源。这类锁通常称为 SIREAD 锁。

它有几个关键特征:

  1. SIREAD 通常不阻塞写入。
    一个事务读取某个范围后,另一个事务仍然可以写入该范围。

  2. 写入会与已记录的读取产生谓词冲突。
    SSI 根据这些冲突判断是否形成不安全的依赖结构。

  3. 它不是普通的排他锁。
    不能把 SIREAD 理解成“这个范围被读锁锁住了,其他事务不能插入”。

  4. 它可以记录在不同粒度。
    可能涉及元组、页面或关系。索引访问路径也会影响谓词锁的表示。

可以观察谓词锁:

SELECT *
FROM pg_predicate_locks;

也可以从 pg_locks 中查看 Serializable 相关锁:

SELECT *
FROM pg_locks
WHERE mode = 'SIReadLock';

具体看到的锁粒度和数量与查询计划、索引、事务状态以及谓词锁汇总有关。

3.4 为什么谓词锁可能扩大范围

数据库不一定为每一个逻辑谓词保存无限精细的区间信息。为了控制内存使用,谓词锁可能从更细的元组粒度汇总到页面或关系粒度。

例如,逻辑上事务只读取了少数行,但内部最终可能记录为某个数据页甚至整张表上的谓词锁。这样做会增加误报的可能性:

  • 两个事务实际上访问的逻辑范围不重叠;
  • 但由于锁粒度被提升,系统认为它们可能冲突;
  • 最终某个 Serializable 事务收到序列化失败。

这不表示结果不正确。SSI 的目标是避免漏报不安全并发,必要时可以保守地中止事务。

相关配置参数包括:

  • max_pred_locks_per_transaction
  • max_pred_locks_per_relation
  • max_pred_locks_per_page

它们影响谓词锁的内存管理和汇总行为。调大参数不能消除逻辑上的冲突,只可能减少某些由锁粒度提升带来的额外冲突,同时会增加共享内存等资源压力。


四、SSI 如何从读写依赖推导出序列化失败

4.1 读写依赖的形式化表示

设事务 TiT_i 读取了某个对象或谓词范围,而事务 TjT_j 写入了可能影响该读取的内容。如果这种读取发生在 TjT_j 的写入之前,并且 TiT_i 的快照没有看到 TjT_j 的写入,则可形成一个读写依赖:

TirwTjT_i \xrightarrow{rw} T_j

直觉是:

如果要把这两个事务排成串行顺序,T_i 必须排在 T_j 前面,否则 T_i 读到的结果无法解释。

这只是一个方向上的约束,并不等于立即发生错误。

4.2 危险结构

SSI 关注的核心结构是两个连续的读写依赖:

T1rwT2rwT3T_1 \xrightarrow{rw} T_2 \xrightarrow{rw} T_3

如果 T1T_1T3T_3 在某个关键时间点仍然并发,那么这条依赖链可能形成无法满足的串行顺序,称为危险结构。

从序列化角度看:

  • 第一条边要求 T1T_1 排在 T2T_2 前面;
  • 第二条边要求 T2T_2 排在 T3T_3 前面;
  • 如果另外的冲突或提交关系又要求 T3T_3 排在 T1T_1 前面;
  • 就形成环,任何串行顺序都无法满足。

SSI 不需要等待所有事务完成后再检查,而是在事务运行和提交过程中维护这些依赖,并在必要时中止事务。

4.3 两事务写偏差

建立一个约束:至少有一名医生值班。初始状态如下:

doctor on_call
Alice true
Bob true

事务 T1:

BEGIN ISOLATION LEVEL SERIALIZABLE;

SELECT count(*)
FROM doctors
WHERE on_call = true;
-- 看到 2

UPDATE doctors
SET on_call = false
WHERE doctor = 'Alice';

COMMIT;

事务 T2 几乎同时执行:

BEGIN ISOLATION LEVEL SERIALIZABLE;

SELECT count(*)
FROM doctors
WHERE on_call = true;
-- 也看到 2

UPDATE doctors
SET on_call = false
WHERE doctor = 'Bob';

COMMIT;

在普通快照隔离下,两个事务可能都看到对方尚未提交,于是都认为“另一名医生仍然值班”,最后得到:

doctor on_call
Alice false
Bob false

约束被破坏。

SSI 可以观察到:

  • T1 读取了 Bob 仍值班这一事实,T2 写入了 Bob;
  • T2 读取了 Alice 仍值班这一事实,T1 写入了 Alice。

于是存在相互交叉的读写依赖:

T1rwT2T_1 \xrightarrow{rw} T_2

T2rwT1T_2 \xrightarrow{rw} T_1

这构成无法串行化的依赖环。系统会让其中一个事务失败,常见错误是:

ERROR: could not serialize access due to read/write dependencies among transactions

客户端通常应看到 SQLSTATE:

40001

需要注意,具体哪个事务失败取决于执行时序和 SSI 的状态,不应把某个事务固定视为“必然失败者”。

4.4 为什么 Serializable 事务必须重试

序列化失败不是普通 SQL 语法错误,也不是只重试失败的那一条语句就够了。

错误发生时,当前事务的快照和此前读取的结果已经不能继续用于保证串行化。正确的处理方式是:

  1. 回滚整个事务;
  2. 重新建立事务;
  3. 重新执行完整的业务逻辑;
  4. 必要时加入有限次数的退避重试。

伪代码如下:

for attempt in 1..N:
    begin transaction at serializable
    execute all reads and writes
    try commit
    if SQLSTATE = 40001:
        rollback
        sleep with backoff
        continue
    if success:
        return
    otherwise:
        report error

应用必须确保事务内的副作用可以安全重试。比如已经发送到外部系统的消息、已经调用的支付接口,不能仅依赖数据库事务回滚来撤销。这属于跨系统一致性问题。


五、Serializable 不等于“所有读都等待”

这是一个常见误解。

在 PostgreSQL 中:

BEGIN ISOLATION LEVEL SERIALIZABLE;

SELECT *
FROM inventory
WHERE sku = 'A';

并不意味着:

  • 其他事务不能更新 inventory
  • 其他事务不能插入满足条件的行;
  • 当前查询会像显式锁一样等待所有潜在写入。

更准确的理解是:

  • 读取建立 SIREAD 谓词依赖;
  • 写入与这些读取产生冲突记录;
  • 如果冲突组合会破坏串行化,事务提交或执行期间可能失败。

因此,Serializable 常见的失败表现不是“永远阻塞”,而是:

could not serialize access due to read/write dependencies among transactions

SELECT FOR UPDATE 是另一种机制:

SELECT *
FROM inventory
WHERE sku = 'A'
FOR UPDATE;

它会锁定已经找到的行,但对于“当前没有任何行”的空集,不能简单地理解为锁住了未来所有可能插入的行。要保护一个不存在的谓词范围,应依赖 Serializable 的谓词冲突检测,或者使用明确的唯一约束、键表、应用锁等设计。


六、死锁:等待图中的环

6.1 死锁的必要条件

死锁不是“锁很多”这么简单。典型死锁需要形成循环等待:

T1T2T1T_1 \rightarrow T_2 \rightarrow T_1

其中箭头表示:

前一个事务正在等待后一个事务持有的资源。

例如:

事务 T1:

BEGIN;

UPDATE accounts
SET balance = balance - 10
WHERE id = 1;

-- 暂停,不提交

事务 T2:

BEGIN;

UPDATE accounts
SET balance = balance - 20
WHERE id = 2;

-- 暂停,不提交

然后 T1 再执行:

UPDATE accounts
SET balance = balance + 10
WHERE id = 2;

T1 等待 T2 释放 id = 2 的行锁。

此时 T2 执行:

UPDATE accounts
SET balance = balance + 20
WHERE id = 1;

T2 等待 T1 释放 id = 1 的行锁,于是形成:

T1 waits for T2
T2 waits for T1

PostgreSQL 的死锁检测器会发现这个等待图中的环,并中止其中一个事务,通常返回:

ERROR: deadlock detected

对应 SQLSTATE:

40P01

6.2 为什么统一加锁顺序有效

如果所有事务都按递增的账户 ID 获取资源:

UPDATE accounts ... WHERE id = 1;
UPDATE accounts ... WHERE id = 2;

那么不应出现一个事务先拿 1 再等 2,另一个事务先拿 2 再等 1 的结构。

这并不是“锁越少越好”,而是:

对同一组资源建立全局一致的加锁顺序,破坏循环等待条件。

应用代码中应特别注意:

  • 批量更新是否按稳定顺序处理;
  • 多张表的访问顺序是否一致;
  • 行锁和咨询锁的顺序是否一致;
  • 是否在事务中调用可能反过来访问数据库的函数或服务。

6.3 死锁与锁等待超时不同

下面几个错误需要区分:

SQLSTATE 含义
40P01 检测到死锁
55P03 lock_timeout 到期
40001 序列化失败,或其他可重试的序列化异常

lock_timeout 只表示等待某个锁超过限制,不一定存在环。

deadlock_timeout 则是 PostgreSQL 在等待一段时间后检查死锁的触发阈值。它不是死锁的最大允许时间,也不是“锁最多只能持有这么久”。检查过于频繁会增加开销,设置过大则会让真正的死锁错误延迟更久。

可以在会话中设置:

SET lock_timeout = '3s';
SET deadlock_timeout = '1s';

生产环境是否调整这些参数,要结合事务时长、正常锁等待和故障恢复策略验证,不能用极小的超时掩盖设计上的锁顺序问题。


七、如何诊断线上锁等待

7.1 找出等待者和阻塞者

可以使用 pg_stat_activitypg_blocking_pids

SELECT
    w.pid AS waiting_pid,
    w.usename AS waiting_user,
    w.query AS waiting_query,
    w.wait_event_type,
    w.wait_event,
    b.pid AS blocking_pid,
    b.usename AS blocking_user,
    b.query AS blocking_query,
    b.state AS blocking_state
FROM pg_stat_activity AS w
LEFT JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS x(pid)
    ON true
LEFT JOIN pg_stat_activity AS b
    ON b.pid = x.pid
WHERE w.wait_event_type = 'Lock';

解释:

  • waiting_pid 是正在等待的后端进程;
  • blocking_pid 是当前阻塞它的进程;
  • wait_event_type = 'Lock' 表示正在等待锁类事件;
  • 阻塞者可能处于 idle in transaction,这通常意味着事务开启后没有及时提交或回滚。

再结合 pg_locks

SELECT
    pid,
    locktype,
    relation::regclass AS relation_name,
    page,
    tuple,
    virtualxid,
    transactionid,
    mode,
    granted,
    waitstart
FROM pg_locks
WHERE pid IN (
    SELECT pid
    FROM pg_stat_activity
    WHERE wait_event_type = 'Lock'
)
ORDER BY pid, granted, locktype;

pg_locks 中:

  • granted = false 是等待中的锁请求;
  • granted = true 是已经持有的锁;
  • relationpagetuple 是否有值取决于锁类型;
  • 不应只看表级锁就断定是“整张表被锁住”,因为阻塞也可能来自行锁、事务 ID 等资源。

7.2 死锁日志

如果发生死锁,服务器日志通常会记录:

  • 哪个进程等待哪个锁;
  • 哪个进程持有什么锁;
  • 参与死锁的 SQL;
  • 最终被回滚的事务。

诊断时应把日志中的 PID、事务时间线和应用请求 ID 对起来。仅看到“deadlock detected”而不保留相关 SQL,很难判断真正的资源访问顺序。


八、咨询锁:应用定义的互斥协议

8.1 咨询锁是什么

咨询锁,也叫 advisory lock,是 PostgreSQL 提供给应用使用的锁命名空间。

数据库本身不知道数字 42 代表:

  • 某个用户;
  • 某个订单;
  • 某个租户;
  • 某个定时任务;
  • 还是一批文件。

应用自行约定一个键,并使用这个键协调并发任务。

它不直接绑定:

  • 表;
  • 行;
  • 索引;
  • MVCC 行版本;
  • 外部系统资源。

例如,使用事务级咨询锁保护同一租户的业务操作:

BEGIN;

SELECT pg_advisory_xact_lock(1001);

-- 执行 tenant_id = 1001 的一组业务操作

COMMIT;

另一个事务也请求键 1001 时,会等待该事务结束。

8.2 事务级与会话级咨询锁

常见函数有两组。

事务级:

SELECT pg_advisory_xact_lock(1001);
SELECT pg_try_advisory_xact_lock(1001);

会话级:

SELECT pg_advisory_lock(1001);
SELECT pg_try_advisory_lock(1001);
SELECT pg_advisory_unlock(1001);

区别如下:

类型 释放时机 回滚是否释放
事务级 事务结束时自动释放 回滚会释放
会话级 显式解锁或数据库会话结束 回滚不会释放

pg_try_* 不等待,立即返回布尔值。例如:

BEGIN;

SELECT pg_try_advisory_xact_lock(1001) AS acquired;

如果返回 false,当前事务没有获得锁,可以选择跳过、排队到应用层,或返回“资源忙”。

事务级咨询锁一般更适合保护数据库事务内的业务操作,因为事务提交或回滚时会自动释放。会话级咨询锁则更容易受到连接池影响:应用逻辑结束了,但连接没有断开,锁仍然存在。

8.3 咨询锁不会自动保护行

下面两个操作之间没有自动关系:

SELECT pg_advisory_xact_lock(1001);

以及:

UPDATE tenants
SET ...
WHERE tenant_id = 1001;

只有遵守同一协议的代码才会被咨询锁协调。另一个没有获取咨询锁、直接更新表的事务仍然可以执行。

因此咨询锁适合:

  • 数据库中没有天然行可锁定的业务资源;
  • 需要协调定时任务或同一租户的串行处理;
  • 需要把多个表或多个步骤视为一个应用级资源。

它不适合替代:

  • 唯一约束;
  • 外键;
  • 行锁;
  • 数据库的实际一致性约束。

如果资源本来就是一行,通常更直接的方案是使用唯一约束或:

SELECT *
FROM job
WHERE id = 1
FOR UPDATE;

8.4 咨询锁也可能死锁

咨询锁并不是死锁免疫的。事务 T1 和 T2 可以这样形成循环:

T1 持有 advisory key 1,等待 advisory key 2
T2 持有 advisory key 2,等待 advisory key 1

更复杂的情况是混合资源:

T1 持有行锁,等待咨询锁
T2 持有咨询锁,等待行锁

因此应用必须把咨询锁纳入整体等待图,统一考虑:

  • 咨询锁之间的顺序;
  • 咨询锁与行锁之间的顺序;
  • 咨询锁与表锁之间的顺序。

咨询锁可以在 pg_locks 中观察:

SELECT
    pid,
    locktype,
    classid,
    objid,
    objsubid,
    mode,
    granted,
    waitstart
FROM pg_locks
WHERE locktype = 'advisory';

8.5 咨询锁键的设计

咨询锁有单个 bigint 键,也有两个 integer 组成的键。应用应明确命名空间,避免不同模块意外使用同一个数字。

例如可以约定:

高位:资源类型
低位:资源 ID

或者使用两个整数:

SELECT pg_advisory_xact_lock(7, 1001);

这里 7 可以代表资源类型,1001 代表资源 ID。

如果应用使用字符串哈希成整数,要考虑哈希碰撞。碰撞不会破坏数据库内部的锁语义,但会把两个本不相关的业务资源错误地串行化。


九、谓词冲突、普通锁和咨询锁的关系

下面这个对比有助于建立边界:

机制 保护对象 是否通常阻塞 是否由 MVCC 自动产生 失败方式
普通表锁 数据库对象 可能阻塞 部分数据库操作自动使用 锁等待、死锁、超时
行锁 已定位的行 可能阻塞 UPDATE 等操作会使用 锁等待、死锁、超时
SIREAD 谓词锁 读取过的谓词范围 通常不阻塞写入 Serializable 事务使用 序列化失败
咨询锁 应用定义的键 取决于函数 不自动产生 锁等待、死锁、超时

特别要注意:

  • SIREAD 不是应用可以随意获取和释放的普通咨询锁;
  • 咨询锁不会让普通 SELECT 变成可串行化读取;
  • 行锁只保护已找到的行,不天然保护一个空谓词范围;
  • Serializable 也不会消除普通锁之间的死锁。

一个 Serializable 事务完全可能同时经历:

  1. 因为 UPDATE 等待行锁;
  2. 因为查询建立 SIREAD 依赖;
  3. 因为冲突结构最终收到 40001
  4. 或者因为和另一个事务的锁顺序相反而收到 40P01

这些是不同的事件,应用处理策略也应分别记录和重试。


十、生产设计中的几个边界

10.1 数据库约束优先于“先查再决定”

如果业务规则可以表达为唯一约束、外键或排他约束,应优先使用数据库约束。

例如,不要仅依赖:

SELECT count(*)
FROM user_roles
WHERE user_id = 1
  AND role = 'owner';

然后在应用层判断是否可以插入。并发事务都可能看到“没有”,最后产生重复数据。

更可靠的方案通常是:

CREATE UNIQUE INDEX user_one_owner
ON user_roles(user_id)
WHERE role = 'owner';

约束负责保证事实,SSI 或咨询锁负责协调更复杂的事务流程。

10.2 使用 Serializable 仍需要处理重试

Serializable 的语义保证只针对成功提交的事务。它不保证每个事务都一定成功,也不保证没有等待。

应用应区分:

  • 40001:重新执行整个事务;
  • 40P01:修正锁顺序,并可对整个事务重试;
  • 55P03:判断是正常高峰等待还是异常阻塞;
  • 其他错误:不能一概当作可安全重试。

重试事务时,随机数、当前时间、外部调用和消息发送等副作用必须重新设计,否则数据库层面虽然重试成功,业务层面可能重复执行。

10.3 长事务会放大这些问题

长事务会:

  • 持有普通锁更久;
  • 让其他事务等待更久;
  • 保留更早的 MVCC 快照;
  • 增加 SSI 需要维护的依赖;
  • 延迟旧版本回收和 Vacuum 的效果。

因此锁诊断不能只看单条 SQL。需要同时关注:

SELECT
    pid,
    state,
    xact_start,
    query_start,
    wait_event_type,
    wait_event,
    query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

idle in transaction 但长期不结束的会话,尤其容易成为锁等待和旧版本保留的根源。


十一、一个完整的测试方式

下面的测试需要同一个 PostgreSQL 数据库中的两个 psql 会话。先初始化数据:

DROP TABLE IF EXISTS doctors;

CREATE TABLE doctors (
    doctor text PRIMARY KEY,
    on_call boolean NOT NULL
);

INSERT INTO doctors(doctor, on_call)
VALUES
    ('Alice', true),
    ('Bob', true);

会话 A

BEGIN ISOLATION LEVEL SERIALIZABLE;

SELECT count(*)
FROM doctors
WHERE on_call = true;
-- 结果为 2

UPDATE doctors
SET on_call = false
WHERE doctor = 'Alice';

会话 B

BEGIN ISOLATION LEVEL SERIALIZABLE;

SELECT count(*)
FROM doctors
WHERE on_call = true;
-- 结果也可能为 2

UPDATE doctors
SET on_call = false
WHERE doctor = 'Bob';

随后分别提交:

COMMIT;

其中一个事务可能成功,另一个事务可能收到 40001。如果两个事务都成功提交,应检查实际执行时序和事务边界;在标准的完整写偏差场景中,Serializable 必须阻止最终不可串行化的结果,而不是保证两个事务都成功。

若改成普通默认隔离级别,并且没有其他约束或显式协调,则两个事务都成功、两名医生都离岗的风险会出现。


十二、最终的判断框架

遇到并发问题时,可以依次询问:

  1. 这是“读到了哪个版本”的问题,还是“谁必须等待谁”的问题?
  2. 冲突针对的是已经存在的行,还是一个查询谓词覆盖的范围?
  3. 当前使用的是普通锁、SIREAD 谓词依赖,还是咨询锁?
  4. 事务是在等待,还是已经因为 40001 被中止?
  5. 如果是等待,等待图中是否存在环?
  6. 如果是咨询锁,所有访问同一业务资源的代码是否遵守同一协议?
  7. 如果事务失败,应用是否回滚并重试了完整事务?
  8. 业务规则是否可以由唯一约束等数据库约束直接表达?

理解这些边界后,PostgreSQL 的“锁住了”“读到了”“可串行化了”和“业务资源被协调了”就不再是同一个概念:普通锁控制等待,MVCC控制可见性,SSI 检测谓词级读写依赖,咨询锁则把应用自定义的资源纳入数据库的等待与死锁体系。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。