Skip to content

一、并发事务带来的问题

在讲解隔离级别之前,我们必须先清楚,如果没有任何隔离,并发操作会带来哪些问题。主要有以下三种:

1. 脏读 (Dirty Read)

  • 比喻: 我正在写一份草稿,还没写完,你就拿去看,并基于我的草草稿内容去做了决策。结果我后来发现草稿里有重大错误,撕了重写,你的决策就建立在了错误的信息之上。

  • 定义: 一个事务读取到了另一个事务尚未提交的数据。事务在这其中并不是相互隔离的。

  • 重点: 事务间的干扰。

2. 不可重复读 (Non-Repeatable Read)

  • 比喻: 你去商店问一台电视的价格,店员告诉你 5000 元。你去取钱的几分钟里,店员把价格改成了 5500 元。你回来再问,发现价格变了。在你的这次"购物"事务中,同一个商品的价格不可复现了。

  • 定义: 一个事务内,多次读取同一行数据,得到的结果却不一致。重点在于同一条数据在一个事务中前后的查询结果可能会出现不一致。

  • 重点: 事务前后的数据一致性的干扰。

3. 幻读 (Phantom Read)

  • 比喻: 你在点名,数了一下教室里有 30 个人。这时,在你没注意的时候,悄悄从后门溜进来一个学生。随即登记到达人数全部为 30 人时发现记入了 31 人。

  • 定义: 一个事务内,多次执行相同的范围查询,得到的结果集记录数不一致。当其他事务出现插入或者删除操作时,即使有行锁也不能保证事务内的查询集数量是一致的。

  • 重点: 事务前后数据集数量不同的干扰。

二、四种隔离级别详解

1. 读未提交 (Read Uncommitted)

技术说明:
这是最低的隔离级别。它允许一个事务读取到另一个事务尚未提交的更改。这种级别下,并发性能最高,但数据一致性最差。由于会产生脏读,在实际生产环境中基本不会使用。在这种情况下事务间并不是相互隔离的。

场景介绍:
某数据分析平台需要对一个巨大的交易表进行实时(但允许有微小误差)的统计汇总,比如计算当前小时的总交易额。为了追求极致的性能,不希望统计查询被任何正在进行的交易长时间阻塞。

举例 (以 MySQL 为例):
几乎所有数据库都支持,但很少使用。

sql
-- 终端 A
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1; -- 账户余额从1000减到900,但不提交

-- 终端 B
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 查询结果是 900 (脏读)
COMMIT;

-- 终端 A
ROLLBACK; -- 终端A回滚事务

-- 再次在终端 B 查询
SELECT balance FROM accounts WHERE id = 1; -- 结果变回 1000

2. 读已提交 (Read Committed)

技术说明:
一个事务只能读取到已经提交的事务所做的修改。这个级别解决了"脏读"问题。这是大多数主流数据库(如 PostgreSQL, Oracle, SQL Server)的默认隔离级别。但是这种情况下会出现"不可重复读"的问题。

解决脏读:
对读操作不加锁,使用了 MVCC 机制来避免脏读,但是时机在于每一次写操作都会重新创建一次快照,然后对写操作后的数据进行行锁。但是创建的快照在事务的进行过程随时读取的,可能前后的某条数据是不一致的。快照的生命周期是语句级别的。

场景介绍:
一个电商网站,用户正在下单。下单过程是一个事务,需要先读取商品库存,再创建订单。与此同时,另一个用户可能正好完成了对同一个商品的购买并付款成功。

举例 (以 PostgreSQL 为例):

sql
-- 终端 A
BEGIN; -- PostgreSQL默认就是READ COMMITTED
SELECT stock FROM products WHERE id = 'P001'; -- 假设返回 10

-- 终端 B
BEGIN;
UPDATE products SET stock = 9 WHERE id = 'P001';
COMMIT; -- 提交事务

-- 终端 A
-- 在同一个事务中再次查询
SELECT stock FROM products WHERE id = 'P001'; -- 查询结果是 9 (不可重复读)
COMMIT;

3. 可重复读 (Repeatable Read)

技术说明:
保证在同一个事务中,多次读取同样记录的结果是一致的。这个级别解决了"不可重复读"的问题。这是 MySQL InnoDB 引擎的默认隔离级别。

场景介绍:
一个银行系统在做一个复杂的日终结算。结算事务需要多次读取某个用户的账户余额来进行不同的计算(例如,计算利息、扣除年费)。在此期间,不能因为其他事务的存取款操作而影响本次结算中账户余额的一致性。

解决不可重复读:
对读操作不加锁,使用了 MVCC 机制 + 事务前一次快照来避免不可重复读,然后对写操作后的数据进行行锁。事务的执行前后读取的数据版本都是一致的。快照的生命周期是事务级别的。

举例 (以 MySQL 为例):

sql
-- 终端 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- MySQL InnoDB默认级别
START TRANSACTION;
SELECT * FROM employees WHERE department_id = 5; -- 假设返回20条记录

-- 终端 B
START TRANSACTION;
INSERT INTO employees (name, department_id) VALUES ('小王', 5);
COMMIT; -- 提交事务

