选择隔离级别,应从操作必须维持的不变量出发。一致快照很有用,但两个事务可能分别作出看似合理的决定,却在一起提交后得到错误结果。PostgreSQL 的可重复读保持事务读取快照稳定,仍允许部分序列化异常。可串行化则增加依赖检测,拒绝无法解释为任何串行执行顺序的并发组合,同时要求应用处理相应重试。关键是知道业务依赖哪项保证,而不是单纯选择一个听起来更强的名称。
稳定视图与正确决策分开判断
假设有两名值班医生,要求至少一名留岗。每名医生只有在看到可用人数大于一时才允许离开。两个可重复读事务可以都看到两人,然后分别把不同医生标记为离岗。它们没有覆盖彼此的同一行,因此都可能提交,最后值班人数变成零,即使每个事务都使用了内部稳定的快照。
这叫写偏差,不是对同一行发生丢失更新。分别检查每个医生布尔字段是否合法,并没有表达“总人数至少为一”的全局规则。只保护自己正在修改的那一行,也会漏掉两个决定之间的联系。适用的序列化协议需要覆盖整个不变量,而不只是其中某个局部字段。
具体数据库实现也很重要。PostgreSQL 把读未提交映射为读已提交;它的可重复读会阻止幻读,尽管 SQL 标准允许这一等级存在幻读。这些特性仍不会消除写偏差。因此,应根据引擎文档解释隔离名称,不能假定所有数据库同名级别的实现完全一样,更不能把某一种异常被排除当成所有并发问题都解决。
坏掉的样子
把可串行化理解为数据库实际每次只执行一个事务,会误导性能判断。PostgreSQL 仍并发运行事务并跟踪依赖,某个事务可能在执行途中或提交时收到 40001。应用需要用新的读取重做完整决定,不能只重新提交,也不能继续使用被拒绝快照中计算出来的值。否则虽然换了一次事务,决策前提仍然是旧的。
可串行化也不能修复一个独自运行时就违反业务规则的事务。所有相关写入路径都必须遵守既定契约;如果另外一条不受限制的更新允许最后一名医生离岗,不变量仍会被破坏。更高隔离提供并发保证,不会自动实现尚未编写的业务逻辑。实验中的条件判断与隔离级别必须一起成立。
快速验证
在同一个可丢弃的 PostgreSQL 18 数据库中,打开甲、乙两个新的 psql -X -v ON_ERROR_STOP=1 会话,使用相同角色,显式事务外保留正常自动提交,并选用尚未使用的模式名称。严格遵守下面顺序;先让两边读取重叠,才能建立要观察的竞争决策。
- 在甲中创建初始值班表。
CREATE SCHEMA isolation_demo;
CREATE TABLE isolation_demo.doctors (id integer PRIMARY KEY,on_call boolean NOT NULL);
INSERT INTO isolation_demo.doctors VALUES (1,true),(2,true);
- 先在甲中开始事务并读取,再在乙中执行对应操作。两边人数都应为二。
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
- 在甲中更新第一名医生,再在乙中更新第二名。每条更新也会在自己的快照中检查人数规则。
UPDATE isolation_demo.doctors SET on_call=false WHERE id=1
AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id;
UPDATE isolation_demo.doctors SET on_call=false WHERE id=2
AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id;
- 先提交甲,再提交乙。两边结束后,在甲中查看已经失效的不变量并重置值班表。
COMMIT;
\set ON_ERROR_STOP off
COMMIT;
\echo commit_state :SQLSTATE
\set ON_ERROR_STOP on
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
UPDATE isolation_demo.doctors SET on_call=true;
- 使用可串行化重复这组重叠决策:开始甲、开始乙、更新甲、更新乙、提交甲,最后尝试提交乙。
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
UPDATE isolation_demo.doctors SET on_call=false WHERE id=1
AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id;
UPDATE isolation_demo.doctors SET on_call=false WHERE id=2
AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id;
COMMIT;
\set ON_ERROR_STOP off
COMMIT;
\echo commit_state :SQLSTATE
\set ON_ERROR_STOP on
- 在甲中检查人数,在乙中以新事务重做完整决定,最后在甲中核对并清理。
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
UPDATE isolation_demo.doctors SET on_call=false WHERE id=2
AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id;
COMMIT;
SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call;
DROP SCHEMA isolation_demo CASCADE;
PostgreSQL 18.6 实测中,可重复读的两个提交都成功,可用人数变成零。在受控的可串行化顺序下,甲提交成功,乙提交返回 40001,仍有一名医生留岗。代码只在需要观察的提交周围关闭遇错停止,让 psql 能打印 SQLSTATE;如果出现其他错误,不能把它当成预期的序列化拒绝。
乙重新开始完整事务后,读到只剩一名医生,带条件的更新没有返回任何行,也没有把最后一人移走。它成功提交后,最终人数仍是一。这同时验证了异常被阻止,以及拒绝后确实重新计算决定。仅仅观察到数据库抛错,还不足以证明应用恢复路径符合业务要求。
评估实际代价
依赖跟踪与重复尝试会消耗资源,显式锁则可能带来等待与协调成本,没有一种方式在所有负载下都更快。应测量真实事务组合,保持事务简短,并给重试次数和请求截止时间设置边界。先选择能够覆盖所有参与路径、维持不变量的机制,再用有代表性的工作验证争用行为,而不是为了追求抽象的并发数放弃具体正确性要求。
来源
- PostgreSQL 18:事务隔离,快照隔离与可串行化依赖检查,已核验 2026-09-10。
- PostgreSQL 18:序列化失败处理,SQLSTATE 分类与完整事务重试,已核验 2026-09-10。