Skip to content

SERIALIZABLE ​

SERIALIZABLE 是 PostgreSQL 能提供的最高隔离级别,也是唯一能保证「并发执行结果等价于某种串行执行」的级别。PostgreSQL 的实现不是靠粗暴加锁,而是 SSI(Serializable Snapshot Isolation,可串行化快照隔离):在快照隔离之上追踪事务间的读写依赖,一旦发现可能产生不可串行化结果的「危险结构」,就让其中一个事务以 40001 失败。它把「并发正确性」变成了一道可以用重试解决的工程问题。

前置知识

阅读本节前,建议先了解:REPEATABLE READ、MVCC 概述

基础概念 ​

事实说明
实现机制是 SSI,不是两阶段锁基于快照 + 依赖追踪,读不阻塞写
保证「可串行化」结果等价于某个串行顺序执行所有事务
消除所有标准异常包括 RR 防不住的写偏斜与其它可串行化异常
失败方式是 40001could not serialize access due to read/write dependencies among transactions
不阻塞读,只在必要时中止事务与 2PL 的「大量阻塞」策略不同
有误报(false positive)极少数本可串行的事务被中止
需要应用重试与 RR 一样,是契约的一部分
代价是 CPU 与内存追踪依赖需要谓词锁信息与共享内存

四个级别的能力边界:

异常READ COMMITTEDREPEATABLE READSERIALIZABLE
脏读不可能不可能不可能
不可重复读可能不可能不可能
幻读可能不可能不可能
丢失更新可能不可能不可能
写偏斜可能可能不可能
读偏斜可能不可能不可能
任何可串行化异常可能可能不可能

语法与用法 ​

text
-- 事务级(必须在本事务任何查询之前)
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;

最简可运行示例:

sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
text
BEGIN
 balance
---------
 1000.00
(1 row)

UPDATE 1
COMMIT
sql
-- 冲突时(另一个事务在本事务快照后修改了同一行)
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
text
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 换了个思路:先乐观执行,事后检查是否可能出错。

text
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 依赖构成的环」。用经典写偏斜案例看这个过程:

