原创

MySQL事务隔离级别与一致性

一、为什么“事务隔离级别”和“一致性”总是被一起讨论

在 MySQL 的工程实践里,“事务隔离级别”解决的是并发事务彼此如何看见对方的数据变化,而“一致性”解决的是数据从一个正确状态转换到另一个正确状态。这两个概念经常被混用,但它们并不是同一件事。

从数据库理论看,事务通常用 ACID 描述:

  • A:Atomicity,原子性
  • C:Consistency,一致性
  • I:Isolation,隔离性
  • D:Durability,持久性

很多人学习事务时,最容易把一致性和隔离性混成一个概念。实际上:

  • 一致性描述的是事务执行前后,数据库是否仍然满足业务约束、数据约束和逻辑约束。
  • 隔离性描述的是并发执行时,一个事务对另一个事务可见到什么程度。

例如转账场景,A 账户向 B 账户转 100 元。无论中间发生什么,只要事务最终提交后满足“总金额不变、A 减 100、B 加 100”,这叫一致性。至于另一个并发事务在转账过程中能不能看到 A 已减钱但 B 还没加钱,这属于隔离性问题。

在 MySQL 中,真正落实事务能力的核心一般不是 MySQL Server 本身,而是存储引擎。日常讨论的事务隔离级别,默认都建立在 InnoDB 引擎之上。MyISAM 不支持事务,因此本篇所有讨论均以 InnoDB 为前提。


二、先把“一致性”说清楚:它不是一句抽象口号

一致性不是“数据库永远正确”这么空泛的说法,它必须落到具体约束上。通常包含三层含义:

1. 结构一致性

数据库定义层面的约束必须成立,例如:

  • 主键不能重复
  • 非空字段不能为 NULL
  • 唯一索引不能出现重复值
  • 外键引用必须存在(如果使用外键)

这些约束由数据库本身强制保证。

2. 业务一致性

数据库不知道你的业务规则,但事务需要保证业务结果正确。例如:

  • 账户余额不能为负数
  • 库存不能扣成负数
  • 订单支付成功后,必须生成支付流水
  • 积分发放与订单状态变更必须同时成功或同时失败

这部分通常依赖事务、约束设计、应用逻辑共同保证。

3. 并发一致性

即使单条 SQL 没问题,只要存在并发更新,也可能出现逻辑错误。例如:

  • 两个线程同时扣库存,导致超卖
  • 两个事务同时更新同一行,后提交者覆盖前提交者
  • 范围查询和插入并发,导致业务判断失效

这时候就必须靠隔离级别、锁机制、版本控制来实现“并发条件下的一致性”。

所以从工程角度看,一致性并不是某个单独配置项,而是一个结果目标。隔离级别只是保证一致性的手段之一,不是全部。


三、MySQL 事务的基础:并发问题到底是什么

理解隔离级别之前,必须先明确数据库并发会出现哪些典型问题。标准数据库理论中,最常见的是三类:

1. 脏读(Dirty Read)

一个事务读到了另一个未提交事务修改的数据。

场景:

  • 事务 T1 把某条记录余额从 100 改成 50,但未提交
  • 事务 T2 读到了 50
  • 随后 T1 回滚
  • 实际有效值仍然是 100,但 T2 已经基于错误数据做了业务处理

脏读的问题本质是:读到了未来可能不存在的数据

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

同一个事务内,两次读取同一行记录,结果不同。

场景:

  • T1 第一次读取某用户余额为 100
  • T2 修改余额为 80 并提交
  • T1 再次读取该用户余额,发现变成 80

这会导致同一个事务内部,对同一条记录的观察不稳定。

3. 幻读(Phantom Read)

同一个事务内,两次按条件范围查询,返回的“记录集合”不同。

场景:

  • T1 查询 status = 1 的订单,得到 10 条
  • T2 插入一条新的 status = 1 订单并提交
  • T1 再查一次,变成 11 条

这里变化的不是原来某一行的值,而是符合条件的结果集里多了一行或少了一行,这就是幻读。


四、SQL 标准定义的四种事务隔离级别

SQL 标准定义了四种隔离级别,MySQL 也支持这四档,但具体实现方式与某些标准教材的描述并不完全一样。

1. READ UNCOMMITTED:读未提交

