1995年,IBM 的研究人员发表了一篇名为"A Critique of ANSI SQL Isolation Levels"的论文。这篇论文指出了 ANSI SQL 标准对事务隔离级别定义的模糊性,并引入了一个在当时鲜为人知的异常现象:写倾斜(Write Skew)。近三十年后,这个概念仍然是数据库领域最容易被误解的问题之一。

要理解写倾斜,需要从数据库事务隔离级别的设计哲学说起。

ANSI SQL 隔离级别的局限

ANSI SQL 标准定义了四个隔离级别,通过禁止特定的异常现象来区分:

  • 读未提交(Read Uncommitted):允许脏读
  • 读已提交(Read Committed):禁止脏读,允许不可重复读
  • 可重复读(Repeatable Read):禁止脏读和不可重复读,允许幻读
  • 可串行化(Serializable):禁止所有异常

这个定义看似清晰,实则存在严重缺陷。Berenson 等人在 1995 年的论文中指出,ANSI 标准对"幻读"的定义过于狭隘——它只考虑了插入新行的情况,忽略了其他可能导致相同逻辑问题的场景。

更关键的是,ANSI 标准完全遗漏了一个在现代数据库中普遍存在的异常:写倾斜。

什么是写倾斜

考虑一个经典的场景:两个医生轮流值班。数据库规则要求任何时刻至少有一名医生值班。

初始状态:
doctor_id | on_call
----------|--------
    1     |  true
    2     |  true

事务 A(医生1想下班):

BEGIN;
-- 检查是否还有其他医生值班
SELECT COUNT(*) FROM doctors WHERE on_call = true AND doctor_id != 1;
-- 结果:1(医生2在值班)
-- 满足条件,医生1下班
UPDATE doctors SET on_call = false WHERE doctor_id = 1;
COMMIT;

事务 B(医生2想下班):

BEGIN;
-- 检查是否还有其他医生值班
SELECT COUNT(*) FROM doctors WHERE on_call = true AND doctor_id != 2;
-- 结果:1(医生1在值班)
-- 满足条件,医生2下班
UPDATE doctors SET on_call = false WHERE doctor_id = 2;
COMMIT;

在可重复读隔离级别下,这两个事务都可以成功提交。但最终结果违反了业务规则:

最终状态:
doctor_id | on_call
----------|--------
    1     |  false
    2     |  false

这就是写倾斜:两个事务读取同一组数据,各自做出满足约束的修改,但组合结果违反了约束。

为什么可重复读无法防止写倾斜

关键在于理解 MVCC(多版本并发控制)的工作原理。

在 PostgreSQL 等使用 MVCC 的数据库中,可重复读隔离级别通过快照实现:事务在开始时获取数据库的一个一致性快照,整个事务期间都读取这个快照中的数据。

这确实防止了不可重复读:如果事务 A 读取了某一行,事务 B 修改了同一行并提交,事务 A 再次读取时仍然看到原来的值。但问题在于:

事务 A 和事务 B 读取的是同一个快照,它们看到的都是"满足约束"的状态。各自修改的是不同的行,彼此互不干扰。

在 PostgreSQL 的实现中,行级锁只阻止对同一行的并发修改。如果两个事务修改的是不同的行,它们可以并行执行。这正是写倾斜得以发生的技术根源。

sequenceDiagram
    participant TA as 事务A
    participant DB as 数据库
    participant TB as 事务B
    
    Note over DB: 初始状态: 两个医生都值班
    
    TA->>DB: BEGIN (获取快照)
    TB->>DB: BEGIN (获取快照)
    
    TA->>DB: SELECT COUNT(*) WHERE on_call=true
    DB->>TA: 返回 2
    
    TB->>DB: SELECT COUNT(*) WHERE on_call=true
    DB->>TB: 返回 2
    
    Note over TA,TB: 两个事务都看到有2人值班<br/>各自认为可以安全下班
    
    TA->>DB: UPDATE doctor1 SET on_call=false
    TB->>DB: UPDATE doctor2 SET on_call=false
    
    TA->>DB: COMMIT
    TB->>DB: COMMIT
    
    Note over DB: 最终状态: 没人值班!<br/>约束被违反

写倾斜与幻读的区别

