做后端这些年,MySQL 的并发控制是我认为最需要啃透、也最容易被低估的一块硬骨头。面试时问"事务、锁、MVCC",大家总能说出几个名词,可真到线上出现死锁、超卖、数据不一致,能十分钟内定位根因的人并不多。这篇文章是我把事务、锁和 MVCC 放在一起讲清楚的尝试——它们不是三个孤立的知识点,而是同一套并发控制体系里互相制衡的三股力量。我希望读完你能理解:为什么要有隔离级别,为什么 InnoDB 要设计那么多锁,为什么 MySQL 在可重复读级别下还能基本挡住幻读,以及当单库手段不够时,分布式场景又该怎么补位。
1. 并发控制的三张底牌:事务、锁与 MVCC 的整体设计思路
1.1 为什么是"三角博弈"
我习惯用一个办公室场景来类比:一堆人同时修改同一份合同。事务是工作规则,规定哪些修改算数、哪些要回滚;锁是门禁,谁在改文件时别人不能抢笔;MVCC 是历史版本存档,别人想看文件时不需要等正在改的人,直接拿一份历史快照看就行。
之所以说是"三角博弈",是因为三者的目标并不完全一致。事务要求强一致性,恨不得所有操作串行化;锁是悲观思路,干脆把资源锁住,冲突越激烈越安全,但并发能力直线下降;MVCC 是乐观思路,读不加锁、写才加锁,用多版本数据换取并发吞吐,可又引入了版本链和可见性判断的复杂度。数据库设计者要在这三者之间找平衡点,这也是 MySQL 提供不同隔离级别、不同加锁策略的根本原因。
关于锁和 MVCC 的关系,网上经常看到"有了 MVCC 就不需要锁"的说法,这是误解。MVCC 主要解决读写互斥的问题——普通 SELECT 可以读历史版本,不用等写事务释放锁。但写写之间依然必须靠锁来串行化,否则两个事务同时改同一行,数据早就乱套了。所以更准确的理解是:MVCC 优化的是读,锁约束的是写,事务则负责把读和写包装成一个个有明确边界的单元。
1.2 三者如何在一个事务里协同
把一条 UPDATE 语句拆开看,最能体现三者协作的过程。假设要执行UPDATE user SET balance = balance - 100 WHERE id = 1,InnoDB 会先定位到 id=1 这条记录并加排他锁(锁),然后读取当前最新数据(当前读),修改后生成一个新版本,旧版本连同事务 ID 一起写入 undo log(MVCC 的版本链)。这个事务最终提交还是回滚,由事务机制控制。整个过程里,锁保证同一时刻只有一个事务能改这条记录,MVCC 保证其他事务的普通 SELECT 还能读到修改前的快照,不用傻等。
再举一个订单查询的例子。事务 A 正在修改订单状态,还没提交;事务 B 同时执行普通SELECT查这张订单。如果只有锁机制,B 必须等 A 提交才能读,体验极差。有了 MVCC,B 直接读取旧版本的已提交数据,A 的修改对 B 完全不可见。这正是"读写分离"在存储引擎层面的实现方式。
2. 隔离级别:先定规则再谈并发
2.1 四档隔离级别的效果差异
隔离级别本质是"你愿意牺牲多少一致性去换并发"。MySQL InnoDB 默认是 REPEATABLE READ(RR),但这不是唯一选择。四大级别从宽松到严格分别是:
- READ UNCOMMITTED:事务能读到别人尚未提交的数据,产生脏读。基本没人会用,除非只是看个大概趋势。
- READ COMMITTED:只能读到已提交的数据,解决了脏读。但同一事务里两次 SELECT 可能结果不一样,产生不可重复读。
- REPEATABLE READ:事务内第一次快照读之后,后续读到的都是同一份快照,解决了不可重复读。这是 MySQL 默认级别。
- SERIALIZABLE:所有读都自动加锁,读写完全互斥,一致性最好但并发最差。
从开发者的角度,真正需要在意的是 RC 和 RR 的取舍。假设统计订单金额的事务,第一次 SELECT 得到 100 元,另一个事务马上提交一笔 50 元的订单,RR 下第二次 SELECT 还是 100 元,RC 下会变成 150 元。业务上如果接受"统计过程中看到最新提交",RC 能换来更高并发;但账务、库存这类对一致性敏感的系统,一般不敢把隔离级别放开。我之前在一个对账系统里见过把隔离级别改成 RC 后出现对账金额不一致的隐患,改回 RR 才恢复正常。
2.2 隔离级别与锁、MVCC 的联动关系
隔离级别不是凭空定义的,而是由"锁 + MVCC 的配合策略"实现的。RR 能做到可重复读,靠的是事务内第一次普通 SELECT 生成一个 ReadView(快照视图),后面一直复用同一份;RC 则每次普通 SELECT 都重新生成 ReadView,所以能看到其他事务新提交的数据。这个细节在第 4 节会细讲。
加锁读方面,RR 下为了防幻读,查询范围数据时用临键锁把记录和前面的间隙一起锁住;RC 下间隙锁基本不生效,并发更高,但范围数据容易被其他事务插入。SERIALIZABLE 则把普通 SELECT 升级成LOCK IN SHARE MODE的当前读,等于彻底放弃 MVCC 的读优化。
MySQL 为什么默认 RR,而不是像很多数据库那样默认 RC?这里有历史原因。老版本 binlog 使用 statement 格式时,如果隔离级别是 RC,某些语句在主库和从库上的执行结果可能不一致;RR 下配合锁机制能保证复制结果一致。现在 binlog 普遍使用 row 格式后,RC 在复制安全性上没有大问题,所以很多团队会主动改成 RC 提升并发。这个取舍没有绝对答案,但理解背后的原理,你才能在"到底选哪个隔离级别"这个问题上做出符合业务的判断。
3. 锁的实战拆解:行锁、间隙锁、临键锁与死锁排查
3.1 InnoDB 锁类型与加锁规则
InnoDB 的锁先说大框架:按模式分,有共享锁 S 锁(读锁)和排他锁 X 锁(写锁)。S 锁之间兼容,S 锁与 X 锁互斥,X 锁之间互斥。按粒度分,有表级锁和行级锁,InnoDB 支持行级锁,这是它能支撑高并发写的基础。
真正容易绕晕的是行锁底下的几种形态:
- 记录锁(Record Lock):锁住单条索引记录。
- 间隙锁(Gap Lock):锁住两个索引记录之间的间隙,防止其他事务在间隙中插入数据。
- 临键锁(Next-Key Lock):记录锁加间隙锁的组合,锁住记录本身以及前面的间隙,是 RR 下默认的行锁形态。
- 插入意向锁(Insert Intention Lock):插入记录前对间隙设置的锁,多个事务可以同时持有一个间隙的插入意向锁,但不能和间隙锁冲突。
还有两种容易被忽视但线上很重要的锁:意向锁和自增锁。意向锁是表级锁,比如事务要给某行加 X 锁,会先在表上加意向排他锁(IX)。另一个事务想给整张表加 X 锁时,发现表上已经有 IX 锁,立刻知道有行被锁住了,不用逐行扫描确认。这就像进园区前先在门口登记一下,省得后面查岗的人满园区找人。自增锁负责管理 AUTO_INCREMENT 列,MySQL 8.0 默认innodb_autoinc_lock_mode = 2,配合 row 格式的 binlog,插入并发高也不容易出现主键间隙问题。
加锁规则上,我最常强调三条。第一,行锁是加在索引记录上的,不是加在"行"这个抽象概念上;如果 UPDATE 或 DELETE 没有走索引,存储引擎需要扫描大量记录,每扫描到的记录都可能加锁,从效果上看就像锁了全表。第二,等值查询且索引唯一时,临键锁会退化成记录锁,因为不需要防幻读。第三,范围查询会在每个扫到的索引记录上加临键锁,这是 RR 防幻读的关键,也是死锁高发区。
3.2 死锁的产生、监测与避免
死锁的经典现场:事务 A 先更新 id=1 再更新 id=2,事务 B 同时先更新 id=2 再更新 id=1。两个事务各持一把锁,互相等待对方手里的下一把锁,循环等待形成死锁。InnoDB 的死锁检测器会立刻介入,回滚其中代价较小的事务并抛错ERROR 1213: Deadlock found when trying to get lock; try restarting transaction,并不会无限等待下去。
排查死锁时,我一般用这几条命令配合:
SHOW ENGINE INNODB STATUS\G重点看输出里的LATEST DETECTED DEADLOCK段,里面有具体哪条 SQL、持有哪把锁、等待哪把锁。MySQL 8.0 还可以直接查:
SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;同时把innodb_print_all_deadlocks = ON打开,把每次死锁都打印到错误日志,避免错过没有成为"最近一次"的死锁信息。
避免死锁的招数,我按自己的优先级排序:
- 业务代码里多个更新统一按主键或唯一键排序,大家按同一个顺序拿锁,破坏循环等待。
- 事务尽量小,一个事务只做一件事,锁持有时间短,碰撞窗口就小。
- 避免大范围无索引更新,否则锁扩散到大量记录,两个事务很容易互相覆盖。
- 应用层捕获 1213 死锁异常后自动重试,这是最后一道兜底,但别把重试当作优化借口。
这里顺便讲清楚一个容易混淆的点:死锁和锁等待不是一回事。死锁是循环等待,InnoDB 会检测并主动回滚一个事务,报错 1213;锁等待是事务等一把被其他事务持有的锁,超过innodb_lock_wait_timeout(默认 50 秒)后报错 1205Lock wait timeout exceeded,事务本身不会被自动回滚,但应用会被中断。两者处理方式也不同:死锁靠重试,锁等待靠排查长事务、缩小锁粒度。
3.3 线上热点更新的锁优化技巧
说一句很多 DBA 不会写在文档里的话:行锁虽然粒度细,但热点行更新的并发能力依然有限。比如秒杀场景里大家都去扣同一个用户余额、同一条库存记录,行锁会让所有更新串行排队,TPS 上不去。
常见的优化手段有几个。一是异步合并写,把高频更新攒到内存里批量落库,减少对同一行的锁竞争。二是分桶,把一条热点记录拆成多条子记录,比如库存拆成 10 个桶,每个桶单独扣减,读取时汇总。三是使用乐观锁,如果冲突率不高,用版本号字段判断更新是否成功,失败重试,比直接FOR UPDATE阻塞更轻。选择哪种方案要看业务冲突率,如果冲突率超过 20%,乐观锁的重试成本会反超悲观锁,这时候老老实实加行锁更靠谱。
4. MVCC 多版本并发控制核心机制:undo log 与 ReadView
4.1 版本链与 ReadView 可见性判断
MVCC 全称 Multiversion Concurrency Control,中文是多版本并发控制。InnoDB 为每行记录隐藏了关键列:DB_TRX_ID(最近修改该行的事务 ID)、DB_ROLL_PTR(回滚指针,指向 undo log 里的旧版本)、可能还有DB_ROW_ID(聚簇索引的隐藏自增列)。每次 UPDATE 不会直接覆盖旧数据,而是生成一个新版本,DB_ROLL_PTR串出一条版本链。普通 SELECT 就是沿着这条链找到自己"应该看见"的版本。
怎么判断该看哪个版本?靠 ReadView。ReadView 在事务执行快照读的那一刻生成,核心包含四部分:m_ids当前活跃事务 ID 列表、min_trx_id活跃事务最小 ID、max_trx_id下一个将分配的事务 ID、creator_trx_id创建这个视图的事务 ID。
可见性判断规则,说白了是四句话:
- 当前事务自己修改的数据肯定可见,因为
DB_TRX_ID = creator_trx_id。 - 版本的
trx_id小于min_trx_id,说明是已提交事务写的,可见。 - 版本的
trx_id大于等于max_trx_id,说明是 ReadView 生成后才开启的事务写的,不可见。 - 版本的
trx_id落在m_ids里,说明写它的事务还没提交,不可见;不在列表里,说明已经提交,可见。
举个具体例子。事务 100 插入一条 id=1、balance=100 的记录并已提交。事务 200 把 balance 更新为 200,但尚未提交。事务 300 启动并发起普通 SELECT,生成 ReadView,此时m_ids = [200, 300],min_trx_id = 200,max_trx_id = 301。读取 id=1 时,最新版本是 trx_id=200,它落在m_ids里,不可见;沿DB_ROLL_PTR找到 trx_id=100 的旧版本,100 小于min_trx_id,可见。所以事务 300 读到 balance=100。如果事务 200 此时提交了,m_ids里不再有 200,版本 trx_id=200 就可见,事务 300 再发起新快照读时就会读到 200。这就是可见性判断的完整路径。
4.2 快照读与当前读,以及 undo log 回收
关于 MVCC 误区最多的就是这里。普通 SELECT 不带FOR UPDATE、不带LOCK IN SHARE MODE时走快照读,借助 ReadView 和版本链直接读旧版本,不加任何锁。而UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE走当前读,必须读最新已提交数据并加锁。对比一下:
SELECT * FROM orders WHERE order_id = 1; -- 快照读,无锁 SELECT * FROM orders WHERE order_id = 1 FOR UPDATE; -- 当前读,加 X 锁RR 和 RC 在快照读上的区别,前面提过:RR 是事务内第一次生成 ReadView 后复用,RC 是每条语句重新生成。这个行为差异直接解释了为什么 RR 可重复读、RC 不可重复读。还要多说一句:RR 下 MVCC 解决的是"快照读层面的幻读"——同一事务内两次普通 SELECT 结果一样;但如果是SELECT ... FOR UPDATE这种当前读,要防幻读还得靠临键锁。两套机制并存,千万别混为一谈。
版本链不可能无限长,否则 undo log 会爆炸。InnoDB 有一个后台 purge 线程,负责清理"已提交且当前没有任何 ReadView 需要访问"的旧版本。重点来了:如果有事务一直不结束,它的 ReadView 就会长期保留,那些本该被清理的旧版本就一直占着 undo log 空间,表现为磁盘空间上涨、history list length指标飙高、性能下降。所以长事务不仅是锁问题的元凶,也是 MVCC 清理机制的死敌。这也就是为什么生产环境严格要求"事务尽量短"。
5. 线上并发故障排查与避坑经验
5.1 常见并发问题速查表
把我在项目里和踩坑中遇到的典型问题整理成一张表,方便遇到问题时对号入座:
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
应用报Deadlock found | 不同事务加锁顺序不一致 | SHOW ENGINE INNODB STATUS看死锁段;代码统一加锁顺序;捕获 1213 重试 |
报Lock wait timeout exceeded | 长事务持锁不释放,或锁冲突严重 | 查information_schema.innodb_trx定位长事务;调innodb_lock_wait_timeout;优化事务边界 |
| 热点行更新慢、大量等待 | 同一行被频繁 UPDATE,行锁串行 | 异步合并写、队列削峰;分桶降低单行热度;必要时引入乐观锁 |
| 普通 SELECT 越来越慢、磁盘增长 | undo log 版本链过长 | 观察history list length;及时提交事务;调优 purge 线程,根本是减少长事务 |
RR 下FOR UPDATE查出的行数比普通 SELECT 多 | 当前读与快照读机制不同,属正常现象 | 理解临键锁与快照读差异,按需要选择读方式 |
这里特别想提"事务日志被写满"这类状况。很多团队遇到类似报错,第一反应是清日志、扩空间,但真正的根因往往是长事务或大事务把日志空间耗光了。正确做法是先用information_schema.innodb_trx定位持有资源的会话,把事务提交或者杀掉,再考虑空间配置。MySQL 的 undo log 膨胀、redo log 写入过猛,都会表现为数据库卡顿、写不进去,只盯着清日志那一步解决不了根本问题。
5.2 实操心得与避坑经验
分享几条我压箱底的经验。
第一,线上排查锁冲突时,别急着 KILL 会话。先用information_schema.innodb_trx看trx_started这个字段,判断事务已经跑了多久。很多时候,是业务代码在一个事务里塞了远程调用、消息推送、外部接口,导致事务被拖成"长事务",锁被抱着不放。我之前处理过一个库存服务,一个事务里调了第三方支付回调,正常 100ms 搞定的事被拖到 3 秒,数据库里的锁全堵在这条链路上。
第二,批量更新踩过无数坑,最终结论是:把所有要更新的主键排序后再逐个更新,配合小批量提交,死锁率肉眼可见地下降。虽然多一次排序开销,但换来的是稳定性和排障简单。这个方案也适合批量导入、批量状态流转的场景。
第三,低并发、低冲突场景优先用乐观锁,别一上来就悲观锁。UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?,影响行数为 0 就重试。代码简单、没有阻塞、也不会有死锁。但前面也提醒过,冲突率高的场景乐观锁重试会打爆数据库,所以要区分场景。
第四,MySQL 8.0 之后,data_locks和data_lock_waits两张表是排查锁问题的大杀器,比老版本只能靠SHOW ENGINE INNODB STATUS方便太多。配合performance_schema,很多锁问题不再是黑盒,能在一分钟内定位到锁的持有和等待链路。
6. MySQL 并发控制的边界:单库手段的局限与分布式延伸
6.1 为什么单库锁和 MVCC 管不了跨库事务
事务、锁和 MVCC 这套组合拳再厉害,作用域也只限定在同一个 MySQL 实例或集群内部。一旦业务拆成多个库、多个服务,比如订单库和库存库分开部署,一次扣减需要同时修改两个库,MySQL 自己的行锁和 MVCC 就无能为力了——它管不到另一个数据库实例里的记录。
很多团队在这里踩过坑:在应用层用数据库本地事务包住两个库的操作,结果第一个库提交成功、第二个库提交失败,又没有回滚第一个库的机制,数据就对不上了。这不是 MySQL 并发控制的问题,而是并发控制的作用域本来就有边界。单库场景下,事务的原子性由 redo log 和 undo log 保证;跨库场景下,需要引入 XA 分布式事务,或者更常见的最终一致性方案。
6.2 分布式事务与分布式锁引入的时机
分布式事务里最常见的两种手段,一是 XA 两阶段提交,二是基于消息队列的最终一致性。XA 强一致但性能开销大、实现复杂,适合小额高频但对一致性要求极强的核心链路;最终一致性用事务消息配合本地消息表,虽然做不到实时一致,但吞吐高、实现相对可控,适合订单和库存这类允许短暂不一致的场景。
分布式锁同样是单库锁的延伸。几个服务同时抢一个资源,比如同一用户多端登录、系统同一份配置变更,单靠 MySQL 行锁在服务层已经无法协调,这时候可以用 Redis 分布式锁或基于数据库的乐观锁。但要意识到,分布式锁和 MySQL 行锁解决的是不同层级的问题:行锁管的是存储引擎内部的写写冲突,分布式锁管的是多服务之间的资源互斥,两者可以共存,不能互相替代。
所以我的建议是:先把手上的单库并发控制做好,再谈分布式。很多团队一上来就上 Redis 分布式锁、上消息队列,结果单库里随手一个无索引 UPDATE 加锁全表,再牛的分布式方案也救不了数据库本身的短板。MySQL 的锁和 MVCC 体系是一面很好的镜子,它能逼你把事务边界、索引设计、锁顺序这些基本功练扎实。
我个人在实际项目里踩过最深的坑,是在订单库存系统里把"扣库存、改订单状态、发消息"全塞进一个数据库事务。表面看保证了强一致,实际上只要一个环节慢,整条链路都被锁堵死,最终只能拆事务:扣库存单独一个短事务,后续状态通过可靠消息异步通知。改完之后,数据库锁等待直线下降,接口可用性明显回升。这个改动的核心思路,正是事务、锁、MVCC 三者平衡的现实版本——牺牲一点点即时一致性,换取系统的可用性和吞吐。把这三者当成一个整体去理解,再回到具体的隔离级别、锁类型和 ReadView 细节,你会发现所有知识点都串在同一条线上:给并发留空间,给一致设底线。