1. 先从一张总被记混的兼容性矩阵说起做 MySQL 开发或者 DBA 的朋友十有八九都背过表锁兼容性这张表。S 锁和 S 锁兼容X 锁和谁都不兼容IS、IX 两个意向锁自己和自己兼容跟 S 锁在一起就要分情况。听起来很简单可真到了线上排查锁等待、写业务 SQL 的时候很多人还是会在 S 锁和 IX 锁、S 锁和 IS 锁这两个组合上卡壳。我在实际排查中见过不少类似的误判。比如有人看到事务 A 持有 X 锁事务 B 申请 S 锁被阻塞就开始怀疑是不是表结构出了问题再比如有人写了一个长事务里面先 SELECT 加 S 锁后面又要 UPDATE 同一张表结果自己把自己堵住最后闹到要 kill 线程。这些问题归根结底都是对 S、X、IS、IX 四种表锁的兼容关系只有“死记硬背”级别的理解没有建立起“为什么它们是这样的兼容关系”的底层认知。这篇内容就是来拆这件事的。我会先把四种锁的定义和它在整个锁体系中的位置讲清楚然后给出完整兼容性矩阵并逐一解释背后的设计动机接着用实际可复现的 SQL 操作把每种组合验证一遍再结合常见的业务场景说明理解了这张矩阵之后能在优化和排障中做什么。适合刚接触 InnoDB 锁机制的开发同学也适合那些已经写过不少 SQL 但从来没有系统梳理过锁兼容关系的从业者。2. 四种表锁的本质S、X、IS、IX 到底是什么2.1 S 锁和 X 锁共享和排他的两条基本线S 锁就是共享锁Shared Lock也常被叫作读锁。事务对一张表加 S 锁之后其他事务仍然可以对这张表加 S 锁也就是多个事务可以同时读这张表但其他事务不能再对这张表加 X 锁也就是没人能在这段时间里写入。X 锁是排他锁Exclusive Lock也叫写锁。谁拿到了 X 锁谁就拥有了这张表的独占权限其他事务无论是想读还是想写都会被卡在锁等待里直到 X 锁被释放。这里要特别强调一下“读”和“写”在锁层面上的真实含义。S 锁保护的通常是 SELECT、SELECT ... LOCK IN SHARE MODE 这类需要保证读取一致性的场景X 锁则对应 INSERT、UPDATE、DELETE 这类需要修改数据的操作。但要注意普通的 SELECT非锁定读在 InnoDB 默认的 REPEATABLE READ 隔离级别下走的是 MVCC 快照读它本身并不会主动去申请 S 锁。这一点特别容易让初学者困惑明明 SELECT 是读为什么它不申请 S 锁因为普通 SELECT 读的是快照不需要真正拦住别人写。S 锁和 X 锁是表锁层面的两种基本类型它们之间的兼容关系是整个矩阵的核心骨架。但真正让很多人搞不明白的其实是后面 IS 和 IX 这两个“意向锁”。2.2 IS 锁和 IX 锁给上层一个“预告”的信号IS 锁是意向共享锁Intention Shared LockIX 锁是意向排他锁Intention Exclusive Lock。这两个锁的名字里带着“意向”作用就是告诉 MySQL这个事务接下来可能要对这张表里的某些行加锁但加的是行级的 S 锁还是 X 锁还不好说先把意图登记在表级别。在 InnoDB 里真正的加锁单位是行锁行锁加在索引记录上。但行锁数量庞大如果每次要判断表级别能不能加 S 锁或 X 锁都得把这张表所有行锁扫一遍代价太大了。意向锁就是用来做这个“粗筛”的一个事务在给某一行加行级锁之前会自动先给包含这一行的表加一个意向锁。有了意向锁其他事务想给这张表加表级 S 锁或 X 锁时只需要看一眼表的意向锁状态就能快速决定自己是否需要等待而不用深入到每一行去检查。用一个类比来说意向锁就像是图书馆在门口挂的告示牌。挂“可阅读”的牌子说明里面有人在看书但不独占某个区域挂“部分占用”的牌子说明有人正在某个区域抄写资料。新来的读者想包下整个阅览室看一眼门口就知道不可能连进去翻座位都不需要。这就是意向锁存在的意义让表级锁的兼容性判断从“每行都查”变成“看一个标记”。2.3 为什么 InnoDB 没有直接省略 IS 和 IX有一种很自然的疑问是既然 InnoDB 的行锁本来就可以精确锁定某几行为什么还要在表级别维护 IS、IX 这两个锁直接不设表级锁不就完了吗这个问题的答案在“混合加锁”场景里能找到。比如一个事务对表里的一行加了 S 锁行级另一个事务想对整张表加 X 锁表级如果没有意向锁机制第二个事务就没有一个快速判断的依据只能去逐一扫描这棵 B 树上所有被锁定的行记录。在海量数据场景下这个扫描成本是不可接受的。意向锁把“这张表内部有没有行级锁”这个信息浓缩成了一个简单的表级标记让表锁和行锁之间可以低成本地互相协调。所以 IS 和 IX 并不是多余的它们是 InnoDB 多粒度锁定Multiple Granularity Locking体系中连接表级锁和行级锁的关键枢纽。3. 兼容性矩阵全解析每一对组合为什么是这样3.1 四乘四兼容矩阵一览把 S、X、IS、IX 四种锁放在同一个二维表里兼容性矩阵长这样锁类型XSIXISX冲突冲突冲突冲突S冲突兼容冲突兼容IX冲突冲突兼容兼容IS冲突兼容兼容兼容先记住最硬的一条规则X 锁是“全冲突选手”它和上面四种锁中的任何一种都不兼容。任何已经存在的锁无论是 S、X 还是 IS、IX都会阻止新事务获得 X 锁反过来一旦事务拿到了 X 锁其他事务再来申请任何一种锁都会被挡住。S 锁的兼容对象只有 S 锁和 IS 锁。它允许其他事务加读锁也允许其他事务提交“我准备读某些行”的意向但不允许别人加 X 锁也不允许别人提交“我准备写某些行”的 IX 意图。IX 锁可以和 IX 锁、IS 锁兼容但和 S 锁、X 锁冲突。这意味着两个事务可以同时表明“我要写这张表”因为真正的冲突要等行级锁具体落到同一行时才会爆发。IS 锁是最“随和”的它和除了 X 锁之外的三种锁都兼容。别人只是“准备读”并不会真正阻止你读或者写。3.2 最容易被忽略的组合一S 锁和 IX 锁为什么冲突很多开发者第一次看到矩阵时会觉得 S 锁和 IX 锁冲突非常不合理。理由是 IX 锁只是“意向写”真正写的行锁还没加上凭什么它就不能和表级 S 锁共存我来还原一个具体场景你就明白了。事务 A 执行 SELECT ... LOCK IN SHARE MODE它获取的是表级 S 锁加对应行级 S 锁。此时事务 B 想执行 UPDATE它会先给表加 IX 锁然后尝试给目标行加行级 X 锁。这里关键来了如果 InnoDB 允许 S 锁和 IX 锁共存事务 B 就可能成功拿到某一行的 X 锁而这一行恰好是事务 A 已经用 S 锁锁住的行。这时再看事务 A它认为整张表处于“只读”状态却有一行已经被改了数据一致性直接破防。所以 S 锁和 IX 锁冲突本质上是表级 S 锁对“整张表不能有任何写操作正在发生”这个语义的坚守。只要有人提交了 IX 意向就意味着这张表下面可能正在进行修改操作读锁持有者就必须等它结束。3.3 最容易被忽略的组合二X 锁为什么连 IS 意向锁都挡另一个让人产生疑惑的组合是 X 锁和 IS 锁。IS 锁只是一个“声明我要读某几行”的意图标记它甚至没有真正阻止别人修改数据为什么 X 锁也要把它挡在门外这里的逻辑和前面是镜像的。X 锁持有者的语义是“这张表现在归我独占我可能正在做各种写操作”。如果有人在这种情况下还去给这张表加 IS 锁他后续就可能对某些行申请 S 锁或 X 锁。如果 InnoDB 允许这个组合就需要时刻去判断 IS 锁的持有者后来到底申请了哪些行锁以及这些行锁和当前 X 锁持有者的行锁有没有重叠。这个判断不仅复杂还存在窗口期风险IS 锁加上的时刻可能不存在行冲突但下一秒加行级 S 锁时冲突就发生了。换句话说X 锁和 IS 锁的冲突本质是“我已经独占这张表了不允许任何新的读者或写者再进这张表去尝试按需加行锁”。3.4 IX 锁和 IX 锁为什么能“和平共处”矩阵里最反直觉的应该是 IX 锁和自己的兼容。两个事务都表明要对这张表做写操作为什么互相之间不冲突难道不会互相覆盖数据吗原因在于 IX 锁并不能锁定任何具体数据行。它只是一个“预告”表示持有者之后要在某些行上加 X 锁。真正决定两个写事务能不能并行的是行级 X 锁之间的冲突判断。假设事务 A 要更新第 1 行事务 B 要更新第 2 行同一个表上两个 IX 锁共存完全没问题——它们各自的行级 X 锁落在不同索引记录上互不干扰只有当两个事务要更新同一行时行级 X 锁冲突才会让其中一个事务等待。这个设计让 InnoDB 在高并发写入场景下不用做无谓的串行化。毕竟表锁的意义是粗粒度控制行锁才是精粒度控制粗粒度上过严的约束会让整个数据库的并发性能归零。提示IS 锁和 IS 锁、IS 锁和 IX 锁兼容的道理也类似——这些“意向”标记本质上都只是最上层的告示真正的账要等行级锁落地之后才算。4. 用三条连接实测把兼容矩阵一行一行验证出来概念讲再多不如亲自动手验证一次。下面这组实测只要你能在本地开三个 MySQL 会话五分钟就能完整跑完。4.1 实验环境准备我用的是 MySQL 8.0InnoDB 引擎隔离级别 REPEATABLE READ。需要一张测试表结构可以非常简单CREATE TABLE lock_test ( id INT PRIMARY KEY, name VARCHAR(20) ) ENGINEInnoDB; INSERT INTO lock_test VALUES (1, a), (2, b);最关键的一步把 autocommit 关掉。不然每条语句执行完就自动提交锁立刻释放根本观察不到等待。SET autocommit 0;需要三个终端窗口分别记为连接 A、连接 B、连接 C。4.2 验证组合一X 锁阻挡所有后继锁连接 ALOCK TABLES lock_test WRITE;这条语句让连接 A 持有 lock_test 表的 X 锁。注意LOCK TABLES 执行后当前连接里其他未被锁定的表都不能访问所以操作要集中在测试表上。连接 B分别尝试申请 S 锁、X 锁、IS 锁、IX 锁LOCK TABLES lock_test READ; -- 期望阻塞 LOCK TABLES lock_test WRITE; -- 期望阻塞 SELECT * FROM lock_test WHERE id 1 FOR SHARE; -- 内部会先申请 IS 锁期望阻塞 UPDATE lock_test SET name a1 WHERE id 2; -- 内部会先申请 IX 锁期望阻塞实测结果非常干净利落四种申请全部卡在等待中一个都进不去。这验证了 X 锁的“全冲突”性质。清理在连接 A 执行UNLOCK TABLES;4.3 验证组合二S 锁和 IS 锁兼容、和 IX 锁冲突连接 ALOCK TABLES lock_test READ;连接 A 持有 S 锁。连接 B先试 S 锁LOCK TABLES lock_test READ;这一步不会卡两个 S 锁并存。在连接 B 里执行UNLOCK TABLES;然后连接 B 再试 IS 锁SELECT * FROM lock_test WHERE id 1 FOR SHARE;这一步也不会卡因为 S 锁和 IS 锁兼容。FOR SHARE 语句会尝试申请表级 IS 锁和行级 S 锁表级 IS 锁顺利通过行级 S 锁本身也没有冲突所以查询能正常返回。接下来连接 B 再试 IX 锁UPDATE lock_test SET name b1 WHERE id 1;注意这条 UPDATE 需要先获取表级 IX 锁而当前 table 上有连接 A 的 S 锁S 锁和 IX 锁冲突UPDATE 会直接卡住。这一步验证的就是矩阵中 S 与 IX 冲突那一格。你可能会问UPDATE 后面跟着的也是行级 X 锁会不会是行级锁冲突导致的阻塞为了排除这个变量你可以把 WHERE 条件改成 id 2即连接 A 完全没碰过的行UPDATE 依然会被卡住。这就说明阻塞发生在表级 IX 锁这一步而不是行级锁。清理在连接 A 执行UNLOCK TABLES;连接 B 的 UPDATE 会在锁释放后自动继续执行。如果不想让它继续在连接 B 执行 ROLLBACK 就可以了。4.4 验证组合三IS 锁和 IX 锁互不冲突连接 ASELECT * FROM lock_test WHERE id 1 FOR SHARE;连接 A 现在持有表级 IS 锁和行 1 的行级 S 锁。连接 BUPDATE lock_test SET name a2 WHERE id 2;这条 UPDATE 会申请表级 IX 锁和行 2 的行级 X 锁。IS 和 IX 在表级是兼容的行锁也不在同一行所以 UPDATE 能立即成功。连接 CUPDATE lock_test SET name a3 WHERE id 2;连接 B 和连接 C 都持有 IX 锁表级兼容但它们更新的目标都是行 2行级 X 锁冲突所以连接 C 会卡住。这正好解释了 IX 锁和 IX 锁“表级兼容、行级算账”的设计精髓。清理连接 A、连接 B、连接 C 依次 ROLLBACK 或 COMMIT。4.5 一个必须说的坑LOCK TABLES 和事务语句混用如果你注意到上面的实验顺序会发现我先用了 LOCK TABLES显式表锁又用了 SELECT ... FOR SHARE隐式表锁。这两种方式的区别非常关键。LOCK TABLES 是 MySQL 提供的显式表锁语法它会在这条语句执行时立即加锁并且不遵循 InnoDB 的行锁协议。而 SELECT ... FOR SHARE 和 UPDATE 等语句是 InnoDB 内部在事务执行过程中自动加的表锁意向锁遵循的是行锁与表锁协同的协议。把 LOCK TABLES 的显式表锁和 InnoDB 的意向锁混在同一轮实验里能直观看出 X、S 和 IS、IX 之间的互相排斥关系但要注意实际业务系统里几乎不会有人直接使用 LOCK TABLES 来加表锁它更多是运维操作、备份或者批量任务中才会出现。InnoDB 日常事务中的表锁基本都以 IS、IX 形式存在真正的隔离靠的是行级锁。5. 兼容矩阵在真实业务场景里的三个应用方向5.1 用矩阵判断自己的 SQL 会不会阻塞别人这是兼容性矩阵最直接的应用。当你准备给某张表加锁时先想想现在可能有哪些锁存在再对照矩阵判断自己会不会被阻塞。举个例子你在跑一个批量报表希望期间这张表不要有人改数据于是准备加 S 锁LOCK TABLES report_table READ;在加锁前你查一下当前有没有事务正在写这张表。如果有一个事务正在执行 UPDATE它已经持有了 IX 锁。对照矩阵S 锁和 IX 锁冲突所以你这条加锁语句会一直等待直到那个事务提交或回滚。此时正确的做法不是干等而是去查 information_schema.innodb_trx 看看持锁事务跑了多久评估它什么时候能结束。如果持锁方是个刚启动的长事务直接改方案更明智。5.2 在长事务优化中预判锁升级路径理解了 IX、S、IS 的兼容关系你就能预判一个事务的锁升级过程。事务里跑的每一个写语句都会先获取 IX 锁这个 IX 锁会一直持有到事务结束。如果这个事务同时还有一条 SELECT ... FOR SHARE它还会额外持有 IS 锁和行级 S 锁。这里有一个非常典型的“自己堵自己”案例。某个应用里事务先查询一条数据并加 S 锁用于后续逻辑判断然后事务又要更新这条数据。如果这个 S 锁是在行级加上的而更新同一行需要行级 X 锁行级 S 锁和行级 X 锁冲突事务自己就会被自己挡住。这类现象在 MySQL 中是真实存在且挺常见的原因就是行级 S 锁转换 X 锁时会等待自身释放。兼容矩阵显然不能直接解决这种行级冲突但它能帮你在做 SQL review 时快速定位一个事务是不是同时获取了多个不兼容类型的表锁然后判断哪些等待是正常的哪些属于自身设计缺陷。5.3 在 ALTER TABLE 窗口期使用矩阵做安全判断还有一个高频场景是 DDL 操作。ALTER TABLE 在 MySQL 8.0 中多数情况下会以在线方式执行但执行过程中依然需要获取表的 MDL 锁Metadata Lock。MDL 锁虽然和表锁不是同一个东西但它的存在会影响新的 S、X 锁申请。很多人在凌晨执行大表 ALTER 时发现后续业务全部卡住就是因为在 ALTER 的某个阶段新的 SELECT、UPDATE 都无法获得允许。碰到这种情况我建议你在执行 DDL 前先查一下当前有哪些长事务SELECT * FROM information_schema.innodb_trx\G重点看 trx_started 比较早、trx_rows_modified 比较大的事务。如果它们已经持有了 IX 锁或 S 锁而你接下来要执行的 ALTER 会请求互斥的 MDL 锁那就要做好心理准备——DDL 会排队等着这些事务结束。5.4 优化建议合理选择加锁方式控制并发把兼容矩阵理解了之后你会发现一个结论表级锁本身并不是为了让你在业务代码里频繁使用的它是一个“兜底”和“协调”机制。真正的高并发方案应该是让事务尽量短、让行锁尽量少、让事务间的锁冲突尽量发生在不同的行上。从实战角度我有三条建议第一任何批量任务都尽量分批提交避免一个事务持有 IX 锁太长时间挡住后续其他事务的 S 锁申请第二报表类需求优先用快照读不要为了追求一致性随手加 LOCK IN SHARE MODE 或 FOR SHARE因为加 S 锁意味着你在告诉数据库“这段时间不允许有任何写操作发生”这对写入型业务是灾难第三监控里一旦发现有 “Lock wait timeout exceeded” 的报错第一时间去查持锁事务不要盲目重启应用。6. 常见问题与排查技巧实录6.1 如何查看当前谁在持锁、谁在等待排查锁问题最重要的入口是 information_schema 里的三张表innodb_trx、innodb_lock_waits、innodb_locks8.0 里是 performance_schema.data_locks 和 data_lock_waits。最简单的组合查询SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;找到 trx_state 为 RUNNING 且 trx_started 时间很早的事务通常就是持锁方。然后到 performance_schema.data_locks 里看具体锁对象SELECT ENGINE_TRANSACTION_ID, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks;LOCK_MODE 列会显示 X、S、IX、IS 以及行级锁的详细信息比如 X,REC_NOT_GAP 表示行级排他锁且不涉及间隙锁。当你看到一个事务持有 X 锁、另一个事务显示 S 锁或 IX 锁排队等待马上对照兼容矩阵定位阻塞方向非常快。6.2 锁等待超时和死锁的处理思路锁等待超时的特征是报错 ERROR 1205Lock wait timeout exceeded。这类问题的本质是持锁方迟迟不释放导致申请方一直等待。处理顺序应该是先查持锁事务是谁、跑了多久如果是一个无人认领的孤儿事务考虑 kill 对应线程如果是正常业务事务但执行时间过长评估是否需要优化 SQL 或缩小事务范围。死锁的报错则是 ERROR 1213Deadlock found when trying to get lock。死锁和锁等待超时的最大区别在于死锁中两个事务互相持有对方需要的资源MySQL 会自动检测并回滚其中一方。从兼容矩阵角度理解死锁的形成非常直观事务 A 持有 IX 锁申请行 2 的 X 锁事务 B 持有 IX 锁申请行 1 的 X 锁而 A 已经持有行 1 的 X 锁、B 已经持有行 2 的 X 锁于是两边都等对方释放。这种情况下 MySQL 会选择一个事务回滚释放它持有的全部锁。应对死锁的原则是让事务按固定顺序访问资源减少锁的持有时间尽量把 SQL 拆成更小的批次。这不仅是工程经验也是和锁兼容矩阵完全吻合的底层规律。6.3 我踩过的最深的一个坑隐式提交最后分享一个很容易被忽略的坑。很多人以为只有显式开启事务的语句才会持有表锁但实际上像 LOCK TABLES 这类语句会隐式提交当前事务。也就是说如果你在一个事务里先做了些数据修改接着执行 LOCK TABLES之前的事务会被自动提交未提交的数据直接落盘。这带来的连锁反应是你这个连接对表锁定状态的预期可能全部被打乱。原本你想借助 LOCK TABLES 来保护某个事务的一致性结果它先把事务提了锁的保护范围大大缩水。我在维护一个需要跨多表保持数据一致的脚本时踩过这个坑后来改成全部用 SELECT ... FOR SHARE 和 SELECT ... FOR UPDATE 来加锁彻底绕开了 LOCK TABLES 的隐式提交问题。如果项目里还在用 LOCK TABLES建议评估能不能替换成事务内的行锁或者直接依赖 InnoDB 的默认行为。显式表锁在 InnoDB 时代基本应该处于“能不用就不用”的状态理解了 S、X、IS、IX 的兼容矩阵之后你会明白它有太多使用限制很多时候并不划算。
阅读完成 · 觉得有帮助?