跳转到内容

可串行化:Postgres 的 SSI 是怎么回事

准备中…

Repeatable Read(快照隔离)已经消除了脏读、不可重复读和幻读。还剩下什么?

答案是写偏斜(write skew)——两个事务各自读了一批数据、各自做出正确的决定、各自写了不同的行,合起来却违反了一个跨行的不变量。

规则:任何时刻至少要有一名医生在岗。 当前 Alice 和 Bob 都在岗。两人同时申请请假:

Repeatable Read 下的写偏斜编排回放 · 非实时执行
Session A
Session B
0 / 7

注意这里没有任何写-写冲突——A 改 Alice 的行,B 改 Bob 的行。隔离级别那一节讲的「写冲突报错」在这里完全不会触发。

问题出在读和写的交叉:A 读了「Bob 在岗」这个事实并据此做决定,而 B 恰好把这个事实改掉了;反之亦然。

「可串行化」的定义是:并发执行的结果,必须等价于某种串行执行的结果

上面这个例子里:

  • 如果 A 先完整执行再执行 B,B 会看到只剩 1 人,拒绝请假;
  • 如果 B 先执行再 A,A 会拒绝;
  • 但实际执行的结果是两个都成功——不等价于任何一种串行顺序。

这就是串行化异常。

传统数据库用两阶段锁(S2PL)实现可串行化:读也要加锁,读锁和写锁互斥。代价是读写重新开始互相阻塞——MVCC 辛苦换来的好处全没了。

Postgres 选了另一条路:可串行化快照隔离(SSI,Serializable Snapshot Isolation)。它保持快照隔离的全部性能特征——读永远不阻塞写——但在后台跟踪事务之间的读写依赖,发现可能构成异常的模式时,主动中止其中一个。

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM orders WHERE status = 'paid';
SELECT current_setting('transaction_isolation') AS 当前隔离级别;
⌘/Ctrl + Enter
COMMIT;
⌘/Ctrl + Enter

同一个场景,换成 SERIALIZABLE

SERIALIZABLE 拦住了写偏斜编排回放 · 非实时执行
Session A
Session B
0 / 6

1. 必须有重试逻辑。 40001serialization_failure)不是 bug,是设计的一部分。没有重试就等于把偶发失败直接暴露给用户。

for attempt in range(5):
try:
with transaction(isolation="serializable"):
do_work()
break
except SerializationFailure:
sleep(random.uniform(0, 0.05 * 2 ** attempt))

2. 重试必须重跑整个事务。 不能只重试失败的那条语句——事务的快照已经被判定为不一致,必须从 BEGIN 重来,重新取快照。

3. 只读事务也要标记。 如果一个事务确定只读,声明出来能让 SSI 少跟踪很多东西:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;

DEFERRABLE 会让事务等到能拿到一个「保证不会冲突」的快照才开始——代价是可能等一会儿,好处是永远不会因为串行化失败而回滚。适合长时间的报表查询。

在用 SERIALIZABLE 之前,先看看这三个更简单的办法是不是够用:

1. 把不变量交给约束。 医生值班这个例子,如果能表达成一个约束(比如用排他约束或者一张「当前在岗人数」的计数表加 CHECK),数据库会直接拦住,不需要隔离级别帮忙。这永远是首选

2. 显式加锁读。SELECT 改成 SELECT ... FOR UPDATE,把「读」变成「写」,写偏斜就退化成普通的写冲突:

BEGIN;
SELECT count(*) FROM doctors WHERE on_call = true FOR UPDATE; -- 锁住读集合
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;

代价是重新引入了读阻塞,但只在这一个操作上,而不是全局改隔离级别。

3. 用一行做串行化点。 所有相关事务先去 UPDATE 同一行计数器,天然排成一队。粗暴但极其有效,适合低频的关键操作。

先自己回答,再点开对照。

什么是写偏斜?为什么 Repeatable Read 挡不住它,而它又不触发写冲突报错?

写偏斜:两个事务各自读了一批数据、各自做出正确的决定、各自写了不同的行,合起来却违反了一个跨行的不变量。医生值班的例子里,Alice 和 Bob 各自看到「有 2 人在岗,我请假还剩 1 人」,然后一个都不剩。

RR 挡不住,是因为快照隔离保证的是读的一致性——事务看到某个时刻的完整切片;它不保证「我读到的那个事实,在我提交的时候还成立」。B 恰好把 A 据以决策的那个事实改掉了,反之亦然。

