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)可以获得最佳效果,但牺牲了可移植性。
在实际工程中,推荐的做法是:
- 识别系统中可能存在写倾斜的业务场景
- 对于关键约束(如资金、库存),使用显式锁定或可串行化隔离级别
- 对于性能敏感的场景,考虑应用层乐观锁
- 理解所选数据库的隔离级别实现细节,不要依赖标准定义的字面含义
写倾斜不是数据库的 bug,而是隔离级别设计哲学的必然结果。理解它,才能在正确性与性能之间做出明智的权衡。
参考资料
- Berenson, A., et al. (1995). A Critique of ANSI SQL Isolation Levels. ACM SIGMOD.
- Fekete, A., et al. (2005). Making Snapshot Isolation Serializable. ACM TODS.
- PostgreSQL Documentation. Chapter 13. Concurrency Control.
- MySQL Reference Manual. InnoDB Locking.
- Vlad Mihalcea. (2017). A Beginner’s Guide to Write Skew. 6.Ports, D. R. K., & Grittner, K. (2012). Serializable Snapshot Isolation in PostgreSQL. VLDB.
- Gray, J., et al. (1976). Granularity of Locks and Degrees of Consistency in a Shared Data Base.
- Wikipedia. Isolation (database systems).
- PostgreSQL Wiki. SSI (Serializable Snapshot Isolation).
- Joe Celko. SQL for Smarties: Advanced SQL Programming.
- Martin Kleppmann. Designing Data-Intensive Applications. O’Reilly, 2017.