ARTICLE DETAIL

资讯详情

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

从原理到排查:MySQL事务隔离级别、锁机制与MVCC实战解析

从原理到排查:MySQL事务隔离级别、锁机制与MVCC实战解析 1. 从一次诡异的扣款事故说起并发事务到底在争什么先讲一个我今年春天处理过的真实问题这比任何理论都更能说明我们今天聊的东西有多重要。当时一个电商项目的账户模块上线后运营反馈有用户投诉两个人同时用一张优惠券下单系统居然都校验通过了最后券被扣成负数。代码里明明写了先查券状态可用则更新为什么还会超发问题就出在并发事务的隔离上——两个会话同时读到券状态是未使用然后各自执行更新。没有行锁的介入没有隔离级别的防护多线程一拥而上账就乱了。这个案例背后正好是 MySQL 面试被问烂、但能讲透彻的人不多的三件事锁机制、事务隔离级别、MVCC多版本并发控制。它们不是一个一个孤立的知识点而是 InnoDB 存储引擎为了在并发性能和数据一致性之间取得平衡而设计的整套协作机制。锁负责挡住写与写的冲突MVCC 负责让读不阻塞写、写不阻塞读事务隔离级别则定义了数据库以多大的容忍度去暴露并发期间的中间状态。把这三条主线串起来才算真正看懂了 MySQL 里一条 UPDATE 语句背后到底发生了什么。这篇文章我不会上来就铺概念而是按照我平时排查问题的思路来走先搞清楚事务隔离解决什么问题再看锁是怎么加上的最后深入 MVCC 的版本链和 Read View把快照读和当前读的差异讲透。中间穿插我自己在生产环境踩过的坑、验证过的 SQL 和清理锁等待的实操手法适合正在准备 MySQL 面试的开发者也适合工作中被线上死锁、锁等待超时折腾过的 DBA 和后端工程师。注意全文默认以 InnoDB 存储引擎为准MySQL 默认的 RR可重复读隔离级别是讨论重点因为国内绝大多数业务库跑的就是这个级别。2. 事务隔离级别四种级别到底隔离了什么2.1 并发事务捅出的三个娄子聊隔离级别之前必须先把并发事务引发的问题说清楚。你想象两个事务 A 和 B 同时在操作同一批数据会出现三类经典的异常现象。第一类是脏读Dirty Read。事务 A 修改了一条记录但还没提交事务 B 此时读到了这条半成品数据。然后 A 回滚了B 手里拿到的数据就成了一个从未真实存在过的值。这就像你看同事的 Excel 编辑到一半没保存你基于那个半成品做了统计回头他 CtrlZ 全撤销了你的统计就废了。第二类是不可重复读Non-Repeatable Read。事务 B 在同一个事务里两次执行同一条 SELECT第一次读到的是 A 提交前的旧值第二次读到的是 A 提交后的新值同一查询两次结果不一样。注意这里 A 是提交了的不存在脏数据但 B 在一个事务内的读结果不稳定。第三类是幻读Phantom Read。事务 B 执行范围查询比如查本月订单金额大于 500 的订单第一次查到 10 条这时事务 A 插入了一条新的符合条件的数据并提交B 再查一次发现变成 11 条。多出来的那条像幽灵一样出现这就是幻读。它和不可重复读的本质区别在于不可重复读是行数据内容变了幻读是记录数量变了。MySQL 官方文档把这三种现象统称为事务隔离问题而隔离级别就是用来控制哪些现象可以被接受、哪些必须杜绝。四级之间是层层收紧的关系从允许一切到全部禁止。2.2 四种隔离级别与默认行为SQL 标准定义了四种事务隔离级别InnoDB 对它们的支持程度如下隔离级别脏读不可重复读幻读加锁情况READ UNCOMMITTED读未提交可能可能可能不加锁读写加行锁READ COMMITTED读已提交不会可能可能读加快照每语句新快照REPEATABLE READ可重复读不会不会基本不会InnoDB 解决了读加快照事务内复用快照SERIALIZABLE串行化不会不会不会读也加锁退化为并发串行表格里有个非常重要的细节SQL 标准说 RR 级别防不住幻读但 InnoDB 的 RR 级别通过间隙锁和 MVCC 实际把幻读也解决了。这是 MySQL 和其他数据库比如 Oracle差异最大的地方。所以我常说在 MySQL 里面试聊隔离级别不能只背标准答案要讲清楚 InnoDB 是超纲完成了任务。而 MySQL 的默认隔离级别就是REPEATABLE READ。很多人会有疑问为什么默认不选更严格的 SERIALIZABLE原因很现实串行化把所有读都变成当前读加锁并发吞吐惨不忍睹。RR 在 InnoDB 的机制加持下既能保证绝大多数场景的一致性需求又有不错的并发性能是工程上的最佳折中。生产系统很少主动去改这个默认值除非你有非常明确的数据可见性要求。2.3 实操验证用一条会话看清脏读与不可重复读理论说多了容易飘我们直接连上 MySQL 用两个终端验证。下面这套操作我每次培训都会现场演示比任何截图都直观。会话 A 执行SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;然后BEGIN;更新一行数据但不提交。会话 B 也把隔离级别调到 READ UNCOMMITTED直接SELECT那行数据你会发现能读到 A 未提交的修改——这就是脏读。如果换到 READ COMMITTED 级别B 再查就查不到脏数据了因为 READ COMMITTED 每条语句都会生成一个新的 Read View只会看到已提交的修改。但 READ COMMITTED 有个典型问题同一个事务里两条 SELECT 之间如果别的事务提交了更新B 的事务内两次查询结果会不一致。我在项目里就遇见过类似问题统计报表的存储过程里两次读同一张汇总表一次读到老数据、一次读到新数据最后对账不平。后来排查半天发现就是存储过程所在的会话默认级别虽然不是 READ COMMITTED但因为内部手动切换了级别导致两次快照不一致。验证不可重复读很简单会话 A 和 B 都调成 READ COMMITTEDB 先BEGIN查一次A 更新并提交B 再查一次结果变了。而把 B 的级别调成 REPEATABLE READ 再重复上述操作B 两次查到的结果始终一致——因为 RR 级别下事务内的 Read View 是第一次 SELECT 时就确定的后面一直复用这个快照。3. 锁机制全景从表锁到行锁的完整体系3.1 锁的分类与一棵树MySQL 里的锁从不同维度可以画出清晰的分类树。按粒度分表锁Table Lock、行锁Row Lock、页锁Page Lock用的少。按模式分共享锁S Lock读锁、排他锁X Lock写锁。按加锁策略分意向锁Intention Lock、记录锁Record Lock、间隙锁Gap Lock、临键锁Next-Key Lock。InnoDB 实际支持的锁粒度是行锁和表锁但表锁也分成两类显式的LOCK TABLES命令加的是表级锁而 DDL 语句加的则是 MDLMeta Data Lock元数据锁。两个锁模式的核心兼容关系很多经验不深的人会搞混共享锁和共享锁兼容。两个事务可以同时读同一行。共享锁和排他锁互斥。有人读时不能写有人写时不能读。排他锁和排他锁互斥。同一行在同一时间只能有一个事务持有写锁。这个兼容矩阵是所有并发控制的地基。我在实际调优时最常做的事情就是通过performance_schema把锁等待关系拉出来看谁在等谁等的是 S 锁还是 X 锁然后倒推业务 SQL 为什么加了不兼容的锁。3.2 行锁三件套Record Lock、Gap Lock、Next-Key LockInnoDB 的行锁不是整行一把锁这么简单。它在索引项上加锁而且会根据查询条件的范围决定到底锁住哪些位置。这里必须分清楚三种行锁形态。Record Lock记录锁锁住索引中的某一条记录。它要求查询条件命中的是唯一索引或主键并且能精确定位到唯一一行。例如SELECT * FROM user WHERE id 10 FOR UPDATE如果 id 是主键这里加的就是记录锁锁住 id10 这一行。记录锁是排他性的别的会话想改这行就会被阻塞。Gap Lock间隙锁锁住一个范围只是这个范围内没有实际记录存在锁的是间隙。比如 user 表里 id 只有 1、5、10 三条记录你对WHERE id BETWEEN 3 AND 8加锁那 (1,5) 和 (5,10) 这两个区间会被锁住作用是堵住其他事务在这个区间内插入新记录——这正是解决幻读的关键。间隙锁有个特性很多人不注意它只阻塞插入不阻塞查询和更新因为锁的本意就是这里暂时不允许长出新数据。Next-Key Lock临键锁可以理解成记录锁 间隙锁的组合锁的是一个左开右闭区间。比如还是上面三条记录在 RR 级别下WHERE id 1 AND id 10加锁实际锁住的范围可能是 (1,5]、(5,10]既锁住了已有记录又锁住了它们之间的间隙。临键锁是 RR 级别默认的行锁形态也是 InnoDB 防幻读的主力。我遇到过一个真实场景一张订单表按订单号做范围查询WHERE order_no 2025010000 AND order_no 2025019999 FOR UPDATE结果一个只有几十行数据的小表在高并发下频繁出现锁等待超时。排查之后发现就是 Next-Key Lock 把整个查询范围的所有索引区间都锁住了两个并发事务各自持锁后又去查对方范围内的数据直接互相阻塞。后来改成只对具体的订单号加锁操作问题立刻缓解。3.3 意向锁表锁和行锁之间的协调员意向锁是我见过最容易被初学者忽略、却在加锁链路中必不可少的机制。它的存在意义就是为了让表锁和行锁之间能快速判断是否存在冲突。假设事务 A 对某张表的几行加了行级排他锁此时事务 B 想对整张表加表级共享锁比如用LOCK TABLES user READ数据库需要知道表里有没有行级锁在占用。如果没有意向锁B 就得逐行扫描检查效率低到没法用。有意向锁之后A 在加行锁之前会先给表加一个意向排他锁IXB 加表级共享锁之前发现表上已经有 IX立刻判断出表上存在行级写锁我不能加表级共享锁从而快速进入阻塞等待。意向锁的兼容规则很简单意向锁之间互相兼容但意向锁和对应模式的所有权冲突。比如 IS 和 IX 兼容因为两个事务想各自锁不同的行互不影响但 IX 和 S 锁冲突IX 和 X 锁冲突因为表级写锁和任何行级锁都不共存。理解了这个先加表锁意向、再加行锁的两阶段加锁流程你才能看懂SHOW ENGINE INNODB STATUS里面那一堆 LOCK_MODE 的含义。3.4 说三个排查锁问题的实用命令生产环境遇到锁问题我常用的不是去翻 B 站教程而是这几个顺手就上的命令排查当前有哪些事务在跑、是否处于锁等待状态SELECT * FROM information_schema.INNODB_TRX\G查看某个事务具体在等哪把锁SELECT * FROM performance_schema.data_lock_waits\G在 MySQL 8.0 中data_lock_waits比老版本的INNODB_LOCK_WAITS信息更全能直接看到阻塞和被阻塞的事务 ID。配合sys.innodb_lock_waits视图使用更舒服一条 SQL 就能看到谁在等谁、等了多久、执行的是什么 SQLSELECT * FROM sys.innodb_lock_waits\G我排查死锁和锁等待的标准动作是先开一个会话执行SHOW ENGINE INNODB STATUS\G拉到LATEST DETECTED DEADLOCK段落那是死锁现场的第一手材料里面包含两个事务的 SQL、持有的锁、等待的锁。等看完这个再结合上面三个数据字典视图去回溯业务代码基本能定位到问题语句。后面第 6 节我会专门拆一个死锁日志案例。4. MVCC 底层原理版本链与 Read View 的运作逻辑4.1 undo log 版本链是怎么长出来的MVCC 全称 Multiversion Concurrency Control多版本并发控制。它最核心的思想是同一行数据在数据库里可能存在多个版本每个版本对应一次修改。读操作可以只读取某个合适的版本而不去阻塞写操作。这个思路翻译成人话就是大家都别抢同一份数据每个人看自己该看的那个历史版本。那多个版本是怎么保存的靠的就是undo log回滚日志。每当你对一行数据执行 UPDATE 或 DELETE 时InnoDB 先把这行修改前的旧值写入 undo log然后才去改当前记录。这些 undo log 会通过一个隐藏字段DB_ROLL_PTR回滚指针串成一条链表链表的头部是最新版本尾部是最早的版本。举个例子。假设 user 表 id1 的 name 最初是Alice执行三次 UPDATE 改成 Bob, Carol, David。在 InnoDB 内部这行数据会变成当前版本 nameDavid回滚指针指向前一个版本 nameCarolCarol 的回滚指针指向 BobBob 的回滚指针指向 Alice。每个版本还带着DB_TRX_ID事务 ID标记是哪个事务产生的这个版本。这条从新到旧的链就是 undo log 版本链。MVCC 读数据的时候不会傻乎乎地直接读当前最新值而是沿着版本链从新往旧找找一个我当前事务的 Read View 认为可见的版本。这就是多版本并发控制的核心操作版本链 可见性判断。旧版本不会一直存在当没有任何事务需要它们时MySQL 的 purge 线程会把它们清理掉。4.2 Read View判断版本可见性的规则手册Read View读视图是 MVCC 判断可见性的核心数据结构。它在一个事务执行快照读即普通SELECT时生成记录了几个关键信息creator_trx_id创建这个 Read View 的事务 ID。m_ids生成 Read View 时当前系统中活跃事务的 ID 列表。活跃事务指的是已经开始但还没提交的事务。min_trx_idm_ids 中最小的那个事务 ID。max_trx_id生成 Read View 时InnoDB 预分配的下一个事务 ID也就是目前尚未出现的事务 ID 下限。有了这几个值判断一个版本是否可见就变成了一套简单的规则。假设版本链上某个数据版本的事务 ID 是 trx_id如果 trx_id min_trx_id说明这个版本在 Read View 生成之前就提交了可见。如果 trx_id max_trx_id说明这个版本是在 Read View 生成之后才产生的不可见。如果 min_trx_id trx_id max_trx_id则看 trx_id 是否在 m_ids 活跃列表里不在列表里说明这个事务已提交可见。在列表里说明这个事务还没提交不可见。特殊情况如果 trx_id 等于 creator_trx_id说明当前事务自己修改的版本自己当然能看到。这套规则刚接触时会觉得绕我一般会用生活场景类比Read View 就像一个相机在某个瞬间按下快门拍下了一个活跃名单数据版本只有拍照前已提交或者就是你本人拍的才值得被看到其他一律视为不存在。4.3 快照读与当前读两个阵营的读写行为理解了版本链和可见性规则MVCC 的读行为就能分成两大类型这也是面试高频考点。快照读Snapshot Read就是我们平时最常见的SELECT不加任何FOR UPDATE或LOCK IN SHARE MODE。这种读不会加锁而是基于事务启动时或第一次 SELECT 时生成的 Read View沿着版本链读取一个历史快照。所以快照读永远不阻塞别人别人提交新版本也影响不了当前事务已经固定的快照——这就是 RR 级别下读一致的来源。当前读Current Read指的是读取最新已提交版本并且会加锁的读操作。比如SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT语句中涉及到的读取都属于当前读。当前读每次都要读最新数据所以它天然需要走加锁流程会和其他事务的写锁发生冲突。这里有个特别容易误解的点UPDATE 语句是先读后写。它先做一次当前读定位到目标行并加 X 锁然后修改行数据并生成新版本。因此如果有两个事务同时执行同一行的 UPDATE后到的事务会在当前读那个环节就被阻塞住而不是等前面事务提交后才开始竞争。这也是为什么高并发更新同一行数据时大量会话会堆积在锁等待状态。MVCC 与锁的分工由此变得清晰读读并发完全不阻塞全靠快照读各看各的版本写写并发必须串行由行锁和当前读拦路。数据库通过这种方式实现了读写不互斥这是它能维持高并发的最根本原因。5. 三大机制协同可重复读为什么能防住幻读5.1 锁与 MVCC 的读归读、写归写分工当我们同时理解了锁和 MVCC就能越过单个知识点看到 InnoDB 的整体协作架构。一个事务在 RR 隔离级别下执行时它的每次读和写走的是两条完全不同的路径。普通 SELECT快照读靠 MVCC 的 Read View 去版本链上找可见版本不走锁。它对并发性能几乎零损耗这也是为什么线上查询量再大只要不涉及同一行的写竞争数据库都能扛住。而 UPDATE、DELETE、SELECT FOR UPDATE当前读则强制走锁系统。事务先拿当前读定位记录给命中的索引项加锁可能是记录锁、间隙锁、临键锁然后生成新版本。锁在这里的作用不是防止别人读而是防止别人同时改同一行或往同一范围插入新数据。所以你会看到一个很有意思的现象InnoDB 的 RR 隔离级别里一个事务内执行两次同样的SELECT结果完全相同——这是 MVCC 快照读在发挥作用但同时如果另一个事务尝试往这个事务查询的范围里插入一条新数据会被间隙锁挡住——这是锁在发挥作用。两条战线互相配合共同实现了比 SQL 标准更强的隔离保证。5.2 实操拆解这条 UPDATE 语句到底加了哪些锁空谈概念不如实战拆解。我们假设有张表 bookid 是主键isbn 是唯一索引title 是普通索引price 是普通字段。表里有 id1、2、3 三行数据分别对应 isbn1001、1002、1003。现在执行这么一条语句BEGIN; UPDATE book SET price 66 WHERE title MySQL实战;这条语句在 RR 级别下的加锁过程大致如下。第一步InnoDB 通过快照/当前读定位到 title 索引上满足条件的记录——这里假设 title 上有普通索引并且命中了 id1 和 id3 两行。第二步对 title 索引命中的记录加 Next-Key Lock这意味着除了 id1 和 id3 这两条记录外它们之间的索引间隙也会被锁上。第三步由于 InnoDB 的行锁最终要落到主键上所以还会对命中的 id1 和 id3 的主键记录加记录锁X 锁。这其中的关键点在第二步普通索引上的范围更新会锁间隙。如果 title 索引上 id1 和 id3 之间还有一条业务上不存在、但索引间隙允许插入的位置任何往这个范围插入新记录的会话都会被阻塞直到本事务提交。这就是为什么很多 UPDATE 语句明明只改了两行却在并发下把整个范围内的插入都堵住了。很多 DBA 给出的优化建议是高频更新的业务表尽量让 WHERE 条件命中主键或唯一索引避免普通索引范围更新触发间隙锁。这句话背后的原理就在这里——锁的范围从明确的一行扩大到了一个区间。5.3 一个经常被忽略的细节RR 下的唯一索引冲突再分享一个平时容易踩坑的场景。在 RR 隔离级别下执行INSERT语句时如果发生唯一键冲突InnoDB 会对已存在的记录加共享锁S 锁并做一次插入检查。此时如果另一个事务正在对该记录做UPDATE并持有 X 锁插入事务就会在 S 锁等待中阻塞。这个情况在批量插入场景里特别容易爆雷一个批量脚本向一张表插入数据其中有一部分和已有数据主键或唯一键冲突另一部分是新的。冲突的部分触发了 S 锁等待而后面的新数据又必须等前面的插入事务结束。如果并发多个这样的批量任务就可能形成连环等待甚至死锁。我的处理办法是批量插入前先做一次去重查询把已存在的记录剔除掉如果要插入的数据量很大则把存在性检查和插入放在同一个事务里且用INSERT ... ON DUPLICATE KEY UPDATE替代先查后插减少锁竞争窗口。6. 实操排查锁等待、死锁与性能优化6.1 锁等待超时别只调 timeout 参数线上数据库经常报这样的错Lock wait timeout exceeded; try restarting transaction。很多人第一反应是调大innodb_lock_wait_timeout参数默认值是 50 秒调成 120 秒后问题看似解决实际是把更大的隐患往后推了。我的建议是先定位到阻塞源头再决定是否调参。用第 3.4 节提到的sys.innodb_lock_waits视图直接查当前所有锁等待的会话找到阻塞者看它的事务到底持有什么锁、执行到什么阶段。多数情况下阻塞源是某个长事务没有及时提交或者代码里事务边界没控制好一个方法里嵌了 RPC 调用、文件 IO导致事务挂了几十秒。案例很典型同事在业务代码的Transactional方法里先查数据再调外部接口最后才更新数据库。外部接口超时 30 秒这个事务就一直敞开着不释放锁。别的线程更新同一行数据全被卡住。解决办法不是调 timeout而是事务内严禁远程调用把外部 IO 挪到事务外面。这个原则我在代码评审中讲了不下十次每次都会有小伙伴踩坑。6.2 死锁日志到底怎么读死锁是最让新手头疼的问题因为 MySQL 会随机选一个事务回滚业务代码直接抛异常。我来说说自己读死锁日志的固定流程。执行SHOW ENGINE INNODB STATUS\G输出内容拉到LATEST DETECTED DEADLOCK段落核心信息包括两部分。第一部分是事务 1 和事务 2 各自执行过的 SQL 语句以及它们持有的锁holds和等待的锁waits for。第二部分是死锁的仲裁结果MySQL 选择了哪个事务回滚。看日志的重点是画出锁等待环。比如事务 1 持有 A 行的 X 锁等待 B 行的 X 锁事务 2 持有 B 行的 X 锁等待 A 行的 X 锁——两者互相等待这就是典型的死锁环。常规解法是让两个事务按固定顺序访问资源比如约定先更新主表再更新明细表或者先按 id 排序再逐条更新破坏环路的形成。还有一种死锁是间隙锁造成的日志里会看到LOCK_MODE: X,GAP或者LOCK_MODE: X,REC_NOT_GAP,X,GAP。这类死锁更隐蔽因为它不是同一行的锁竞争而是不同事务的间隙锁相互排斥。我记得有一个案例是两张表互做先删后插操作A 事务删 T1 插 T2B 事务删 T2 插 T1间隙锁一叠加死锁立刻触发。最后的处理方案是把批处理操作改成同一个事务内统一顺序消灭交叉。6.3 从锁机制出发的优化清单当了这么多年数据库背锅侠我总结了一套基于锁机制和 MVCC 原理的优化清单按优先级排务短小事务里只做必要的数据库操作远程调用、消息发送、文件操作全部移出。锁持有时间越短竞争概率越低。走索引UPDATE 和 DELETE 的 WHERE 条件一定要命中索引否则 InnoDB 会锁全表扫描到的所有记录甚至加上间隙锁等于把表的写并发直接干没。避免热点行更新像计数器、库存这种被高频更新的行尽量用原子操作或在应用层做合并更新减少同一行的锁等待。关注索引顺序复合索引的设计要贴合 WHERE 条件的过滤顺序否则范围条件会让后面的索引列无法用于定位锁范围进而扩大。大批量操作分批做一次性 UPDATE 几万行会在相当长一段时间内持有大量行锁其他事务全部排队。分批提交能显著降低锁竞争。还有一个小技巧我基本每次都会推荐给业务团队在代码里给更新操作设置明确的超时时间。以 Java 为例SET innodb_lock_wait_timeout是实例级配置不方便单独改但可以在连接层面通过SET SESSION innodb_lock_wait_timeout 10来控制单个会话的等待上限。这样即使出现异常锁等待业务也不会卡死在请求上而是快速失败并触发重试逻辑。写在最后我个人在几次排障过程中最深的体会是锁、事务隔离和 MVCC 这三件事单独背概念都很容易难的是把它们放到一个真实的并发场景里推演。每次遇到死锁或者奇怪的锁等待我都会回到这条主线上来问自己这个事务的隔离级别是什么它执行当前读的时候加了什么锁它的快照读基于哪个 Read View三条线索一交叉问题基本就浮出水面了。最近我还在继续整理这个系列的第二部分打算专门聊 InnoDB 在 RR 隔离级别下各种 SQL 语句的加锁矩阵以及从源码层面看 Read View 的生成时机。如果你在工作中也遇到过类似的现象欢迎在评论里把场景描述出来我们拿真实案例去拆解比背一百遍概念都管用。
返回列表