-- 终端 A
-- 在同一个事务中再次查询
SELECT * FROM employees WHERE department_id = 5; -- 查询结果仍然是20条记录 (解决了不可重复读)

-- 但是,如果执行更新
UPDATE employees SET status = 'calculated' WHERE department_id = 5; -- 会发现 "21 rows affected"
-- 再次查询
SELECT * FROM employees WHERE department_id = 5; -- 此时会看到21条记录,包括新插入的小王
COMMIT;

PostgreSQL: 它的可重复读实现与 MySQL 类似,也是基于 MVCC。快照是在事务开始时创建的,并且整个事务期间都使用这一个快照。

MySQL (InnoDB): 实现上更进一步。除了在事务开始时创建 MVCC 快照外,它还引入了Next-Key Lock(间隙锁+行锁的组合)。当进行范围查询或更新时,它不仅会锁住满足条件的行,还会锁住这些行之间的"间隙",防止新的数据插入到这个范围内。这就在很大程度上避免了幻读。所以说,MySQL 的 REPEATABLE READ 比标准定义的要更强一些。

4. 可串行化 (Serializable)

技术说明:
最高的隔离级别。它通过强制事务串行执行,即一个接一个地执行,来避免了前面提到的所有并发问题。对与读操作加上读锁(共享锁)。当其他事务要对有读锁的行进行操作时因为有了读锁不能添加排他锁,因此必须等待。

共享锁的特点:

  • 读读兼容: 其他事务也可以对这些行加共享锁(即也可以 SELECT ... LOCK IN SHARE MODE),大家可以一起读。

  • 读写互斥: 任何事务都不能对这些加了共享锁的行进行修改 (UPDATE, DELETE) 或者加排他锁 (FOR UPDATE)。

场景介绍:
在一个金融或库存管理系统中,进行"秒杀"或分配唯一资源(如优惠券码)的操作。绝对不能出现两个用户同时抢到最后一个商品,或者领到同一个优惠券码的情况。

解决幻读:
对事务开始后的查询进行读锁,在这个相关范围内阻塞同样范围的写操作。事务就像在一个静态的、被冻结的环境中工作。

举例 (MySQL/PostgreSQL 通用):

sql
-- 终端 A
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM products WHERE stock > 0; -- 此时会对所有满足条件的行加读锁

-- 终端 B
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- 下面的插入操作会因为终端A的范围锁(间隙锁)而被阻塞,直到终端A提交或回滚
INSERT INTO products (name, stock) VALUES ('新商品', 10);

引擎实现简介:

  • MySQL (InnoDB): 在 REPEATABLE READ 的基础上,会将所有的普通 SELECT 语句自动转换为 SELECT ... LOCK IN SHARE MODE,即给读取的行加上读锁(共享锁)。当其他事务尝试修改这些行时,必须等待。

  • PostgreSQL: 它使用一种更先进的技术叫做SSI (Serializable Snapshot Isolation)。它比传统的加锁方式更乐观,允许多个事务并发读写。但在事务提交时,它会检查是否存在可能破坏串行化执行的"读写依赖",如果存在,则会主动让其中一个事务回滚失败,并提示用户重试。这种方式在无冲突时性能更好。

可串行化快照隔离 (Serializable Snapshot Isolation - SSI) (PostgreSQL):

PostgreSQL 实现可串行化的方式更为"聪明"和"乐观",它代表了现代数据库设计的趋势。

核心思想: 我先乐观地让你并发执行,不随便加锁阻塞你。但是,在你要提交的时候,我会做一个"犯罪风险评估",检查你在执行期间,有没有和其他事务形成"读写依赖环",这种环路可能导致结果不再是串行的。如果发现有风险,就判你这次提交无效,让你回滚重试。

如果检测到这种危险的依赖关系,数据库会主动让后提交的那个事务失败,并抛出一个"串行化失败 (serialization failure)"的错误,要求应用程序捕获这个错误并重试整个事务。

三、并发下的其他隐患

首先要明确一个至关重要的点:

  • SELECT 读是共享的,无论是哪个隔离级别,对读的操作都是共享的(可串行化会添加 s 锁,但依旧是读共享的)。

  • UPDATE 都是"当前读"(Current Read): 不走 MVCC 那一套,而是要去读取数据库里最新、最新、最新的版本,区别就是是否对写的数据行进行行锁,并且有没有对应的处理。

1. 锁等待死锁 (Lock-wait Deadlock)

如果两个事务互相等待对方持有的资源,就会形成死锁。除了读未提交,其他三种隔离级别都有可能出现。可串行化: 死锁的风险更高。因为它不仅写操作加锁,连读操作也加了共享锁。这就大大增加了锁的种类和持有时间,更多的锁交互自然带来了更高的死锁概率。

InnoDB 的死锁检测与处理:

  • InnoDB 具有内置的死锁检测机制。当它检测到死锁时,它不会让这两个事务无限期地等待下去。
  • 为了打破死锁,InnoDB 会选择其中一个事务作为死锁牺牲者 (deadlock victim) 并将其回滚 (rollback)。通常,InnoDB 会选择修改数据量较少、回滚成本较低的事务来回滚。
  • 被回滚的事务会收到一个错误,例如 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

