Skip to content

快照隔离与可串行化异常 ​

快照隔离(Snapshot Isolation, SI)是现代数据库最重要的并发控制技术之一,PostgreSQL 的 REPEATABLE READ 就是它的实现。它用「事务级一致快照 + 首个提交者获胜」解决了不可重复读、读偏斜和丢失更新,却留下了一个致命盲区:写偏斜(write skew)。本节深入 SI 的规则边界,用完整的多会话案例演示它能防与不能防的异常,并说明 SERIALIZABLE 如何在 SI 之上用 SSI 检测并中止可串行化冲突。

前置知识

阅读本节前,建议先了解:REPEATABLE READ、SERIALIZABLE、脏读 / 不可重复读 / 幻读

基础概念 ​

快照隔离的核心规则只有两条:

规则说明
读规则:读自己的快照事务读到的是「事务开始时已提交」的数据版本,全程不变
写规则:首个提交者获胜(first-committer-wins)若两个并发事务修改了同一行,后提交者被中止(40001)

由此可以精确划出 SI 的能力边界:

异常SI(即 PG 的 REPEATABLE READ)原因
脏读不可能只读已提交版本
不可重复读不可能事务级快照
幻读不可能快照包含「范围」的隐含版本
读偏斜不可能同一事务共用一个快照
丢失更新不可能(报 40001)首个提交者获胜
写偏斜可能写的是不同行,不构成写写冲突

一句话概括:SI 能防住所有「同一数据项」层面的异常,但防不住「不同数据项合起来违反约束」的异常。

术语缩写说明
快照隔离SIPostgreSQL 的 REPEATABLE READ 实现
可串行化快照隔离SSIPostgreSQL 的 SERIALIZABLE 实现,在 SI 上加依赖检测
读已提交RC语句级快照,PostgreSQL 默认级别
写偏斜write skew各事务单独合法,合起来破坏约束
误报false positiveSSI 中止了本可串行化的事务

语法与用法 ​

text
-- 快照隔离(PostgreSQL 中即 REPEATABLE READ)
BEGIN ISOLATION LEVEL REPEATABLE READ;
...
COMMIT;

-- 只读快照隔离:最常用的一致性读组合
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
...
COMMIT;

-- 可串行化快照隔离(SSI):在 SI 之上做依赖检测
BEGIN ISOLATION LEVEL SERIALIZABLE;
...
COMMIT;

-- 查看当前事务状态与快照
SHOW transaction_isolation;
SELECT pg_current_snapshot();
SELECT pg_current_xact_id_if_assigned();

最小可运行示例(写偏斜在 SI 下成功、在 SSI 下失败):

sql
-- SI:两个并发事务各自删一个人,都成功,约束被破坏
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night';   -- 2
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;
text
BEGIN
 count
-------
     2
(1 row)

DELETE 1
COMMIT
sql
-- SSI:同样的操作,后提交者报错
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;
text
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

详细说明 ​

快照隔离的定义:一个事务、一个快照、一条写规则 ​

快照隔离把「事务看到什么」和「事务能写什么」拆成两条独立的规则。理解 SI 的全部难点,都在于这两条规则之间的缝隙。

text
快照隔离(SI)的两条规则

规则 1:读规则——事务级快照
  事务在执行第一条语句时取一个快照
  → 之后所有读,无论跨越多少语句、多少时间,都基于这一个快照
  → 并发已提交事务的修改,对本事务不可见(Read Committed 则可见)

规则 2:写规则——首个提交者获胜(first-committer-wins)
  若 T2 试图修改的行,被「在 T2 快照之后提交的 T1」改过
  → T2 被中止,报 40001
  → 「快照之后、提交之前」这个区间内发生的写,才构成冲突

缝隙在哪里?
  写规则只检查「是否写同一行」
  它不检查「读到的数据是否已被别人写过」← 写偏斜就从这里漏出去

验证「事务级快照」最直接的方式是观察快照标识:同一事务内多次调用,结果完全相同。

sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT now(), pg_current_snapshot() AS snap;
SELECT now(), pg_current_snapshot() AS snap;
COMMIT;
text
BEGIN
              now              |        snap
