Skip to content

필사 모드: 事务隔离级别与真实的异常现象 — 标准定义与实现产生分歧的地方

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 隔离级别都调高了,数据为什么还是对不上

遇到并发 bug 时,常见的处方是:「把隔离级别提到 Repeatable Read。」而相当多的情况下,这个处方并不能解决问题。余额变成负数,库存被超卖,重复预约还在不断产生。

原因有两个。第一,从隔离级别表里学到的定义与真实实现并不一致。PostgreSQL 的 Read Committed 根本产生不了标准所允许的 dirty read,而它的 Repeatable Read 连标准允许的幻读也大都挡住了。MySQL InnoDB 的 Repeatable Read 又是另一套行为。第二,有一种异常现象是四个级别里没有一个能抓住的。而实务中的并发 bug,大部分恰恰就是它。

本文先整理标准里的那张表,再用两个会话的 SQL 来确认这张表在真实数据库里是如何崩塌的。

标准定义的四个级别与三种异常现象

ANSI SQL 标准是按「允许什么」来定义隔离级别的。以禁止清单而非算法来定义这一点,后来成了混乱的种子。

  • dirty read(脏读) — 读到其他事务尚未提交的修改
  • non-repeatable read(不可重复读) — 同一行读两次,值却变了
  • phantom read(幻读) — 用同样的条件查两次,行数却变了

再加上一个标准里没有、但在实务中最重要的现象,整理出来就是下面这张表。

隔离级别dirty readnon-repeatable readphantom readwrite skew
Read Uncommitted标准上允许 (PostgreSQL 中不可能)允许允许允许
Read Committed不可能允许允许允许
Repeatable Read不可能不可能标准上允许 (PostgreSQL 中不可能)允许
Serializable不可能不可能不可能不可能

表最右边那一列才是核心。除 Serializable 之外的所有级别都允许 write skew(写偏斜)。而大多数服务都跑在 Read Committed 上。

当前设置这样确认。

-- PostgreSQL
SHOW default_transaction_isolation;   -- 默认值: read committed
SELECT current_setting('transaction_isolation');

-- 按事务指定
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 或者
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- MySQL
SELECT @@transaction_isolation;   -- 默认值: REPEATABLE-READ
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

首先要意识到默认值不同这一点。PostgreSQL 是 Read Committed,MySQL 是 Repeatable Read。从 MySQL 迁移到 PostgreSQL 时,行为悄悄改变的第一个地方就在这里。

PostgreSQL 的 Read Committed 与标准不同

PostgreSQL 用 MVCC 而不是锁来实现隔离。所有读取都通过快照进行,而快照里只能看到已提交的版本。所以即使请求 Read Uncommitted,它也按 Read Committed 运行。语法会被接受,但 dirty read 在结构上就不可能发生。

BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SHOW transaction_isolation;
 transaction_isolation
-----------------------
 read uncommitted

配置值照原样显示,但内部行为与 Read Committed 相同。有人不知道这一点,提出「降到 Read Uncommitted 来提升性能」,而在 PostgreSQL 中这没有任何效果。

在 Read Committed 下,快照是按语句重新获取的。即使在同一个事务里,执行两次 SELECT 也会看到不同的快照。

-- 会话 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;   -- 10000
-- 会话 B
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;
-- 会话 A (同一个事务内)
SELECT balance FROM accounts WHERE id = 1;   -- 5000  <-- 值变了
COMMIT;

到这里都还是教科书写法。但 Read Committed 还有一个不太为人所知的行为。当 UPDATE 遇到被其他事务锁住的行时会等待,而对方提交后,它会重新读取更新后的最新版本并重新评估 WHERE 条件

-- 会话 A
BEGIN;
UPDATE accounts SET balance = balance - 3000 WHERE id = 1;
-- 不提交,保持等待
-- 会话 B
BEGIN;
UPDATE accounts SET balance = balance - 4000 WHERE id = 1;   -- 等待
-- 会话 A
COMMIT;

锁一释放,会话 B 就重新读取 balance 已变为 7000 的最新行,并更新为 3000。结果是 3000,也就是两次扣减都被反映了。像 balance = balance - 4000 这种以当前值为基准的更新,正是靠这次重新评估才不会发生 lost update。

问题出在应用读取数值、计算之后再作为常量写回的模式上。

-- 应用经常做的事
SELECT balance FROM accounts WHERE id = 1;   -- 读到 10000
-- 应用侧计算 10000 - 4000 = 6000
UPDATE accounts SET balance = 6000 WHERE id = 1;   -- 用常量覆盖

这种情况下,会话 A 的扣减会消失得无影无踪。因为重新评估只作用于 WHERE 条件,SET 子句里的常量会被原样使用。ORM 读出整个实体、改掉某个字段再保存的做法,正是这个模式。

Repeatable Read 其实是快照隔离