Postgres 的死锁检测与处理:

  • 它并非全程阻塞,而是乐观地允许事务执行,同时跟踪它们之间的读写依赖关系。如果在事务准备提交时,发现其读写依赖关系构成了无法串行化的“环”,就会使其中一个事务失败,并报告“序列化失败”错误。
  • 虽然底层机制不同,但对用户来说,最终结果(一个事务失败)与死锁非常相似。

场景:互相转账

  • 数据: 账户 A 有 100 元,账户 B 有 100 元。

  • 事务 A: UPDATE accounts SET balance = balance - 10 WHERE id = 'A'; (锁住 A 行)

  • 事务 B: UPDATE accounts SET balance = balance - 10 WHERE id = 'B'; (锁住 B 行)

  • 事务 A: 接着想给 B 加钱:UPDATE accounts SET balance = balance + 10 WHERE id = 'B'; (尝试锁 B,但 B 被事务 B 锁住,等待...)

  • 事务 B: 接着想给 A 加钱:UPDATE accounts SET balance = balance + 10 WHERE id = 'A'; (尝试锁 A,但 A 被事务 A 锁住,等待...)

此时,A 在等 B 释放锁,B 在等 A 释放锁,形成死锁。InnoDB 的死锁检测机制会发现这种情况,为了打破僵局,它会选择一个"代价"最小的事务(通常是回滚 undo 量较少的那个),强制其回滚失败,并抛出死锁错误。

以 MySQL 为例:

sql
CREATE TABLE accounts (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    balance DECIMAL(10, 2)
);

INSERT INTO accounts (id, name, balance) VALUES
(1, 'Alice', 1000.00),
(2, 'Bob', 2000.00);


-- 步驟 1: 在會話 A 中開啟一個新事務
START TRANSACTION;

-- 步驟 2: 在會話 B 中也開啟一個新事務 
START TRANSACTION;

-- 步驟 3: 更新 Alice (id=1) 的餘額,這會鎖定 id=1 這一行。
--         請在會話 B 執行完【步驟 2】之後再執行此步驟。
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

-- 步驟 4: 更新 Bob (id=2) 的餘額,這會鎖定 id=2 這一行。 -- 請在會話 A 執行完【步驟 3】之後再執行此步驟。 
UPDATE accounts SET balance = balance - 200 WHERE id = 2;

-- 步驟 5: 接著,嘗試更新 Bob (id=2) 的餘額。
--         此時,因為 id=2 的行已經被會話 B 鎖定,這個指令會被阻塞,進入等待狀態。
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

-- 步驟 6: 現在,嘗試更新 Alice (id=1) 的餘額。 -- 由於會話 A 正在等待會話 B 的鎖,而會話 B 又要請求會話 A 的鎖, -- 執行此命令將立即觸發死鎖檢測機制。 
UPDATE accounts SET balance = balance + 200 WHERE id = 1;

-- 如果死鎖發生後,你的會話是倖存者,你可以用 COMMIT 提交事務。
-- 如果你的會話是犧牲品,你會收到錯誤,事務會自動回滾。
-- COMMIT;
-- ROLLBACK;

2. 锁等待超时

除了读未提交,其他三种隔离级别都有可能出现。可串行化: 死锁的风险更高。因为它不仅写操作加锁,连读操作也加了共享锁。这就大大增加了锁的种类和持有时间,更多的锁交互自然带来了更高的死锁概率。前面分析的"S 锁升级 X 锁"就是可串行化独有的死锁场景。如果事务 B 等待事务 A 释放锁的时间太长,超过了 innodb_lock_wait_timeout 参数设定的阈值(默认 50 秒),事务 B 会自动放弃等待,并回滚失败,抛出锁等待超时错误。

这通常发生在事务 A 是一个包含了大量操作的"长事务"时。

3. 业务逻辑失效

即使都成功了,在非可串行化的三个级别中,数据库保证了物理上的更新会依次进行,但业务逻辑可能已经不再成立。

场景:秒杀抢购最后一件商品

  • 数据: stock = 1

  • 事务 A: 读取到 stock=1,判断可以购买。执行 UPDATE stock = 0 ...,抢到锁,成功。

  • 事务 B: 也读取到 stock=1,判断可以购买。执行 UPDATE stock = 0 ...,被 A 阻塞。

  • A 提交后,B 被唤醒: 如果你的 UPDATE 语句只是 UPDATE products SET stock = stock - 1 WHERE id = 101;,那么 B 会成功执行,导致 stock 最终变为 -1!这在物理上成功了,但在业务上是灾难。

问题根源在于普通读操作的共享性(非阻塞性)。无论是读已提交还是可重复读,它们的普通 SELECT 走的都是不加锁的 MVCC 机制。两个事务(或更多)在并发执行的初期,都可以基于某个数据快照做出"我能执行"的判断。

这个"并发的乐观判断"是导致后续业务逻辑失效的根源。当它们真正去执行 UPDATE 时,锁机制虽然保证了物理操作的串行,但已经无法挽回那个基于过时数据做出的、可能错误的业务决策。

Released under the MIT License.