并发控制这东西,我这两年不管是在线上救火还是面别人,都绕不开。很多时候业务慢、报警多、数据对不上,根源都出在事务隔离和锁定上,MySQL 默认的可重复读隔离级别听起来很安全,但真正用起来,如果不懂 MVCC 和间隙锁的配合逻辑,你连 SQL 为什么突然卡住都说不清。这篇文章我会以 MySQL 8.0 的 InnoDB 引擎为主线,把脏读、不可重复读、幻读、MVCC、间隙锁这些概念全部用实战视角讲透,附上可以照抄的排查命令,适合正在准备面试的后端开发,也适合被线上锁问题折磨过的 DBA 和业务同学。内容不堆理论,讲到每个点都会告诉你它对应什么场景、怎么复现、怎么解决。
1. 先把并发问题讲透:事务隔离级别和三种读异常
1.1 为什么并发下会出现“读了不该读的数据”
我们先把问题缩小到最基础的一点:多个事务同时操作同一批数据时会发生什么。假设你有个账户表,A 事务正在给余额扣款,还没提交,B 事务同时去读这笔余额。如果数据库允许 B 直接读到 A 改了一半的数据,那 A 一旦回滚,B 拿到的就是一笔不存在的金额,这就是脏读问题。再换个场景,B 事务先读了一次余额,A 事务随后把余额改了并提交,B 在同一个事务里第二次读同一个账号,发现余额变了,这就叫不可重复读。
脏读和不可重复读的区别很简单:脏读读到的是别人未提交、可能回滚的数据;不可重复读读到的是别人已经提交的最新数据,但前后不一致。业务上最怕的其实是脏读,因为未提交的数据本身是半成品,拿去做决策会出大事故。但不可重复读在某些场景下也有问题,比如财务对账需要整个事务期间看到一致的账户状态,结果中间被别的转账改了数值,对账就乱了。
幻读则是另一种诡异的情况。B 事务执行SELECT * FROM order WHERE status = '待支付',第一次查出 100 条,这时候另一个事务插入了一条新的待支付订单并提交,B 再次执行同样的查询,结果变成 101 条。数据行本身没变,但结果集多了一行“无中生有”的记录,就像产生了幻觉一样,所以叫幻读。严格来说,幻读强调的是记录数量的变化,而不是某一行值的改变。
1.2 不可重复读和幻读,到底差在哪里
很多初学者会把不可重复读和幻读搞混,因为他们都表现为“同一个事务里前后读到的东西不一样”。但只要你抓住两个关键区别就能分清楚:第一,看数据变化是 update 还是 insert。不可重复读通常对应的是已有行被更新,比如把 id=1 的金额从 100 改成 200;幻读通常对应的是新行被插入,比如多了一条 id=10086 的记录。第二,看现象是行值变了还是结果集变了。不可重复读是同一行内容变了,幻读是同样的查询条件多出或者少了几行。
为了更直观,可以用一个生活化的类比。不可重复读就像你盯着墙上的一张价格表,别人把某个商品的价格单独改了,你再看同一排时候发现数字变了;幻读就像你在数菜单上的菜品数量,第一次数了十道菜,有人偷偷往菜单里加了一道新菜,你再数变成十一道。这两种异常对业务的影响也不一样,不可重复读主要影响对单条记录的一致读取,幻读主要影响统计、分页、聚合这类需要结果集稳定的操作。
1.3 隔离级别与异常现象的映射关系
为了解决这些并发问题,SQL 标准定义了四种隔离级别,MySQL 从低到高分别是:读未提交、读已提交、可重复读、串行化。隔离级别其实就是“并发安全性和性能”之间的天平,级别越高越安全,但锁的代价也越大。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不会 | 可能 | 可能 |
| 可重复读 | 不会 | 不会 | 部分解决 |
| 串行化 | 不会 | 不会 | 不会 |
注意,MySQL 的默认隔离级别是可重复读,但它的可重复读比 SQL 标准的可重复读更强:InnoDB 通过 MVCC 解决了快照读的不可重复读,又通过间隙锁解决了大部分幻读问题。所以在 MySQL 里,默认的 RR 级别下,只要你的快照读和当前读走了合理的索引,幻读是能被挡住的。真正的坑在于,如果你对“幻读被挡住”的理解只停留在口号层面,一旦遇到不走索引的更新或范围查询,锁范围放大,整个表的并发都会被拖死。这部分后面会详细讲。
2. MVCC 多版本并发控制:怎么做到读写不互斥
2.1 版本链与隐藏字段
MVCC 全称是 Multi-Version Concurrency Control,多版本并发控制。它的核心思想不是用锁去堵住读操作,而是给数据行维护多个历史版本,让读操作去读取一个“符合隔离规则”的旧版本快照,从而做到读操作和写操作之间不互相阻塞。也就是说,别人在更新一行数据时,你仍然可以读,而且读到的是一致性的数据。
这听起来很玄乎,但底层实现其实不复杂。InnoDB 的每张表都有一个主键索引,也就是聚簇索引。在聚簇索引的记录里,除了我们定义的业务字段,还有两个隐藏的必备字段:DB_TRX_ID,记录最近一次修改该行数据的事务 ID;DB_ROLL_PTR,回滚指针,指向该行修改前的旧版本记录。旧版本记录不是单独存放在某个大表里的,而是记录在 undo log 中,通过回滚指针串成一条版本链,最新版本在链头,旧版本在后面。
当一个事务需要读取某行数据时,它不能简单拿最新版本,而是要判断自己能看到哪个版本。判断依据就是两个规则:第一,如果行的 DB_TRX_ID 表示的事务是“在我开始之前已经提交的”,那我就能看到它;第二,如果这个事务还在活跃中,或者它发生在我之后,我就不能看到它,此时就要沿着回滚指针去读上一个旧版本,直到找到一个我能看到的版本为止。
2.2 ReadView 的生成规则与可见性判断
这个“我能看到哪个版本”的判断,是通过 ReadView 来实现的。ReadView 可以理解成事务在某一时刻给数据库里所有事务拍的一张“快照清单”。它主要包含四个关键信息:
m_ids:当前系统中所有还没提交的活跃事务 ID 列表。min_trx_id:活跃事务里最小的 ID。max_trx_id:下一个即将分配的事务 ID,也就是大于所有活跃事务 ID 的值。creator_trx_id:创建这个 ReadView 的事务自己的 ID。
当我们读取一条记录时,拿到记录的 DB_TRX_ID,然后按下面这套规则判断可见性:
- 如果
trx_id == creator_trx_id,说明这条记录就是当前事务自己改的,那当然可见。 - 如果
trx_id < min_trx_id,说明修改这条记录的事务已经提交了,可见。 - 如果
trx_id >= max_trx_id,说明是在当前 ReadView 生成之后才开始的事务,不可见,继续读旧版本。 - 如果
min_trx_id <= trx_id < max_trx_id,要判断 trx_id 是否在m_ids列表里:如果在,说明事务还未提交,不可见;如果不在,说明事务已提交,可见。
是不是像在做一道逻辑题?现实场景中确实就是靠这套规则实现“一致性读”的。比如事务 A 开启后第一次执行查询,生成了一个 ReadView,假设当时事务 B 正在修改 id=5 的记录但还没提交。A 去读 id=5 时,发现记录的 DB_TRX_ID 正是 B 的事务 ID,而这个 ID 还躺在 A 的 ReadView 活跃列表里,于是 A 就沿着回滚指针去读 B 修改前的旧版本,这样 A 就看不到 B 的半成品数据。等到 B 提交了,A 的事务还开着,A 再读 id=5,仍使用第一次生成的 ReadView 做判断,B 的 ID 依然在活跃列表里,于是 A 继续读旧版本,两次结果完全一致,这就是可重复读。如果是在读已提交级别下,每次 SELECT 都会重新生成一个新的 ReadView,B 提交后 A 再查就能看到新数据,于是就会发生不可重复读。
2.3 快照读和当前读的区别,你应该掌握的另一个维度
MVCC 并不是万能的,它只负责处理一种读取模式,叫快照读。普通的SELECT就是快照读,它不加锁,直接基于 ReadView 读版本链。而SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这些操作都属于当前读,读取的是记录的最新已提交版本,而且会对读取到的记录加锁,防止其他事务同时修改。当前读不能用 MVCC 回避一致性冲突,因为你要做修改,就必须基于最新数据,否则就会发生丢失更新。
理解“快照读和当前读”是理解 MySQL 并发控制的关键之一。快照读让普通查询几乎零成本,读写互不干扰;当前读则是真正需要锁的战场,所有锁等待、死锁都发生在这里。我见过不少同学面试时说“MVCC 解决了幻读”,这句话其实不准确。MVCC 解决的是快照读下的不可重复读问题,而幻读的解决主要靠间隙锁,因为幻读是“插入新记录”导致的,快照读未必能感知新记录,但在当前读的范围内,必须有锁把新插入挡在外面。
3. 间隙锁与临键锁:MySQL 怎么解决幻读
3.1 记录锁、间隙锁、临键锁到底锁的是什么
InnoDB 行锁有三种形态:记录锁、间隙锁、临键锁。很多文章讲到这里就开始堆名词,但如果你想真的用好,必须搞懂它们锁的“范围”是什么。
记录锁是最直观的,一个事务锁定某条索引记录,其他事务不能修改或删除这条记录。比如SELECT * FROM user WHERE id=10 FOR UPDATE,就是把主键 id=10 这一条记录锁住。间隙锁则完全不同,它锁的是“索引记录之间的缝隙”,而不是记录本身。比如表里有 id=10、 id=20、 id=30 三条记录,那么在 10 和 20 之间就存在一个开区间 (10,20),间隙锁就是锁住这个区间,防止别的事务在这个区间里插入新的记录。要注意,间隙锁不阻止其他事务对间隙里现有记录的修改,因为间隙里本来就没有记录,它的唯一目的就是挡插入。
临键锁可以理解为“记录锁+前面的间隙锁”,它锁定的范围是左开右闭的区间,比如 (10,20] 表示锁住 10 到 20 之间的间隙,以及 id=20 这条记录本身。InnoDB 在默认的可重复读隔离级别下,使用临键锁对索引记录加锁。示例:执行SELECT * FROM user WHERE id=20 FOR UPDATE,此时不仅 id=20 的记录被锁,连 (10,20) 这个间隙也被锁了,也就是说另一个事务想插入 id=15 会被阻塞。
这里有个容易忽略的点:间隙锁只在可重复读隔离级别下生效。在读已提交级别下,InnoDB 只使用记录锁,间隙锁会被禁用,这也是为什么说 RC 下幻读问题无法避免。MySQL 之所以默认用 RR,很大一部分原因就是想用间隙锁在应用层减少幻读带来的业务复杂度。
3.2 用两个事务复现间隙锁挡住幻读
光说概念不好懂,我们直接模拟一个订单场景。假设订单表 order 中有两条记录:id=10 和 id=20,当前隔离级别为可重复读。
事务 A 执行:
SELECT * FROM `order` WHERE id BETWEEN 10 AND 20 FOR UPDATE;这条当前读命中了 id=10 和 id=20 两条记录。由于查询条件是范围条件,InnoDB 会对区间 (10,20] 范围内的现有记录加临键锁,同时对 id=20 之后的间隙 (20, 正无穷) 和 id=10 之前的间隙也进行相应锁定。简化讲,A 持有 id=10、id=20 的记录锁,也持有 (10,20) 这个间隙锁。
此时事务 B 试图插入一条 id=15 的订单:
INSERT INTO `order` (id, status) VALUES (15, '新建');B 会进入锁等待状态,因为 id=15 落在 A 持有的间隙锁范围内。只有当 A 提交或回滚,B 的插入才会继续。这就是间隙锁阻断幻读的直观效果。如果没有这个间隙锁,B 插入 id=15 就能立即成功,随后 A 再次执行同样的查询就能看到新记录,幻读就发生了。
但是要注意,如果事务 A 执行的等值查询恰好命中一条不存在的记录,比如WHERE id=15 FOR UPDATE,由于 id=15 不在表中,InnoDB 也会对 (10,20) 间隙加锁,同样会阻止别的事务插入 id=15。这个现象经常被初学的人当成 bug,其实是故意设计,目的就是为了防幻读。
3.3 间隙锁的副作用和最常见的坑
间隙锁能防幻读,但它也带来了并发性能的下降和更麻烦的死锁。这是整个 MySQL 并发控制里最需要权衡的地方。
第一个坑是间隙锁会扩大锁范围。如果当前读条件没有走索引,InnoDB 无法确定需要锁的精确间隙,只能对全表所有索引记录之间的间隙都加锁,相当于整个表的新增操作都被锁住了。我之前处理过一个线上慢事务报警,应用层执行了一条UPDATE user SET level=5 WHERE age BETWEEN 20 AND 25,而 age 列上没有索引,结果这条更新把所有 user 记录之间的间隙都锁了,导致全表无法插入新用户。排查之后发现 SQL 明明不复杂,就是索引缺失害死人。
第二个坑是插入死锁。间隙锁之间不互斥,两个事务都持有同一个间隙的间隙锁,再各自尝试插入记录时,就可能互相等待对方的锁释放,形成死锁。比如按固定顺序插入是安全的,但如果你在业务里动态拼接 WHERE 条件,让不同事务对同一片区间的间隙锁顺序不一致,死锁概率会显著上升。
第三个坑是如果想完全避免幻读,直接使用串行化隔离级别即可,但那样并发能力会大打折扣。实际生产环境里,很多团队在高并发写入压力的场景会主动把隔离级别调到读已提交,以禁用间隙锁,换取更低的锁冲突。代价则是要自己在应用层处理幻读,比如在事务里加分布式锁或唯一索引约束。这也是为什么没有绝对好坏,只有合适不合适。
4. 实操复现与排查技巧:从理论到命令行的完整闭环
4.1 用两个终端手动复现三种读异常和间隙锁
不管书上看多少遍,都不如自己动手跑一遍。我这里给出的复现步骤基于 MySQL 8.0,建议你开两个终端窗口,都连接同一个库,用于模拟两个并发事务。先确认当前隔离级别:
SELECT @@transaction_isolation;MySQL 8.0 默认应该是REPEATABLE-READ。要复现脏读,需要把两个会话都切到读未提交:
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;终端 A 开启事务并更新数据但不提交:
BEGIN; UPDATE user SET balance=200 WHERE id=1;终端 B 开启事务并查询:
BEGIN; SELECT balance FROM user WHERE id=1;在读未提交级别下,B 能直接读到 200,而实际上 A 还没提交,这就是脏读。接着在终端 A 执行ROLLBACK;,B 再查一次会发现余额变回原来的值,数据前后矛盾,脏读的“脏”字体现得尤为明显。
再复现不可重复读,把两个会话都切到读已提交:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;A 事务开启后先查询余额为 100,B 事务更新并提交,把余额改成 200,A 在同一个事务里再次查询就看到 200。两次查询的间隔里,数据被人改了,这就是不可重复读。如果想复现可重复读下的隔离效果,切回 REPEATABLE-READ 重复以上操作,A 第二次查询仍会看到 100,因为 MVCC 让 A 复用了第一个快照。
复现幻读则要回到可重复读,但要小心区分快照读和当前读。A 事务第一次执行普通SELECT COUNT(*) FROM user WHERE age > 18;得到 10,B 事务插入一条 age=20 的新用户并提交,A 再执行同样的普通 SELECT,结果仍然是 10,说明快照读下幻读被 MVCC 挡住了。但如果 A 执行的是SELECT COUNT(*) FROM user WHERE age > 18 FOR UPDATE;这类当前读,并且该查询条件没有合适索引时,B 的插入可能会因为间隙锁被阻塞。所以严格来说,可重复读下快照读没有幻读,但当前读要防幻读,拼的是间隙锁是否生效。
4.2 通过 performance_schema 实时观测锁等待
线上遇到 SQL 卡住不动,第一件事不是瞎猜,而是查锁等待。MySQL 8.0 提供了非常好用的视角,直接查performance_schema.data_locks。这个表会列出当前所有的锁记录,每行代表一个持有或等待中的锁。
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks;重点关注 LOCK_STATUS 列,如果是WAITING,说明这个事务在等锁;同时看 LOCK_DATA,它能告诉你具体卡在哪条记录上,比如LOCK_DATA: 15表示事务想锁住 id=15 的记录但被别的锁挡住了。再配合information_schema.innodb_trx可以找到阻塞的源头:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx WHERE trx_state = 'RUNNING';确认了阻塞源之后,通常的做法是让持有锁的事务尽快提交或回滚。如果那个事务是个已经僵死的老事务,可以记录它的 trx_mysql_thread_id,也就是 MySQL 连接线程 ID,然后在另一个会话执行:
KILL 线程ID;注意 KILL 前要确认这个线程不是正在执行关键业务的连接,最好先和业务方确认,避免误杀。生产环境我习惯先用它查出来,再辅以SHOW PROCESSLIST看每个连接在干什么,两边对照后才能放心动手。
4.3 死锁日志分析实录:一条一条读明白
如果说锁等待还能靠 KILL 解决,那死锁就更麻烦一点,因为它不是单方面等待,而是两个或多个事务互相等待对方的锁,导致谁都无法前进。MySQL 的 InnoDB 引擎会自动检测死锁,并回滚其中一个事务来打破僵局,所以死锁发生后,你会看到一个事务报错,另一个事务继续执行。但我们不能只靠数据库自己处理,否则在流量高峰期回滚的恰好是核心业务事务,就麻烦了。
查看死锁信息的标准命令是:
SHOW ENGINE INNODB STATUS\G在输出里找到LATEST DETECTED DEADLOCK段。这里会列出两个事务各自执行过的最后一条 SQL,以及它们各自持有和等待的锁。举一个我遇到过的典型死锁场景:
事务 A:先更新 id=1 的记录,再更新 id=2 的记录。 事务 B:先更新 id=2 的记录,再更新 id=1 的记录。
如果 A 和 B 同时执行,A 拿到了 id=1 的锁,等待 id=2;B 拿到了 id=2 的锁,等待 id=1,于是陷入死锁。解决方式也很简单,在所有需要同时操作多行的业务里,强制按照相同的顺序访问,比如总是先更新 id 小的记录。另外还要注意,间隙锁也会引起死锁,而且更难直观理解,因为事务表面上操作的是不同行,但间隙锁的范围交错导致相互等待。遇到这种情况,最好的办法不是事后分析日志,而是从源头降低锁冲突面:确保 WHERE 条件走索引、缩小事务范围、减少单事务操作的行数。
5. 并发调优实践:从理论到落地的几个建议
5.1 隔离级别选型:别迷信默认值
MySQL 默认是可重复读,但生产环境真的都要用可重复读吗?答案是不一定。如果你做的业务是大量并发写入,比如电商秒杀、订单状态流转,可重复读带来的间隙锁可能让系统并发能力大幅下降。而你真正需要的可能只是读已提交:每次读到的都是已提交的数据,不会出现脏读,代价是会有不可重复读和幻读风险,但很多业务场景可以接受。
我见过很多团队在设计之初完全没有思考隔离级别,直接用默认值,结果等到活动大促时大量锁等待,才想起来要不要把隔离级别调成 RC。实际上阿里云等不少云数据库默认就是 RC,因为 RC 下没有间隙锁,写入并发更高。如果你的业务对数据一致性要求没那么苛刻,可以评估切换到 RC。但切换前一定要审查所有 SQL,特别是有SELECT ... FOR UPDATE或UPDATE ... WHERE范围条件的场景,确认业务是否能承受结果集变化。不能为了性能而在应用层埋下数据不一致的隐患。
5.2 索引对锁范围的影响,比你想的更重要
我从经验来看,MySQL 并发问题中最常见的根因就是索引没走对。简单记一个规律:当前读的锁范围取决于访问索引的方式。如果是主键或唯一索引等值查询且记录存在,通常退化为记录锁,锁影响最小;如果是普通索引等值查询,InnoDB 会在匹配到的记录上加临键锁,同时在索引两侧加间隙锁,锁范围更大;如果是无索引或全表扫描,就会锁全表所有间隙,基本把写入全堵住。
所以优化并发的第一步永远是优化 SQL 的索引。具体做法是每次执行慢查询或锁等待语句都先看执行计划:
EXPLAIN SELECT ... FOR UPDATE;重点看type列和key列,如果是ALL(全表扫描)或者key为 NULL,那问题很大,赶紧加索引。我曾有过一次排查经历:一条更新语句只是改了status=1,但因为 WHERE 条件是另一张表关联出来的状态值导致索引失效,锁范围瞬间放大到好几万行,进而引发大量锁等待。后来把关联查询改成先查主表再循环更新子表,锁范围降了几个量级。
5.3 常用监控命令与紧急处理顺序
最后分享一套我在生产环境处理并发问题的固定流程,做个简单梳理。
- 第一步,
SHOW PROCESSLIST看有没有长时间Waiting for lock的连接。 - 第二步,查
performance_schema.data_locks,确认锁类型和锁住的记录。 - 第三步,查
information_schema.innodb_trx,确认持锁事务运行了多久、执行了什么 SQL。 - 第四步,确认无误后,KILL 掉占用锁时间过长的事务。
- 第五步,分析原 SQL 的索引情况、隔离级别、事务内操作数量,从根源上修复。
这个方法我已经实践了好几年,基本上没有遇到搞不定的锁问题。很多新手一上来就SHOW ENGINE INNODB STATUS,被海量信息淹没,反而不知道从哪看起。其实先看进程列表和当前锁更直接。
另外,还有一点特别值得说:不要在一个事务里做太多事情。事务时间越长,持有锁的时间越长,和其他事务交集等待的概率就越大。如果业务允许,把大事务拆成小事务,每个事务只处理必要的操作,该提交就提交,可以极大降低锁冲突和死锁概率。
说到这对 MySQL 并发控制的知识你基本能形成一个完整闭环了。在实际操作中我的体会是,这些概念从来不是孤立存在的:MVCC 让读不阻塞写,锁让写不随意干扰读,间隙锁则专门补上了插入操作的漏洞。你在设计一个高并发的数据更新流程时,不妨先走一遍这几步:SQL 走没走索引、事务范围够不够短、隔离级别有没有必要上 RR、并发高峰会不会触发间隙锁死锁。只要每一步都想清楚,线上关于脏读、不可重复读、幻读和锁等待的麻烦,基本都能提前避开。