PostgreSQL 的 Repeatable Read 在事务的第一条语句时刻获取一次快照,并一直只看这一个快照直到结束。这比标准中的 Repeatable Read 更强。不仅同一行的值不会变,符合条件的行数也不会变。也就是说,幻读同样看不到。

-- 会话 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders WHERE status = 'pending';   -- 42
-- 会话 B
INSERT INTO orders (status) VALUES ('pending');
COMMIT;
-- 会话 A
SELECT count(*) FROM orders WHERE status = 'pending';   -- 42  <-- 没变
COMMIT;

代价是发生写冲突时事务会直接死掉。如果试图更新快照之后被其他事务提交过的行,PostgreSQL 不会等待再重新评估,而是直接报错。

-- 会话 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;   -- 10000
-- 会话 B
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;
-- 会话 A
UPDATE accounts SET balance = balance - 3000 WHERE id = 1;
ERROR:  could not serialize access due to concurrent update
SQLSTATE: 40001

这就是使用 Repeatable Read 时必须知道的契约。提高隔离级别,就等于把重试的责任压到了应用身上。没有重试逻辑就只把隔离级别调高,不过是让用户更频繁地看到 500 错误的一次改动罢了。

Repeatable Read 下还会一起出现另一个错误。

ERROR:  could not serialize access due to concurrent delete

它发生在你读过的行被其他事务删除的时候。应对方式同样是重试。

MySQL InnoDB 的 Repeatable Read 与间隙锁

名字相同,但 InnoDB 的 Repeatable Read 行为不一样。差别集中在两处。

第一,普通 SELECT 看到的是事务快照,而加锁读取和更新语句看到的是最新的已提交版本。所以在同一个事务里,SELECT 和 UPDATE 可能看到不同的数据。

-- 会话 A (MySQL, REPEATABLE READ)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;   -- 10000 (快照)
-- 会话 B
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;
-- 会话 A
SELECT balance FROM accounts WHERE id = 1;          -- 10000 (仍然是快照)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 5000 (最新)
UPDATE accounts SET balance = balance - 3000 WHERE id = 1;
COMMIT;

换成 PostgreSQL,第三条语句本该抛出 40001 错误,而 InnoDB 却悄无声息地继续执行。没有报错并不意味着安全。如果应用是拿第一次 SELECT 得到的 10000 作为判断依据,那个判断早就过时了。

第二,InnoDB 用间隙锁来阻止幻读。加锁读取或更新语句锁住的不只是索引记录,还包括记录之间的间隙。这就是临键锁。

-- 会话 A
BEGIN;
SELECT * FROM orders WHERE user_id = 42 FOR UPDATE;
-- user_id = 42 的索引区间以及它前后的间隙都会被锁住
-- 会话 B
INSERT INTO orders (user_id, status) VALUES (42, 'pending');   -- 等待

间隙锁确实能挡住幻读,但代价不小。连不存在的值也会被加锁,锁范围一扩大,死锁概率就随之上升。特别是条件列上没有索引时,InnoDB 会给扫过的每一条记录加锁,实际上等于锁住了整张表。这就是 MySQL 里死锁频发时应该先去检查索引的原因。

-- 查看当前锁状态 (MySQL 8.0)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

因为受不了间隙锁的负担,许多团队在 MySQL 上也把级别降到 Read Committed。这在大规模服务中确实是常见选择,而这种情况下幻读就得由应用自己承担了。

write skew — 快照隔离真正的漏洞

现在来看表最右边那一列。当两个事务读取同一个集合、却更新不同的行时,快照隔离检测不到任何冲突。既然没有重叠的写入,说没有冲突也没错。可是不变式却被打破了。

值班医生的例子是经典。规则是「至少要留一名值班医生」。

CREATE TABLE doctors (
  id       int PRIMARY KEY,
  name     text NOT NULL,
  on_call  boolean NOT NULL
);
INSERT INTO doctors VALUES (1, 'Kim', true), (2, 'Lee', true);
-- 会话 A: 金医生退出值班
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true;   -- 2, 条件通过
-- 会话 B: 李医生同时退出值班
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true;   -- 2, 条件通过
-- 会话 A
UPDATE doctors SET on_call = false WHERE id = 1;
COMMIT;
-- 会话 B
UPDATE doctors SET on_call = false WHERE id = 2;
COMMIT;
SELECT count(*) FROM doctors WHERE on_call = true;
 count
-------
     0

两个事务都成功了,也没有报错,但值班医生变成了 0 人。它们各自更新的是不同的行,所以没有写冲突,而快照隔离没有任何手段能抓住这一点。

同样结构的 bug 在实务中反复出现。查库存后扣减、座位重复预约检查、账户余额合计校验、没有唯一约束的重复注册检查,全都是 write skew。把隔离级别从 Read Committed 提到 Repeatable Read,一个也修不好。

PostgreSQL 的 SERIALIZABLE 能抓住它。实现方式不是锁,而是 SSI,即 Serializable Snapshot Isolation。它在快照隔离之上追踪事务之间的读写依赖,一旦检测到无法用任何串行顺序解释的模式,就中断其中一方。