不触发写冲突报错,是因为写冲突检测的对象是同一行的并发更新:A 改 Alice 那行,B 改 Bob 那行,没有任何一行被两个事务同时写,xmax 层面毫无冲突。

常见错误:以为「RR 连幻读都挡住了,跨行的不变量当然也安全」。防幻读和防写偏斜是两件事——幻读是读和读之间的不一致,写偏斜是读和写的交叉。快照隔离在读这一侧已经做到极致,问题恰恰出在它管不到的那一侧。

「可串行化」的准确定义是什么?用医生值班的例子说明为什么那个结果不可串行化。

定义:并发执行的结果,必须等价于某种串行执行的结果。 注意是「某种」——不要求等价于某个指定的顺序,只要存在一个串行顺序能产生同样的结果就算数。

医生值班只有两种串行顺序,都产生不了实际结果:

  • A 先完整执行再执行 B:B 会看到只剩 1 人,拒绝请假;
  • B 先执行再 A:A 会拒绝。

实际结果是两个都成功——不等价于任何一种串行顺序。这就是串行化异常。

常见错误:以为「两个事务都成功提交、每个事务内部的逻辑也都正确,那就说明没问题」。可串行化衡量的不是单个事务对不对,而是这个组合结果能不能由某种串行顺序产生。每个事务单独看都无懈可击、合起来仍然是异常——这正是写偏斜类 bug 极难在代码评审中被发现的原因,评审是逐个函数看的。

SSI 和两阶段锁的根本区别是什么?SSI 保住了 MVCC 的什么好处?

两阶段锁(S2PL)用阻塞实现可串行化:读也要加锁,读锁和写锁互斥。代价是读写重新开始互相阻塞,MVCC 辛苦换来的好处全部作废。

SSI 用跟踪代替阻塞:它记下每个事务读了什么、写了什么,在后台维护事务之间的读写依赖边,发现构成「危险结构」的模式时主动中止其中一个。

保住的好处就是那一条:读永远不阻塞写,写也不阻塞读。

常见错误:以为把隔离级别调到 SERIALIZABLE 之后查询会变慢、事务会开始互相排队——在用 S2PL 的数据库上这个直觉是对的。Postgres 的 SERIALIZABLE 不引入任何新的阻塞,它的代价不在延迟上,在回滚率上。所以给 SERIALIZABLE 负载做监控,该盯的是 40001 的发生率和重试成功率,而不是锁等待时间——盯错了指标会看到一片岁月静好。

SIREAD 锁会阻塞谁?粒度升级会带来什么问题?

谁都不阻塞。 SIREAD lock(谓词锁)只是一条「某事务读过这些数据」的记录,名字里的 “lock” 纯属历史包袱。

粒度是自适应的:读的行少就按行记录,读的行多就升级成按页、按表记录。升级会带来假阳性——本来不冲突的两个事务,因为被粗粒度地记成「读了同一页」而被误判成冲突,其中一个白白回滚。这是 SSI 最主要的实用代价。

常见错误:把 SIREAD 当成一种「读锁」,进而推断 SERIALIZABLE 下读操作会互相阻塞、或者会挡住写。它不阻塞任何东西。它真正的成本是内存(要保留所有活跃事务的读写集合)和假阳性回滚——一旦超出 max_pred_locks_per_transaction,粒度升级触发,假阳性率会突然飙升。长事务在 SERIALIZABLE 下格外危险,原因就在这里。

在改用 SERIALIZABLE 之前,应该先考虑哪两个更简单的方案?

1. 把不变量交给约束。 能用排他约束、CHECK、唯一索引表达的,就交给数据库直接拦住,根本用不着隔离级别帮忙。这永远是首选。

2. 显式加锁读。SELECT 改成 SELECT ... FOR UPDATE,锁住读集合——「读」变成了「写」,写偏斜就退化成普通的写冲突:

BEGIN;
SELECT count(*) FROM doctors WHERE on_call = true FOR UPDATE;
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;

代价是重新引入了读阻塞,但只在这一个操作上,而不是全局改隔离级别。

常见错误:把 SELECT ... FOR UPDATE 当成 SERIALIZABLE 的万能平替,到处套上去。它有一个前提:你得能说清读集合是哪些行,并且能把它们全部锁住。不变量跨多张表、或者读集合本身说不清楚的时候,FOR UPDATE 无从下手——那才是 SERIALIZABLE 真正的位置。反过来,如果冲突极其频繁,两个方案都不该用,该重新设计数据模型。

详见约束