脏读 / 不可重复读 / 幻读
并发异常(concurrency anomaly)是理解隔离级别的钥匙:SQL 标准正是用「哪些异常必须被禁止」来定义四个隔离级别的。本节逐一剖析脏读、不可重复读、幻读三种经典异常,并补充读偏斜、丢失更新、写偏斜这三种同样致命的异常,用双会话时间线在 PostgreSQL 18 中真实复现它们,最后给出「什么场景选什么级别」的决策表。
前置知识
阅读本节前,建议先了解:READ UNCOMMITTED / READ COMMITTED、REPEATABLE READ
基础概念
SQL 标准定义了四种「现象(phenomenon)」,并用它们来划分隔离级别的下限:
| 异常 | 英文 | SQL 标准的定义 |
|---|---|---|
| 脏读 | dirty read | 一个事务读取了并发未提交事务写入的数据 |
| 不可重复读 | nonrepeatable read | 一个事务重新读取之前读过的数据,发现它被另一个已提交事务修改了 |
| 幻读 | phantom read | 一个事务重新执行返回行集的查询,发现行集因另一个最近提交的事务而改变 |
| 串行化异常 | serialization anomaly | 一组事务成功提交的结果,与这些事务按任一顺序逐个执行的结果都不一致 |
除此之外,数据库文献还用另外三种异常描述 MVCC 类系统的行为:
| 异常 | 英文 | 一句话定义 |
|---|---|---|
| 读偏斜 | read skew | 同一事务内不同读取看到数据库的不同版本,整体不自洽 |
| 丢失更新 | lost update | 两个事务读同一值再各自写回,后写覆盖先写,先写静默丢失 |
| 写偏斜 | write skew | 各事务单独看都满足业务约束,并发提交后合起来破坏了约束 |
PostgreSQL 的实际防护能力(注意与 SQL 标准表格的差异):
| 异常 | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| 脏读 | 不可能 | 不可能 | 不可能 | 不可能 |
| 不可重复读 | 可能 | 可能 | 不可能 | 不可能 |
| 幻读 | 可能 | 可能 | 不可能 | 不可能 |
| 读偏斜 | 可能 | 可能 | 不可能 | 不可能 |
| 丢失更新 | 可能 | 可能 | 不可能(报 40001) | 不可能(报 40001) |
| 写偏斜 | 可能 | 可能 | 可能 | 不可能 |
这张表有两个「反直觉」之处,正是 PostgreSQL 的独特之处:
- READ UNCOMMITTED 一列和 READ COMMITTED 完全相同——PostgreSQL 内部只有三种隔离级别,
READ UNCOMMITTED被映射为READ COMMITTED。 - REPEATABLE READ 已经能防幻读——SQL 标准允许 RR 出现幻读(只要求禁止不可重复读),PostgreSQL 的实现比标准更强。
被禁止的现象用「不可能」表示,未被禁止的用「可能」表示。「可能」不等于「一定发生」——异常需要特定的并发时序才会出现,所以它是最难排查的一类 bug。
语法与用法
复现并发异常的标准套路是双会话交错执行:一个会话负责「写」,另一个会话负责「读」,通过控制提交时机把异常逼出来。
-- 通用模板
会话 A(写方) 会话 B(读方)
────────────────────────────── ──────────────────────────────
BEGIN; BEGIN ISOLATION LEVEL <级别>;
<修改数据> <读取数据> ← 第一次读
─── 此刻 A 提交 ───
COMMIT; <读取相同数据> ← 第二次读
COMMIT;
-- 若两次读到的值不同 → 出现不可重复读
-- 若第二次读到了 A 未提交的值 → 出现脏读
-- 若第二次读到的行集变了 → 出现幻读最小可运行示例(不可重复读,READ COMMITTED):
-- 会话 A
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;-- 会话 B(在 A 的 COMMIT 之前读到 1000,之后读到 500)
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- 1000
SELECT balance FROM accounts WHERE id = 1; -- 500(同一事务内变了)
COMMIT; balance
---------
1000.00
(1 row)
balance
---------
500.00
(1 row)把会话 B 的级别换成 REPEATABLE READ,同样的时序下两次读都返回 1000.00,不可重复读消失。
详细说明
脏读(dirty read):PostgreSQL 中根本不可能
SQL 标准对脏读的定义是「一个事务读取了并发未提交事务写入的数据」。它是所有异常里最危险的一种,因为那个未提交的写入随时可能被回滚,读到的数据等于从未存在过。
PostgreSQL 基于 MVCC(多版本并发控制),所有写操作产生的都是新版本行,未提交的新版本对其他事务不可见。因此脏读在 PostgreSQL 的任何隔离级别下都不可能发生——包括被映射为 READ COMMITTED 的 READ UNCOMMITTED。
| 时刻 | 会话 A | 会话 B |
|---|---|---|
| t1 | BEGIN; | |
| t2 | UPDATE accounts SET balance = 0 WHERE id = 1; | |
| t3 | BEGIN; | |
| t4 | SELECT balance FROM accounts WHERE id = 1; → 1000(读不到 0) | |
| t5 | ROLLBACK; | |
| t6 | SELECT balance FROM accounts WHERE id = 1; → 1000 |
-- 会话 A:改了但故意不提交
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
-- 此时不提交、不回滚-- 会话 B:任何隔离级别都读不到 A 的未提交值
BEGIN ISOLATION LEVEL READ UNCOMMITTED; -- PG 内部按 READ COMMITTED 处理
SELECT balance FROM accounts WHERE id = 1;
COMMIT; balance
---------
1000.00
(1 row)如果会话 A 最终 ROLLBACK,会话 B 从头到尾看到的都是正确的 1000.00,没有被污染。这一点上 PostgreSQL 比 SQL 标准更安全:标准允许 READ UNCOMMITTED 出现脏读,PostgreSQL 直接不提供这种能力。
不可重复读(nonrepeatable read):READ COMMITTED 的常见坑
不可重复读指「同一事务内两次读同一行,结果不同」。它的根因是 READ COMMITTED 的语句级快照:每条语句开始时取一次新快照,所以两条语句之间提交的事务会被后一条语句看到。
这类 bug 在业务代码里非常隐蔽,典型形态是「先查一次判断,再查一次使用」:
| 时刻 | 会话 A(写方) | 会话 B(读方,READ COMMITTED) |
|---|---|---|
| t1 | BEGIN; | |
| t2 | SELECT balance ... WHERE id=1; → 1000 | |
| t3 | BEGIN; UPDATE ... SET balance=500 WHERE id=1; COMMIT; | |
| t4 | SELECT balance ... WHERE id=1; → 500 | |
| t5 | COMMIT; |
-- 会话 B:同一事务内两次读,值却变了
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- 中间会话 A 提交了一次 UPDATE
SELECT balance FROM accounts WHERE id = 1;
COMMIT; balance
---------
1000.00
(1 row)
balance
---------
500.00
(1 row)同时读两行时问题更严重,会出现「读偏斜」(见下文)。要消除不可重复读,把会话 B 的级别改成 REPEATABLE READ 即可:快照在事务的第一条语句时获取,之后整个事务都看这一个版本。
-- 修复:升级到 REPEATABLE READ
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 1000
SELECT balance FROM accounts WHERE id = 1; -- 仍是 1000
COMMIT;幻读(phantom read):行集而不是行的变化
幻读关注的是行集而不是单行:同一事务内两次执行同一个范围查询,第二次多出(或少了)满足条件的行。它通常由并发事务的 INSERT 或 DELETE 引起。
-- 场景:统计某个时段的订单数
-- 会话 B
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT count(*) FROM orders WHERE created_at >= '2026-10-01';
-- 中间会话 A 插入了一条 2026-10-01 的订单并提交
SELECT count(*) FROM orders WHERE created_at >= '2026-10-01';
COMMIT; count
-------
1200
(1 row)
count
-------
1201
(1 row)| 时刻 | 会话 A(写方) | 会话 B(读方,READ COMMITTED) |
|---|---|---|
| t1 | BEGIN; | |
| t2 | SELECT count(*) ...; → 1200 | |
| t3 | BEGIN; INSERT INTO orders ...; COMMIT; | |
| t4 | SELECT count(*) ...; → 1201(幻影行出现) | |
| t5 | COMMIT; |
PostgreSQL 的 REPEATABLE READ 能防幻读,这是它与 SQL 标准的重要差异。标准只要求 RR 禁止不可重复读,允许幻读;PostgreSQL 用快照隔离实现 RR,事务全程用同一个快照,所以第二次范围查询不会看到新插入的行。
-- 修复:REPEATABLE READ 下幻读消失
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders WHERE created_at >= '2026-10-01'; -- 1200
SELECT count(*) FROM orders WHERE created_at >= '2026-10-01'; -- 仍是 1200
COMMIT;需要警惕的是:防住幻读不等于防住写偏斜。RR 能让「读到的行集」稳定,但两个事务分别对同一行集做检查再各自写入不同行时,仍可能破坏业务约束(详见写偏斜小节)。
读偏斜(read skew):一个事务看到两个时刻的数据库
读偏斜是不那么出名但危害更大的一种异常:它不一定重复读同一行,而是读取不同数据项时看到了数据库的不同版本,导致整体不自洽。经典案例是转账期间读余额:
初始状态:账户 1 = 500,账户 2 = 500,合计 1000
事务 T1(转账):从账户 2 转 100 到账户 1
账户 1 → 600
账户 2 → 400
事务 T2(对账,READ COMMITTED):
① 读账户 1 → 500 (T1 提交前)
─── T1 提交 ───
② 读账户 2 → 400 (T1 提交后)
合计 = 900 ← 凭空少了 100!| 时刻 | 会话 A(转账) | 会话 B(对账,READ COMMITTED) |
|---|---|---|
| t1 | BEGIN; | |
| t2 | UPDATE accounts SET balance = balance + 100 WHERE id = 1; | |
| t3 | UPDATE accounts SET balance = balance - 100 WHERE id = 2; | |
| t4 | BEGIN; | |
| t5 | SELECT balance FROM accounts WHERE id = 1; → 500(旧版本) | |
| t6 | COMMIT; | |
| t7 | SELECT balance FROM accounts WHERE id = 2; → 400(新版本) | |
| t8 | COMMIT; → 合计 900 |
-- 会话 B:两次读各自取新快照,读到不一致的中间状态
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- 500
SELECT balance FROM accounts WHERE id = 2; -- 400
COMMIT; balance
---------
500.00
(1 row)
balance
---------
400.00
(1 row)读偏斜在 REPEATABLE READ 及以上被彻底消除——整个事务共用一个快照,两次读必然互相一致。
-- 修复:快照隔离下对账结果自洽
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 要么 (600,400),要么 (500,500),绝无 (500,400)
SELECT balance FROM accounts WHERE id = 2;
COMMIT;对于「逻辑备份」「跨表校验」「报表快照」这类场景,读偏斜是无法接受的,必须使用 REPEATABLE READ 或更高的级别。
丢失更新(lost update):后写覆盖先写
丢失更新的形态是:两个事务读取同一个值,各自计算后写回,后者覆盖前者,前者的修改被静默丢弃。它是「读—算—写」模式的经典陷阱。
初始:库存 stock = 10
会话 A:读 stock=10,判断 10 > 0,写 stock = 10 - 1 = 9
会话 B:读 stock=10,判断 10 > 0,写 stock = 10 - 1 = 9 ← A 的扣减丢失了
最终:9(正确应为 8),卖出了 2 件却只扣了 1 件| 时刻 | 会话 A(READ COMMITTED) | 会话 B(READ COMMITTED) |
|---|---|---|
| t1 | BEGIN; | |
| t2 | SELECT stock FROM items WHERE id=1; → 10 | |
| t3 | BEGIN; | |
| t4 | SELECT stock FROM items WHERE id=1; → 10 | |
| t5 | UPDATE items SET stock=9 WHERE id=1; | |
| t6 | COMMIT; | |
| t7 | UPDATE items SET stock=9 WHERE id=1;(覆盖) | |
| t8 | COMMIT; → 最终 9 |
-- 反例:应用层算好值再写回,丢失更新
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT stock FROM items WHERE id = 1; -- 10
UPDATE items SET stock = 9 WHERE id = 1; -- 应用层计算 10-1
COMMIT;有三种正确写法:
-- 写法 1(推荐):让数据库自己做相对更新,天然无丢失更新
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;-- 写法 2:先把行锁住,再读再写
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT stock FROM items WHERE id = 1 FOR UPDATE; -- 阻塞并发修改
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT;-- 写法 3:升级到 REPEATABLE READ,冲突会以 40001 暴露(而不是静默丢失)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT stock FROM items WHERE id = 1;
UPDATE items SET stock = 9 WHERE id = 1; -- 若被并发修改过则报错,需重试
COMMIT;ERROR: could not serialize access due to concurrent update注意 REPEATABLE READ 下的丢失更新不是「被静默覆盖」,而是变成显式的 40001 错误——问题被暴露出来,但仍然需要应用重试。
写偏斜(write skew):每个事务单独看都合法
写偏斜是快照隔离(Snapshot Isolation)的致命盲区,也是 REPEATABLE READ 与 SERIALIZABLE 的分水岭。它的特征极其反直觉:每个事务单独执行都是正确的,但并发提交后业务约束被破坏。
最经典的例子是「医生值班」:约束是「night 班至少 1 人在岗」。
初始:night 班有 Alice 和 Bob 两人
会话 A:BEGIN; SELECT count(*) ... WHERE shift='night'; -- 2,> 1,可以下班
DELETE FROM oncall WHERE doctor='Alice';
会话 B:BEGIN; SELECT count(*) ... WHERE shift='night'; -- 也是 2,也可以下班
DELETE FROM oncall WHERE doctor='Bob';
两个事务都提交成功 → night 班 0 人在岗,约束被破坏| 时刻 | 会话 A(REPEATABLE READ) | 会话 B(REPEATABLE READ) |
|---|---|---|
| t1 | BEGIN; | |
| t2 | SELECT count(*) ...; → 2 | |
| t3 | BEGIN; | |
| t4 | SELECT count(*) ...; → 2(自己的快照) | |
| t5 | DELETE ... doctor='Alice'; | |
| t6 | COMMIT; | |
| t7 | DELETE ... doctor='Bob'; | |
| t8 | COMMIT; → 约束被破坏 |
-- 会话 A 与 B 在 REPEATABLE READ 下都会成功,约束被破坏
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 2
DELETE FROM oncall WHERE doctor = 'Alice'; -- 另一个会话删 Bob
COMMIT;为什么 REPEATABLE READ 拦不住?因为两个事务写的是不同的行(Alice 与 Bob),不存在写写冲突,行锁根本不冲突;它们只是都「读到了 2」这个已经被对方改变的事实。丢失更新是「写同一行」的冲突,写偏斜是「写不同行但读同一范围」的冲突。
只有 SERIALIZABLE 能拦住它——SSI 追踪读写依赖,发现 rw 依赖环后中止其中一方:
-- 修复:SERIALIZABLE 下必有一方报 40001
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.如果业务约束能用唯一约束或 CHECK 表达,优先用约束固化,比 SERIALIZABLE 更便宜(详见「最佳实践」)。
PostgreSQL 各隔离级别防护能力:为什么比 SQL 标准更强
把 PostgreSQL 的实际行为与 SQL 标准的最低要求并排放:
| 异常 | SQL 标准要求禁止的级别 | PostgreSQL 实际禁止的级别 |
|---|---|---|
| 脏读 | READ COMMITTED 及以上 | 所有级别(含 READ UNCOMMITTED) |
| 不可重复读 | REPEATABLE READ 及以上 | REPEATABLE READ 及以上 |
| 幻读 | SERIALIZABLE | REPEATABLE READ 及以上 |
| 串行化异常 | SERIALIZABLE | SERIALIZABLE |
两处「超额交付」的原因:
① READ UNCOMMITTED 被映射为 READ COMMITTED。 PostgreSQL 只实现了三种内部隔离级别。MVCC 架构下不存在「读未提交版本」的能力,所以干脆把 READ UNCOMMITTED 映射为 READ COMMITTED:
-- READ UNCOMMITTED 实际得到的是 READ COMMITTED
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SHOW transaction_isolation;
COMMIT; transaction_isolation
-----------------------
read committed
(1 row)② REPEATABLE READ 用快照隔离实现,顺带防住了幻读。 SQL 标准只要求 RR 禁止不可重复读;PostgreSQL 的 RR 是快照隔离,事务全程一个快照,幻读也就自然不可能发生。这是标准明确允许的增强(标准规定的是「最低要求」,更高保证是允许的):
BEGIN ISOLATION LEVEL REPEATABLE READ;
SHOW transaction_isolation;
COMMIT; transaction_isolation
-----------------------
repeatable read
(1 row)需要强调的是:「更强」不等于「可以升级使用」。REPEATABLE READ 引入了 40001 重试成本,不会凭空变成免费的 SERIALIZABLE——它仍然防不住写偏斜。选择级别时要看「业务不变式的形态」,而不是「哪个更强」。
复现异常的方法论:双会话时间线
排查并发 bug 的关键能力是「稳定复现」。推荐一套可重复的方法论:
- 固定数据起点:每次复现前把测试数据重置到已知状态。
- 两个独立连接:用两个 psql 会话(或两个数据库客户端),不要用同一连接。
- 用时间线表格记录:把每一步操作与观察结果写下来,交错顺序就是异常的本质。
- 控制提交时机:异常通常出现在「读—写—提交」的交错点上,需要人为停顿。
- 逐级验证:在同一时序下依次跑
READ COMMITTED/REPEATABLE READ/SERIALIZABLE,观察异常是否消失。
-- 第 1 步:准备可复现的数据
DROP TABLE IF EXISTS anomaly_demo;
CREATE TABLE anomaly_demo (id int PRIMARY KEY, val int NOT NULL);
INSERT INTO anomaly_demo VALUES (1, 100);DROP TABLE
CREATE TABLE
INSERT 0 1-- 第 2 步:会话 A 改而不提交(用于验证脏读)
BEGIN;
UPDATE anomaly_demo SET val = 999 WHERE id = 1;
-- 保持这个事务不结束-- 第 3 步:会话 B 在不同级别下读取,记录结果
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT val FROM anomaly_demo WHERE id = 1; -- 100,不是 999
COMMIT; val
-----
100
(1 row)用 pg_stat_activity 可以观察两个会话的实时状态,确认它们确实处在不同的交错点:
SELECT pid, state, now() - xact_start AS xact_age, left(query, 40) 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 | query
------+---------------------+----------+------------------------------------------
7312 | idle in transaction | 00:00:12 | UPDATE anomaly_demo SET val = 999 WHERE
7345 | active | 00:00:01 | SELECT val FROM anomaly_demo WHERE id =
(2 rows)把每次复现的时间线表格与 SQL 序列一起保存下来,就是最好的 bug 报告与回归测试用例。
异常的检测、重试与工程化处理
不同异常需要不同的工程对策,把处理方式固化到框架层比在每个业务函数里手写更可靠:
| 异常 | 检测方式 | 工程对策 |
|---|---|---|
| 脏读 | PostgreSQL 中不存在 | 无需处理 |
| 不可重复读 / 读偏斜 | 业务数据对不上账 | 只读事务用 REPEATABLE READ READ ONLY |
| 幻读 | 范围统计结果抖动 | REPEATABLE READ 及以上 |
| 丢失更新 | 计数/库存对不上 | 相对更新 / FOR UPDATE / 升级级别 |
| 写偏斜 | 约束被破坏但找不到并发写 | SERIALIZABLE + 重试,或改用约束 |
| 串行化失败 | 捕获 SQLSTATE 40001 | 重试整个事务(指数退避) |
| 死锁 | 捕获 SQLSTATE 40P01 | 重试整个事务,并统一加锁顺序 |
40001 与 40P01 都属于「可重试错误」,重试必须覆盖整个事务:
import time, random, psycopg
from psycopg import errors
RETRYABLE = (errors.SerializationFailure, errors.DeadlockDetected)
def run_with_retry(conn, fn, max_attempts: int = 5):
for attempt in range(max_attempts):
try:
with conn.transaction():
return fn(conn)
except RETRYABLE:
if attempt == max_attempts - 1:
raise
delay = min(0.05 * (2 ** attempt), 1.0)
time.sleep(delay + random.uniform(0, delay / 2))40002(完整性约束违例)、40003(语句完成状态未知)虽然也在 SQLSTATE Class 40 里,但语义不同,不能无脑重试:
-- 属于 Class 40(事务回滚)的常见错误码
-- 40001 serialization_failure ← 可重试
-- 40P01 deadlock_detected ← 可重试
-- 40002 transaction_integrity_constraint_violation ← 一般是业务错误
-- 40003 statement_completion_unknown ← 需人工核对在 PostgreSQL 里定位「谁的并发导致了我的异常」,可以查统计视图的冲突计数:
SELECT datname,
xact_commit,
xact_rollback,
deadlocks,
pg_stat_get_db_conflict_all(oid) AS all_conflicts
FROM pg_stat_database
WHERE datname = current_database(); datname | xact_commit | xact_rollback | deadlocks | all_conflicts
---------+-------------+---------------+-----------+---------------
mydb | 204512 | 2103 | 4 | 37
(1 row)xact_rollback 与 all_conflicts 持续增长,说明重试率偏高,应检查事务是否过长、读范围是否过大。
隔离级别选型决策表
把「业务不变式的形态」作为第一判据,而不是「习惯用哪个级别」:
| 业务特征 | 推荐级别 | 关键理由 |
|---|---|---|
| 普通 CRUD、单行更新 | READ COMMITTED(默认) | 语句级原子性足够,无重试成本 |
x = x + n 类型的相对更新 | READ COMMITTED | 由数据库计算,天然无丢失更新 |
| 一致性报表、对账、逻辑备份 | REPEATABLE READ READ ONLY | 需要消除读偏斜的稳定快照 |
| 长事务中反复读同一批数据 | REPEATABLE READ | 事务级快照,视图稳定 |
| 「读—算—写」同一行 | REPEATABLE READ 或 FOR UPDATE | 冲突以 40001 暴露而非静默丢失 |
| 「读—算—写」多个不同行 | SERIALIZABLE | 写偏斜只有 SSI 能防 |
| 排班、占座、额度分配 | SERIALIZABLE 或唯一约束 | 典型写偏斜场景 |
| 极高吞吐的计数器 | READ COMMITTED | 重试成本会让强隔离得不偿失 |
| 只读从库上的长查询 | REPEATABLE READ READ ONLY | 与主库写事务互不干扰 |
一句话原则:能用约束或单语句表达的规则,就不要靠隔离级别去保证;只有无法用约束表达的跨行不变式,才上 SERIALIZABLE。
-- 能用唯一约束表达的规则,优先用约束(便宜、可靠、无重试)
ALTER TABLE bookings ADD CONSTRAINT bookings_room_slot_uniq UNIQUE (room_id, slot);-- 无法用约束表达的跨行不变式,才用 SERIALIZABLE + 重试
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
-- 业务判断后决定是否 DELETE
COMMIT;实战示例
场景:订单库存扣减。先复现「丢失更新导致的超卖」,再逐级验证三种隔离级别的实际效果。
第 1 步:准备数据
DROP TABLE IF EXISTS items;
CREATE TABLE items (
id int PRIMARY KEY,
name text NOT NULL,
stock int NOT NULL CHECK (stock >= 0)
);
INSERT INTO items VALUES (1, '限量商品', 10);DROP TABLE
CREATE TABLE
INSERT 0 1第 2 步:单会话正常扣减(相对更新的正确写法)
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
SELECT * FROM items WHERE id = 1;UPDATE 1
id | name | stock
----+----------+-------
1 | 限量商品 | 9
(1 row)第 3 步:双会话在 READ COMMITTED 下复现「应用层读—算—写」的丢失更新
时刻 会话 A 会话 B
──── ──────────────────────────────── ────────────────────────────────
t1 BEGIN;
t2 SELECT stock ...; → 9
t3 BEGIN;
t4 SELECT stock ...; → 9
t5 UPDATE items SET stock=8 ...;
t6 COMMIT;
t7 UPDATE items SET stock=8 ...;
t8 COMMIT;
── 两次扣减只生效一次,stock = 8,静默超卖 1 件 ──-- 反例(会话 A 与 B 都执行这段,级别为 READ COMMITTED)
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT stock FROM items WHERE id = 1; -- 9
-- 应用层算出 9-1=8
UPDATE items SET stock = 8 WHERE id = 1; -- 后提交者覆盖前者
COMMIT;BEGIN
stock
-------
9
(1 row)
UPDATE 1
COMMIT第 4 步:改为相对更新,问题消失
-- 正确写法:计算与写入在同一条语句内,由数据库保证原子性
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;BEGIN
UPDATE 1
COMMIT第 5 步:验证 REPEATABLE READ 下「读—算—写」会报 40001 而不是静默丢失
-- 会话 A 与 B 并发执行;A 先提交,B 报错
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT stock FROM items WHERE id = 1;
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT;BEGIN
stock
-------
8
(1 row)
ERROR: could not serialize access due to concurrent update应用捕获 40001 后重试整个事务,第二轮就能看到对方已提交的结果:
-- 重试:新事务快照能看到对方的结果
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT stock FROM items WHERE id = 1; -- 7(对方已扣减)
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;BEGIN
stock
-------
7
(1 row)
UPDATE 1
COMMIT第 6 步:验证 CHECK 约束是最后一道防线
-- 人为把库存压到 0 后再扣减,约束会拦住超卖
UPDATE items SET stock = 0 WHERE id = 1;
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0; -- 条件不满足,0 行
UPDATE items SET stock = stock - 1 WHERE id = 1; -- 绕过条件,触发约束UPDATE 1
UPDATE 0
ERROR: new row for relation "items" violates check constraint "items_stock_check"
DETAIL: Failing row contains (1, 限量商品, -1).第 7 步:清理
DROP TABLE items, anomaly_demo;DROP TABLE注意事项
不要用「一定不会发生」的心态依赖 READ COMMITTED。 它的「可能」只表示需要特定时序,而高并发下这种时序每天都在发生。错误! 用
SELECT判断后再UPDATE,认为单用户测试通过就没问题。READ UNCOMMITTED在 PostgreSQL 中不等于真正的读未提交。 它会静默变成READ COMMITTED。错误! 为了「性能」显式写SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED——既拿不到语义差异,也不会带来性能收益。
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SHOW transaction_isolation; -- read committed
COMMIT;REPEATABLE READ不是「更强的 READ COMMITTED」那么简单。 它带来40001重试契约,且仍然防不住写偏斜。不推荐在没准备好重试框架时盲升级别。SERIALIZABLE需要重试,不是「设了就没事」。 它把并发正确性变成「可能失败但可重试」的工程问题。错误! 把40001当致命错误返回 500 给用户。只重试失败语句是错的。 事务在错误后进入
aborted状态,后续语句全部报25P02。必须整段ROLLBACK后重试整个事务。
-- 错误!
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE items SET stock = stock - 1 WHERE id = 1; -- 40001
SELECT 1; -- ERROR: current transaction is aborted「读到一致快照」和「结果可串行化」是两件事。
REPEATABLE READ保证前者,只有SERIALIZABLE保证后者。判断业务要求的是「数据一致」还是「结果正确」。序列(sequence)不回退不参与事务。
nextval的推进立即对其他事务可见且不随回滚撤销,用序列号做「无间隙编号」的假设本身就是错的。
SELECT nextval('orders_id_seq'); -- 即使事务回滚,这个值也不会被退回最佳实践
默认留在
READ COMMITTED,只在有明确理由时升级。 大多数 CRUD 与相对更新在这里既正确又最快。把「读—算—写」改写成单条语句或相对更新,这是消除丢失更新最便宜的方式。
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;- 只读的一致性查询统一用
REPEATABLE READ READ ONLY,彻底消除读偏斜与不可重复读,且不会产生写冲突。对延迟敏感的报表可进一步加DEFERRABLE。
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM orders;
SELECT sum(amount) FROM orders;
COMMIT;- 能用约束表达的规则一律用约束(
UNIQUE/CHECK/ 外键),它比任何隔离级别都便宜、可靠、且无需重试。
ALTER TABLE bookings ADD CONSTRAINT bookings_room_slot_uniq UNIQUE (room_id, slot);SERIALIZABLE与重试框架必须成对出现,并保证重试路径幂等(唯一键 +ON CONFLICT、业务幂等键)。
INSERT INTO payments (order_id, amount, idempotency_key)
VALUES (42, 100.00, 'order-42-attempt')
ON CONFLICT (idempotency_key) DO NOTHING;- 把并发异常写成回归测试。 用两个连接按时间线复现,作为 CI 的一部分,防止后续改动悄悄把级别降回去。
-- 回归测试骨架:断言 REPEATABLE READ 下两次读结果相同
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT val FROM anomaly_demo WHERE id = 1;
-- 并发会话在此提交一次 UPDATE
SELECT val FROM anomaly_demo WHERE id = 1; -- 必须与第一次相同
COMMIT;- 监控
40001/40P01与xact_rollback的增长率,把它当作容量与设计的反馈信号,而不是当作噪音。
SELECT datname, xact_rollback, deadlocks, pg_stat_get_db_conflict_all(oid) AS conflicts
FROM pg_stat_database
WHERE datname = current_database();下一节
继续学习:快照隔离与可串行化异常