-- 两个会话都用 SERIALIZABLE 重复上面的场景
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.
SQLSTATE: 40001

有三点需要注意。

  1. 错误可能在提交时刻才出现。即便事务过程中一切正常,也可能在 COMMIT 时失败,所以重试的范围必须是整个事务。
  2. 只有参与其中的所有事务都是 SERIALIZABLE,保证才成立。只把一边调高毫无意义。
  3. 如果是只读事务,可以声明 SET TRANSACTION READ ONLY DEFERRABLE,以降低追踪开销和被中断的概率。

MySQL 的 SERIALIZABLE 不是 SSI。它的做法是把普通 SELECT 变成共享加锁读取,更接近两阶段锁。安全,但并发度的下降要大得多。

在 SERIALIZABLE、SELECT FOR UPDATE 和乐观锁之间怎么选

有三个选项,各自适合的位置不同。

第一,如果要锁的行很明确,悲观锁最简单也最可预测。

BEGIN;
SELECT stock FROM products WHERE id = 77 FOR UPDATE;
-- 从这里开始其他事务无法锁住同一行
UPDATE products SET stock = stock - 1 WHERE id = 77;
COMMIT;

如果连行不存在的情况也要挡住,FOR UPDATE 就不够了,因为不存在的行是锁不住的。这时要用唯一约束或者 advisory lock。

-- 应用层面的显式加锁 (事务结束时自动释放)
SELECT pg_advisory_xact_lock(hashtext('reserve-seat:' || 'A12'));

想控制等待时间,就配合使用选项。

SELECT * FROM products WHERE id = 77 FOR UPDATE NOWAIT;        -- 立即报错
SELECT * FROM products WHERE id = 77 FOR UPDATE SKIP LOCKED;   -- 跳过被锁的行

SKIP LOCKED 在实现队列表时特别有用。多个工作进程可以从同一张表里各自取走不同的任务。

-- 从任务队列中安全地取出一条
UPDATE jobs SET status = 'running', picked_at = now()
WHERE id = (
  SELECT id FROM jobs
  WHERE status = 'queued'
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING *;

第二,当冲突很少见、而且事务中间夹着用户交互时,乐观锁更合适。因为不可能让用户开着页面的同时一直持有锁。

-- 用版本列只做冲突检测
UPDATE documents
SET title = 'new title',
    version = version + 1
WHERE id = 5 AND version = 12;
-- 如果更新行数为 0,说明有人先改过了

第三,当不变式横跨多行或多张表、无法确定要锁什么时,SERIALIZABLE 就是答案。前面那个值班医生的例子正是这种情况。不过在高吞吐路径上中断率会上升,所以最好把中断率作为指标持续观察,同时收窄应用范围。

只要有可能,把它改成数据库约束是最稳固的做法。把原本靠隔离级别守护的不变式搬到约束上,连重试都不需要了。

-- 用约束而不是隔离级别来阻止重复预约
CREATE UNIQUE INDEX uq_seat_reservation
  ON reservations (showtime_id, seat_no)
  WHERE cancelled_at IS NULL;

-- 用约束阻止时间区间重叠
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings
  ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (room_id WITH =, during WITH &&);

结语 — 没有重试的隔离级别提升毫无意义

只要记住三件事就够了。

第一,Read Committed 之上的所有隔离级别,都会给应用压上重试义务。如果没有捕获 SQLSTATE 40001 和 40P01 并重新执行整个事务的代码,提高隔离级别就是一次会增加故障的改动。

import time, random
import psycopg

RETRYABLE = {"40001", "40P01"}  # serialization_failure, deadlock_detected

def run_in_tx(conn, fn, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            with conn.transaction():
                return fn(conn)
        except psycopg.errors.Error as e:
            if e.sqlstate not in RETRYABLE or attempt == max_attempts - 1:
                raise
            # 指数退避 + 抖动。避免在同一瞬间再次冲突。
            time.sleep((2 ** attempt) * 0.05 * (0.5 + random.random()))

重试函数里只应该放数据库操作。如果混进了发邮件或调用外部 API,每次重试都会重复产生副作用。

第二,大多数并发 bug 不是 non-repeatable read 或 phantom read,而是 write skew。在隔离级别表里,那一列只有在 Serializable 那一行才是空的。如果提到 Repeatable Read 还是修不好,就该怀疑眼下的问题是不是 write skew。

第三,如果有想靠隔离级别守护的不变式,请先考察它能否表达成约束。唯一索引和 EXCLUDE 约束与隔离级别无关、永远成立,不需要重试,后来加入的同事也无法误操作绕过它们。

현재 단락 (1/208)

遇到并发 bug 时,常见的处方是:「把隔离级别提到 Repeatable Read。」而相当多的情况下,这个处方并不能解决问题。余额变成负数,库存被超卖,重复预约还在不断产生。

작성 글자: 0원문 글자: 8,215작성 단락: 0/208