text
医生值班(要求:至少 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,必须重试
text
-- 会话 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 下必有一方失败。

sql
-- 重试后得到正确结果:第二个事务看到的是「只有 1 人在岗」,从而拒绝下班
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';   -- 重试后读到 1
-- 业务层判断 1 <= 1,拒绝执行删除
ROLLBACK;
text
BEGIN
 count
-------
     1
(1 row)

ROLLBACK
python
# 配套重试:写偏斜场景下 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:
                raise

40001 的两种形态与 HINT ​

SERIALIZABLE(以及 REPEATABLE READ)下的 40001 有两种典型消息,理解它们的差别有助于定位问题:

消息触发场景含义
could not serialize access due to concurrent updateRR 或 SERIALIZABLE 中 UPDATE/DELETE/SELECT FOR UPDATE 撞上并发修改行级写冲突
could not serialize access due to read/write dependencies among transactionsSERIALIZABLE 下的 SSI 检测到依赖环可串行化冲突
sql
-- 形态 1:行级写冲突(RR 也会报)
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
text
ERROR:  could not serialize access due to concurrent update
sql
-- 形态 2:SSI 依赖环(只有 SERIALIZABLE 会报)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
INSERT INTO oncall (doctor, shift) VALUES ('dr-x', 'night');
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 对排查很有用,常见取值与实际含义:

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——它让事务等到获取一个「安全快照」之后再开始,从而永远不需要中止。

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;
SELECT count(*) FROM order_items;
COMMIT;
text
BEGIN
 count
-------
 10000
(1 row)

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

 count
-------
 35000
(1 row)

COMMIT

注意 DEFERRABLE 只对 SERIALIZABLE READ ONLY 有意义(PostgreSQL 会忽略其它组合下的 DEFERRABLE):

sql
-- DEFERRABLE 在非「可串行化只读」组合下会被忽略(不报错,但无效果)
BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE DEFERRABLE;
SELECT 1;
COMMIT;
text
BEGIN
 ?column?
----------
        1
(1 row)

COMMIT
sql
-- 查看会话是否处于 DEFERRABLE 只读模式
SHOW transaction_deferrable;
SHOW transaction_read_only;
SHOW default_transaction_deferrable;
text
 transaction_deferrable
------------------------
 off
(1 row)

 transaction_read_only
-----------------------
 off
(1 row)

 default_transaction_deferrable
--------------------------------
 off
(1 row)

谓词锁:SSI 如何追踪「范围读」 ​

SSI 需要知道「一个事务读了哪些数据」,包括范围查询覆盖的、但当前并不存在的行(否则无法检测幻读类的依赖)。为此 PostgreSQL 使用谓词锁(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

当条目超过上限时,PostgreSQL 会提升(promote)粒度:从「行级谓词锁」提升为「页级」,再提升为「关系级」。粒度变粗意味着误报增多——更多本来不冲突的事务会被判定为冲突。

sql
-- 需要重启才能修改这些参数
ALTER SYSTEM SET max_pred_locks_per_transaction = 128;
-- SELECT pg_reload_conf();   -- 不足,此项需要重启
text
ALTER SYSTEM

SSI 的代价:CPU、内存与误报 ​

SERIALIZABLE 不是免费的,它的成本来自三处:

成本项说明
谓词锁内存每个 SERIALIZABLE 事务都要存活读集信息,占用共享内存
检测 CPU提交时需检查依赖图,高并发下成为瓶颈
误报(false positive)极少数本可串行化的事务被中止,需要重试
重试成本冲突率高的负载下,重试会放大资源消耗
长事务放大事务越长,读集体积越大,冲突概率越高

误报是 SSI 的固有权衡:为了保证「绝不漏判」,它宁可「偶尔错杀」。误报的两个典型来源:

text
误报来源 1:谓词锁粒度提升
  本应只影响一行的读,被提升为关系级谓词锁
  → 同表上任何写操作都可能被判定为冲突

误报来源 2:只读事务的依赖追踪
  SERIALIZABLE READ ONLY 事务也会参与依赖分析
  → 大量只读事务 + 高并发写,容易产生「无害但被判定为环」的结构
  → 用 READ ONLY DEFERRABLE 可完全避免
sql
-- 减少误报的实践:把大范围只读统计挪到 DEFERRABLE 只读事务
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM orders WHERE amount > 1000;
COMMIT;
text
BEGIN
 count
-------
   812
(1 row)

COMMIT

SSI 与显式锁:两条路线怎么选 ​

实现同样的业务正确性,SERIALIZABLE 与显式锁(FOR UPDATE / LOCK TABLE / 咨询锁)是两条路线:

维度SERIALIZABLE(SSI)显式锁
正确性来源数据库自动检测依赖由开发者自己推理锁顺序
出错方式40001,可重试死锁(40P01)、阻塞、漏锁导致静默错误
应用复杂度需重试框架,逻辑简单逻辑复杂,易漏锁
阻塞行为几乎不阻塞读可能大量阻塞
性能冲突高时重试放大冲突高时排队等待
可维护性高(正确性由数据库兜底)低(锁逻辑分散在代码里)
适用复杂不变式、多表一致性已知热点、需要精确控制并发
sql
-- 显式锁路线:需要自己保证「所有路径都锁了同一批资源」;人工构造冲突点
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;
sql
-- SERIALIZABLE 路线:不需要知道热点在哪里,由 SSI 兜底
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';   -- count = 2
DELETE FROM oncall WHERE doctor = 'Alice';
COMMIT;

两条路线可以混用:对已知热点用显式锁减少 SSI 的冲突概率,其余部分交给 SERIALIZABLE 兜底。

sql
-- 混用:先锁热点行,缩小 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 是「请重试」而不是「失败」,因此应用必须有一个可靠的重试框架。要点:

  1. 重试整个事务,而不是单条语句。
  2. 退避(backoff),避免高冲突下的重试风暴。
  3. 次数上限 + 最终失败,不能让请求无限重试。
  4. 重试必须幂等:事务整段重放时不能产生重复副作用(重复扣款、重复插入)。
  5. 重试前必须真正 ROLLBACK,确保事务状态干净。
python
# 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 一样属于「可重试错误」,一并纳入重试是常见做法。

sql
-- 可重试的错误都属于 SQLSTATE Class 40(事务回滚类)
-- 40001 serialization_failure
-- 40P01 deadlock_detected
-- 40002 transaction_integrity_constraint_violation
-- 40003 statement_completion_unknown

幂等性示例——用「唯一键 + ON CONFLICT」让重放安全:

sql
-- 重试安全的写法:同一业务键重复执行不会产生重复数据
INSERT INTO payments (order_id, amount, idempotency_key)
VALUES (42, 100.00, 'order-42-attempt')
ON CONFLICT (idempotency_key) DO NOTHING;
text
INSERT 0 1

监控与分析中止事务 ​

SSI 中止的事务在 pg_stat_activity 与统计视图中都留有痕迹:

sql
-- 实时查看正在执行的 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;
text
 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)
sql
-- 数据库级统计与累计的 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();
text
 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 暴露
跨行不变式(配平、容量约束)SERIALIZABLERR 防不住写偏斜
排班、占座、库存分片SERIALIZABLE典型写偏斜场景
资金划转涉及多账户规则SERIALIZABLE强一致要求
极高吞吐的计数器READ COMMITTED冲突率会让 SSI 得不偿失
sql
-- 典型写偏斜场景:会议室预订(要求同一时段同一房间只能有一场)
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');
text
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 COMMITTEDREPEATABLE READSERIALIZABLE
快照语句级事务级事务级 + 依赖追踪
脏读 / 不可重复读 / 幻读后两者可能都不可能都不可能
写偏斜可能可能不可能
读偏斜可能不可能不可能
写冲突行为阻塞 + 重检查4000140001
只读事务会失败吗不会不会可能(DEFERRABLE 可避免)
需要重试一般不需要需要需要(含误报)
额外内存/CPU无无有(谓词锁、依赖检测)
吞吐(低冲突)最高高中高
吞吐(高冲突)高下降(重试)明显下降(重试 + 误报)
推荐用途默认一致性读、单行写跨行不变式

选择路径:

text
需要「并发正确性」吗?
 ├─ 不需要(业务能容忍语句级语义)
 │    → READ COMMITTED
 └─ 需要
      ├─ 只要「事务内视图一致」,且写冲突可用 40001 暴露
      │    → REPEATABLE READ
      │      (只读时加 READ ONLY,必要时加 DEFERRABLE → 零冲突)
      └─ 涉及「多行/多表合起来满足不变式」
           ├─ 规则能用 UNIQUE / CHECK / 外键表达
           │    → 用约束 + READ COMMITTED(最省)
           └─ 表达不了(写偏斜类)
                → SERIALIZABLE + 重试框架
sql
-- 三种级别的「一句话」设置示例
BEGIN ISOLATION LEVEL READ COMMITTED;              -- 默认,日常 CRUD
COMMIT;
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;   -- 报表、对账
COMMIT;
BEGIN ISOLATION LEVEL SERIALIZABLE;                -- 跨行不变式
COMMIT;

实战示例 ​

场景:会议室预订系统,要求「同一房间同一时段只能有一场预订」,用 SERIALIZABLE + 重试保证正确性。

第 1 步:准备数据

sql
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, '第七会议室');
text
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 1