最低隔离级别。

特征:

  • 允许读取未提交数据
  • 可能发生脏读、不可重复读、幻读

这一级别几乎不用于严肃业务系统,因为脏读会直接破坏数据判断的可靠性。

2. READ COMMITTED:读已提交

只能读取其他事务已经提交的数据。

特征:

  • 避免脏读
  • 仍可能发生不可重复读
  • 仍可能发生幻读

这是很多数据库产品的默认隔离级别,例如 Oracle 常见语义接近 RC,但 MySQL 默认不是它。

3. REPEATABLE READ:可重复读

在同一个事务中,多次读取同一条记录,结果保持一致。

特征:

  • 避免脏读
  • 避免不可重复读
  • 按 SQL 标准理解,理论上仍可能幻读
  • 但 MySQL InnoDB 的实现比标准定义更强,通过 MVCC 与 Next-Key Lock 组合,在很多场景下也能避免幻读

这是 MySQL InnoDB 的默认隔离级别

4. SERIALIZABLE:串行化

最高隔离级别。

特征:

  • 强制事务串行执行效果
  • 避免脏读、不可重复读、幻读
  • 并发能力最差,锁竞争最重

它通过更强的锁策略换取最严格的一致性,适用于极少数对正确性要求高于吞吐的场景。


五、MySQL 中隔离级别的查看与设置

查看全局和会话级隔离级别:

-- MySQL 8.0 推荐写法
SELECT @@global.transaction_isolation;
SELECT @@session.transaction_isolation;

-- 兼容旧写法
SELECT @@global.tx_isolation;
SELECT @@session.tx_isolation;

设置当前会话隔离级别:

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

设置全局隔离级别:

SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

需要注意的是:

  • SESSION 级别只影响当前连接
  • GLOBAL 级别影响新连接,已有连接一般不受影响
  • 业务系统如果使用连接池,不要误以为改了全局变量就能立即覆盖所有请求线程

六、用于演示的一套完整表结构

讨论事务问题,最适合用账户表和订单表。下面给出完整建表 SQL,可直接用于实验。

1. 账户表:演示转账、并发更新、一致性

DROP TABLE IF EXISTS account;