-------------------------------+--------------------
 2026-09-28 10:15:02.331891+08 | 74123:74130:74123
(1 row)

              now              |        snap
-------------------------------+--------------------
 2026-09-28 10:15:02.345102+08 | 74123:74130:74123
(1 row)

COMMIT

两次 now() 不同(时间在走),但 pg_current_snapshot() 完全相同(快照被冻结)。这正是 SI 与 RC 的分水岭:RC 下每条语句都取新快照,两次调用会返回不同的快照(除非期间无事务提交)。

首个提交者获胜:SI 如何处理丢失更新 ​

「首个提交者获胜」是 SI 处理写写冲突的方式,它把丢失更新从「静默覆盖」变成「显式错误」。这个转变是 SI 相对 RC 最重要的改进之一。

text
丢失更新在两种级别下的表现

READ COMMITTED(静默丢失):
  A 读 stock=10 → B 读 stock=10 → A 写 9 → A 提交 → B 写 9 → B 提交
  结果 9,A 的扣减被静默覆盖,无任何错误提示

REPEATABLE READ / SI(显式失败):
  A 读 stock=10 → B 读 stock=10 → A 写 9 → A 提交
  → B 写 9:B 的快照里这行的版本已被 A 改过且已提交
  → B 被中止,报 40001(cannot modify a row it cannot see the new version of)
  结果:应用必须重试 B,重试后能看到 9,从而正确算出 8
sql
-- SI 下的写写冲突:后提交者报错
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT stock FROM items WHERE id = 1;   -- 快照里是 10
-- 并发事务 A 已提交 UPDATE stock = 9
UPDATE items SET stock = 9 WHERE id = 1;
COMMIT;
text
BEGIN
 stock
-------
    10
(1 row)

ERROR:  could not serialize access due to concurrent update

这里的错误语义很重要:出错的原因不是「数据不一致」,而是「SI 无法让 B 基于一个过时版本继续执行」。B 读到的 10 是不存在的历史,让它写回 9 就会丢掉 A 的修改,所以 SI 选择拒绝。重试后新快照能看到 A 的结果(9),从而正确算出 8——BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT stock FROM items; -- 9; UPDATE items SET stock = 8; COMMIT;。

写规则只在「同一行」上生效。这是它能力的边界,也是写偏斜产生的根源。

写偏斜:SI 的致命盲区 ​

写偏斜是 SI 唯一无法处理的经典异常,理解它需要抓住一个关键区别:冲突发生在「读集」上,而不是「写集」上。

text
写偏斜的抽象结构

事务 T1:
  r1: 读 S1(满足条件的数据集)       → 判断约束成立
  w1: 写 X(X ∈ S1)

事务 T2:
  r2: 读 S2(同一个条件的数据集)      → 判断约束成立
  w2: 写 Y(Y ∈ S2)

约束:S 中至少保留 N 个元素

关键:
  X ≠ Y  → 写集不相交,SI 的「首个提交者获胜」无冲突,两个事务都提交
  S 相同 → 双方都基于「还有 N 个」的判断做出「可以删一个」的决定
  结果:S 减少 2 个,约束被破坏

SI 的检查只看 w1 vs w2(写写),看不到 r1 vs w2、r2 vs w1(读写)

三个最经典的案例共用同一结构,只是「不变式」的表达方式不同:

案例不变式双方读集各自写入
医生值班night 班至少 1 人在岗oncall(shift='night')DELETE Alice / DELETE Bob
会议室预订同房间同时段只能一场bookings(room_id=7, slot=...)INSERT Alice / INSERT Bob
账户余额所有余额之和 >= 0accounts 全表求和UPDATE id=1 / UPDATE id=2

完整示例:医生值班(至少 1 人在岗)

sql
-- 初始:night 班 Alice 与 Bob 两人
SELECT doctor, shift FROM oncall WHERE shift = 'night' ORDER BY doctor;
text
 doctor | shift
--------+-------
 Alice  | night
 Bob    | night
