快照隔离与可串行化异常
快照隔离(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 能防住所有「同一数据项」层面的异常,但防不住「不同数据项合起来违反约束」的异常。
| 术语 | 缩写 | 说明 |
|---|---|---|
| 快照隔离 | SI | PostgreSQL 的 REPEATABLE READ 实现 |
| 可串行化快照隔离 | SSI | PostgreSQL 的 SERIALIZABLE 实现,在 SI 上加依赖检测 |
| 读已提交 | RC | 语句级快照,PostgreSQL 默认级别 |
| 写偏斜 | write skew | 各事务单独合法,合起来破坏约束 |
| 误报 | false positive | SSI 中止了本可串行化的事务 |
语法与用法
-- 快照隔离(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 下失败):
-- SI:两个并发事务各自删一个人,都成功,约束被破坏
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 2
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;BEGIN
count
-------
2
(1 row)
DELETE 1
COMMIT-- SSI:同样的操作,后提交者报错
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;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 的全部难点,都在于这两条规则之间的缝隙。
快照隔离(SI)的两条规则
规则 1:读规则——事务级快照
事务在执行第一条语句时取一个快照
→ 之后所有读,无论跨越多少语句、多少时间,都基于这一个快照
→ 并发已提交事务的修改,对本事务不可见(Read Committed 则可见)
规则 2:写规则——首个提交者获胜(first-committer-wins)
若 T2 试图修改的行,被「在 T2 快照之后提交的 T1」改过
→ T2 被中止,报 40001
→ 「快照之后、提交之前」这个区间内发生的写,才构成冲突
缝隙在哪里?
写规则只检查「是否写同一行」
它不检查「读到的数据是否已被别人写过」← 写偏斜就从这里漏出去验证「事务级快照」最直接的方式是观察快照标识:同一事务内多次调用,结果完全相同。
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT now(), pg_current_snapshot() AS snap;
SELECT now(), pg_current_snapshot() AS snap;
COMMIT;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 最重要的改进之一。
丢失更新在两种级别下的表现
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-- 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;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 唯一无法处理的经典异常,理解它需要抓住一个关键区别:冲突发生在「读集」上,而不是「写集」上。
写偏斜的抽象结构
事务 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 |
| 账户余额 | 所有余额之和 >= 0 | accounts 全表求和 | UPDATE id=1 / UPDATE id=2 |
完整示例:医生值班(至少 1 人在岗)
-- 初始:night 班 Alice 与 Bob 两人
SELECT doctor, shift FROM oncall WHERE shift = 'night' ORDER BY doctor; doctor | shift
--------+-------
Alice | night
Bob | night
(2 rows)时刻 会话 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 人在岗 ──-- 会话 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;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 的事务级快照正好解决这一点。
读偏斜:转账中途对账
初始:账户 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-- SI 下对账结果自洽:要么都是旧值,要么都是新值
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT balance FROM accounts WHERE id = 1;
SELECT balance FROM accounts WHERE id = 2;
COMMIT;BEGIN
balance
---------
600.00
(1 row)
balance
---------
400.00
(1 row)
COMMIT注意 READ ONLY 在这里的作用:只读事务不参与写冲突,「首个提交者获胜」规则也不会触发,因此它是无重试成本的一致快照读。报表、对账、逻辑备份都应该用这个组合。
-- 只读快照:整份报表基于同一个时刻,不会出现内部矛盾
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)。
SSI = SI + 依赖追踪
SI 阶段(不变):
事务照常在自己的快照上读写,读不阻塞写
SSI 检测层(新增):
记录事务间的依赖:
rw 依赖:T1 读了数据,T2 后来写了它 ← 危险方向
wr 依赖:T1 写了数据,T2 后来读了它
ww 依赖:T1、T2 写同一数据(SI 的写规则已处理)
检查是否存在「危险结构」:
① 两个连续 rw 依赖构成环:T1 →rw→ T2 →rw→ T1
② 或涉及已提交只读事务的 rw 依赖
→ 发现即中止其中一个事务,报 40001
中止策略:
优先中止「pivot」(环上的枢纽事务),不一定是先做错的那个写偏斜案例在 SSI 下的完整过程:
会话 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 依赖环 → 危险结构 → 中止后提交的一方-- 会话 B 在 SSI 下被中止
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Bob';
COMMIT;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)信息——它不是真正的锁,不阻塞任何人,只是一条记录:
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_transaction | 64 | 每事务谓词锁条目上限(按 max_connections 放大) |
max_pred_locks_per_relation | -2 | 单关系条目上限,负数表示按 max_pred_locks_per_transaction / abs(值) 计算 |
max_pred_locks_per_page | 2 | 单页面条目上限,超过则提升为页级/关系级谓词锁 |
# postgresql.conf:SERIALIZABLE 事务多或谓词条件复杂时适度调大
max_pred_locks_per_transaction = 128
max_pred_locks_per_relation = -2
max_pred_locks_per_page = 2-- 注意:这三个参数修改后需要重启,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 会让事务等到取得安全快照后再开始,此后永不冲突。
普通 SERIALIZABLE READ ONLY:
立即取快照 → 若快照不安全,后续可能被 40001 中止
SERIALIZABLE READ ONLY DEFERRABLE:
等待「当前所有活跃写事务都已结束」的时刻 → 取得安全快照
→ 之后所有读都不会产生可串行化冲突,永不 40001
代价:可能等待较长时间-- 零冲突的一致性长查询:报表、逻辑备份、跨表校验
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM orders;
SELECT sum(amount) FROM orders;
COMMIT;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 + 重试 | 中高 | 无法用约束表达、热点分散的跨行不变式 |
方案一:用唯一约束固化不变式(最便宜)
-- 会议室预订:用唯一约束替代 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');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.方案二:用显式锁把「读集」变成「冲突点」
写偏斜的根源是双方读了同一范围却没有任何冲突点。人为制造冲突点即可消除:给「被计数的那个整体」加一把行锁。
-- 加一把"值班状态"行锁,让所有修改都在同一行上冲突
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 兜底
适合热点分散、无法用锁或不变量无法用约束表达的场景。
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 可避免) |
| 误报 | 无 | 有(极少数) |
| 推荐用途 | 一致性读、单行「读—算—写」 | 跨行/跨表不变式 |
选择路径:
不变式能否用 UNIQUE / CHECK / 外键表达?
├─ 能 → 用约束 + READ COMMITTED(最省)
└─ 不能
├─ 是否有明确的"整体"可以加锁?
│ ├─ 有 → 显式行锁(FOR UPDATE / 咨询锁)+ READ COMMITTED
│ └─ 没有 → SERIALIZABLE + 重试框架
└─ 读写比例如何?
├─ 以只读为主 → SERIALIZABLE READ ONLY DEFERRABLE(零冲突)
└─ 读写混合且冲突高 → 缩短事务 + 缩小读范围,再评估是否值得重试与幂等:SI / SSI 的必备配套
无论是 SI 的写写冲突还是 SSI 的依赖环,失败都是「请重试」。应用层必须有一套可靠的重试机制。
重试的五个要点:
- 重试整个事务,不是失败的那条语句。
- 指数退避 + 抖动,避免高冲突下的重试风暴。
- 次数上限 + 最终失败,不能让请求无限重试。
- 重试必须幂等,整段重放不能产生重复副作用。
- 重试前真正
ROLLBACK,确保事务状态干净。
# 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 deco40001 与 40P01 都属于 SQLSTATE Class 40(事务回滚类),是标准的可重试错误,应当一并覆盖:
-- 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,不影响重试逻辑):
-- 唯一键 + 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 是关键工具:
-- 查看所有活跃事务及其存续时间
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; 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 的冲突压力:
-- 事务提交/回滚、死锁与全部冲突计数
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(); 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 步:准备数据
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;DROP TABLE
CREATE TABLE
INSERT 0 2
total
---------
200.00
(1 row)第 2 步:SI 下并发取现,双方都成功,业务规则被突破(各取 60,总额只剩 80)
时刻 会话 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(业务上限被突破)──-- 会话 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;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 拦截其中一方
UPDATE accounts SET balance = 100.00;UPDATE 2时刻 会话 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!-- 会话 B 在提交时被 SSI 中止
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT sum(balance) FROM accounts; -- 200.00
UPDATE accounts SET balance = balance - 60 WHERE id = 2;
COMMIT;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 步:按业务规则重试,得到正确结果
-- 会话 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;ROLLBACK
BEGIN
total
---------
140.00
(1 row)
UPDATE 1
COMMIT第 6 步:用 CHECK 约束表达单行规则,用应用层或触发器表达跨行规则
很多「跨行不变式」其实可以拆成「单行规则 + 少量聚合检查」。单行部分交给 CHECK,聚合部分交给只在必要时才用的强隔离:
-- 单行规则用 CHECK(最便宜,且任何隔离级别下都有效)
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;ALTER TABLE
ERROR: new row for relation "accounts" violates check constraint "balance_non_negative"
DETAIL: Failing row contains (1, Alice, -960.00).第 7 步:清理
DROP TABLE accounts, oncall, bookings, shift_state, items, anomaly_demo;
-- 清理期间可能报 "table does not exist",属于正常现象DROP TABLE注意事项
不要以为
REPEATABLE READ就等于「可串行化」。 它是快照隔离,能力边界止于写偏斜。错误! 把涉及跨行不变式的业务逻辑放在REPEATABLE READ下,认为「用了非默认级别就安全了」。写偏斜不会报错,只会静默破坏约束。 这是它最危险的地方。错误! 只在日志里找并发错误,而在两个
REPEATABLE READ事务并发删人后SELECT count(*) FROM oncall WHERE shift = 'night'从 2 变成 0 时,日志里什么都不会有。SERIALIZABLE的失败必须重试,不能只重试失败语句。 事务已进入aborted状态,后续语句报25P02。
-- 错误!
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 60 WHERE id = 1; -- 40001
SELECT 1; -- ERROR: current transaction is abortedSSI 会误报。 极少数本可串行化的事务被中止,这是为了不漏判付出的代价。不推荐在超高冲突的负载上无脑全量使用
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,彻底杜绝只读事务失败。
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM orders;
SELECT sum(amount) FROM orders;
COMMIT;- 确需
SERIALIZABLE时,把重试框架作为基础设施而不是业务代码的一部分,统一覆盖40001/40P01,带退避与上限。
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。
-- 回归断言: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();持续观察;数值偏高说明该负载不适合全量强隔离,应转向约束、显式锁或业务层串行化。
下一节
继续学习:设置隔离级别