ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL并发控制:事务、锁与MVCC的协同机制与实战排查

MySQL并发控制:事务、锁与MVCC的协同机制与实战排查 做后端这些年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 1InnoDB 会先定位到 id1 这条记录并加排他锁锁然后读取当前最新数据当前读修改后生成一个新版本旧版本连同事务 ID 一起写入 undo logMVCC 的版本链。这个事务最终提交还是回滚由事务机制控制。整个过程里锁保证同一时刻只有一个事务能改这条记录MVCC 保证其他事务的普通 SELECT 还能读到修改前的快照不用傻等。再举一个订单查询的例子。事务 A 正在修改订单状态还没提交事务 B 同时执行普通SELECT查这张订单。如果只有锁机制B 必须等 A 提交才能读体验极差。有了 MVCCB 直接读取旧版本的已提交数据A 的修改对 B 完全不可见。这正是读写分离在存储引擎层面的实现方式。2. 隔离级别先定规则再谈并发2.1 四档隔离级别的效果差异隔离级别本质是你愿意牺牲多少一致性去换并发。MySQL InnoDB 默认是 REPEATABLE READRR但这不是唯一选择。四大级别从宽松到严格分别是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 先更新 id1 再更新 id2事务 B 同时先更新 id2 再更新 id1。两个事务各持一把锁互相等待对方手里的下一把锁循环等待形成死锁。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 与 ReadView4.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 插入一条 id1、balance100 的记录并已提交。事务 200 把 balance 更新为 200但尚未提交。事务 300 启动并发起普通 SELECT生成 ReadView此时m_ids [200, 300]min_trx_id 200max_trx_id 301。读取 id1 时最新版本是 trx_id200它落在m_ids里不可见沿DB_ROLL_PTR找到 trx_id100 的旧版本100 小于min_trx_id可见。所以事务 300 读到 balance100。如果事务 200 此时提交了m_ids里不再有 200版本 trx_id200 就可见事务 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 细节你会发现所有知识点都串在同一条线上给并发留空间给一致设底线。
返回列表