幻读(Phantom Read)是指:事务 A 执行查询得到一组行,事务 B 插入了符合查询条件的新行并提交,事务 A 再次执行相同查询时看到了额外的行。

写倾斜与幻读的区别在于:

特征 幻读 写倾斜
操作类型 一方读取,另一方插入 两方都修改现有数据
冲突检测 可通过范围锁检测 行级锁无法检测
MySQL 可重复读 可防止(通过 next-key lock) 无法防止
PostgreSQL 可重复读 可防止(通过快照) 无法防止

幻读的核心是"结果集变化",写倾斜的核心是"逻辑约束被绕过"。

写倾斜的实际案例

案例1:会议室预订系统

约束:同一时间同一会议室不能有两个预订。

初始状态:会议室A在14:00-15:00无预订

事务A:查询14:00-15:00的预订 → 空 → 预订会议1
事务B:查询14:00-15:00的预订 → 空 → 预订会议2

两个事务都认为时间可用,结果是双重预订。

案例2:银行账户联合余额

约束:两个关联账户的总余额不能低于某个阈值。

初始状态:账户A有500,账户B有500,阈值是800

事务A:查询总余额 → 1000 → 从A取出300
事务B:查询总余额 → 1000 → 从B取出300

两个事务各自检查通过,最终总余额为400,违反约束。

案例3:库存管理系统

约束:产品库存不能为负。

初始状态:产品X库存为10

事务A:查询库存 → 10 → 订购8个
事务B:查询库存 → 10 → 订购5个

这不是传统的"丢失更新"(两个事务修改同一行),而是基于读取结果做出的决策冲突。

不同数据库的处理方式

MySQL:Next-Key Lock 的双刃剑

MySQL 的 InnoDB 引擎在可重复读隔离级别下使用 next-key lock。这是一种结合了记录锁和间隙锁的机制。

当执行 SELECT * FROM doctors WHERE on_call = true FOR UPDATE 时,MySQL 不仅锁定匹配的行,还锁定行之间的"间隙"。这可以防止幻读——新插入的行无法落在锁定的范围内。

但在写倾斜场景中:

-- 事务 A
SELECT COUNT(*) FROM doctors WHERE on_call = true AND doctor_id != 1;
-- MySQL 不会自动加锁,除非使用 FOR UPDATE 或 LOCK IN SHARE MODE

如果查询使用的是普通 SELECT(不带锁定子句),MySQL 不会获取任何锁。结果:写倾斜仍然可能发生。

如果查询使用 SELECT ... FOR UPDATE,MySQL 会锁定所有匹配的行。但问题在于:

事务A锁定doctor_id=2的行
事务B锁定doctor_id=1的行
两者锁的是不同的行,不会互相阻塞

PostgreSQL:快照隔离的本质

PostgreSQL 的可重复读实现了快照隔离。每个事务看到的是事务开始时数据库的一个一致性快照。

快照隔离有一个重要特性:只检查写-写冲突。如果两个事务修改同一行,后提交的事务会被回滚(-first-committer-wins 规则)。但如果修改不同的行,两者都可以提交。

这正是写倾斜的技术本质:两个事务基于相同的读取结果,做出了各自看起来正确的写入决策,但组合结果不正确。

Oracle:类似的行为

Oracle 的可串行化隔离级别实际上实现的是快照隔离,因此同样无法防止写倾斜。

解决方案

方案1:使用可串行化隔离级别

PostgreSQL 从 9.1 版本开始实现了 SSI(Serializable Snapshot Isolation)。这是一种真正的可串行化实现,可以检测并防止写倾斜。

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM doctors WHERE on_call = true AND doctor_id != 1;
-- 如果检测到潜在的写倾斜,事务会在提交时被回滚
UPDATE doctors SET on_call = false WHERE doctor_id = 1;
COMMIT;

SSI 的工作原理是追踪事务之间的读写依赖关系。当检测到可能导致异常的依赖环时,选择一个事务进行回滚。

代价是性能:SSI 需要维护额外的追踪结构,可能导致更多的事务回滚。

方案2:显式锁定

使用 SELECT ... FOR UPDATE 可以在某些情况下防止写倾斜,但需要谨慎设计。

-- 锁定所有相关行
SELECT * FROM doctors WHERE on_call = true FOR UPDATE;
-- 此时其他事务无法读取或修改这些行,直到当前事务提交