(2 rows)
text
时刻   会话 A(REPEATABLE READ)              会话 B(REPEATABLE READ)
────   ──────────────────────────────────    ──────────────────────────────────
t1     BEGIN;
t2     SELECT count(*) ... shift='night'  →2
t3                                            BEGIN;
t4                                            SELECT count(*) ...        →2
t5     DELETE ... doctor='Alice';             (业务判断 2>1,可以下班)
t6     COMMIT;                                (业务判断 2>1,可以下班)
t7                                            DELETE ... doctor='Bob';
t8                                            COMMIT;
       ── 两个事务都提交成功,night 班 0 人在岗 ──
sql
-- 会话 A 与 B 都执行这段,SI 下两者均成功
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night';   -- 2
DELETE FROM oncall WHERE doctor = 'Alice';           -- B 删 Bob
COMMIT;
text
BEGIN
 count
-------
     2
(1 row)

DELETE 1
COMMIT

 count        -- 验证约束已被破坏
-------
     0
(1 row)

另两个案例同理:会议室预订中两个会话同时 SELECT count(*) ... = 0 后各 INSERT 一行,room_id=7 变成两条记录——插入的是新行,不存在写写冲突;账户余额中两个事务各改不同账户,SELECT sum(balance) 都得到 100,各取 60 后总额变为 -20,同样无人报错。

三个案例的共同结构完全一致:跨行不变式(cross-row invariant)在快照上成立,但并发写不同行使它在现实中失效。

读偏斜:SI 能防,但值得单独理解 ​

读偏斜与写偏斜只差一个字,但命运完全不同:SI 能防读偏斜。原因是读偏斜不涉及写不同行的问题,它纯属「读取阶段看到了多个时刻的数据」,而 SI 的事务级快照正好解决这一点。

text
读偏斜:转账中途对账

初始:账户 1 = 500,账户 2 = 500,合计 1000
转账 T1:账户 1 → 600,账户 2 → 400

READ COMMITTED 下对账(错误):
  读账户 1 → 500(T1 提交前)
  T1 提交
  读账户 2 → 400(T1 提交后)
  合计 900  ← 凭空消失 100

REPEATABLE READ / SI 下对账(正确):
  快照固定为「T1 之前」或「T1 之后」的某一个时刻
  读到 (500, 500) 或 (600, 400),合计恒为 1000
sql
-- SI 下对账结果自洽:要么都是旧值,要么都是新值
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT balance FROM accounts WHERE id = 1;
SELECT balance FROM accounts WHERE id = 2;
COMMIT;
text
BEGIN
 balance
---------
  600.00
(1 row)

 balance
---------
  400.00
(1 row)

COMMIT

注意 READ ONLY 在这里的作用:只读事务不参与写冲突,「首个提交者获胜」规则也不会触发,因此它是无重试成本的一致快照读。报表、对账、逻辑备份都应该用这个组合。

sql
-- 只读快照:整份报表基于同一个时刻,不会出现内部矛盾
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM orders;
SELECT sum(amount) FROM orders;
SELECT count(*) FROM order_items;
COMMIT;

SSI:在 SI 之上检测可串行化冲突 ​

SERIALIZABLE 在 PostgreSQL 里不是另一套并发控制机制,而是在 SI 之上增加了一个非阻塞的检测层。这个检测层就是 SSI(Serializable Snapshot Isolation)。

text
SSI = SI + 依赖追踪

SI 阶段(不变):
  事务照常在自己的快照上读写,读不阻塞写

SSI 检测层(新增):
  记录事务间的依赖:
    rw 依赖:T1 读了数据,T2 后来写了它        ← 危险方向
    wr 依赖:T1 写了数据,T2 后来读了它
    ww 依赖:T1、T2 写同一数据(SI 的写规则已处理)

  检查是否存在「危险结构」:
    ① 两个连续 rw 依赖构成环:T1 →rw→ T2 →rw→ T1
    ② 或涉及已提交只读事务的 rw 依赖
  → 发现即中止其中一个事务,报 40001

中止策略:
  优先中止「pivot」(环上的枢纽事务),不一定是先做错的那个

写偏斜案例在 SSI 下的完整过程:

text
会话 A(SERIALIZABLE):
  BEGIN ISOLATION LEVEL SERIALIZABLE;
  SELECT count(*) ... shift='night';   -- 读集:oncall(shift='night')
  DELETE ... doctor='Alice';           -- 写 Alice

会话 B(SERIALIZABLE):
  BEGIN ISOLATION LEVEL SERIALIZABLE;
  SELECT count(*) ... shift='night';   -- 读集:oncall(shift='night')
  DELETE ... doctor='Bob';             -- 写 Bob

依赖分析:
  A 读了 oncall(shift='night'),B 写了其中的 Bob → A →rw→ B
  B 读了 oncall(shift='night'),A 写了其中的 Alice → B →rw→ A
  构成 rw 依赖环 → 危险结构 → 中止后提交的一方
sql
-- 会话 B 在 SSI 下被中止
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Bob';
COMMIT;
text
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

DETAIL 里的 Reason code 是排查 SSI 中止的关键线索,常见取值:

Reason code 片段含义
Canceled on identification as a pivot, during write本事务被识别为环的枢纽,在写操作时中止
Canceled on identification as a pivot, during commit attempt在提交时才被判定为枢纽
Canceled on conflict out to pivot本事务与某个枢纽冲突而被连带中止
Canceled on commit attempt with conflict in from prepared与已准备的两阶段事务冲突

追踪「读过的范围」靠的是谓词锁(predicate lock)信息——它不是真正的锁,不阻塞任何人,只是一条记录:

text
SELECT count(*) FROM oncall WHERE shift = 'night';
  → 记录谓词锁:relation = oncall,条件 shift = 'night'
  → 之后任何事务插入/更新满足该条件的行,都会登记为与本事务有 rw 依赖

SELECT * FROM orders WHERE amount > 1000;
  → 记录谓词锁:relation = orders,条件 amount > 1000

这类记录存放在共享内存的谓词锁表中,容量由以下参数控制:

参数默认值含义
max_pred_locks_per_transaction64每事务谓词锁条目上限(按 max_connections 放大)
max_pred_locks_per_relation-2单关系条目上限,负数表示按 max_pred_locks_per_transaction / abs(值) 计算
max_pred_locks_per_page2单页面条目上限,超过则提升为页级/关系级谓词锁
ini
# postgresql.conf:SERIALIZABLE 事务多或谓词条件复杂时适度调大
max_pred_locks_per_transaction = 128
max_pred_locks_per_relation = -2
max_pred_locks_per_page = 2
sql
-- 注意:这三个参数修改后需要重启,pg_reload_conf() 不足
ALTER SYSTEM SET max_pred_locks_per_transaction = 128;

条目超过上限时,PostgreSQL 会把谓词锁提升(promote)到更粗的粒度(行级 → 页级 → 关系级)。粒度越粗,误报越多。

误报(false positive):SSI 的固有权衡 ​

SSI 为了保证「绝不漏判」可串行化冲突,宁可「偶尔错杀」。这就是误报(false positive):事务实际上可以串行化,但被 SSI 中止了。

误报来源机制缓解方式
谓词锁粒度提升本该只影响一行的读被提升为关系级谓词锁,同表上任何写都被视为冲突调大 max_pred_locks_per_transaction
只读事务参与追踪SERIALIZABLE READ ONLY 事务也进入依赖图,大量只读 + 高并发写易产生无害环改用 READ ONLY DEFERRABLE
长事务读集过大事务越长读集越大,进入危险结构的概率越高缩短事务、缩小范围

针对只读误报,PostgreSQL 提供了优雅的解法:SERIALIZABLE READ ONLY DEFERRABLE 会让事务等到取得安全快照后再开始,此后永不冲突。

text
普通 SERIALIZABLE READ ONLY:
  立即取快照 → 若快照不安全,后续可能被 40001 中止

SERIALIZABLE READ ONLY DEFERRABLE:
  等待「当前所有活跃写事务都已结束」的时刻 → 取得安全快照
  → 之后所有读都不会产生可串行化冲突,永不 40001
  代价:可能等待较长时间
