Skip to content

MySQL 并发读写与事务隔离

很多人一提到 MySQL 并发读写,第一反应是:会不会冲突。

这个理解不算错,但还是太粗。

MySQL 并发读写真正要处理的是两类问题:

  1. 多个事务同时操作同一批数据时,结果还能不能保持正确
  2. 在保证正确性的前提下,系统还能不能维持足够高的吞吐

也就是说,这个主题不只是“会不会锁住”,而是:如何在正确性和并发能力之间找到平衡。

这篇更适合作为 MySQL 并发控制的总览页。

它负责把:

  1. 并发问题是什么
  2. 隔离级别在隔离什么
  3. 锁和 MVCC 大致怎么分工
  4. 工程上最常见的坑在哪里

这一整条主线讲顺。

至于 Read Viewundo log、版本链、可见性规则这些更底层的 MVCC 细节,则放到独立专题里继续展开:

这篇文章按下面这条主线展开:

  1. 并发读写为什么会出问题
  2. 典型的数据异常有哪些
  3. MySQL 靠什么机制控制这些问题
  4. 事务隔离级别到底在隔离什么
  5. 锁和 MVCC 分别负责什么
  6. 工程上应该怎么落地

1. 并发读写到底在说什么

把概念说清楚。

这里的“并发读写”,指的是多个事务在时间上重叠地访问同一批数据,其中可能包括:

  1. 一个事务在读,另一个事务在写
  2. 两个事务都在写
  3. 一个事务先读后写,另一个事务也先读后写

例如下面这些场景都属于并发读写:

  1. 用户下单时扣减库存,同时别的请求在查询库存
  2. 两个请求同时给同一个账户扣款
  3. 一个事务在查询某个范围的数据,另一个事务在这个范围内插入新记录

如果数据库完全不做控制,就容易出现:

  1. 读到不该读的数据
  2. 覆盖掉别人的更新
  3. 同一事务里前后两次读出来的结果不一致

所以并发控制的核心目标可以压缩成一句话:让多个事务可以同时推进,但最终结果仍然符合业务预期。

2. 为什么并发读写会天然有冲突

冲突的根源不复杂:同一份数据在同一时刻只能有一个“最终状态”,但多个事务都可能想改变它,或者想从不同时间点观察它。

例如账户余额原本是 1000

  1. 事务 A 读取余额,准备扣 100
  2. 事务 B 也读取余额,准备扣 200
  3. A 计算后准备写回 900
  4. B 计算后准备写回 800

如果这两个事务彼此不知道对方的存在,就可能把其中一次扣款覆盖掉。

读操作也有类似问题。

如果事务 A 正在修改一条订单,但还没提交,事务 B 此时读到了这条“半成品”数据,那么 B 基于这份数据继续做业务判断,就可能把错误一路放大。

所以数据库要回答的其实是 3 个问题:

  1. 现在这次读,能不能看见别人的未提交修改
  2. 现在这次写,需不需要等别人先完成
  3. 同一事务里前后两次读取,要不要保证看到同一份结果

3. 不加控制时,典型会出现哪些异常

3.1 脏读

Dirty Read 可以理解成“读到了脏数据”。

这里的“脏”,不是数据格式脏,而是:读到了另一个事务还没提交的数据。

例如:

  1. 事务 A 把库存从 10 改成 0,但还没提交
  2. 事务 B 读到了 0
  3. 事务 A 最后回滚
  4. 实际库存又回到 10

这时事务 B 刚才读到的 0,就是一份本来不应该对外可见的数据。

脏读最麻烦的地方在于:你不是读到了旧数据,而是读到了一份可能根本不会成立的数据。

3.2 不可重复读

Non-Repeatable Read 可以理解成“同一事务里,同一条件下两次读取结果不一样”。

例如:

  1. 事务 A 第一次查询某个账户余额,读到 100
  2. 事务 B 把余额改成 80 并提交
  3. 事务 A 在同一事务里再次查询,读到 80

这就叫不可重复读。

它强调的是:同一行记录的值前后变了。

3.3 幻读