问题在于:这把锁的粒度可能过大。如果查询条件是 on_call = true,只有值为 true 的行会被锁定。如果新插入一行 on_call = true 的记录,锁定无法阻止。

更好的做法是锁定一个"哨兵"行:

-- 假设有一个元数据表存储配置
SELECT * FROM config WHERE key = 'doctor_schedule' FOR UPDATE;
-- 这会获取一个单一的锁,所有需要修改医生排班的事务都必须获取这个锁

方案3:应用层约束检查

在应用层实现乐观锁机制:

-- 使用版本号
UPDATE doctors SET on_call = false, version = version + 1 
WHERE doctor_id = 1 AND version = :expected_version;

如果受影响行数为 0,说明数据已被其他事务修改,需要重试或报错。

方案4:数据库约束

使用触发器或检查约束:

CREATE TRIGGER check_doctor_availability
BEFORE UPDATE ON doctors
FOR EACH ROW
BEGIN
    DECLARE cnt INT;
    SELECT COUNT(*) INTO cnt FROM doctors WHERE on_call = true AND doctor_id != NEW.doctor_id;
    IF cnt = 0 AND NEW.on_call = false THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'At least one doctor must be on call';
    END IF;
END;

触发器的问题在于性能开销:每次更新都会执行额外的查询。

深入理解:为什么写倾斜难以检测

写倾斜难以检测的根本原因是:数据库只知道"读什么"和"写什么",但不知道"为什么这样写"

当事务 A 更新 doctor_id = 1 的行时,数据库看到的是一个普通的 UPDATE 操作。数据库不知道这个 UPDATE 是基于"还有其他医生值班"这个假设做出的决策。

要在数据库层面检测写倾斜,需要追踪"读集"(事务读取了哪些数据)和"写集"(事务修改了哪些数据),并检查是否存在冲突模式:

  • 事务 A 读取了行 X
  • 事务 B 读取了行 X
  • 事务 A 写入了行 Y
  • 事务 B 写入了行 Z
  • 行 X 的值暗示 Y 和 Z 不能同时被修改

这正是 SSI(Serializable Snapshot Isolation)要做的事情,但即使在 SSI 中,检测逻辑也是保守的——宁可误杀(回滚不一定会出错的事务),不可漏杀(允许可能出错的事务提交)。

总结:工程实践的权衡

写倾斜揭示了一个深刻的设计权衡:

  • 性能 vs 正确性:完全防止写倾斜需要可串行化隔离级别或显式锁定,这会影响并发性能。
  • 简洁 vs 安全:应用层约束检查代码更简洁,但可能遗漏边界情况。
  • 数据库独立性 vs 功能利用:充分利用特定数据库的特性(如 PostgreSQL 的 SSI)可以获得最佳效果,但牺牲了可移植性。

在实际工程中,推荐的做法是:

  1. 识别系统中可能存在写倾斜的业务场景
  2. 对于关键约束(如资金、库存),使用显式锁定或可串行化隔离级别
  3. 对于性能敏感的场景,考虑应用层乐观锁
  4. 理解所选数据库的隔离级别实现细节,不要依赖标准定义的字面含义

写倾斜不是数据库的 bug,而是隔离级别设计哲学的必然结果。理解它,才能在正确性与性能之间做出明智的权衡。


参考资料

  1. Berenson, A., et al. (1995). A Critique of ANSI SQL Isolation Levels. ACM SIGMOD.
  2. Fekete, A., et al. (2005). Making Snapshot Isolation Serializable. ACM TODS.
  3. PostgreSQL Documentation. Chapter 13. Concurrency Control.
  4. MySQL Reference Manual. InnoDB Locking.
  5. Vlad Mihalcea. (2017). A Beginner’s Guide to Write Skew. 6.Ports, D. R. K., & Grittner, K. (2012). Serializable Snapshot Isolation in PostgreSQL. VLDB.
  6. Gray, J., et al. (1976). Granularity of Locks and Degrees of Consistency in a Shared Data Base.
  7. Wikipedia. Isolation (database systems).
  8. PostgreSQL Wiki. SSI (Serializable Snapshot Isolation).
  9. Joe Celko. SQL for Smarties: Advanced SQL Programming.
  10. Martin Kleppmann. Designing Data-Intensive Applications. O’Reilly, 2017.