sql
-- 零冲突的一致性长查询:报表、逻辑备份、跨表校验
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM orders;
SELECT sum(amount) FROM orders;
COMMIT;
text
BEGIN
 count
-------
 10000
(1 row)

    sum
-----------
 1234567.89
(1 row)

COMMIT

需要特别注意 DEFERRABLE 只在 SERIALIZABLE READ ONLY 组合下有效,其它组合(如 SERIALIZABLE READ WRITE DEFERRABLE)会被静默忽略:SHOW transaction_deferrable 会显示 on,但对读写事务没有任何实际效果。

在 SI 与 SSI 之间做选择:决策与规避策略 ​

写偏斜的根治方式不止 SERIALIZABLE 一种。按「成本从低到高」排序,优先考虑更便宜的方案:

方案成本适用条件
唯一约束 / CHECK / 外键最低不变式能用声明式约束表达
条件写(WHERE 带判断)低单行判定即可保证正确
显式行锁(FOR UPDATE)中已知热点行、需要串行化同一批资源
SERIALIZABLE + 重试中高无法用约束表达、热点分散的跨行不变式

方案一:用唯一约束固化不变式(最便宜)

sql
-- 会议室预订:用唯一约束替代 SERIALIZABLE,级别可降回 READ COMMITTED
ALTER TABLE bookings ADD CONSTRAINT bookings_room_slot_uniq UNIQUE (room_id, slot);
INSERT INTO bookings (room_id, slot, owner) VALUES (7, '2026-10-01 09:00', 'Bob');
text
ALTER TABLE
ERROR:  duplicate key value violates unique constraint "bookings_room_slot_uniq"
DETAIL:  Key (room_id, slot)=(7, 2026-10-01 09:00) already exists.

方案二:用显式锁把「读集」变成「冲突点」

写偏斜的根源是双方读了同一范围却没有任何冲突点。人为制造冲突点即可消除:给「被计数的那个整体」加一把行锁。

sql
-- 加一把"值班状态"行锁,让所有修改都在同一行上冲突
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT * FROM shift_state WHERE shift = 'night' FOR UPDATE;   -- 人为冲突点
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;

方案三:升级到 SERIALIZABLE,让 SSI 兜底

适合热点分散、无法用锁或不变量无法用约束表达的场景。

sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;

SERIALIZABLE 与 REPEATABLE READ 的决策表:

维度REPEATABLE READ(SI)SERIALIZABLE(SSI)
快照事务级事务级 + 依赖追踪
不可重复读 / 幻读 / 读偏斜都不可能都不可能
丢失更新不可能(报 40001)不可能(报 40001)
写偏斜可能不可能
冲突中止时机写操作或提交时写操作或提交时(依赖环)
额外内存无有(谓词锁信息)
额外 CPU无有(提交时检查依赖图)
只读事务会失败吗不会可能(DEFERRABLE 可避免)
误报无有(极少数)
推荐用途一致性读、单行「读—算—写」跨行/跨表不变式

选择路径:

text
不变式能否用 UNIQUE / CHECK / 外键表达?
 ├─ 能 → 用约束 + READ COMMITTED(最省)
 └─ 不能
      ├─ 是否有明确的"整体"可以加锁?
      │    ├─ 有 → 显式行锁(FOR UPDATE / 咨询锁)+ READ COMMITTED
      │    └─ 没有 → SERIALIZABLE + 重试框架
      └─ 读写比例如何?
           ├─ 以只读为主 → SERIALIZABLE READ ONLY DEFERRABLE(零冲突)
           └─ 读写混合且冲突高 → 缩短事务 + 缩小读范围,再评估是否值得

重试与幂等:SI / SSI 的必备配套 ​

无论是 SI 的写写冲突还是 SSI 的依赖环,失败都是「请重试」。应用层必须有一套可靠的重试机制。

重试的五个要点:

  1. 重试整个事务,不是失败的那条语句。
  2. 指数退避 + 抖动,避免高冲突下的重试风暴。
  3. 次数上限 + 最终失败,不能让请求无限重试。
  4. 重试必须幂等,整段重放不能产生重复副作用。
  5. 重试前真正 ROLLBACK,确保事务状态干净。