Phantom Read 可以理解成“同一事务里,同一范围查询前后多出或少了几行”。

例如:

  1. 事务 A 查询“金额大于 100 的订单”,一开始查到 10 条
  2. 事务 B 插入了一条金额为 200 的新订单并提交
  3. 事务 A 再查一次,结果变成 11 条

事务 A 会感觉像“凭空冒出了一条记录”,所以叫幻读。

它和不可重复读的区别是:

  1. 不可重复读更强调“同一行的值变了”
  2. 幻读更强调“满足条件的结果集变了”

3.4 更新丢失

更新丢失不是标准隔离级别定义里最常拿来背的那一个,但工程上非常重要。

它通常指:两个事务基于同一个旧值计算新值,后提交的那次把前一次更新结果覆盖掉了。

还是账户余额例子:

  1. 初始余额 1000
  2. 事务 A 读取 1000,准备扣 100
  3. 事务 B 读取 1000,准备扣 200
  4. A 写回 900
  5. B 写回 800

最终结果是 800,但正确结果应该是 700

要注意的是:更新丢失很多时候不是“数据库坏了”,而是应用把“读 -> 算 -> 写”拆成了不安全的流程。

4. MySQL 靠什么处理并发读写

MySQL,准确地说是 InnoDB,主要靠下面几层机制协同处理:

  1. 事务
  2. MVCC
  3. 隔离级别

这几个词经常被混着说,但职责并不一样。

4.1 事务

事务可以理解成 一组要么都成功、要么都失败的操作边界

它解决的第一层问题是:

  1. 操作能不能一起提交
  2. 出错后能不能一起回滚

但事务本身不等于并发控制全部。

实际写项目时,更多是这样:事务定义了操作边界,而并发控制决定多个事务重叠执行时该怎么看、怎么等、怎么互相隔离。

4.2 锁

锁的作用可以粗略理解成:谁先占住当前这份数据,别人就得按规则等待或者绕开。

锁更偏向处理:

  1. 写写冲突
  2. 当前读冲突
  3. 范围内插入或修改冲突

4.3 MVCC

MVCCMulti-Version Concurrency Control,即多版本并发控制。

它的核心思想不是“所有读都去抢最新数据”,而是:让普通读可以按自己的事务视角,去看一个对自己合法的数据版本。

它主要解决的是:

  1. 普通查询和写操作之间不要互相堵得太严重
  2. 在合适隔离级别下,让事务读到一致性视图

4.4 隔离级别

隔离级别规定的是:一个事务在执行过程中,允许看到其他事务哪一类变化。

它决定的是并发可见性边界,而不是“数据库快不快”这么简单。

5. 事务隔离级别到底在隔离什么

MySQL 常见隔离级别有 4 个:

  1. Read Uncommitted
  2. Read Committed
  3. Repeatable Read
  4. Serializable

看一张总表:

隔离级别脏读不可重复读幻读并发代价
Read Uncommitted可能可能可能最低
Read Committed避免可能可能较低
Repeatable Read避免避免需要结合锁进一步控制中等
Serializable避免避免避免最高

5.1 Read Uncommitted

Read Uncommitted 可以理解成“读未提交”。

顾名思义,它允许事务读到别的事务还没提交的数据,因此可能出现脏读。

这个级别在真实业务里通常很少作为默认选择,因为一致性代价太高。

5.2 Read Committed

Read Committed 可以理解成“读已提交”。

它至少保证:你不会读到别人的未提交数据。

但它不保证同一事务里前后两次普通查询一定一致,所以仍可能出现不可重复读。

很多数据库把它作为默认级别。

5.3 Repeatable Read

Repeatable Read 可以理解成“可重复读”。

InnoDB 默认就是这个级别。

它的核心目标是:同一事务里的快照读,前后尽量看到同一份一致性视图。

所以它可以避免不可重复读。

但要注意两个边界:

  1. 它不是说所有查询都完全不变
  2. 当前读场景下,仍然要靠锁配合控制范围并发

5.4 Serializable

Serializable 可以理解成“串行化”。

它追求的效果接近于:让并发事务看起来像一个一个串行执行。

