
做数据库开发的同学十有八九都遇到过这样的场景一个订单系统仓库存量还剩 1 件两台客户端同时提交订单结果库存硬生生被扣成了负数。明明代码里已经加了事务MySQL 也选了默认的 InnoDB 引擎怎么还是出乱子答案不在事务本身而在于事务、锁、MVCC多版本并发控制这三者之间如何协作。MySQL 的并发控制本质上就是这三者的一场“三角博弈”事务划定边界锁解决写写互斥MVCC 解决读写阻塞。这篇文章我会把这三者的底层原理、配合方式以及实际调优经验一次讲透适合后端开发、DBA、以及对数据库内核机制感兴趣的读者。1. 并发控制问题的本质三角博弈如何启动1.1 并发场景下的三类冲突先说一个朴素的结论并发控制要解决的其实就是多个事务同时访问同一份数据时产生的冲突。按照读写方向组合可以分成三类写写冲突两个事务同时改同一行后写的人会把先写的人覆盖掉。这必须靠锁去强制串行。读写冲突一个事务正在读另一个事务同时改。如果不做处理读的人可能读到一半被改掉的数据产生脏读、不可重复读、幻读。写读冲突同上只是视角反过来。本质是同一种冲突的两面。这三类冲突如果全部用锁解决数据库性能会惨不忍睹。如果全部不用锁数据一致性又无法保证。MySQL 最终选择的是“锁 MVCC 分工合作”的路线锁负责让写写之间互斥MVCC 负责让读写之间尽量互相不等待。1.2 事务、锁、MVCC 各自的角色用一个生活化类比来理解这三个角色事务是操作边界相当于你在图书馆借书时签的“借阅规则”要么全套流程都完成要么全都不算数。它决定了“哪些操作必须作为一个整体”。锁是悲观控制手段相当于书架上的实体锁你要拿某本书就得等前一个人还回来。它简单粗暴但保证绝对串行。MVCC是乐观控制手段相当于给每本书维护了多个历史版本有人正在看第 3 版你照样可以拿第 2 版副本看互不干扰。它牺牲一点点内存/磁盘换来极高的并发度。事务、锁、MVCC 不是独立工作的。事务开启后MySQL 会根据隔离级别决定哪些场景用锁哪些场景用 MVCC 快照读。这也是为什么很多人单独背了各种概念遇到实际问题还是不知道从何下手。理解三者的分工和交互才是真正解开 MySQL 并发控制的钥匙。2. 事务隔离级别是并发控制的“剧本设定”2.1 ACID 中一致性如何落实到并发锁事务的四大特性 ACID 大家耳熟能详但真正和并发控制强相关的只有两个原子性Atomicity和隔离性Isolation。原子性依赖 undo log 实现真出问题就回滚。隔离性则是通过锁和 MVCC 联合实现的。这就引出一个关键点隔离性不是一个“有或无”的问题而是一个“强或弱”的问题。MySQL 提供了四种隔离级别每一种对应一套不同的锁与快照策略隔离级别脏读不可重复读幻读实现手段读未提交 (READ UNCOMMITTED)可能可能可能写加排他锁读不加锁读已提交 (READ COMMITTED)不可能可能可能写加行锁读用快照每条语句生成新快照可重复读 (REPEATABLE READ)不可能不可能不可能InnoDB 默认写加行锁/间隙锁读用快照整个事务复用同一快照串行化 (SERIALIZABLE)不可能不可能不可能读写都加锁本质上完全串行大多数业务场景下MySQL 默认的可重复读已经足够。但注意隔离级别是“剧本设定”同样一条 SELECT在不同隔离级别下走的路径可能完全不同。我曾经见过一个团队线上突然出现大量锁等待排查到最后发现有人把隔离级别从 RR 改成了 SERIALIZABLE。一个普通 SELECT 在 RR 下是快照读在 SERIALIZABLE 下却会被转成当前读加共享锁整个系统的并发度直接崩塌。2.2 隔离级别如何驱动锁与 MVCC 的配合具体来说InnoDB 在四个隔离级别下的策略是这样落地的读未提交SELECT 不做任何版本控制直接读数据页上的最新数据。正因为如此它能读到其他事务未提交的修改也就是脏读。读已提交每次执行 SELECT 时生成一个新的 ReadView读视图因此只能看到这个时间点之前已提交的数据。它避免了脏读但没法避免“同一个事务里两次 SELECT 读到不同值”的不可重复读问题。可重复读事务第一次执行 SELECT 时生成 ReadView后续无论执行多少次 SELECT都复用同一个视图。这就是“可重复读”名字的由来。而幻读则是通过间隙锁Gap Lock和临键锁Next-Key Lock来规避的。串行化所有 SELECT 默认升级为SELECT ... LOCK IN SHARE MODE或FOR SHARE读写全部加锁。这不是快照读是纯粹的悲观并发控制。2.3 一个真实的库存超卖实验理论说完直接上实验。假设有一个product表里面只有一行数据id1, stock1。两个事务并发执行以下逻辑-- 事务A START TRANSACTION; SELECT stock FROM product WHERE id 1; -- 读到 1 UPDATE product SET stock stock - 1 WHERE id 1; COMMIT; -- 事务B几乎同时 START TRANSACTION; SELECT stock FROM product WHERE id 1; -- 如果先于A提交读到1后于A提交读到0 UPDATE product SET stock stock - 1 WHERE id 1; COMMIT;如果业务代码是“先查余量再在 Java 里判断是否大于 0最后执行 UPDATE”那么事务 B 在它自己的 SELECT 里读到 1判断通过最终也会执行 UPDATE。两条 UPDATE 本身因为有行锁会被串行执行但库存已经提前被业务代码里的旧值判断“预演”过了。最终结果就是两次扣减都成功库存变成 -1。正确做法是什么要么用SELECT ... FOR UPDATE把读取改成当前读让事务 B 的 SELECT 等事务 A 提交后再执行自然看到 stock0要么干脆用一条原子 UPDATEUPDATE product SET stock stock - 1 WHERE id 1 AND stock 0;然后通过影响行数affected rows来判断是否扣减成功。这个例子看着简单却是 MySQL 并发控制里最常见的失误以为开了事务就万事大吉没搞清楚事务本身并不会阻止“旧值判断”。3. 锁悲观路径上给数据“上保险”3.1 InnoDB 锁的家族体系InnoDB 的锁很多但常用的可以归成两个维度表级锁意向共享锁IS事务准备给某些行加共享锁。意向排他锁IX事务准备给某些行加排他锁。自增锁AUTO-INC LockINSERT 自增列时使用的特殊表级锁。行级锁记录锁Record Lock锁住索引记录本身。间隙锁Gap Lock锁住索引记录之间的间隙防止其他事务在间隙中插入。临键锁Next-Key Lock记录锁 间隙锁的组合锁住一个前开后闭区间。插入意向锁Insert Intention Lock插入前在间隙上声明的一种特殊锁多个事务可以在同一间隙上同时持有插入意向锁只要插入的位置不冲突。很多新手看到意向锁会困惑它到底锁住了什么答案是什么都没锁住。意向锁更像一个“表级状态标签”用来快速判断表级锁和行级锁是否冲突。如果没有意向锁某个事务要对整张表加表锁MySQL 就不得不逐行检查是否存在行锁效率太差。3.2 行锁的兼容矩阵与锁竞争行锁的兼容性很简单锁类型共享锁 S排他锁 X共享锁 S兼容冲突排他锁 X冲突冲突也就是说两个事务可以同时读同一行两个 S 锁兼容但一个写X 锁会阻塞其他所有读和写直到释放。这保证了写不丢失也保证了读不会读到中途修改的数据。这里有个极其重要的实操细节InnoDB 的行锁是建立在索引上的。如果你的 WHERE 条件没有走索引InnoDB 只能全表扫描相当于把每一行都加上锁锁的范围就从一行膨胀成了整张表。我以前接手过一个订单表status字段没建索引业务里有大量UPDATE order SET ... WHERE status PAID结果任何两个订单的更新都互相阻塞数据库负载直接打满。解决办法很简单给status建一个索引或者让 UPDATE 语句带上主键范围。哪怕是一个普通二级索引也能让行锁精确落在目标记录上。3.3 间隙锁与临键锁可重复读防线间隙锁的概念是 MySQL 并发控制里最反直觉的东西之一。它的作用是在可重复读隔离级别下防止其他事务向某个范围插入新记录从而避免幻读。举例说明。假设orders表里根本没有order_no ORD100的记录事务 A 执行START TRANSACTION; SELECT * FROM orders WHERE order_no ORD100 FOR UPDATE;这条语句返回空结果但因为用了当前读InnoDB 会在ORD100这个不存在的点上加一个间隙锁。这个间隙锁不会阻塞其他事务去查这条记录但会阻塞其他事务向这个间隙插入ORD100。换句话说事务 A 用一把“锁空气”的方式阻止了事务 B 往这个位置插数据。事务 B 的 INSERT 会一直等待直到事务 A 提交或回滚。间隙锁最坑的点在于它只防插入不防其他间隙锁。两个事务可以同时对同一个间隙加间隙锁然后各自尝试插入不同的记录。一旦插入的位置相互覆盖就很容易死锁。所以在高并发插入场景下如果要追求极致并发很多人会把隔离级别降到读已提交因为 RC 下 InnoDB 不会启用间隙锁只保留记录锁。3.4 自增锁的隐藏影响另一个容易被忽略的是自增锁。传统模式下执行一条多行 INSERT 时自增锁会一直持有到语句结束期间阻塞其他插入操作。参数innodb_autoinc_lock_mode可以调整0传统模式所有 INSERT 都用表级自增锁性能最差。1连续模式MySQL 8.0 默认普通 INSERT 使用轻量级互斥量批量的还是用表锁保证自增值连续。2交错模式自增值可以跳跃并发最高但不保证连续性。实际使用中如果没有“自增值必须连续”这种强迫症需求2模式配合 ROW 格式的 binlog 是没问题的。但如果你把innodb_autoinc_lock_mode2和 STATEMENT 格式的 binlog 一起用主从复制时自增值可能对不上这就是一个隐藏大坑。3.5 死锁的产生与处理死锁是并发控制里最让人头疼的问题。它的四个必要条件互斥、持有并等待、不可剥夺、循环等待。InnoDB 里最典型的死锁场景是两条 SQL 加锁顺序相反-- 事务A UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2; -- 事务B UPDATE account SET balance balance - 100 WHERE id 2; UPDATE account SET balance balance 100 WHERE id 1;A 持有 id1 的锁等待 id2B 持有 id2 的锁等待 id1两边互不相让形成死循环。InnoDB 有一个后台死锁检测机制默认开启发现死锁后会立刻回滚其中一个代价较小的事务并抛出Deadlock found when trying to get lock错误。你可以通过以下命令查看最近一次死锁的详细信息SHOW ENGINE INNODB STATUS;重点看LATEST DETECTED DEADLOCK部分它会列出两个事务各自持有什么锁、等待什么锁、执行的 SQL 是什么。避免死锁最有效的习惯就是多个资源的更新尽量保持相同的加锁顺序。比如上面这个场景让两个事务都先更新 id 较小的记录再更新 id 较大的记录死锁根本不会形成。我在团队里经常讲一句话死锁不可怕可怕的是死锁之外还有锁等待超时。死锁是 MySQL 主动帮你解开锁等待超时则是业务真的卡死了。4. MVCC乐观路径上“各读各的版本”4.1 隐藏列与 undo log 版本链MVCC 的实现依赖 InnoDB 在每行记录上维护的几个隐藏列DB_TRX_ID最近一次修改这一行的事务 ID。DB_ROLL_PTR回滚指针指向 undo log 中该行的上一个版本。DB_ROW_ID如果没有显式主键InnoDB 会用它作为聚簇索引。每一行数据被修改时InnoDB 不会直接覆盖旧值而是先把旧值写入 undo log再把新值写到当前记录DB_ROLL_PTR从新记录指向旧版本。多个事务依次修改同一行就会形成一条从最新版本向旧版本延伸的版本链。这个版本链就是 MVCC 的底层素材库。任何一个时刻事务想要读这一行都能顺着版本链找到自己“应该看到”的那个版本。4.2 ReadView 与可见性规则ReadView读视图是 MVCC 判断“这条版本链上哪个版本对当前事务可见”的核心结构。它包含四个关键信息m_ids生成 ReadView 时当前系统中所有未提交事务的 ID 集合。min_trx_idm_ids中最小的那个事务 ID。max_trx_id下一个将被分配的事务 ID也就是当前系统里最大的事务 ID 1。creator_trx_id创建这个 ReadView 的事务自己的 ID。判断逻辑通俗版如果版本里的DB_TRX_ID小于min_trx_id说明这个事务在我生成视图之前已经提交了可见。如果DB_TRX_ID大于等于max_trx_id说明这个事务在我生成视图之后才启动不可见。如果DB_TRX_ID在两者之间就检查它是否在m_ids中。在说明还没提交不可见不在说明已经提交可见。如果DB_TRX_ID等于我自己的事务 ID那就是我自己改的可见。这套规则决定了 MVCC 下“每个事务看到的数据库快照”到底是什么。它的本质是只承认在我这个视图生成之前就已经提交的那些修改不承认后续的修改和尚未提交的修改。4.3 快照读与当前读的系统性区别MVCC 只对“快照读”生效。什么是快照读就是普通SELECT语句不加任何锁直接读版本链。什么是当前读就是SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE这类必须要读“最新已提交版本”的操作。这条区分极其关键。在可重复读隔离级别下一个事务内先执行普通 SELECT然后又执行 UPDATE可能产生一种让人困惑的现象START TRANSACTION; SELECT stock FROM product WHERE id 1; -- 读到 1快照读 -- 另一个事务提交了 update把 stock 改成 5 UPDATE product SET stock 5 WHERE id 1; -- 当前读读到最新值 5UPDATE 走的是当前读路径它会主动读取最新已提交的数据而不会继续理会事务启动时生成的旧 ReadView。这是很多人容易踩的坑以为 RR 下整个事务都是“与世隔绝”的快照结果发现 UPDATE 会把外界的新变化“拽”进来。4.4 可重复读与读已提交的快照差异MVCC 的快照生成时机是 RC 和 RR 最核心的区别读已提交每一条 SELECT 语句执行前都会生成一个新的 ReadView。这意味着同一个事务里前后两条 SELECT 可能看到不同的数据因为中间可能有其他事务提交了。可重复读只在事务第一次执行 SELECT 时生成 ReadView之后一直复用。后边的所有 SELECT 都基于同一份快照因此不会出现“同一个事务两次读值不一样”的问题。举个例子。事务 A 启动后第一次 SELECT 看到balance100。这时事务 B 执行UPDATE balance200并提交。在 RC 下事务 A 的第二次 SELECT 会看到200在 RR 下第二次 SELECT 依然看到100。这就引出一个实践结论如果你的业务希望“每次查询都尽可能接近最新数据”但又不想承担锁开销选 RC 更合适如果业务要求“整个事务看到一致快照”RR 更合适。没有人能两全其美因为数据库的一致性就是这样要么你享受隔离带来的稳定要么你享受实时带来的新鲜两者不可兼得。5. 三角博弈的落地调优从原理到配置实战5.1 隔离级别选型与争议MySQL 默认隔离级别是 REPEATABLE READInnoDB 通过 MVCC 间隙锁已经把幻读问题拦住了。但很多生产环境会选择把隔离级别降为 READ COMMITTED。原因很简单RC 下没有间隙锁锁竞争更少并发吞吐更高而且配合binlog_formatROW时主从一致性也有保障。历史原因也值得一说。早年 MySQL 的 binlog 默认是 STATEMENT 格式只记录 SQL 语句本身。如果主库在 RR 下执行一条当前读锁定的范围如果在从库回放时锁不住从库的数据就可能不一致。正因为这个原因RR STATEMENT binlog 是早期最安全的主流组合。后来 ROW 格式和binlog_row_image优化逐步普及RC 才逐渐在更多业务里被接受。我给团队的默认建议是不要盲目跟风改成 RC。如果业务读多写少、对数据一致性要求高RR 不会成为瓶颈反而因为 MVCC 快照避免了更多锁等待。如果业务写多、热点集中、插入频繁RC 能带来更低的锁等待概率但要确保 binlog 用 ROW 格式。5.2 用对几个关键参数以下几个参数是并发控制实战里最常用的“控制开关”参数默认值说明innodb_lock_wait_timeout50事务等待行锁的最长时间超过则抛错Lock wait timeout exceededinnodb_deadlock_detectON是否开启死锁检测关闭后死锁只能靠锁超时兜底transaction_isolationREPEATABLE-READ事务隔离级别8.0 里取代了旧参数tx_isolationbinlog_formatROW8.0默认强烈建议 ROW避免 STATEMENT 格式下复制不一致autocommitON关闭自动提交 手动 COMMIT 是长事务超时的常见源头关于innodb_lock_wait_timeout我的实际建议是不要一味调大。50 秒内一个事务没拿到锁说明它大概率已经影响业务了与其让用户等 50 秒得到一条错误数据不如 5 秒快速失败让上层应用走降级或重试。我在一个高并发订单系统里把超时时间从 50 调成 5 秒配合应用层重试机制线上锁等待期间的“营业损失”反而降低了。5.3 热点行更新的排队化改造热点行是锁竞争的温床。最常见的场景是“爆款商品库存扣减”“单账户大额转账”。当大量事务同时更新同一行时即便每个人的 UPDATE 都极短排队也会让整体吞吐下降。可以从几个方向优化减少锁粒度不要把用户余额或库存只存在一行里。把一个库存拆成多个库存桶例如 10 个桶每个桶各存一部分库存。扣减时随机取一个桶进行 UPDATE热点被水平拆开锁竞争大幅下降。延迟扣减对秒杀这类场景不必在用户点击瞬间扣库存可以先用 Redis 做预扣再异步批量落地到 MySQL减少数据库事务数量和锁持有时间。控制事务大小一个事务里不要塞太多无关操作。锁持有时间越长被阻塞的并发事务越多。事务里只放必要 SQL其他耗时操作挪到事务外。这些手段不是理论上的花架子我在多个项目里实测拆桶方案在单行热点场景下能把 TPS 提升一个数量级以上。具体拆多少桶取决于并发量和业务容忍度一般 10~20 个桶起步。5.4 监控事务与锁的运行状态实战中我习惯定期查几个信息源能快速定位“是谁拿着锁不放手”-- 查看当前所有事务 SELECT * FROM information_schema.innodb_trx; -- 查看当前锁等待 SELECT * FROM information_schema.innodb_lock_waits; -- 查看当前持锁情况 SELECT * FROM information_schema.innodb_locks;注意 8.0 里innodb_locks改名成了performance_schema.data_locksinnodb_lock_waits对应的是performance_schema.data_lock_waits。如果你还在用 5.7 的旧视图写监控脚本升级到 8.0 后要同步更新。有了这些信息你可以快速判断是哪个事务持有了锁等了多久SQL 是什么。配合SHOW ENGINE INNODB STATUS里的锁信息基本能在几分钟内锁定问题。6. 高频问题与排障经验快查6.1 一张表搞懂典型症状我在实际操作中遇到的并发控制问题大部分都能归入下表症状可能原因处理思路Deadlock found when trying to get lock多个事务加锁顺序不一致形成循环等待查看SHOW ENGINE INNODB STATUS统一加锁顺序尽量一次 SQL 完成Lock wait timeout exceeded一条 SQL 等待行锁超过innodb_lock_wait_timeout查innodb_trx和innodb_lock_waits定位持锁事务优化 SQL 或拆分事务更新一行整表锁死WHERE 条件没走索引行锁升级为全表扫描锁给条件字段建索引或改用主键/唯一键作为过滤条件一个事务里两次 SELECT 结果不一致隔离级别是 READ COMMITTED每次 SELECT 新快照升级为 REPEATABLE READ或确认业务是否真的需要整个事务快照一致自增值跳跃或主从自增值不一致innodb_autoinc_lock_mode2或 binlog 格式为 STATEMENT根据业务要求调整自增锁模式binlog 切到 ROW长事务导致 undo log 膨胀磁盘飙高事务长时间不提交旧版本链一直保留控制事务大小避免一个事务里做大量操作及时 COMMIT/ROLLBACK6.2 一次线上死锁的排查复盘说一个我印象很深的案例。某天晚上线上突然出现大量死锁报错报错 SQL 是两条普通的 UPDATE。我拉出SHOW ENGINE INNODB STATUS发现两个事务都在更新一张用户积分表但更新顺序恰好方向相反一个按 user_id 从小到大一个按 user_id 从大到小。两个都不是恶意操作只是业务代码里两条路径用了不同的排序规则。那次之后我直接把团队里的数据库规范定成了一条强制要求凡是更新多条记录必须对记录的主键进行排序后再执行。用代码处理也就是一行orderBy的事但能让事务 A 和事务 B 的加锁顺序完全一致循环等待就失去了存在的土壤。这种问题靠调数据库参数是治标不治本根源在代码习惯。6.3 排查锁问题时的一个小技巧如果你怀疑某条 SQL 正在等待锁但不想打断业务可以用一个轻量级查询直接看出阻塞链条SELECT waiting_trx_id, waiting_pid, blocking_trx_id, blocking_pid FROM performance_schema.data_lock_waits;blocking_pid就是当前阻塞别人的人。拿到 PID 后在sys.sys_processlist或performance_schema.threads里查它正在执行的 SQL基本就能确定是谁在“赖着锁不放”。很多时候找到那个持锁的慢 SQL问题就解决了一大半。不要一上来就KILL线程如果那个事务正在做合法操作贸然杀掉会造成更大的业务损失。写在最后的一点实战心得我之前处理过一个库存系统开发同学把“扣库存写订单发消息”都塞进一个事务里结果消息队列抖动两秒钟整个事务就一直拿着库存行的锁不放后边所有用户的下单全部排队。当时我才意识到事务不是越大越好锁也不是越少越好关键是在正确的地方做正确的事。后来我们把发消息挪到事务外用事务消息或者本地消息表解决库存行锁的持有时间从几百毫秒降到了几毫秒系统整体吞吐直接翻倍。这个优化没有改任何数据库参数只是重新设计了一下事务边界。如果你现在读完了这篇文章最该带走的一句话是慢的不是锁本身而是你让锁锁住了太长时间。把事务划小、把 SQL 写准、把索引建对再把 MVCC 的快照读和锁的当前读分清MySQL 并发控制这盘棋你就算真正能下了。