第 2 步:两个会话并发预订同一时段(SERIALIZABLE)

text
会话 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
text
-- 会话 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 步:重试后得到正确结果

sql
-- 会话 B 回滚并重试整个事务(新一轮快照能看到 Alice 的记录)
ROLLBACK;
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM bookings WHERE room_id = 7 AND slot = '2026-10-01 09:00';
-- 1 → 业务层判定已被占用,拒绝插入
ROLLBACK;
text
ROLLBACK
BEGIN
 count
-------
     1
(1 row)

ROLLBACK

第 4 步:验证最终只有一条预订

sql
SELECT room_id, slot, owner FROM bookings ORDER BY id;
text
 room_id |        slot          | owner
---------+----------------------+-------
       7 | 2026-10-01 09:00     | Alice
(1 row)

第 5 步:补充唯一约束作为第二道防线,并清理

sql
-- 更省资源的方案:用唯一约束把不变式固化,级别可降回 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
DROP TABLE bookings, rooms;
text
DROP TABLE

注意事项 ​

  • SERIALIZABLE 的错误是「请重试」,不是「失败」。 必须实现重试,否则用户会看到随机报错。错误! 把 40001 当系统故障记录告警并返回 500。

  • 不能只重试失败的那条语句。 事务已 aborted,后续语句报 25P02。