一致性最强,但并发能力通常最差,等待和锁冲突也更明显。

除非业务对正确性要求极高且并发压力可控,否则不会轻易把它作为常规默认方案。

6. 为什么 InnoDB 默认选 RR,而不是一味上最强隔离

因为数据库不是只追求“越严越好”。

真正的目标是:在业务能接受的正确性边界内,把吞吐做上去。

Serializable 虽然更强,但阻塞更多。

Read Committed 虽然更灵活,但一致性视图没那么稳定。

Repeatable Read 配合 MVCC 与锁机制,在很多 OLTP 场景里是一个比较平衡的选择:

  1. 普通读不至于到处阻塞
  2. 一致性比 RC 更强
  3. 再结合当前读和间隙锁,可以继续控制一部分范围并发问题

7. 快照读和当前读,一定要分开看

这是理解并发控制最关键的一步。

你可以直接记住下面这组对应关系:

读类型更关注什么主要依赖什么
快照读当前事务此刻“应该看见哪个版本”MVCC
当前读当前最新版本能不能安全地读、改、锁

7.1 快照读

快照读通常就是普通 select

它的重点不是强行拿到最新版本,而是:基于事务视角,读到一个对自己合法的数据版本。

所以它更偏向“可见性判断”。

7.2 当前读

当前读更关注当前最新版本,常见于:

  1. select ... for update
  2. select ... lock in share mode
  3. update
  4. delete

它更偏向“并发修改时怎么保持有序”。

7.3 这一区分为什么重要

很多误解都来自把二者混成一种读。

例如:

  1. 为什么普通 select 往往不直接阻塞更新
  2. 为什么 select ... for update 的表现完全不同
  3. 为什么事务里的普通查询和加锁查询,感受到的并发行为不一样

本质上都是因为:快照读和当前读根本不是同一套处理路径。

如果想继续看快照读为什么能成立、可见性怎么判断,可以直接看独立专题:

8. 锁和 MVCC 分别负责什么

把这两者关系先压缩成一句话:MVCC 负责让很多普通读不必和写操作正面阻塞;锁负责让当前读和修改冲突保持有序。

把这层分工拉开看:

  1. MVCC 更偏向解决“读什么版本”
  2. 锁更偏向解决“谁先改、谁先等”
  3. 范围并发控制通常还要继续依赖锁

8.1 锁更像在处理什么问题

锁主要处理这些冲突:

  1. 写写冲突
  2. 当前读冲突
  3. 范围内插入、修改冲突

这里最值得先记住的一个边界是:行锁不是脱离索引独立存在的。

如果 SQL 没有走到合适索引,锁的实际影响范围就可能比你想象得更大。

8.2 MVCC 更像在处理什么问题

MVCC 主要处理的是:普通查询在并发写入下,怎样既少阻塞,又保持一致性视图。

它不是替代锁,而是把一部分原本会互相卡住的读写冲突,改成通过版本可见性判断来解决。

8.3 为什么两者必须一起看

很多人会误以为:

  1. 有了 MVCC 就不需要锁
  2. 或者只要加锁就不需要理解 MVCC

这两种理解都不完整。

把这件事说完整一点:

  1. 普通查询大量依赖 MVCC
  2. 更新、删除、加锁读取依赖锁
  3. 范围并发控制也离不开锁

如果想继续看 Read Viewundo log、版本链这些实现细节,可以直接看:

10. 并发读写里最常见的几个工程场景

10.1 余额扣减

最危险的写法通常是:

  1. 先查余额
  2. 应用里计算新余额
  3. 再把结果写回

因为这会把“读旧值”和“写新值”拆开,容易产生更新丢失。

更常见的做法是直接让数据库原子更新:

sql
update account
set balance = balance - 100
where id = 1
  and balance >= 100;

然后再检查受影响行数。

这样做的好处是:

  1. 条件判断和更新放在同一条语句里
  2. 能减少并发窗口
  3. 更容易避免超扣

10.2 库存扣减

库存问题和余额扣减很像。

核心原则仍然是:不要把“查库存够不够”和“真正扣库存”拆成两个彼此松散的动作。

