Appearance
MySQL 并发读写与事务隔离
很多人一提到 MySQL 并发读写,第一反应是:会不会冲突。
这个理解不算错,但还是太粗。
MySQL 并发读写真正要处理的是两类问题:
- 多个事务同时操作同一批数据时,结果还能不能保持正确
- 在保证正确性的前提下,系统还能不能维持足够高的吞吐
也就是说,这个主题不只是“会不会锁住”,而是:如何在正确性和并发能力之间找到平衡。
这篇更适合作为 MySQL 并发控制的总览页。
它负责把:
- 并发问题是什么
- 隔离级别在隔离什么
- 锁和 MVCC 大致怎么分工
- 工程上最常见的坑在哪里
这一整条主线讲顺。
至于 Read View、undo log、版本链、可见性规则这些更底层的 MVCC 细节,则放到独立专题里继续展开:
这篇文章按下面这条主线展开:
- 并发读写为什么会出问题
- 典型的数据异常有哪些
- MySQL 靠什么机制控制这些问题
- 事务隔离级别到底在隔离什么
- 锁和 MVCC 分别负责什么
- 工程上应该怎么落地
1. 并发读写到底在说什么
把概念说清楚。
这里的“并发读写”,指的是多个事务在时间上重叠地访问同一批数据,其中可能包括:
- 一个事务在读,另一个事务在写
- 两个事务都在写
- 一个事务先读后写,另一个事务也先读后写
例如下面这些场景都属于并发读写:
- 用户下单时扣减库存,同时别的请求在查询库存
- 两个请求同时给同一个账户扣款
- 一个事务在查询某个范围的数据,另一个事务在这个范围内插入新记录
如果数据库完全不做控制,就容易出现:
- 读到不该读的数据
- 覆盖掉别人的更新
- 同一事务里前后两次读出来的结果不一致
所以并发控制的核心目标可以压缩成一句话:让多个事务可以同时推进,但最终结果仍然符合业务预期。
2. 为什么并发读写会天然有冲突
冲突的根源不复杂:同一份数据在同一时刻只能有一个“最终状态”,但多个事务都可能想改变它,或者想从不同时间点观察它。
例如账户余额原本是 1000:
- 事务 A 读取余额,准备扣
100 - 事务 B 也读取余额,准备扣
200 - A 计算后准备写回
900 - B 计算后准备写回
800
如果这两个事务彼此不知道对方的存在,就可能把其中一次扣款覆盖掉。
读操作也有类似问题。
如果事务 A 正在修改一条订单,但还没提交,事务 B 此时读到了这条“半成品”数据,那么 B 基于这份数据继续做业务判断,就可能把错误一路放大。
所以数据库要回答的其实是 3 个问题:
- 现在这次读,能不能看见别人的未提交修改
- 现在这次写,需不需要等别人先完成
- 同一事务里前后两次读取,要不要保证看到同一份结果
3. 不加控制时,典型会出现哪些异常
3.1 脏读
Dirty Read 可以理解成“读到了脏数据”。
这里的“脏”,不是数据格式脏,而是:读到了另一个事务还没提交的数据。
例如:
- 事务 A 把库存从
10改成0,但还没提交 - 事务 B 读到了
0 - 事务 A 最后回滚
- 实际库存又回到
10
这时事务 B 刚才读到的 0,就是一份本来不应该对外可见的数据。
脏读最麻烦的地方在于:你不是读到了旧数据,而是读到了一份可能根本不会成立的数据。
3.2 不可重复读
Non-Repeatable Read 可以理解成“同一事务里,同一条件下两次读取结果不一样”。
例如:
- 事务 A 第一次查询某个账户余额,读到
100 - 事务 B 把余额改成
80并提交 - 事务 A 在同一事务里再次查询,读到
80
这就叫不可重复读。
它强调的是:同一行记录的值前后变了。
3.3 幻读
Phantom Read 可以理解成“同一事务里,同一范围查询前后多出或少了几行”。
例如:
- 事务 A 查询“金额大于 100 的订单”,一开始查到 10 条
- 事务 B 插入了一条金额为 200 的新订单并提交
- 事务 A 再查一次,结果变成 11 条
事务 A 会感觉像“凭空冒出了一条记录”,所以叫幻读。
它和不可重复读的区别是:
- 不可重复读更强调“同一行的值变了”
- 幻读更强调“满足条件的结果集变了”
3.4 更新丢失
更新丢失不是标准隔离级别定义里最常拿来背的那一个,但工程上非常重要。
它通常指:两个事务基于同一个旧值计算新值,后提交的那次把前一次更新结果覆盖掉了。
还是账户余额例子:
- 初始余额
1000 - 事务 A 读取
1000,准备扣100 - 事务 B 读取
1000,准备扣200 - A 写回
900 - B 写回
800
最终结果是 800,但正确结果应该是 700。
要注意的是:更新丢失很多时候不是“数据库坏了”,而是应用把“读 -> 算 -> 写”拆成了不安全的流程。
4. MySQL 靠什么处理并发读写
MySQL,准确地说是 InnoDB,主要靠下面几层机制协同处理:
- 事务
- 锁
- MVCC
- 隔离级别
这几个词经常被混着说,但职责并不一样。
4.1 事务
事务可以理解成 一组要么都成功、要么都失败的操作边界。
它解决的第一层问题是:
- 操作能不能一起提交
- 出错后能不能一起回滚
但事务本身不等于并发控制全部。
实际写项目时,更多是这样:事务定义了操作边界,而并发控制决定多个事务重叠执行时该怎么看、怎么等、怎么互相隔离。
4.2 锁
锁的作用可以粗略理解成:谁先占住当前这份数据,别人就得按规则等待或者绕开。
锁更偏向处理:
- 写写冲突
- 当前读冲突
- 范围内插入或修改冲突
4.3 MVCC
MVCC 是 Multi-Version Concurrency Control,即多版本并发控制。
它的核心思想不是“所有读都去抢最新数据”,而是:让普通读可以按自己的事务视角,去看一个对自己合法的数据版本。
它主要解决的是:
- 普通查询和写操作之间不要互相堵得太严重
- 在合适隔离级别下,让事务读到一致性视图
4.4 隔离级别
隔离级别规定的是:一个事务在执行过程中,允许看到其他事务哪一类变化。
它决定的是并发可见性边界,而不是“数据库快不快”这么简单。
5. 事务隔离级别到底在隔离什么
MySQL 常见隔离级别有 4 个:
Read UncommittedRead CommittedRepeatable ReadSerializable
看一张总表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发代价 |
|---|---|---|---|---|
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 默认就是这个级别。
它的核心目标是:同一事务里的快照读,前后尽量看到同一份一致性视图。
所以它可以避免不可重复读。
但要注意两个边界:
- 它不是说所有查询都完全不变
- 当前读场景下,仍然要靠锁配合控制范围并发
5.4 Serializable
Serializable 可以理解成“串行化”。
它追求的效果接近于:让并发事务看起来像一个一个串行执行。
一致性最强,但并发能力通常最差,等待和锁冲突也更明显。
除非业务对正确性要求极高且并发压力可控,否则不会轻易把它作为常规默认方案。
6. 为什么 InnoDB 默认选 RR,而不是一味上最强隔离
因为数据库不是只追求“越严越好”。
真正的目标是:在业务能接受的正确性边界内,把吞吐做上去。
Serializable 虽然更强,但阻塞更多。
Read Committed 虽然更灵活,但一致性视图没那么稳定。
Repeatable Read 配合 MVCC 与锁机制,在很多 OLTP 场景里是一个比较平衡的选择:
- 普通读不至于到处阻塞
- 一致性比
RC更强 - 再结合当前读和间隙锁,可以继续控制一部分范围并发问题
7. 快照读和当前读,一定要分开看
这是理解并发控制最关键的一步。
你可以直接记住下面这组对应关系:
| 读类型 | 更关注什么 | 主要依赖什么 |
|---|---|---|
| 快照读 | 当前事务此刻“应该看见哪个版本” | MVCC |
| 当前读 | 当前最新版本能不能安全地读、改、锁 | 锁 |
7.1 快照读
快照读通常就是普通 select。
它的重点不是强行拿到最新版本,而是:基于事务视角,读到一个对自己合法的数据版本。
所以它更偏向“可见性判断”。
7.2 当前读
当前读更关注当前最新版本,常见于:
select ... for updateselect ... lock in share modeupdatedelete
它更偏向“并发修改时怎么保持有序”。
7.3 这一区分为什么重要
很多误解都来自把二者混成一种读。
例如:
- 为什么普通
select往往不直接阻塞更新 - 为什么
select ... for update的表现完全不同 - 为什么事务里的普通查询和加锁查询,感受到的并发行为不一样
本质上都是因为:快照读和当前读根本不是同一套处理路径。
如果想继续看快照读为什么能成立、可见性怎么判断,可以直接看独立专题:
8. 锁和 MVCC 分别负责什么
把这两者关系先压缩成一句话:MVCC 负责让很多普通读不必和写操作正面阻塞;锁负责让当前读和修改冲突保持有序。
把这层分工拉开看:
MVCC更偏向解决“读什么版本”- 锁更偏向解决“谁先改、谁先等”
- 范围并发控制通常还要继续依赖锁
8.1 锁更像在处理什么问题
锁主要处理这些冲突:
- 写写冲突
- 当前读冲突
- 范围内插入、修改冲突
这里最值得先记住的一个边界是:行锁不是脱离索引独立存在的。
如果 SQL 没有走到合适索引,锁的实际影响范围就可能比你想象得更大。
8.2 MVCC 更像在处理什么问题
MVCC 主要处理的是:普通查询在并发写入下,怎样既少阻塞,又保持一致性视图。
它不是替代锁,而是把一部分原本会互相卡住的读写冲突,改成通过版本可见性判断来解决。
8.3 为什么两者必须一起看
很多人会误以为:
- 有了 MVCC 就不需要锁
- 或者只要加锁就不需要理解 MVCC
这两种理解都不完整。
把这件事说完整一点:
- 普通查询大量依赖 MVCC
- 更新、删除、加锁读取依赖锁
- 范围并发控制也离不开锁
如果想继续看 Read View、undo log、版本链这些实现细节,可以直接看:
10. 并发读写里最常见的几个工程场景
10.1 余额扣减
最危险的写法通常是:
- 先查余额
- 应用里计算新余额
- 再把结果写回
因为这会把“读旧值”和“写新值”拆开,容易产生更新丢失。
更常见的做法是直接让数据库原子更新:
sql
update account
set balance = balance - 100
where id = 1
and balance >= 100;然后再检查受影响行数。
这样做的好处是:
- 条件判断和更新放在同一条语句里
- 能减少并发窗口
- 更容易避免超扣
10.2 库存扣减
库存问题和余额扣减很像。
核心原则仍然是:不要把“查库存够不够”和“真正扣库存”拆成两个彼此松散的动作。
更稳妥的思路通常包括:
- 单 SQL 条件更新
- 必要时配合事务
- 结合业务唯一约束避免重复扣减
10.3 列表查询和并发插入
如果事务 A 正在按条件分页查询,事务 B 在这个范围里不断插入新数据,就可能出现结果不稳定、翻页重复或遗漏。
这类问题不一定单靠某个隔离级别就能优雅解决,往往还要结合:
- 稳定排序条件
- 游标翻页
- 明确查询时间边界
- 必要时的加锁策略
10.4 先查询再更新
例如:
sql
select status from orders where id = 1;应用判断状态可修改后,再执行:
sql
update orders set status = 'paid' where id = 1;这种模式如果没有额外保护,两个事务可能都认为“自己可以改”。
工程上常见做法是:
- 用
select ... for update先锁住当前记录 - 或者通过乐观并发控制,在
where条件里带上旧状态 / 版本号
11. 工程上真正容易踩的坑
11.1 误以为开了事务就自动安全
事务只说明“这些操作在一个提交边界里”,不等于自动避免所有并发问题。
如果事务里的 SQL 设计不对,仍然会有:
- 锁竞争严重
- 更新丢失
- 范围结果不稳定
11.2 误以为普通 select 天然能看到最新值
在 MVCC 场景下,普通 select 读到的是:对当前事务可见的版本。
它不一定等于别的事务刚刚提交后的“全局最新值”。
11.3 误以为 RR 已经把所有幻读都单独解决了
更准确的说法是:
RR下的快照读可以维持稳定视图- 当前读和范围修改仍要依赖间隙锁、临键锁等机制配合
所以不要把它讲成:只要是 RR,就彻底没有幻读问题。
11.4 误以为行锁和业务主键概念完全等价
数据库到底锁多大范围,取决于:
- SQL 语句
- 索引命中情况
- 扫描范围
- 当前读还是快照读
不是简单看你心里认为“我只动了一行”。
11.5 长事务会把并发问题放大
长事务的影响往往不只是“占着连接不放”,还包括:
- 锁持有时间更长
- 历史版本回收更慢
- 回滚成本更高
- 冲突链条更长
所以工程上很重要的一条原则是:事务尽量短,小步快跑,不要把网络调用、复杂计算、长时间等待塞进事务里。
12. 一份更实用的落地建议
如果你在业务里真的要处理并发读写,下面这些习惯通常更重要:
- 先明确业务到底要求“强一致”还是“可接受短暂旧数据”
- 能用单 SQL 原子完成的操作,尽量不要拆成“查 -> 算 -> 写”
- 需要锁当前数据时,明确使用当前读,例如
for update - 给高频更新语句配好索引,减少锁范围扩大
- 控制事务长度,避免长事务
- 对余额、库存、状态流转这类场景,检查受影响行数,不要只看 SQL 执行成功
- 不要把隔离级别当成万能开关,很多问题最终还是要回到具体 SQL 和业务约束
13. 总结
把 MySQL 并发读写压缩成最核心的几句话,就是:
- 并发读写的本质,是多个事务同时读写同一批数据时如何同时兼顾正确性和吞吐
- 不加控制时,常见问题包括脏读、不可重复读、幻读和更新丢失
- InnoDB 主要通过事务、锁、MVCC 和隔离级别协同处理这些问题
- 快照读主要依赖 MVCC,当前读和范围并发控制主要依赖锁
- 真正的工程关键,不只是选一个隔离级别,而是把 SQL 写法、索引设计、事务边界和业务约束一起设计好
如果你接下来想继续往下挖,最自然的阅读顺序通常是:
- 先读这篇,建立“并发读写问题全景”
- 再读 MVCC 详解,理解快照读为什么能工作
- 再结合具体业务场景,分析哪些地方需要当前读、哪些地方适合原子更新