CREATE TABLE account (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
    account_no VARCHAR(32) NOT NULL COMMENT '账户编号',
    user_name VARCHAR(64) NOT NULL COMMENT '用户名',
    balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-冻结',
    version INT NOT NULL DEFAULT 0 COMMENT '版本号,用于乐观锁',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_account_no (account_no),
    KEY idx_user_name (user_name),
    KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='账户表';

初始化数据:

INSERT INTO account (account_no, user_name, balance, status, version)
VALUES
('A10001', 'Alice', 1000.00, 1, 0),
('B10001', 'Bob',   1000.00, 1, 0);

2. 订单表:演示范围查询、幻读、索引锁

DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
    order_no VARCHAR(64) NOT NULL COMMENT '订单号',
    user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
    status TINYINT NOT NULL COMMENT '订单状态:1-待支付,2-已支付,3-已取消',
    amount DECIMAL(18,2) NOT NULL COMMENT '订单金额',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_order_no (order_no),
    KEY idx_user_status (user_id, status),
    KEY idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单表';

初始化数据:

INSERT INTO orders (order_no, user_id, status, amount)
VALUES
('O202603090001', 1001, 1, 88.00),
('O202603090002', 1001, 1, 66.00),
('O202603090003', 1002, 2, 99.00);

七、MySQL 如何实现事务一致性:不是只靠锁,而是 MVCC + Undo Log + 锁

很多初学者一看到隔离级别,就认为数据库全靠“加锁”实现事务。这种理解不完整。InnoDB 实现一致性读的核心机制是:

  • Redo Log
  • Undo Log
  • MVCC
  • 行锁 / 间隙锁 / 临键锁

1. Redo Log:保证持久性

Redo Log 是重做日志,用来保证提交后的修改不会因宕机丢失,主要对应 ACID 里的 D。

它解决的是“写入最终会落盘”的问题,不直接解决隔离性。

2. Undo Log:支持回滚,也为 MVCC 提供历史版本

Undo Log 用于记录数据修改前的旧版本。其作用有两个:

  • 事务回滚时恢复旧数据
  • 为一致性读提供历史版本链

可以理解为:每次更新不是简单覆盖,而是保留可追溯的旧版本信息。

3. MVCC:多版本并发控制

MVCC 是 InnoDB 在 RC、RR 隔离级别下实现高并发读的关键。

核心思想不是“读时加锁”,而是“读一个适合当前事务视角的数据版本”。

在 InnoDB 中,一行记录实际上会携带隐藏字段,例如:

  • trx_id:最后修改该行的事务 ID
  • roll_pointer:指向 Undo Log 中历史版本的指针

这样一条记录就形成了版本链。查询时,事务根据自己的 Read View 决定应该看到哪个版本。

4. 锁:用于当前读和写冲突控制

MVCC 主要解决的是一致性读,但对更新、删除、加锁查询,仍然需要锁。

常见情况:

  • UPDATE ...:会加排他锁
  • DELETE ...:会加排他锁
  • SELECT ... FOR UPDATE:当前读,加排他锁
  • SELECT ... LOCK IN SHARE MODE / FOR SHARE:共享锁读

所以 MySQL 事务不是“MVCC 或 锁”二选一,而是二者协同。


八、Read View 到底是什么,它为什么决定你看到什么数据

在 MySQL InnoDB 中,所谓“一致性读”依赖 Read View。这是理解 RC 和 RR 差异的关键。

Read View 可以理解成事务在某个时刻拍下的一张“活跃事务快照”,它记录了:

  • 当前系统中哪些事务仍未提交
  • 哪些事务 ID 已经提交
  • 当前事务自己的事务 ID

读取某条记录版本时,会比较该版本的 trx_id 与 Read View 的关系,判断这个版本对当前事务是否可见。

RC 与 RR 的差异,本质上就是 Read View 生成时机不同

READ COMMITTED

  • 每次执行普通 SELECT 时,都会创建新的 Read View
  • 因此同一事务内两次查询,可能看到不同结果

REPEATABLE READ

  • 第一次执行一致性读时创建 Read View
  • 整个事务期间重复使用这个 Read View
  • 因此同一事务内多次普通查询结果保持一致

这就是为什么 RR 能避免不可重复读,而 RC 不能。


九、四种隔离级别在 MySQL 中的实际表现

下面结合 MySQL InnoDB 的实现逐个分析。

九点一 READ UNCOMMITTED:几乎没有业务价值

在这个级别下,事务可以看到别人尚未提交的修改。

示意:

事务 T1:

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE account_no = 'A10001';
-- 此时未提交

事务 T2:

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE account_no = 'A10001';

T2 有可能读到已经扣减后的余额,即使 T1 最后回滚。

在生产环境里,这种级别一般只会在极端强调吞吐且允许脏数据的统计型场景被讨论,但严肃业务系统基本不应采用。


九点二 READ COMMITTED:很多系统喜欢它,因为锁冲突更少

RC 的语义是:只能看到已经提交的数据

优点:

  • 不会脏读
  • 读写冲突相对较轻
  • 在高并发 OLTP 场景下常被采用

缺点:

  • 同一事务内重复查询可能得到不同结果
  • 范围查询结果也可能变化

示例:

事务 T1:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE account_no = 'A10001';
-- 假设结果是 1000.00

事务 T2:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE account SET balance = 800.00 WHERE account_no = 'A10001';
COMMIT;

T1 再次执行:

SELECT balance FROM account WHERE account_no = 'A10001';

第二次结果会变成 800.00,这就是不可重复读。

RC 的一个工程特点

RC 下每次一致性读都重新生成 Read View,因此更容易看到其他事务最新提交的变化。这对于某些要求“尽快看见最新提交结果”的业务是有意义的,比如:

  • 审核后台
  • 报表近实时查询
  • 管理端运营查询

但同时也要接受事务内部读取不稳定的事实。


九点三 REPEATABLE READ:MySQL 默认级别,也是最容易被误解的级别

RR 是 InnoDB 默认隔离级别。它最核心的承诺是:

  • 同一事务中,对同一行的普通读取结果一致

示例:

事务 T1:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE account_no = 'A10001';
-- 结果 1000.00

事务 T2:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE account SET balance = 700.00 WHERE account_no = 'A10001';
COMMIT;

事务 T1 再查:

SELECT balance FROM account WHERE account_no = 'A10001';

如果是普通 SELECT,T1 仍然会看到第一次快照中的 1000.00

这正是 RR 的价值:事务内部视图稳定。

为什么很多人说 RR 还能防幻读

这句话只说一半是危险的。必须拆开讲:

1. 对“普通快照读”而言

在 RR 下,普通 SELECT 使用固定 Read View,因此两次范围查询看到的结果集可能保持一致。表面上像是“没有幻读”。

2. 对“当前读”而言

例如:

  • SELECT ... FOR UPDATE
  • UPDATE ...
  • DELETE ...

这些并不是快照读,而是当前读。当前读会读取最新已提交版本,并配合锁来控制并发。

InnoDB 为避免当前读场景的幻读,使用了:

  • Record Lock(记录锁)
  • Gap Lock(间隙锁)
  • Next-Key Lock(临键锁)

因此,准确说法应该是:

MySQL InnoDB 在 REPEATABLE READ 下,普通一致性读依赖 MVCC 保证可重复读;对当前读和范围修改,则通过 Next-Key Lock 等锁机制大幅抑制幻读问题。

这比简单说“RR 彻底解决幻读”更准确。


九点四 SERIALIZABLE:最严格,也最贵

在 SERIALIZABLE 下,普通 SELECT 都可能被转换为加锁读,数据库倾向于让事务表现得像串行执行。

优点:

  • 理论上隔离效果最强
  • 数据正确性边界最清晰

缺点:

  • 并发能力差
  • 锁等待、死锁概率显著上升
  • 吞吐量下降明显

这一级别不适合大多数互联网业务系统。只有在极少数:

  • 核心账务清算
  • 极强一致要求
  • 并发量可控

的场景中才可能考虑。


十、MySQL 中“快照读”和“当前读”一定要分清

很多关于幻读的争论,本质都是因为没有区分快照读和当前读。

1. 快照读(Snapshot Read)

普通 SELECT 属于快照读,例如:

SELECT * FROM orders WHERE user_id = 1001 AND status = 1;

特点:

  • 不加锁(一般情况下)
  • 通过 MVCC 读取合适版本
  • RC、RR 下都广泛使用

2. 当前读(Current Read)

读取最新版本,并对读取对象加锁或准备修改,例如:

SELECT * FROM orders WHERE user_id = 1001 AND status = 1 FOR UPDATE;

SELECT * FROM orders WHERE user_id = 1001 AND status = 1 LOCK IN SHARE MODE;

UPDATE orders SET status = 2 WHERE order_no = 'O202603090001';

DELETE FROM orders WHERE order_no = 'O202603090002';

特点:

  • 读取的是最新已提交版本
  • 需要锁控制并发
  • 可能触发行锁、间隙锁、临键锁

为什么这个区分极其重要

因为你在事务里执行:

SELECT ...

和执行:

SELECT ... FOR UPDATE

虽然表面只差几个字,但并发语义完全不同。


十一、InnoDB 锁机制:理解一致性的另一个半边

十一点一 记录锁(Record Lock)

锁住索引中的某条具体记录。

例如按主键命中一行:

SELECT * FROM account WHERE id = 1 FOR UPDATE;

如果命中的是唯一索引上的单条记录,通常会加记录锁。

十一点二 间隙锁(Gap Lock)

锁住一个索引区间中的“间隙”,但不锁已有记录本身。

作用是防止其他事务在该间隙插入新记录,从而避免范围条件下的幻读。

十一点三 临键锁(Next-Key Lock)

临键锁 = 记录锁 + 间隙锁。

它锁住“某条索引记录及其前面的间隙”,是 InnoDB 在 RR 下处理范围条件最典型的锁形式。

举例说明

假设订单表上有 (user_id, status) 索引,你执行:

START TRANSACTION;
SELECT * FROM orders 
WHERE user_id = 1001 AND status = 1
FOR UPDATE;

如果这是范围锁定场景,InnoDB 可能不仅锁住已存在记录,还会锁住相邻间隙,阻止别的事务插入新的 (1001, 1) 记录,从而避免你后续范围更新或判断失效。

为什么“有没有索引”会影响一致性与性能

因为 InnoDB 的行锁本质上是基于索引项加锁。如果查询条件没有合适索引:

  • 锁范围可能扩大
  • 扫描更多记录
  • 甚至导致大量行被锁
  • 一致性虽然可能还在,但性能会严重下降

所以事务正确性设计,不只是 SQL 语法问题,更是索引设计问题。


十二、一个最常见的误区:MySQL 的 RR 是否彻底没有幻读

这个问题不能简单回答“是”或“否”。

更准确的结论

  • 对普通快照读来说,RR 由于固定 Read View,事务内结果集通常稳定,看起来像避免了幻读。
  • 对当前读和写操作来说,InnoDB 依赖 Next-Key Lock 等机制抑制幻读。
  • 从理论层面,幻读讨论的是“同一条件下结果集变化”;而在 MySQL 实践里,不同读类型语义不同,所以必须分情况说。

一个容易让人误判的例子

事务 T1:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM orders WHERE user_id = 1001 AND status = 1;

事务 T2:

START TRANSACTION;
INSERT INTO orders (order_no, user_id, status, amount)
VALUES ('O202603090100', 1001, 1, 128.00);
COMMIT;

事务 T1 再执行普通查询:

SELECT * FROM orders WHERE user_id = 1001 AND status = 1;

由于 T1 走的是同一 Read View,它可能仍然看不到新插入数据。很多人因此说“RR 没有幻读”。

但如果 T1 执行的是当前读,比如:

SELECT * FROM orders WHERE user_id = 1001 AND status = 1 FOR UPDATE;

那么讨论点就变成当前读的锁控制逻辑,而不是单纯的 MVCC 快照一致性。

因此,在 MySQL 里谈幻读,不能脱离具体 SQL 类型。


十三、事务一致性在业务里的真实挑战:不是级别越高越安全,而是要匹配业务模型

隔离级别不是越高越好,而是越合适越好。

1. 余额扣减场景

错误写法:

SELECT balance FROM account WHERE account_no = 'A10001';
-- 应用层判断余额足够
UPDATE account SET balance = balance - 100 WHERE account_no = 'A10001';

这会产生典型的并发问题:两个事务都读到余额足够,然后同时扣减,可能导致余额异常。

更稳妥的写法有两种。

方案一:当前读 + 行锁

START TRANSACTION;

SELECT balance
FROM account
WHERE account_no = 'A10001'
FOR UPDATE;

-- 应用层判断余额是否足够

UPDATE account
SET balance = balance - 100
WHERE account_no = 'A10001';

COMMIT;

这种方式通过 FOR UPDATE 锁住该账户记录,防止并发修改。

方案二:单条原子更新

START TRANSACTION;

UPDATE account
SET balance = balance - 100
WHERE account_no = 'A10001'
  AND balance >= 100;

COMMIT;

然后检查受影响行数:

  • 1:扣减成功
  • 0:余额不足或记录不存在

这种方式往往比先查后改更好,因为它把“校验”和“更新”合并成原子动作,更不容易出错。

2. 库存扣减场景

库存更新尤其适合用单 SQL 原子扣减:

UPDATE product
SET stock = stock - 1
WHERE id = 1001
  AND stock > 0;

这比先查库存再更新更安全。

3. 唯一业务约束场景

例如“同一用户每天只能签到一次”。不要依赖先查再插入,而要依赖数据库唯一约束,例如:

  • 唯一索引 (user_id, sign_date)
  • 插入失败则说明已签到

这也是一致性的核心思想:能交给数据库约束的,不要只靠应用层判断。


十四、MySQL 5.7 与 8.0 在事务相关使用上的几个注意点

事务底层原理在 5.7 与 8.0 的主干逻辑上保持一致,尤其是 InnoDB 的 MVCC、Undo Log、锁模型等核心思想没有根本变化。但在实际使用上,仍有一些值得明确标注的差异。

1. 隔离级别变量名差异

MySQL 5.7

常见写法:

SELECT @@tx_isolation;

MySQL 8.0

推荐使用:

SELECT @@transaction_isolation;

说明:

  • tx_isolation 属于旧名称
  • transaction_isolation 是新名称,更推荐使用

2. 加锁读语法差异

MySQL 5.7 常见写法

SELECT ... LOCK IN SHARE MODE;
SELECT ... FOR UPDATE;

MySQL 8.0 更推荐写法

SELECT ... FOR SHARE;
SELECT ... FOR UPDATE;

说明:

  • FOR SHARE 是更现代、更清晰的共享锁读语法
  • LOCK IN SHARE MODE 在很多项目里仍可见,但新项目建议优先使用 FOR SHARE

3. 字符集与排序规则默认值差异

MySQL 5.7

很多项目仍使用:

  • utf8mb4
  • utf8mb4_general_ci

MySQL 8.0

更常见默认排序规则是:

  • utf8mb4_0900_ai_ci

这虽然不直接影响事务原理,但会影响建表 SQL 的默认风格,生产环境应按版本和系统规范统一。

4. 元数据管理和内部实现优化

MySQL 8.0 在数据字典、DDL 能力、性能诊断、执行器细节等方面有不少增强,但这些改进并没有改变“RC/RR 的基本事务语义”。因此学习事务原理时,不必把版本差异夸大为完全不同的机制。


十五、为什么 InnoDB 默认选择 REPEATABLE READ,而不是 READ COMMITTED

这个选择背后体现的是 MySQL 对通用 OLTP 场景的平衡。

1. RR 能提供更稳定的事务视图

对于一个事务来说,看到的数据前后稳定,逻辑更容易推导。例如:

  • 业务规则校验
  • 多步骤处理
  • 事务内多次读取同一对象

这种稳定性非常有价值。

2. 配合 MVCC,RR 不一定意味着性能很差

很多人误以为 RR 一定比 RC 慢很多。实际上在 InnoDB 里:

  • 普通一致性读主要依靠 MVCC
  • 并不是所有读都加重锁
  • 所以 RR 在大量场景下并没有想象中那么昂贵

3. MySQL 更重视默认场景下的结果稳定性

对很多业务开发者而言,RR 让同事务内读取稳定,能减少一些难以复现的并发问题。

当然,这不表示 RR 一定优于 RC。某些系统会主动切换到 RC,以降低锁冲突和间隙锁带来的影响,尤其是在:

  • 并发写很多
  • 范围更新多
  • 更强调吞吐和响应时间
  • 业务接受“事务中看到最新提交结果”

的场景中。


十六、死锁不是事务失败,而是数据库在保护一致性

一提到锁,很多人最怕死锁。实际上,死锁并不代表数据库做错了,而是数据库在复杂并发下主动选择“牺牲一个事务,保全系统正确性”。

典型死锁场景:

  • T1 锁住记录 A,等待记录 B
  • T2 锁住记录 B,等待记录 A

如果数据库不主动干预,两个事务会永远等待。InnoDB 会检测死锁,并回滚其中一个事务。

如何降低死锁概率

1. 保持访问顺序一致

例如多个事务都按相同顺序锁资源:先锁账户 A,再锁账户 B。

2. 使用命中索引的精确条件

避免范围过大、扫描过多记录造成锁范围扩大。

3. 事务尽量短

不要在事务里做:

  • 长时间业务计算
  • 网络调用
  • 远程接口请求
  • 大量无关逻辑

事务越长,持锁时间越长,冲突概率越高。

4. 对死锁做应用层重试

死锁是正常并发现象,不是灾难。核心业务应具备:

  • 捕获死锁异常
  • 短暂退避
  • 幂等重试

能力。


十七、如何从工程上真正保证“一致性”

只靠设置隔离级别,远远不够。真正的一致性设计通常需要多层配合。

1. 用事务包裹必须原子完成的操作

例如转账:

START TRANSACTION;

UPDATE account
SET balance = balance - 100
WHERE account_no = 'A10001'
  AND balance >= 100;

UPDATE account
SET balance = balance + 100
WHERE account_no = 'B10001';

COMMIT;

如果任何一步失败,应回滚,避免出现只扣不加、只加不扣。

2. 用约束表达天然规则

例如:

  • 唯一索引防止重复单号
  • 非空约束防止关键字段缺失
  • 合理字段类型防止非法值

3. 用索引缩小锁范围

没有合适索引,事务可能“逻辑正确但性能崩溃”。

4. 用原子 SQL 替代“先查后改”

这是高并发系统中最重要的实践之一。

5. 必要时引入乐观锁

例如账户表中的 version 字段可以这样使用:

UPDATE account
SET balance = balance - 100,
    version = version + 1
WHERE account_no = 'A10001'
  AND version = 3
  AND balance >= 100;

如果受影响行数为 0,说明并发冲突,需要重新读取后重试。

乐观锁适合:

  • 冲突概率不高
  • 不希望频繁持有数据库悲观锁
  • 允许失败后重试

的场景。

6. 分布式系统里,不要幻想单库事务解决所有一致性问题

MySQL 单机事务解决的是单个数据库实例内的一致性。进入分布式系统后,还要面对:

  • 本地事务与消息一致性
  • 分库分表后跨库事务
  • 缓存与数据库一致性
  • 搜索索引与数据库一致性
  • 下游服务调用失败补偿

这时候就需要进一步使用:

  • 可靠消息最终一致性
  • Outbox 模式
  • Saga / TCC
  • 幂等控制
  • 补偿机制

所以,MySQL 隔离级别是基础,但不是分布式一致性的全部答案。


十八、面试和工作中最常见的几个错误说法

1. “RR 彻底解决幻读”

不严谨。应明确区分快照读和当前读,并说明 InnoDB 通过 MVCC 与 Next-Key Lock 协同处理。

2. “事务隔离级别越高越好”

错误。隔离级别越高,通常并发越差,锁冲突越重。应根据业务需求权衡。

3. “只要用了事务,就一定一致”

错误。事务只能保证你写进事务范围内的操作具备原子性、隔离性等特征,但:

  • 事务边界定义错了
  • 业务规则没放进原子操作
  • 缺少约束
  • 并发条件判断写错

一样会出错。

4. “普通 SELECT 一定会加锁”

错误。InnoDB 在 RC、RR 下的普通 SELECT 一般是快照读,不主动加锁。

5. “行锁就是锁住表中的一行”

表述不准确。InnoDB 的行锁本质上是锁索引记录。没有合适索引时,锁行为与预期可能不一致。


十九、实际项目中如何选择隔离级别

1. 大多数业务系统

优先考虑:

  • REPEATABLE READ
  • 或根据团队经验选择 READ COMMITTED

如果业务大量使用 MySQL 默认能力,且希望事务内读取稳定,RR 是较稳妥选择。

2. 高并发、强调吞吐、范围锁影响明显的系统

可以考虑:

  • READ COMMITTED

但前提是开发团队清楚接受:

  • 同事务内查询结果可能变化
  • 业务逻辑必须避免依赖事务内重复读取的一致性假设

3. 金融清算、强一致极高要求且并发可控的少数场景

可以评估:

  • SERIALIZABLE

但一定要充分压测锁等待、吞吐下降和死锁风险。

4. 不建议的选择

  • READ UNCOMMITTED:除非非常特殊的容忍脏读场景,否则不建议使用

二十、一个更接近真实系统的结论

MySQL 的事务隔离级别并不是背诵四个英文单词那么简单。真正要掌握它,必须同时理解以下几个层面:

  • SQL 标准里定义了哪几类并发异常
  • InnoDB 用什么机制实现隔离
  • MVCC 解决了什么,锁又解决了什么
  • 快照读和当前读的区别是什么
  • RR 在 MySQL 中为什么会表现得比教材里的定义更复杂
  • 一致性不仅取决于隔离级别,还取决于事务边界、原子 SQL、索引设计、唯一约束、应用重试与幂等策略

从系统设计角度看,最重要的不是“背出定义”,而是建立一个清晰的判断:

  1. 你的业务一致性约束到底是什么
  2. 哪些约束应由数据库保证
  3. 哪些约束应由事务原子化保证
  4. 哪些约束需要应用层幂等、重试、补偿来保证
  5. 当前隔离级别是否与并发模型匹配

只有把这些问题想清楚,事务隔离级别才不再是一个抽象概念,而会真正变成系统可靠性的基础设施。

当你理解了这一点,就会明白:MySQL 的事务隔离级别讨论的从来不只是“读到了什么”,而是在并发世界里,如何以可控成本得到正确的数据结果。这才是“一致性”的工程本质。

正文到此结束
评论插件初始化中...
Loading...