sql
-- 错误!
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE bookings SET owner = 'x' WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
SELECT 1;
text
ERROR:  current transaction is aborted, commands ignored until end of transaction block
  • SSI 会「错杀」本可串行化的事务。 这是为保证不漏判付的代价,只能通过缩短事务、缩小读范围、调整谓词锁参数来缓解。不推荐在极高冲突的负载上默认使用 SERIALIZABLE。
sql
-- 缓解误报:把只读统计挪到 DEFERRABLE 只读事务
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM bookings WHERE slot LIKE '2026-10%';
COMMIT;
  • 长事务会显著放大 SSI 的代价。 读集越大,冲突与误报越多;SERIALIZABLE 事务尤其要短。不推荐在长事务里包大量读操作、中间还做外部调用。
sql
-- 能用唯一约束表达的规则不要用 SERIALIZABLE:约束更快、更可靠、且不产生重试
ALTER TABLE bookings ADD CONSTRAINT bookings_room_slot_uniq UNIQUE (room_id, slot);
  • DEFERRABLE 只在 SERIALIZABLE READ ONLY 下有效,其它组合会被静默忽略。错误! 以为 SERIALIZABLE READ WRITE DEFERRABLE 能减少冲突。
sql
BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE DEFERRABLE;
SHOW transaction_deferrable;   -- on,但对读写事务无实际效果
COMMIT;
text
BEGIN
 transaction_deferrable
------------------------
 on
(1 row)

COMMIT
  • max_pred_locks_* 参数修改需要重启。 仅 pg_reload_conf() 不会生效。
sql
ALTER SYSTEM SET max_pred_locks_per_transaction = 128;   -- 需重启才能生效

最佳实践 ​

  • 只在真正需要跨行/跨表不变式时使用 SERIALIZABLE,其余场景优先约束、条件写、REPEATABLE READ。重试框架统一封装,覆盖 40001 与 40P01,带指数退避、次数上限与日志记录。
python
except (errors.SerializationFailure, errors.DeadlockDetected) as e:
    if attempt == max_attempts - 1:
        raise
    time.sleep(min(0.05 * (2 ** attempt), 1.0))
  • 让重试安全(幂等):用唯一键 + ON CONFLICT、业务幂等键、状态机判断,避免重放产生重复副作用。
sql
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),消除只读事务的冲突与重试。
sql
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT count(*) FROM bookings;
SELECT sum(...) FROM ...;
COMMIT;
  • 监控 40001 频率,把它当作容量指标;持续偏高说明该负载不适合全局 SERIALIZABLE,应缩小事务范围或对热点加显式锁。
sql
SELECT pg_stat_get_db_conflict_all(d.oid) AS serialization_conflicts
FROM   pg_database d
WHERE  d.datname = current_database();

下一节

继续学习:脏读 / 不可重复读 / 幻读

参考链接 ​