更稳妥的思路通常包括:

  1. 单 SQL 条件更新
  2. 必要时配合事务
  3. 结合业务唯一约束避免重复扣减

10.3 列表查询和并发插入

如果事务 A 正在按条件分页查询,事务 B 在这个范围里不断插入新数据,就可能出现结果不稳定、翻页重复或遗漏。

这类问题不一定单靠某个隔离级别就能优雅解决,往往还要结合:

  1. 稳定排序条件
  2. 游标翻页
  3. 明确查询时间边界
  4. 必要时的加锁策略

10.4 先查询再更新

例如:

sql
select status from orders where id = 1;

应用判断状态可修改后,再执行:

sql
update orders set status = 'paid' where id = 1;

这种模式如果没有额外保护,两个事务可能都认为“自己可以改”。

工程上常见做法是:

  1. select ... for update 先锁住当前记录
  2. 或者通过乐观并发控制,在 where 条件里带上旧状态 / 版本号

11. 工程上真正容易踩的坑

11.1 误以为开了事务就自动安全

事务只说明“这些操作在一个提交边界里”,不等于自动避免所有并发问题。

如果事务里的 SQL 设计不对,仍然会有:

  1. 锁竞争严重
  2. 更新丢失
  3. 范围结果不稳定

11.2 误以为普通 select 天然能看到最新值

在 MVCC 场景下,普通 select 读到的是:对当前事务可见的版本。

它不一定等于别的事务刚刚提交后的“全局最新值”。

11.3 误以为 RR 已经把所有幻读都单独解决了

更准确的说法是:

  1. RR 下的快照读可以维持稳定视图
  2. 当前读和范围修改仍要依赖间隙锁、临键锁等机制配合

所以不要把它讲成:只要是 RR,就彻底没有幻读问题。

11.4 误以为行锁和业务主键概念完全等价

数据库到底锁多大范围,取决于:

  1. SQL 语句
  2. 索引命中情况
  3. 扫描范围
  4. 当前读还是快照读

不是简单看你心里认为“我只动了一行”。

11.5 长事务会把并发问题放大

长事务的影响往往不只是“占着连接不放”,还包括:

  1. 锁持有时间更长
  2. 历史版本回收更慢
  3. 回滚成本更高
  4. 冲突链条更长

所以工程上很重要的一条原则是:事务尽量短,小步快跑,不要把网络调用、复杂计算、长时间等待塞进事务里。

12. 一份更实用的落地建议

如果你在业务里真的要处理并发读写,下面这些习惯通常更重要:

  1. 先明确业务到底要求“强一致”还是“可接受短暂旧数据”
  2. 能用单 SQL 原子完成的操作,尽量不要拆成“查 -> 算 -> 写”
  3. 需要锁当前数据时,明确使用当前读,例如 for update
  4. 给高频更新语句配好索引,减少锁范围扩大
  5. 控制事务长度,避免长事务
  6. 对余额、库存、状态流转这类场景,检查受影响行数,不要只看 SQL 执行成功
  7. 不要把隔离级别当成万能开关,很多问题最终还是要回到具体 SQL 和业务约束

13. 总结

把 MySQL 并发读写压缩成最核心的几句话,就是:

  1. 并发读写的本质,是多个事务同时读写同一批数据时如何同时兼顾正确性和吞吐
  2. 不加控制时,常见问题包括脏读、不可重复读、幻读和更新丢失
  3. InnoDB 主要通过事务、锁、MVCC 和隔离级别协同处理这些问题
  4. 快照读主要依赖 MVCC,当前读和范围并发控制主要依赖锁
  5. 真正的工程关键,不只是选一个隔离级别,而是把 SQL 写法、索引设计、事务边界和业务约束一起设计好

如果你接下来想继续往下挖,最自然的阅读顺序通常是:

  1. 先读这篇,建立“并发读写问题全景”
  2. 再读 MVCC 详解,理解快照读为什么能工作
  3. 再结合具体业务场景,分析哪些地方需要当前读、哪些地方适合原子更新

基于 VitePress 构建的个人技术笔记。