SERIALIZABLE
SERIALIZABLE 是 PostgreSQL 能提供的最高隔离级别,也是唯一能保证「并发执行结果等价于某种串行执行」的级别。PostgreSQL 的实现不是靠粗暴加锁,而是 SSI(Serializable Snapshot Isolation,可串行化快照隔离):在快照隔离之上追踪事务间的读写依赖,一旦发现可能产生不可串行化结果的「危险结构」,就让其中一个事务以 40001 失败。它把「并发正确性」变成了一道可以用重试解决的工程问题。
前置知识
阅读本节前,建议先了解:REPEATABLE READ、MVCC 概述
基础概念
| 事实 | 说明 |
|---|---|
| 实现机制是 SSI,不是两阶段锁 | 基于快照 + 依赖追踪,读不阻塞写 |
| 保证「可串行化」 | 结果等价于某个串行顺序执行所有事务 |
| 消除所有标准异常 | 包括 RR 防不住的写偏斜与其它可串行化异常 |
失败方式是 40001 | could not serialize access due to read/write dependencies among transactions |
| 不阻塞读,只在必要时中止事务 | 与 2PL 的「大量阻塞」策略不同 |
| 有误报(false positive) | 极少数本可串行的事务被中止 |
| 需要应用重试 | 与 RR 一样,是契约的一部分 |
| 代价是 CPU 与内存 | 追踪依赖需要谓词锁信息与共享内存 |
四个级别的能力边界:
| 异常 | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|
| 脏读 | 不可能 | 不可能 | 不可能 |
| 不可重复读 | 可能 | 不可能 | 不可能 |
| 幻读 | 可能 | 不可能 | 不可能 |
| 丢失更新 | 可能 | 不可能 | 不可能 |
| 写偏斜 | 可能 | 可能 | 不可能 |
| 读偏斜 | 可能 | 不可能 | 不可能 |
| 任何可串行化异常 | 可能 | 可能 | 不可能 |
语法与用法
-- 事务级(必须在本事务任何查询之前)
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
COMMIT;
-- 或直接在 BEGIN 时指定
BEGIN ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 可串行化 + 只读(配合 DEFERRABLE 可做到零冲突)
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
-- 会话级
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 查看
SHOW transaction_isolation;最简可运行示例:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;BEGIN
balance
---------
1000.00
(1 row)
UPDATE 1
COMMIT-- 冲突时(另一个事务在本事务快照后修改了同一行)
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during write.
HINT: The transaction might succeed if retried.详细说明
SSI 的工作原理:依赖追踪而非悲观加锁
传统可串行化靠两阶段锁(2PL):读加共享锁、写加排他锁,锁持有到提交。它保证正确性,但会大量阻塞。SSI 换了个思路:先乐观执行,事后检查是否可能出错。
SSI 的检测思路:
1. 事务照常在自己的快照上执行(读不阻塞写,写不阻塞读)
2. 记录事务间的依赖关系:
rw 依赖(读写依赖):T1 读了数据,T2 之后写了它 ← 危险方向
wr 依赖(写读依赖):T1 写了数据,T2 之后读了它(快照保证了顺序)
ww 依赖(写写依赖):T1、T2 写同一数据(由行锁直接串行化)
3. 检查是否存在「危险结构」:
- 两个连续 rw 依赖构成的环(T1 →rw→ T2 →rw→ T1)
- 或者涉及提前提交的只读事务的 rw 依赖
4. 发现危险结构 → 中止其中一个事务,报 40001
5. 无危险结构 → 全部提交,结果可串行化关键点:SSI 只在「有可能出问题」时才中止事务,而且它中止的是「可能出错」的那个,不一定是「先做错」的那个。 这解释了为什么 40001 可能在看起来完全正常的事务上报出。
危险结构:rw 依赖环与写偏斜
SSI 的核心判定标准是「是否存在两个连续 rw 依赖构成的环」。用经典写偏斜案例看这个过程:
医生值班(要求:至少 1 名医生在岗)
会话 A(SERIALIZABLE):
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 读:2 → 记录读集
DELETE FROM oncall WHERE doctor = 'Alice'; -- 写 Alice
会话 B(SERIALIZABLE):
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 读:2 → 记录读集
DELETE FROM oncall WHERE doctor = 'Bob'; -- 写 Bob
依赖分析:
A 读了 oncall(shift='night'),B 写了其中的行 → A →rw→ B
B 读了 oncall(shift='night'),A 写了其中的行 → B →rw→ A
形成 rw 依赖环 → 危险结构!
结果:后提交的一方报 40001,必须重试-- 会话 B 输出
BEGIN
count
-------
2
(1 row)
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during write.
HINT: The transaction might succeed if retried.这就是 SERIALIZABLE 相对 REPEATABLE READ 的核心价值:RR 下这两个事务都会成功并破坏业务约束,SERIALIZABLE 下必有一方失败。
-- 重试后得到正确结果:第二个事务看到的是「只有 1 人在岗」,从而拒绝下班
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- 重试后读到 1
-- 业务层判断 1 <= 1,拒绝执行删除
ROLLBACK;BEGIN
count
-------
1
(1 row)
ROLLBACK# 配套重试:写偏斜场景下 40001 是预期行为
from psycopg import errors
def go_off_duty(conn, doctor: str) -> None:
for attempt in range(5):
try:
with conn.transaction():
with conn.cursor() as cur:
cur.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
cur.execute(
"SELECT count(*) FROM oncall WHERE shift = 'night'"
)
if cur.fetchone()[0] <= 1:
raise BusinessRuleError("不能最后一个下班")
cur.execute("DELETE FROM oncall WHERE doctor = %s", (doctor,))
return
except errors.SerializationFailure:
if attempt == 4:
raise40001 的两种形态与 HINT
SERIALIZABLE(以及 REPEATABLE READ)下的 40001 有两种典型消息,理解它们的差别有助于定位问题:
| 消息 | 触发场景 | 含义 |
|---|---|---|
could not serialize access due to concurrent update | RR 或 SERIALIZABLE 中 UPDATE/DELETE/SELECT FOR UPDATE 撞上并发修改 | 行级写冲突 |
could not serialize access due to read/write dependencies among transactions | SERIALIZABLE 下的 SSI 检测到依赖环 | 可串行化冲突 |
-- 形态 1:行级写冲突(RR 也会报)
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;ERROR: could not serialize access due to concurrent update-- 形态 2:SSI 依赖环(只有 SERIALIZABLE 会报)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
INSERT INTO oncall (doctor, shift) VALUES ('dr-x', 'night');
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 对排查很有用,常见取值与实际含义:
| 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 | 与已准备的两阶段事务冲突 |
DEFERRABLE 只读事务:零冲突的一致快照
只读事务在 SSI 下也可能被中止(因为它也参与依赖追踪)。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;
SELECT count(*) FROM order_items;
COMMIT;BEGIN
count
-------
10000
(1 row)
sum
-----------
1234567.89
(1 row)
count
-------
35000
(1 row)
COMMIT注意 DEFERRABLE 只对 SERIALIZABLE READ ONLY 有意义(PostgreSQL 会忽略其它组合下的 DEFERRABLE):
-- DEFERRABLE 在非「可串行化只读」组合下会被忽略(不报错,但无效果)
BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE DEFERRABLE;
SELECT 1;
COMMIT;BEGIN
?column?
----------
1
(1 row)
COMMIT-- 查看会话是否处于 DEFERRABLE 只读模式
SHOW transaction_deferrable;
SHOW transaction_read_only;
SHOW default_transaction_deferrable; transaction_deferrable
------------------------
off
(1 row)
transaction_read_only
-----------------------
off
(1 row)
default_transaction_deferrable
--------------------------------
off
(1 row)谓词锁:SSI 如何追踪「范围读」
SSI 需要知道「一个事务读了哪些数据」,包括范围查询覆盖的、但当前并不存在的行(否则无法检测幻读类的依赖)。为此 PostgreSQL 使用谓词锁(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当条目超过上限时,PostgreSQL 会提升(promote)粒度:从「行级谓词锁」提升为「页级」,再提升为「关系级」。粒度变粗意味着误报增多——更多本来不冲突的事务会被判定为冲突。
-- 需要重启才能修改这些参数
ALTER SYSTEM SET max_pred_locks_per_transaction = 128;
-- SELECT pg_reload_conf(); -- 不足,此项需要重启ALTER SYSTEMSSI 的代价:CPU、内存与误报
SERIALIZABLE 不是免费的,它的成本来自三处:
| 成本项 | 说明 |
|---|---|
| 谓词锁内存 | 每个 SERIALIZABLE 事务都要存活读集信息,占用共享内存 |
| 检测 CPU | 提交时需检查依赖图,高并发下成为瓶颈 |
| 误报(false positive) | 极少数本可串行化的事务被中止,需要重试 |
| 重试成本 | 冲突率高的负载下,重试会放大资源消耗 |
| 长事务放大 | 事务越长,读集体积越大,冲突概率越高 |
误报是 SSI 的固有权衡:为了保证「绝不漏判」,它宁可「偶尔错杀」。误报的两个典型来源:
误报来源 1:谓词锁粒度提升
本应只影响一行的读,被提升为关系级谓词锁
→ 同表上任何写操作都可能被判定为冲突
误报来源 2:只读事务的依赖追踪
SERIALIZABLE READ ONLY 事务也会参与依赖分析
→ 大量只读事务 + 高并发写,容易产生「无害但被判定为环」的结构
→ 用 READ ONLY DEFERRABLE 可完全避免-- 减少误报的实践:把大范围只读统计挪到 DEFERRABLE 只读事务
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM orders WHERE amount > 1000;
COMMIT;BEGIN
count
-------
812
(1 row)
COMMITSSI 与显式锁:两条路线怎么选
实现同样的业务正确性,SERIALIZABLE 与显式锁(FOR UPDATE / LOCK TABLE / 咨询锁)是两条路线:
| 维度 | SERIALIZABLE(SSI) | 显式锁 |
|---|---|---|
| 正确性来源 | 数据库自动检测依赖 | 由开发者自己推理锁顺序 |
| 出错方式 | 40001,可重试 | 死锁(40P01)、阻塞、漏锁导致静默错误 |
| 应用复杂度 | 需重试框架,逻辑简单 | 逻辑复杂,易漏锁 |
| 阻塞行为 | 几乎不阻塞读 | 可能大量阻塞 |
| 性能 | 冲突高时重试放大 | 冲突高时排队等待 |
| 可维护性 | 高(正确性由数据库兜底) | 低(锁逻辑分散在代码里) |
| 适用 | 复杂不变式、多表一致性 | 已知热点、需要精确控制并发 |
-- 显式锁路线:需要自己保证「所有路径都锁了同一批资源」;人工构造冲突点
BEGIN;
SELECT * FROM shift_state WHERE shift = 'night' FOR UPDATE; -- 拿到行锁
SELECT count(*) FROM oncall WHERE shift = 'night'; -- count = 2
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;-- SERIALIZABLE 路线:不需要知道热点在哪里,由 SSI 兜底
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night'; -- count = 2
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;两条路线可以混用:对已知热点用显式锁减少 SSI 的冲突概率,其余部分交给 SERIALIZABLE 兜底。
-- 混用:先锁热点行,缩小 SSI 需要处理的范围
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM shift_state WHERE shift = 'night' FOR UPDATE;
SELECT count(*) FROM oncall WHERE shift = 'night';
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;重试框架与幂等性
40001 是「请重试」而不是「失败」,因此应用必须有一个可靠的重试框架。要点:
- 重试整个事务,而不是单条语句。
- 退避(backoff),避免高冲突下的重试风暴。
- 次数上限 + 最终失败,不能让请求无限重试。
- 重试必须幂等:事务整段重放时不能产生重复副作用(重复扣款、重复插入)。
- 重试前必须真正
ROLLBACK,确保事务状态干净。
# Python:一个可复用的重试装饰器
import functools, time, random, psycopg
from psycopg import errors
def serializable_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
@serializable_retry()
def book_room(conn, room_id: int, slot: str) -> None:
with conn.cursor() as cur:
cur.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
cur.execute(
"SELECT count(*) FROM bookings WHERE room_id = %s AND slot = %s",
(room_id, slot),
)
if cur.fetchone()[0] > 0:
raise BusinessRuleError("时段已被占用")
cur.execute(
"INSERT INTO bookings (room_id, slot) VALUES (%s, %s)",
(room_id, slot),
)注意 errors.DeadlockDetected(40P01)与 40001 一样属于「可重试错误」,一并纳入重试是常见做法。
-- 可重试的错误都属于 SQLSTATE Class 40(事务回滚类)
-- 40001 serialization_failure
-- 40P01 deadlock_detected
-- 40002 transaction_integrity_constraint_violation
-- 40003 statement_completion_unknown幂等性示例——用「唯一键 + ON CONFLICT」让重放安全:
-- 重试安全的写法:同一业务键重复执行不会产生重复数据
INSERT INTO payments (order_id, amount, idempotency_key)
VALUES (42, 100.00, 'order-42-attempt')
ON CONFLICT (idempotency_key) DO NOTHING;INSERT 0 1监控与分析中止事务
SSI 中止的事务在 pg_stat_activity 与统计视图中都留有痕迹:
-- 实时查看正在执行的 SERIALIZABLE 事务与其状态
SELECT pid,
state,
now() - xact_start AS xact_age,
left(query, 60) 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
------+---------------------+----------+---------------------------------
8421 | active | 00:00:02 | SELECT count(*) FROM oncall WHE
8422 | idle in transaction | 00:00:08 | DELETE FROM oncall WHERE doctor
(2 rows)-- 数据库级统计与累计的 SSI 冲突次数
SELECT d.datname, s.xact_commit, s.xact_rollback, s.deadlocks,
pg_stat_get_db_conflict_all(d.oid) AS serialization_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 | serialization_conflicts
---------+-------------+---------------+-----------+-------------------------
mydb | 120456 | 1893 | 0 | 12
(1 row)如果 serialization_conflicts 随时间快速增长,说明该负载下 SERIALIZABLE 的代价偏高,需要考虑:缩短事务、缩小读范围、对热点用显式锁前置、或把部分只读查询改为 DEFERRABLE 只读。
何时用 SERIALIZABLE:三级对比与选择路径
不是所有场景都值得上 SERIALIZABLE,判断标准是「业务是否存在跨行/跨表的不变式」:
| 场景 | 建议级别 | 理由 |
|---|---|---|
| 普通 CRUD、单行更新 | READ COMMITTED | 语句级原子性足够 |
相对更新(x = x - n) | READ COMMITTED | 数据库自己算,无丢失更新 |
| 一致性报表 / 对账 | REPEATABLE READ(只读) | 只需一致快照 |
| 「读—算—写」单行逻辑 | REPEATABLE READ | 冲突会被 40001 暴露 |
| 跨行不变式(配平、容量约束) | SERIALIZABLE | RR 防不住写偏斜 |
| 排班、占座、库存分片 | SERIALIZABLE | 典型写偏斜场景 |
| 资金划转涉及多账户规则 | SERIALIZABLE | 强一致要求 |
| 极高吞吐的计数器 | READ COMMITTED | 冲突率会让 SSI 得不偿失 |
-- 典型写偏斜场景:会议室预订(要求同一时段同一房间只能有一场)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM bookings WHERE room_id = 7 AND slot = '2026-10-01 09:00';
-- 若为 0 则插入;并发时其中一个必报 40001,重试后能看到已占用的记录
INSERT INTO bookings (room_id, slot) VALUES (7, '2026-10-01 09:00');
COMMIT;
-- 若不想用 SERIALIZABLE,也可以用唯一约束把不变式固化(更便宜)
ALTER TABLE bookings ADD CONSTRAINT bookings_room_slot_uniq UNIQUE (room_id, slot);
INSERT INTO bookings (room_id, slot) VALUES (7, '2026-10-01 09:00');BEGIN
count
-------
0
(1 row)
INSERT 0 1
COMMIT
ERROR: duplicate key value violates unique constraint "bookings_room_slot_uniq"
DETAIL: Key (room_id, slot)=(7, 2026-10-01 09:00+08) already exists.这条对比值得记住:能用唯一约束/CHECK 表达的规则,优先用约束(更便宜、更可靠);只有无法用约束表达的跨行不变式,才需要 SERIALIZABLE。
三个级别综合对比:
| 维度 | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|
| 快照 | 语句级 | 事务级 | 事务级 + 依赖追踪 |
| 脏读 / 不可重复读 / 幻读 | 后两者可能 | 都不可能 | 都不可能 |
| 写偏斜 | 可能 | 可能 | 不可能 |
| 读偏斜 | 可能 | 不可能 | 不可能 |
| 写冲突行为 | 阻塞 + 重检查 | 40001 | 40001 |
| 只读事务会失败吗 | 不会 | 不会 | 可能(DEFERRABLE 可避免) |
| 需要重试 | 一般不需要 | 需要 | 需要(含误报) |
| 额外内存/CPU | 无 | 无 | 有(谓词锁、依赖检测) |
| 吞吐(低冲突) | 最高 | 高 | 中高 |
| 吞吐(高冲突) | 高 | 下降(重试) | 明显下降(重试 + 误报) |
| 推荐用途 | 默认 | 一致性读、单行写 | 跨行不变式 |
选择路径:
需要「并发正确性」吗?
├─ 不需要(业务能容忍语句级语义)
│ → READ COMMITTED
└─ 需要
├─ 只要「事务内视图一致」,且写冲突可用 40001 暴露
│ → REPEATABLE READ
│ (只读时加 READ ONLY,必要时加 DEFERRABLE → 零冲突)
└─ 涉及「多行/多表合起来满足不变式」
├─ 规则能用 UNIQUE / CHECK / 外键表达
│ → 用约束 + READ COMMITTED(最省)
└─ 表达不了(写偏斜类)
→ SERIALIZABLE + 重试框架-- 三种级别的「一句话」设置示例
BEGIN ISOLATION LEVEL READ COMMITTED; -- 默认,日常 CRUD
COMMIT;
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; -- 报表、对账
COMMIT;
BEGIN ISOLATION LEVEL SERIALIZABLE; -- 跨行不变式
COMMIT;实战示例
场景:会议室预订系统,要求「同一房间同一时段只能有一场预订」,用 SERIALIZABLE + 重试保证正确性。
第 1 步:准备数据
DROP TABLE IF EXISTS bookings, rooms;
CREATE TABLE rooms (
id int PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE bookings (
id bigserial PRIMARY KEY,
room_id int NOT NULL REFERENCES rooms(id),
slot text NOT NULL,
owner text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO rooms (id, name) VALUES (7, '第七会议室');DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 1第 2 步:两个会话并发预订同一时段(SERIALIZABLE)
会话 A(SERIALIZABLE):
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM bookings WHERE room_id = 7 AND slot = '2026-10-01 09:00';
-- 0
INSERT INTO bookings (room_id, slot, owner) VALUES (7, '2026-10-01 09:00', 'Alice');
会话 B(SERIALIZABLE,同时):
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM bookings WHERE room_id = 7 AND slot = '2026-10-01 09:00';
-- 0(自己的快照里还没有 Alice 的记录)
INSERT INTO bookings (room_id, slot, owner) VALUES (7, '2026-10-01 09:00', 'Bob');
会话 A:COMMIT; -- 成功
会话 B:COMMIT; -- 报 40001-- 会话 B 输出
BEGIN
count
-------
0
(1 row)
INSERT 0 1
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.第 3 步:重试后得到正确结果
-- 会话 B 回滚并重试整个事务(新一轮快照能看到 Alice 的记录)
ROLLBACK;
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM bookings WHERE room_id = 7 AND slot = '2026-10-01 09:00';
-- 1 → 业务层判定已被占用,拒绝插入
ROLLBACK;ROLLBACK
BEGIN
count
-------
1
(1 row)
ROLLBACK第 4 步:验证最终只有一条预订
SELECT room_id, slot, owner FROM bookings ORDER BY id; room_id | slot | owner
---------+----------------------+-------
7 | 2026-10-01 09:00 | Alice
(1 row)第 5 步:补充唯一约束作为第二道防线,并清理
-- 更省资源的方案:用唯一约束把不变式固化,级别可降回 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.DROP TABLE bookings, rooms;DROP TABLE注意事项
SERIALIZABLE 的错误是「请重试」,不是「失败」。 必须实现重试,否则用户会看到随机报错。错误! 把
40001当系统故障记录告警并返回 500。不能只重试失败的那条语句。 事务已
aborted,后续语句报25P02。
-- 错误!
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE bookings SET owner = 'x' 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- SSI 会「错杀」本可串行化的事务。 这是为保证不漏判付的代价,只能通过缩短事务、缩小读范围、调整谓词锁参数来缓解。不推荐在极高冲突的负载上默认使用 SERIALIZABLE。
-- 缓解误报:把只读统计挪到 DEFERRABLE 只读事务
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM bookings WHERE slot LIKE '2026-10%';
COMMIT;- 长事务会显著放大 SSI 的代价。 读集越大,冲突与误报越多;SERIALIZABLE 事务尤其要短。不推荐在长事务里包大量读操作、中间还做外部调用。
-- 能用唯一约束表达的规则不要用 SERIALIZABLE:约束更快、更可靠、且不产生重试
ALTER TABLE bookings ADD CONSTRAINT bookings_room_slot_uniq UNIQUE (room_id, slot);DEFERRABLE只在SERIALIZABLE READ ONLY下有效,其它组合会被静默忽略。错误! 以为SERIALIZABLE READ WRITE DEFERRABLE能减少冲突。
BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE DEFERRABLE;
SHOW transaction_deferrable; -- on,但对读写事务无实际效果
COMMIT;BEGIN
transaction_deferrable
------------------------
on
(1 row)
COMMITmax_pred_locks_*参数修改需要重启。 仅pg_reload_conf()不会生效。
ALTER SYSTEM SET max_pred_locks_per_transaction = 128; -- 需重启才能生效最佳实践
- 只在真正需要跨行/跨表不变式时使用 SERIALIZABLE,其余场景优先约束、条件写、REPEATABLE READ。重试框架统一封装,覆盖
40001与40P01,带指数退避、次数上限与日志记录。
except (errors.SerializationFailure, errors.DeadlockDetected) as e:
if attempt == max_attempts - 1:
raise
time.sleep(min(0.05 * (2 ** attempt), 1.0))- 让重试安全(幂等):用唯一键 +
ON CONFLICT、业务幂等键、状态机判断,避免重放产生重复副作用。
INSERT INTO payments (order_id, amount, idempotency_key)
VALUES (42, 100.00, 'order-42-attempt')
ON CONFLICT (idempotency_key) DO NOTHING;- 把只读长查询全部改为
SERIALIZABLE READ ONLY DEFERRABLE(或至少REPEATABLE READ READ ONLY),消除只读事务的冲突与重试。
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM bookings;
SELECT sum(...) FROM ...;
COMMIT;- 监控
40001频率,把它当作容量指标;持续偏高说明该负载不适合全局 SERIALIZABLE,应缩小事务范围或对热点加显式锁。
SELECT pg_stat_get_db_conflict_all(d.oid) AS serialization_conflicts
FROM pg_database d
WHERE d.datname = current_database();下一节
继续学习:脏读 / 不可重复读 / 幻读