python
# Python(psycopg3):可复用的重试装饰器
import functools, time, random
from psycopg import errors

def si_retry(max_attempts: int = 5):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(conn, *args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    with conn.transaction():
                        return fn(conn, *args, **kwargs)
                except (errors.SerializationFailure,
                        errors.DeadlockDetected) as e:
                    if attempt == max_attempts - 1:
                        raise
                    delay = min(0.05 * (2 ** attempt), 1.0)
                    time.sleep(delay + random.uniform(0, delay / 2))
        return wrapper
    return deco

40001 与 40P01 都属于 SQLSTATE Class 40(事务回滚类),是标准的可重试错误,应当一并覆盖:

sql
-- SQLSTATE Class 40 的常见成员
-- 40001  serialization_failure       ← 可重试(SI 写冲突、SSI 依赖环)
-- 40P01  deadlock_detected           ← 可重试
-- 40002  transaction_integrity_constraint_violation  ← 业务错误
-- 40003  statement_completion_unknown ← 需人工核对

幂等性靠业务键实现,让「重放」不会产生重复数据(ON CONFLICT DO NOTHING 会返回 INSERT 0 0,不影响重试逻辑):

sql
-- 唯一键 + ON CONFLICT:同一幂等键重复执行是安全的
INSERT INTO payments (order_id, amount, idempotency_key)
VALUES (42, 100.00, 'order-42-attempt')
ON CONFLICT (idempotency_key) DO NOTHING;

观测 SI / SSI 的运行状态 ​

定位并发问题的第一步是看清「当前有哪些事务、处在什么阶段」。pg_stat_activity 是关键工具:

sql
-- 查看所有活跃事务及其存续时间
SELECT pid,
       state,
       now() - xact_start AS xact_age,
       backend_xid,
       backend_xmin,
       left(query, 50) AS query
FROM   pg_stat_activity
WHERE  backend_type = 'client backend'
  AND  xact_start IS NOT NULL
ORDER  BY xact_start;
text
 pid  |        state        | xact_age | backend_xid | backend_xmin |       query
------+---------------------+----------+-------------+--------------+--------------------
 8421 | active              | 00:00:02 |       74133 |        74120 | SELECT count(*) FRO
 8422 | idle in transaction | 00:00:12 |       74134 |        74118 | DELETE FROM oncall
(2 rows)

关键列的含义:

列含义排查价值
xact_start事务开始时间识别长事务(xact_age 大 = 危险)
backend_xid后端已分配的写事务 ID判断事务是否已开始写
backend_xmin后端持有快照的最小 XID判断它「钉住」了多旧的数据版本
state会话状态idle in transaction 是典型反模式

数据库级统计可以反映 SI / SSI 的冲突压力:

sql
-- 事务提交/回滚、死锁与全部冲突计数
SELECT d.datname,
       s.xact_commit,
       s.xact_rollback,
       s.deadlocks,
       pg_stat_get_db_conflict_all(d.oid) AS all_conflicts
FROM   pg_stat_database s
JOIN   pg_database d ON d.oid = s.datid
WHERE  d.datname = current_database();
text
 datname | xact_commit | xact_rollback | deadlocks | all_conflicts
---------+-------------+---------------+-----------+---------------
 mydb    |      204512 |          2103 |         4 |            37
(1 row)

如果 all_conflicts 随负载线性增长,说明强隔离级别的重试成本在放大。此时应检查:事务是否过长、读集是否过大、是否该把部分规则改成约束或显式锁。

另外,pg_current_xact_id_if_assigned() 与 pg_current_xact_id() 的区别值得记住:前者不分配新 XID,只读事务调用它通常返回 NULL;后者会强制分配一个真实 XID。因此在只读事务里查询 pg_current_xact_id_if_assigned() 得到空值,是正常现象而非故障。

实战示例 ​

场景:账户余额约束(所有账户余额之和不得为负)。用四个会话完整演示 SI 下的约束破坏,以及 SSI 如何拦截,最后给出不需要高隔离级别的替代方案。

第 1 步:准备数据

sql
DROP TABLE IF EXISTS accounts;

CREATE TABLE accounts (
  id      int PRIMARY KEY,
  owner   text    NOT NULL,
  balance numeric(12,2) NOT NULL
);

INSERT INTO accounts VALUES
  (1, 'Alice', 100.00),
  (2, 'Bob',   100.00);

SELECT sum(balance) AS total FROM accounts;
text
DROP TABLE
CREATE TABLE
INSERT 0 2
  total
---------
  200.00
(1 row)

第 2 步:SI 下并发取现,双方都成功,业务规则被突破(各取 60,总额只剩 80)

text
时刻   会话 A(REPEATABLE READ)            会话 B(REPEATABLE READ)
────   ────────────────────────────────    ────────────────────────────────
t1     BEGIN;
t2     SELECT sum(balance) → 200.00
t3                                          BEGIN;
t4                                          SELECT sum(balance) → 200.00
t5     UPDATE ... id=1 SET -60;              (双方判断 200-60=140 >= 0)
t6     COMMIT;                               (双方判断合法,均取 60)
t7                                          UPDATE ... id=2 SET -60;
t8                                          COMMIT;
       ── 两个事务都成功,总余额 = 200.00 - 120.00 = 80.00(业务上限被突破)──
sql
-- 会话 A 与 B 并发执行(写的是不同账户,故无写写冲突)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT sum(balance) FROM accounts;                          -- 200.00
UPDATE accounts SET balance = balance - 60 WHERE id = 1;    -- B 改的是 id = 2
COMMIT;

-- 并发结束后验证:约束已被破坏
SELECT id, owner, balance FROM accounts ORDER BY id;
SELECT sum(balance) AS total_now FROM accounts;
text
BEGIN
  total
---------
  200.00
(1 row)

UPDATE 1
COMMIT

 id | owner | balance
----+-------+---------
  1 | Alice |   40.00
  2 | Bob   |   40.00
(2 rows)

 total_now
-----------
     80.00
(1 row)

第 3 步:重置数据,改用 SERIALIZABLE,SSI 拦截其中一方

sql
UPDATE accounts SET balance = 100.00;
text
UPDATE 2
text
时刻   会话 A(SERIALIZABLE)              会话 B(SERIALIZABLE)
────   ────────────────────────────────    ────────────────────────────────
t1     BEGIN;
t2     SELECT sum(balance) → 200.00
t3                                          BEGIN;
t4                                          SELECT sum(balance) → 200.00
t5     UPDATE ... id=1 SET -60;
t6     COMMIT;                              (提交成功,建立 rw 依赖)
t7                                          UPDATE ... id=2 SET -60;
t8                                          COMMIT → 40001!
sql
-- 会话 B 在提交时被 SSI 中止
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT sum(balance) FROM accounts;                          -- 200.00
UPDATE accounts SET balance = balance - 60 WHERE id = 2;
COMMIT;
text
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

第 5 步:按业务规则重试,得到正确结果

sql
-- 会话 B 回滚重试:新快照里总额已是 140.00,60 已不可取
ROLLBACK;
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT sum(balance) FROM accounts;   -- 140.00
-- 业务判断 140.00 - 60 = 80.00 >= 0,仍可继续(若业务上限不同则拒绝)
UPDATE accounts SET balance = balance - 60 WHERE id = 2;
COMMIT;
text
ROLLBACK
BEGIN
  total
---------
  140.00
(1 row)

UPDATE 1
COMMIT

第 6 步:用 CHECK 约束表达单行规则,用应用层或触发器表达跨行规则

很多「跨行不变式」其实可以拆成「单行规则 + 少量聚合检查」。单行部分交给 CHECK,聚合部分交给只在必要时才用的强隔离:

sql
-- 单行规则用 CHECK(最便宜,且任何隔离级别下都有效)
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
text
ALTER TABLE
ERROR:  new row for relation "accounts" violates check constraint "balance_non_negative"
DETAIL:  Failing row contains (1, Alice, -960.00).

第 7 步:清理

sql
DROP TABLE accounts, oncall, bookings, shift_state, items, anomaly_demo;

-- 清理期间可能报 "table does not exist",属于正常现象
text
DROP TABLE

注意事项 ​

  • 不要以为 REPEATABLE READ 就等于「可串行化」。 它是快照隔离,能力边界止于写偏斜。错误! 把涉及跨行不变式的业务逻辑放在 REPEATABLE READ 下,认为「用了非默认级别就安全了」。

  • 写偏斜不会报错,只会静默破坏约束。 这是它最危险的地方。错误! 只在日志里找并发错误,而在两个 REPEATABLE READ 事务并发删人后 SELECT count(*) FROM oncall WHERE shift = 'night' 从 2 变成 0 时,日志里什么都不会有。

  • SERIALIZABLE 的失败必须重试,不能只重试失败语句。 事务已进入 aborted 状态,后续语句报 25P02。

sql
-- 错误!
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 60 WHERE id = 1;   -- 40001
SELECT 1;   -- ERROR: current transaction is aborted
  • SSI 会误报。 极少数本可串行化的事务被中止,这是为了不漏判付出的代价。不推荐在超高冲突的负载上无脑全量使用 SERIALIZABLE。

  • DEFERRABLE 只在 SERIALIZABLE READ ONLY 下有效。 其它组合会被静默忽略。错误! 以为 SERIALIZABLE READ WRITE DEFERRABLE 能减少冲突——此时 SHOW transaction_deferrable 虽显示 on,但对读写事务毫无作用。

  • max_pred_locks_* 参数需要重启,pg_reload_conf() 不够。 ALTER SYSTEM SET max_pred_locks_per_transaction = 128; 之后必须重启实例才生效。

  • 只读事务在 SSI 下也可能被中止。 想让只读事务永不失败,请用 DEFERRABLE;只读事务的失败与写事务的失败原因不同(它参与了依赖图)。

  • 不要用长事务换取「简单」。 SI 下长事务拖住 xmin,阻塞 VACUUM,加剧表膨胀;SSI 下长事务的读集更大,冲突与误报都上升。用 SELECT pid, now() - xact_start AS xact_age, state FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start; 即可定位长事务。

最佳实践 ​

  • 先问「不变式能不能用约束表达」,能就用约束 + READ COMMITTED,这是成本最低、可靠性最高的方案,例如 ALTER TABLE bookings ADD CONSTRAINT bookings_room_slot_uniq UNIQUE (room_id, slot);。

  • 一致性只读查询一律用 REPEATABLE READ READ ONLY,消除读偏斜与不可重复读,且无重试成本;延迟不敏感的报表、备份、跨表校验则升级为 SERIALIZABLE READ ONLY DEFERRABLE,彻底杜绝只读事务失败。

sql
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM orders;
SELECT sum(amount) FROM orders;
COMMIT;
  • 确需 SERIALIZABLE 时,把重试框架作为基础设施而不是业务代码的一部分,统一覆盖 40001 / 40P01,带退避与上限。
python
except (errors.SerializationFailure, errors.DeadlockDetected):
    if attempt == max_attempts - 1:
        raise
    time.sleep(min(0.05 * (2 ** attempt), 1.0))
  • 让重试路径幂等:业务幂等键 + 唯一约束 + ON CONFLICT(INSERT ... ON CONFLICT (idempotency_key) DO NOTHING),避免整段重放产生重复副作用。

  • 把写偏斜场景写成回归测试:用两个连接按时间线复现,断言在 REPEATABLE READ 下两个并发事务都成功(说明风险存在)、在 SERIALIZABLE 下必有一方报 40001。

sql
-- 回归断言:REPEATABLE READ 下两个并发 DELETE 都成功,SERIALIZABLE 下必有一方报 40001
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;
  • 监控冲突率并把它当作设计指标,用 SELECT datname, xact_rollback, deadlocks, pg_stat_get_db_conflict_all(oid) FROM pg_stat_database WHERE datname = current_database(); 持续观察;数值偏高说明该负载不适合全量强隔离,应转向约束、显式锁或业务层串行化。

下一节

继续学习:设置隔离级别

参考链接 ​