REPEATABLE READ
PostgreSQL 的 REPEATABLE READ 不是简单地把 READ COMMITTED 的「语句级快照」换成「事务级快照」,它的实现是快照隔离(Snapshot Isolation, SI)。这带来一个比 SQL 标准要求更强的特性:PG 的 REPEATABLE READ 还能防止幻读。代价是写冲突不再是静默重检查,而是直接报错 40001,应用必须能重试。
前置知识
阅读本节前,建议先了解:READ UNCOMMITTED / READ COMMITTED、MVCC 概述、事务 ID 与快照
基础概念
| 事实 | 说明 |
|---|---|
| PG 的 RR 实现是快照隔离(SI) | 语义上比 SQL 标准的 RR 更强 |
| 快照在事务中第一条语句执行时获取 | 而不是 BEGIN 时(惰性分配) |
| 整个事务共用同一个快照 | 事务内所有语句看到完全一致的数据视图 |
| 杜绝不可重复读 | 同一行读多少次都一样 |
| 还能防幻读 | 比 SQL 标准对 RR 的最低要求更强 |
写冲突会报 40001 | could not serialize access due to concurrent update |
| 不会静默丢失更新 | 冲突以错误形式暴露,而非被覆盖 |
| 不能防写偏斜 | SI 的固有限制,需 SERIALIZABLE |
| 冲突后必须回滚整个事务 | 不能只重试失败的那条语句 |
等级定位对比:
| 异常 | READ COMMITTED | REPEATABLE READ(SI) | SERIALIZABLE(SSI) |
|---|---|---|---|
| 脏读 | 不可能 | 不可能 | 不可能 |
| 不可重复读 | 可能 | 不可能 | 不可能 |
| 幻读 | 可能 | 不可能 | 不可能 |
| 丢失更新(写冲突) | 可能(静默) | 不可能(报 40001) | 不可能(报 40001) |
| 写偏斜 | 可能 | 可能 | 不可能 |
| 读偏斜 | 可能 | 不可能 | 不可能 |
语法与用法
-- 事务级设置(必须在事务中任何查询之前)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
COMMIT;
-- 或直接在 BEGIN 时指定
BEGIN ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY; -- 只读长查询
-- 会话级
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 查看
SHOW transaction_isolation;最简可运行示例:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 整个事务内固定不变
SELECT balance FROM accounts WHERE id = 1;
COMMIT;BEGIN
balance
---------
1000.00
(1 row)
balance
---------
1000.00
(1 row)
COMMIT-- 环境变量方式(会话级)
SET default_transaction_isolation = 'repeatable read';
SHOW transaction_isolation;SET
transaction_isolation
-----------------------
repeatable read
(1 row)详细说明
快照隔离:事务级一致性视图
SI 的核心是一个事务从头到尾只用一个快照。这个快照在事务内第一条语句执行时确定,之后无论别的会话提交了什么,你都只看到快照时点已提交的数据:
会话 A(REPEATABLE READ):
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders; -- 语句 1:此时确定快照,得到 100
-- 会话 B 插入 10 条订单并提交
SELECT count(*) FROM orders; -- 语句 2:仍是 100(快照未变)
COMMIT;-- 会话 A 输出
BEGIN
count
-------
100
(1 row)
count
-------
100
(1 row)
COMMIT对比 READ COMMITTED 的同一段代码,第二次会读到 110。这个差异对「需要一致视图」的场景至关重要:报表、对账、跨表核对、备份导出。
-- 观察快照的固定性:对比两条不同时间点的语句
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT now(), pg_current_snapshot() AS snap;
SELECT pg_sleep(1);
SELECT now(), pg_current_snapshot() AS snap;
COMMIT;BEGIN
now | snap
-------------------------------+--------------------
2026-09-28 10:00:00.111111+08 | 732401:732405:
(1 row)
pg_sleep
----------
(1 row)
now | snap
-------------------------------+--------------------
2026-09-28 10:00:01.222222+08 | 732401:732405:
(1 row)
COMMIT注意两行的 now() 不同(它是语句开始时间),但 pg_current_snapshot() 完全一样——这正是「事务级快照」的直接证据。
不可重复读被彻底消除
因为快照固定,同一行在事务内读多少次结果都相同:
会话 A(REPEATABLE READ):
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 1000.00
-- 会话 B 把余额改成 500 并提交
SELECT balance FROM accounts WHERE id = 1; -- 仍 1000.00
COMMIT;-- 会话 A 输出
BEGIN
balance
---------
1000.00
(1 row)
balance
---------
1000.00
(1 row)
COMMIT-- 会话 B 在此期间执行
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT; -- 提交成功,但 A 看不到两个事务在自己的视图里都是「对的」,只是看到的世界不同。这是 SI 的本质:并行执行但各自独立的一致视图。
幻读也被防止:PG 比 SQL 标准更强
SQL 标准允许 REPEATABLE READ 出现幻读(只有 SERIALIZABLE 必须杜绝)。PostgreSQL 的 RR 基于 SI,范围查询的结果集也受快照保护,因此幻读不会发生:
会话 A(REPEATABLE READ):
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders WHERE amount > 1000; -- 5
-- 会话 B 插入一条 amount = 2000 的订单并提交
SELECT count(*) FROM orders WHERE amount > 1000; -- 仍 5(无幻读)
COMMIT;-- 会话 A 输出
BEGIN
count
-------
5
(1 row)
count
-------
5
(1 row)
COMMIT这是 PostgreSQL 相对许多数据库(包括某些配置下的 MySQL、Oracle)的实际优势,在 PG 里「需要防幻读」不需要一路升到 SERIALIZABLE。
-- 副作用:REPEATABLE READ 下 UPDATE 也只看快照内的行
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE orders SET reviewed = true WHERE amount > 1000;
-- 只会更新快照内的那些行;B 在快照后插入的行不会被本次 UPDATE 触及
COMMIT;BEGIN
UPDATE 5
COMMIT写冲突检测:40001 与「首个提交者获胜」
SI 遇到两个事务修改同一行时采用首个提交者获胜(first-committer-wins)规则:
会话 A(RR):
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 拿锁,未提交
会话 B(RR):
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- 阻塞,等待 A
会话 A:COMMIT; -- 提交成功
会话 B:-- 解除阻塞后检查发现:目标行在 B 的快照之后被修改过
-- 直接报错,不尝试重检查-- 会话 B 输出
BEGIN
ERROR: could not serialize access due to concurrent update这就是与 READ COMMITTED 最大的区别:
| 行为 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| 冲突时 | 阻塞后取最新版本重检查 WHERE,通常成功 | 直接报 40001,不重检查 |
| 语义 | 「基于最新数据执行」 | 「要么整个事务基于快照成功,要么失败」 |
| 应用负担 | 小 | 必须实现重试 |
注意报错时的细节:40001 之后整个事务进入 aborted 状态,必须 ROLLBACK 后重试整个事务,不能只重试那一条 UPDATE。
-- 错误!40001 之后事务已废,不能继续
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
SELECT 1;ERROR: current transaction is aborted, commands ignored until end of transaction block重试模式:REPEATABLE READ 的必备配套
既然 40001 是正常现象而非异常故障,应用必须实现「整事务重试 + 退避」:
# Python + psycopg3:REPEATABLE READ 的完整重试模板
import psycopg, time, random
from psycopg import errors
def transfer(from_id: int, to_id: int, amount: float, max_attempts: int = 5) -> None:
for attempt in range(max_attempts):
try:
with psycopg.connect(DSN) as conn: # 每次重试都用新连接/新事务
with conn.transaction():
conn.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
conn.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, from_id),
)
conn.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(amount, to_id),
)
return
except errors.SerializationFailure: # SQLSTATE 40001
if attempt == max_attempts - 1:
raise
# 指数退避 + 抖动,避免重试风暴
time.sleep((2 ** attempt) * 0.01 + random.random() * 0.01)
raise RuntimeError("unreachable")// JDBC:捕获 40001 并整事务重试
for (int attempt = 0; attempt < 5; attempt++) {
try {
conn.setAutoCommit(false);
try (Statement st = conn.createStatement()) {
st.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
}
// ... 业务语句 ...
conn.commit();
return;
} catch (SQLException e) {
conn.rollback();
if (!"40001".equals(e.getSQLState())) throw e; // 非串行化失败直接抛出
Thread.sleep(10L * (1L << attempt)); // 退避后重试
}
}-- 也可以用 PL/pgSQL 在数据库内做有限重试
DO $$
DECLARE
v_attempt int := 0;
BEGIN
LOOP
v_attempt := v_attempt + 1;
BEGIN
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
EXIT; -- 成功则退出循环
EXCEPTION WHEN serialization_failure THEN
IF v_attempt >= 5 THEN RAISE; END IF;
-- 注意:在 PL/pgSQL 中异常处理依赖子事务,此处需在新语句块中重试
END;
END LOOP;
END $$;实践中更推荐在应用层做重试:数据库内重试受 PL/pgSQL 异常处理语义限制(异常处理块内的事务状态复杂),而应用层可以清晰地控制连接、退避与超时。
只读长查询:REPEATABLE READ 的最佳场景
「需要一致视图且不写数据」是最适合 RR 的场景,例如生成报表、跨表对账、导出快照:
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
-- 下面所有查询共享同一个快照,结果互相自洽
SELECT count(*) FROM orders;
SELECT sum(amount) FROM orders;
SELECT count(*) FROM order_items;
SELECT sum(amount) FROM order_items; -- 与上面 orders 的合计一致
COMMIT;BEGIN
count
-------
10000
(1 row)
sum
-----------
1234567.89
(1 row)
count
-------
35000
(1 row)
sum
-----------
1234567.89
(1 row)
COMMITREAD ONLY 有两个好处:一是语义明确,二是 PG 可以为只读事务跳过 XID 分配,减少开销。
-- 只读事务里误写会直接报错
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
INSERT INTO orders (amount) VALUES (1);ERROR: cannot execute INSERT in a read-only transactionpg_export_snapshot():把快照共享给其它会话
有时需要让多个会话看到完全相同的快照,例如并行导出、分布式一致性读取。pg_export_snapshot() 导出一个快照标识,其它会话用 SET TRANSACTION SNAPSHOT 导入:
-- 会话 A:导出快照(必须在有快照的事务内)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_export_snapshot();BEGIN
pg_export_snapshot
--------------------
00000003-0000001E-1
(1 row)-- 会话 B:导入同一个快照,即可看到与 A 完全一致的数据
BEGIN ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION SNAPSHOT '00000003-0000001E-1';
SELECT count(*) FROM orders; -- 与 A 的视图一致
COMMIT;BEGIN
SET
count
-------
10000
(1 row)
COMMIT约束条件:
- 导出快照的事务必须保持打开,直到所有导入者都已导入。
SET TRANSACTION SNAPSHOT必须在导入事务的任何查询之前执行。- 事务必须已是
REPEATABLE READ或SERIALIZABLE级别。
-- 错误!必须在任何查询之前设置快照
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT 1;
SET TRANSACTION SNAPSHOT '00000003-0000001E-1';ERROR: SET TRANSACTION SNAPSHOT must be called before any query写偏斜(write skew):SI 的致命盲区
SI 能防「同一行」的写冲突,但防不住「不同行但相关」的写冲突,这就是写偏斜(write skew)。经典例子是「医生值班」:要求任何时候至少有一名医生在岗。
会话 A(RR):
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 2(有 Alice、Bob)
-- A 认为「还有 1 个人在岗,我可以下班」
DELETE FROM oncall WHERE doctor = 'Alice';
-- 未提交
会话 B(RR,同时):
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 也是 2
-- B 也认为「还有 1 个人在岗,我可以下班」
DELETE FROM oncall WHERE doctor = 'Bob';
COMMIT; -- 成功(删的是不同行,无写冲突)
会话 A:COMMIT; -- 也成功!
结果:oncall 表里 0 个人,违反「至少 1 人在岗」的约束-- 会话 A 输出
BEGIN
count
-------
2
(1 row)
DELETE 1
COMMIT -- 成功,但业务约束已被破坏关键点:两个事务写的是不同的行(Alice 与 Bob),SI 的「同一行冲突检测」完全看不到问题。要杜绝写偏斜,只有三条路:
-- 方案 1(推荐):升级到 SERIALIZABLE,由 SSI 检测 rw 依赖环
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;
-- 其中一个事务会收到 40001,重试后能看到正确状态-- 方案 2:用物化冲突点,让两个事务争抢同一行
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM shift_locks WHERE shift = 'night' FOR UPDATE; -- 显式锁同一行
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;-- 方案 3:用数据库约束把不变式固化(本例可用排班表 + 外键计数)
ALTER TABLE oncall
ADD CONSTRAINT oncall_shift_not_empty
CHECK (shift <> 'night') NOT VALID;
-- 更实用的做法:把「至少一人在岗」建模为不可删除的「值班长」行 + 唯一约束# 方案 1 的配套重试(写偏斜场景下 40001 是常态)
try:
with conn.transaction():
cur.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
cur.execute("SELECT count(*) FROM oncall WHERE shift = 'night'")
if count <= 1:
raise BusinessError("不能在岗人数不足时下班")
cur.execute("DELETE FROM oncall WHERE doctor = %s", (doctor,))
except errors.SerializationFailure:
retry()与 READ COMMITTED 的对比
| 对比维度 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| 快照获取时机 | 每条语句 | 事务内第一条语句 |
| 视图一致性 | 语句级 | 事务级 |
| 不可重复读 | 可能 | 不可能 |
| 幻读 | 可能 | 不可能 |
| 写冲突处理 | 阻塞 + 重检查(通常成功) | 报 40001(必须重试) |
| 并发吞吐 | 更高 | 略低(冲突时会失败重试) |
| 应用复杂度 | 低 | 中(需重试框架) |
| 长事务对 VACUUM 影响 | 有 | 有(且更明显,快照更久) |
| 典型用途 | 默认 CRUD | 一致性读、对账、报表、导出 |
-- 同一段代码在两种级别下的不同命运
-- READ COMMITTED:两条 UPDATE 都可能成功(累计生效)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- REPEATABLE READ:若期间 id=1 被别的事务改过,报 40001
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;BEGIN
UPDATE 1
COMMIT
BEGIN
ERROR: could not serialize access due to concurrent update实战示例
场景:银行日终对账。要求「总账与分户账在同一时点读取,两者必须相等」。
第 1 步:准备数据
DROP TABLE IF EXISTS ledger_entries, accounts_balance;
CREATE TABLE accounts_balance (
id bigint PRIMARY KEY,
owner text NOT NULL,
balance numeric(12,2) NOT NULL
);
CREATE TABLE ledger_entries (
id bigserial PRIMARY KEY,
account_id bigint NOT NULL REFERENCES accounts_balance(id),
amount numeric(12,2) NOT NULL
);
INSERT INTO accounts_balance (id, owner, balance) VALUES
(1, 'Alice', 1000.00),
(2, 'Bob', 800.00);
INSERT INTO ledger_entries (account_id, amount) VALUES
(1, 1000.00),
(2, 800.00);DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 2第 2 步:用 REPEATABLE READ 做一致对账
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
-- 两个查询共享同一快照,必然相等
SELECT sum(balance) AS balance_total FROM accounts_balance;
SELECT sum(amount) AS ledger_total FROM ledger_entries;
COMMIT;BEGIN
balance_total
---------------
1800.00
(1 row)
ledger_total
--------------
1800.00
(1 row)
COMMIT第 3 步:对比 READ COMMITTED 下可能出现的错账
BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY;
SELECT sum(balance) AS balance_total FROM accounts_balance;
-- 此刻另一个事务完成了一次转账并提交(两个表都被修改)
SELECT sum(amount) AS ledger_total FROM ledger_entries;
COMMIT;BEGIN
balance_total
---------------
1800.00
(1 row)
ledger_total
--------------
1800.00
(1 row)
COMMIT数值上本例相等,但逻辑上没有保证——两个 sum() 来自不同时刻,只要中间有提交,就可能读到不一致的组合。这正是对账必须用 RR 的原因。
第 4 步:复现写冲突并重试
会话 A(RR):
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts_balance SET balance = balance - 100 WHERE id = 1;
会话 B(RR):
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts_balance SET balance = balance - 50 WHERE id = 1;
-- 阻塞
会话 A:COMMIT;
会话 B:-- 报 40001,需回滚后重试整个事务-- 会话 B 输出
ERROR: could not serialize access due to concurrent update第 5 步:清理
DROP TABLE ledger_entries, accounts_balance;DROP TABLE注意事项
- 快照在第一条语句时才取,不是
BEGIN那一刻。BEGIN后长时间不执行语句,快照并未开始;这会影响对「快照时点」的预期。
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 此时还没有快照
SELECT pg_sleep(10);
-- 第一条查询执行时快照才确定
SELECT count(*) FROM orders;
COMMIT;- 40001 不是故障,是契约的一部分。 必须实现重试,否则随机失败会变成业务可用性问题。错误! 把 40001 当作「系统错误」直接抛给用户。
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 冲突后整个事务作废,只能 ROLLBACK 重来40001后不能只重试失败的那条语句。 事务已aborted,所有后续语句报25P02。
-- 错误!
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- 又一次报错- REPEATABLE READ 防不住写偏斜。 需要「多个相关行合起来满足不变式」的场景,必须用 SERIALIZABLE 或显式加锁。
-- 错误!以为 RR 能保证「至少一人在岗」
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 2
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT; -- 与另一个事务同时提交后,可能在岗人数变 0- 长 RR 事务对 VACUUM 的拖累更久。 事务级快照意味着
xmin在整个事务期间不变,死元组无法回收。
-- 监控长 RR 事务
SELECT pid, state, now() - xact_start AS age, left(query, 50)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;SET TRANSACTION SNAPSHOT必须在任何查询之前,且必须处于 RR / SERIALIZABLE 事务中。
-- 错误!
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT 1;
SET TRANSACTION SNAPSHOT '00000003-0000001E-1';最佳实践
- 只读一致性场景优先用
REPEATABLE READ READ ONLY:报表、对账、导出、跨表校验。这是 RR 性价比最高的用法。
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT sum(balance) FROM accounts;
SELECT sum(amount) FROM transfers;
COMMIT;- 把所有涉及「读—算—写」的写事务统一到 RR 并配套重试框架,让丢失更新从「静默」变成「显式可重试」。
-- 关键点:计算基于事务内固定快照,冲突时 40001 让应用重来
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;- 重试带指数退避与次数上限,避免高冲突下形成重试风暴;重试必须幂等或整事务重放。
for attempt in range(5):
try:
run_transaction()
break
except errors.SerializationFailure:
time.sleep((2 ** attempt) * 0.01 + random.random() * 0.01)
else:
raise- 事务内不要做外部调用,RR 下事务越长,写冲突概率与重试成本越高。
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE orders SET status = 'paid' WHERE id = 42;
COMMIT; -- 立刻提交,然后才发通知- 需要「多行不变式」时不要停在 RR,直接用 SERIALIZABLE,或用显式锁构造冲突点。
-- 用显式锁把写偏斜变成写冲突(RR 下也能生效)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM shift_state WHERE shift = 'night' FOR UPDATE;
-- 检查并修改相关行
COMMIT;下一节
继续学习:SERIALIZABLE