MySQL - 锁

发布时间:2026/7/24 17:19:33
MySQL - 锁 MVCC 只能解决快照读该看哪个版本而INSERT/UPDATE/DELETE以及SELECT ... FOR UPDATE这类当前读必须读最新数据、还要防止别人同时改靠的不是版本链而是锁。这篇就把锁这套机制补齐——它是隔离性的另一半也是脏写和幻读最终被堵住的地方。这篇文章按为什么要加锁 → 锁怎么分类 → 表级锁 → 行级锁重点→ 悲观锁与乐观锁 → 锁怎么选 → 死锁 → 最佳实践这条线索展开重点还是放在每种锁为了解决什么问题而设计上。目录为什么需要锁锁的全景三个划分维度全局锁与表级锁意向锁一个容易被忽略的设计行级锁InnoDB 的主战场间隙锁与临键锁怎么防住幻读悲观锁与乐观锁实战锁该怎么选死锁成因、检测与自动回滚死锁的排查与规避最佳实践一、为什么需要锁并发事务如果不加约束地混着跑会踩几个坑这几个问题在 MVCC 那篇列过这里只回顾结论脏写、脏读、不可重复读、幻读。其中读操作普通 SELECT的问题——脏读、不可重复读、幻读——可以靠 MVCC 的版本链 ReadView 解决让读不阻塞写、写不阻塞读并发性能好。写操作以及必须读最新版本的当前读SELECT ... FOR UPDATE/LOCK IN SHARE MODE、UPDATE、DELETE、INSERT没法靠多看一个历史版本糊弄过去——两个事务同时要改同一行必须有一个先等着。这种必须排队的约束就是锁。所以可以把 InnoDB 的并发控制理解成两条腿走路快照读用 MVCC当前读用锁。脏写这种最严重的问题任何隔离级别都不允许靠的就是锁——一个事务改了某行就锁住它另一个事务想改必须等它提交、释放锁。二、锁的全景三个划分维度MySQL 的锁乍看很多其实是从三个不同维度去切同一批东西理清维度就不乱了① 按锁的粒度分——锁的是整个库、一张表还是一行粒度典型代表特点全局锁FLUSH TABLES WITH READ LOCK整库只读主要用于全库备份表级锁表锁、意向锁、AUTO-INC 锁加锁快、开销小但并发度低行级锁记录锁、间隙锁、临键锁加锁慢、开销大但并发度高InnoDB 的看家本领粒度越细并发度越高但加锁/解锁本身的开销也越大——这是一对天然的权衡。② 按锁的功能分——是读锁还是写锁共享锁S 锁 / 读锁允许多个事务同时读同一份数据但阻塞写。排他锁X 锁 / 写锁独占一个事务持有时其他事务既不能读指当前读也不能写。兼容性可以记成一句话读读兼容读写、写写都互斥。S 锁X 锁S 锁✓ 兼容✗ 互斥X 锁✗ 互斥✗ 互斥③ 按设计思想分——悲观还是乐观悲观锁默认会发生冲突所以操作前先加锁前面说的 S/X 锁都属于这一类。乐观锁默认不会冲突不加数据库锁靠版本号或 CAS 在提交时检测冲突第七节细说。这三个维度是交叉的比如行级 排他 悲观就是一次SELECT ... FOR UPDATE锁住一行。下面几节按粒度从粗到细展开。三、全局锁与表级锁全局锁FLUSH TABLES WITH READ LOCK会让整个库进入只读状态所有写操作都被阻塞。它的典型用途是全库逻辑备份——保证备份期间数据不变、快照一致。但代价极大整库不能写所以现在更常用的是基于 MVCC 的一致性快照备份mysqldump --single-transaction只有 MyISAM 这种不支持事务的引擎才不得不上全局锁。表级锁分几种表级 S/X 锁LOCK TABLES ... READ / WRITE手动给整张表加读锁或写锁。这里有个容易搞混的点——InnoDB 执行普通的增删改查并不会加表级 S/X 锁它有行锁可用这个表级锁在 InnoDB 里相当鸡肋只在崩溃恢复等特殊场景才用得上主要是 MyISAM 这类只有表锁的引擎在用。批量更新一张 MyISAM 表时与其逐行加锁不如直接一把表级写锁反而省开销。元数据锁MDL这不是 InnoDB 的锁而是 server 层的锁但很值得一提。执行SELECT/INSERT/UPDATE/DELETE时会自动加 MDL 读锁执行ALTER TABLE/DROP TABLE这类 DDL 时会加 MDL 写锁——读写互斥。它的作用是防止一个事务正在读写表时、另一个会话把表结构改了。所以线上给大表加字段前一定要小心长事务没提交时执行 DDLDDL 会被 MDL 卡住进而把后面所有对这张表的查询全堵死。意向锁IS / IX表级的辅助锁由 InnoDB 自动加是一个很巧妙的设计单独放到下一节讲。AUTO-INC 锁给自增列服务的表级锁保证并发插入时自增值连续、不重复。它有个特点语句级别插入语句一执行完就释放不等事务提交和普通锁不一样。具体加不加这把重锁由innodb_autoinc_lock_mode控制——为 0 时一律用 AUTO-INC 锁、并发插入互相阻塞为 2 时一律用更轻量的锁、并发高但同一事务分到的自增值可能不连续、且主从复制下不安全默认的 1 是折中能提前算出插入行数的如普通INSERT用轻量锁算不出的如INSERT ... SELECT用 AUTO-INC 锁。一句话总结表级锁的定位加锁快、冲突判断简单但一锁锁一大片只适合并发不高、或者引擎本身不支持行锁的场景。四、意向锁一个容易被忽略的设计意向锁Intention Lock经常被一带而过但它的设计动机很值得说因为它回答了一个很实际的问题表级锁和行级锁怎么共存设想这个场景事务 A 已经给表里某几行加了行级 X 锁这时事务 B 想给整张表加一个表级 X 锁。表级 X 锁要求整张表没有任何冲突的锁那 B 怎么知道表里有没有行被锁住了最笨的办法是逐行扫描检查每一行有没有行锁——表一大这个检查就慢得离谱。InnoDB 的解法是加一层意向标记事务在给某些行加行锁之前先在表级别加一个意向锁昭告我准备在这张表的某些行上加锁了。意向共享锁IS准备给某些行加 S 锁前先在表上加 IS。意向排他锁IX准备给某些行加 X 锁前先在表上加 IX。这样一来事务 B 想加表级 X 锁时只需要看一眼表上有没有 IS/IX 锁不用扫全表——有意向锁就说明表里有行被锁着直接冲突。意向锁把逐行检查变成了看一眼表头标记这就是它存在的全部意义。需要注意意向锁的兼容性和普通锁不一样意向锁之间彼此全兼容IS-IS、IS-IX、IX-IX 都不冲突因为它们只是意向、并不真的锁住具体的行——两个事务当然可以各自去锁不同的行。意向锁只在别人要加表级S/X 锁时才可能冲突冲突关系仍然遵循读写互斥的直觉读意向和表级读兼容其余互斥兼容性XIXSISX✗✗✗✗IX✗✓✗✓S✗✗✓✓IS✗✓✓✓这里容易记错的是IX 和表级 S 不兼容有人正准备写、你就不能整表加读锁了而IS 和表级 S 兼容都是读互不影响。五、行级锁InnoDB 的主战场行级锁是 InnoDB 高并发能力的核心。它不是一种锁而是一族锁按锁定的范围不同分成几种。下面用一个例子贯穿假设users表主键 id 上现有这几条记录id: 5 10 15 20① 记录锁Record Lock只锁住某一条已存在的记录本身。-- 精确命中 id10 这条记录只锁它一条SELECT*FROMusersWHEREid10FORUPDATE;别的事务想改或锁 id10 这行就得等但完全不影响 id5、15 那些行。② 间隙锁Gap Lock锁住的不是记录而是记录之间的开区间作用是不让别人往这个间隙里插入新记录。-- 假设 id12 不存在这条语句会在 (10, 15) 这个间隙加锁SELECT*FROMusersWHEREid12FORUPDATE;此时别的事务想INSERT INTO users (id) VALUES (13)会被阻塞因为 13 落在被锁的 (10,15) 间隙里。注意间隙锁不锁记录本身所以它和另一个间隙锁是兼容的两个事务可以同时持有同一个间隙的间隙锁——它唯一的使命就是拦截插入。这里有个常被追问的细节间隙锁锁的是记录前面的间隙那最后一条记录id20之后那段 (20, ∞) 的无穷间隙怎么锁答案是给页面里一条叫Supremum 的伪记录代表页内最大记录加间隙锁——它专门用来兜住尾部这段开区间防止别人插入 id 更大的新记录。③ 临键锁Next-Key Lock等于记录锁 它前面的间隙锁锁定一个左开右闭的区间既锁住记录本身、又锁住它前面的间隙。它是 InnoDB 在 REPEATABLE READ 级别下的默认行锁。-- 命中 id15实际锁的是 (10, 15] 这个区间既锁住 15 这行也锁住 (10,15) 间隙SELECT*FROMusersWHEREid15FORUPDATE;④ 插入意向锁Insert Intention Lock插入记录前加的一种特殊间隙锁表明我想在这个间隙里插一条。多个事务想在同一个间隙的不同位置插入时插入意向锁之间是兼容的不同位置不冲突但会被别人已持有的间隙锁挡住——这正是间隙锁拦截插入的落地方式。⑤ 隐式锁这是个省事的设计。一条刚被某事务插入、还没提交的记录InnoDB 并不会立刻为它显式加锁而是靠记录上的trx_id就是 MVCC 那篇讲的隐藏列充当隐式锁——别的事务访问到这条记录、发现trx_id属于一个还活跃的事务时会先反过来帮这个插入事务补上一个真正的显式锁然后自己再排队等待。这样在没有冲突时就省下了加锁的开销属于能不加就不加的懒加载思路。二级索引记录本身没有trx_id靠页头的PAGE_MAX_TRX_ID先粗判一下这一页是否可能有未提交事务需要时再回表走聚簇索引那套逻辑。六、间隙锁与临键锁怎么防住幻读这一节把上一节的间隙锁 / 临键锁和 MVCC 那篇留下的问题接上——MySQL 的 REPEATABLE READ 凭什么能避免幻读先回顾幻读同一个事务两次按相同条件查询第二次多出了别的事务新插入的记录。快照读普通 SELECT靠 ReadView 已经能挡住幻读新插入的记录 trx_id 太大不可见。但当前读挡不住——SELECT ... FOR UPDATE要读最新数据如果只加记录锁别的事务照样能往中间插入新行下次当前读就见鬼了。临键锁就是为此而生当前读时InnoDB 不只锁住命中的记录还用间隙锁锁住记录之间的间隙别的事务就没法在这些间隙里插入新记录了。举个例子-- 事务 ARR 级别BEGIN;SELECT*FROMusersWHEREid8ANDid18FORUPDATE;-- 锁住 (8,10]、(10,15]、(15,18) 这些区间-- 事务 B 此时想插入 id12INSERTINTOusers(id)VALUES(12);-- 被阻塞12 落在被锁的间隙里事务 B 插不进去事务 A 再查一次的结果就不会凭空多出记录——幻读被堵住了。所以呼应 MVCC 那篇的结论MySQL 的 REPEATABLE READ 之所以比 SQL 标准更强、连幻读都能防靠的正是 MVCC管快照读 临键锁管当前读两者配合缺一不可。也正因为间隙锁会锁住一整段区间它是死锁和插入阻塞的一大来源——这也是为什么后面会提到业务允许时降到 RC 级别能规避一部分死锁RC 级别下没有间隙锁只有记录锁。七、悲观锁与乐观锁前面所有的 S/X 锁都属于悲观锁假设冲突一定会发生所以动手之前先把锁加上别人只能等。它的代价是锁竞争——高并发下大家排队等锁吞吐上不去。乐观锁换了个思路假设冲突很少发生根本不加数据库锁而是在更新时才检测这期间有没有人动过这条数据。最常见的实现是加一个版本号列-- 读的时候把版本号一起读出来SELECTbalance,versionFROMaccountWHEREid1;-- 假设读到 version 7-- 更新时把版本号作为条件并让它 1UPDATEaccountSETbalancebalance-10,versionversion1WHEREid1ANDversion7;如果这期间没人改过version还是 7更新成功影响行数为 1如果被别人抢先改了version已经变成 8WHERE version 7匹配不到影响行数为 0——应用层据此知道冲突了重新读一遍再重试。这其实就是一次 CAScompare-and-swap。两者的取舍很清楚悲观锁乐观锁加锁时机操作前先加锁不加数据库锁提交时检测适合场景写多、冲突频繁读多写少、冲突稀少代价锁竞争、可能死锁冲突时要重试冲突多了反而更慢一句话冲突多就用悲观锁省得反复重试冲突少就用乐观锁省下锁开销。八、实战锁该怎么选把前面几节的锁映射到具体场景其实有一套比较顺的选择逻辑场景推荐锁类型原因全库备份全局锁或 MVCC 一致性快照备份保证备份期间数据一致批量更新 MyISAM 表表级写锁避免逐行加锁的开销高并发转账行级排他锁细粒度控制减少互相阻塞读多写少的计数器更新乐观锁避免锁竞争开销用一个决策流程串起来就是需要锁定整个库 │ ┌─────────┴─────────┐ 是 否 │ │ 全局锁 操作对象是整张表 │ ┌─────────┴─────────┐ 是 否 │ │ 表级锁 行级锁 → 写并发高吗 │ ┌─────────┴─────────┐ 是 否 │ │ 悲观锁 乐观锁核心原则就一句在满足正确性的前提下锁的粒度越细、持有时间越短越好——粒度细则并发高持有短则等待少。九、死锁成因、检测与自动回滚行级锁把并发度提上来了但也带来一个新麻烦死锁。本质定义多个事务因为争抢资源形成了环形等待链彼此持有对方需要的锁又都不释放谁也走不下去。比如事务 A 等事务 B 释放资源事务 B 又在等事务 A 释放资源。死锁的发生需要同时满足四个必要条件缺一不可这也是操作系统里死锁的经典四条件互斥条件资源如行锁一次只能被一个事务占用。请求与保持事务持有一部分资源的同时又去请求新的资源。不可剥夺已经拿到的资源事务结束前不能被强行抢走。循环等待事务之间形成了环形的等待链A→B→C→A。几种最典型的死锁场景操作顺序不一致事务 A 先改行 1 再改行 2事务 B 先改行 2 再改行 1。双方各持一把锁又互相请求对方的锁——最经典的循环等待。索引缺失导致锁升级UPDATE users SET status0 WHERE phone13800138000如果 phone 列没索引会全表扫描、把扫过的行几乎全锁上大面积阻塞其他事务极易勾连成死锁。间隙锁冲突RR 级别下事务 A 删除id BETWEEN 5 AND 10的数据持有间隙锁事务 B 想在这个区间插入被间隙锁挡住——两个事务再稍微交叉一下就死锁。唯一键冲突并发插入相同的唯一键值比如主键冲突回滚时锁的释放顺序也可能引发死锁。InnoDB 怎么发现死锁它维护了一张锁的等待依赖图通过深度优先搜索DFS遍历这张图一旦发现环路、或者搜索深度超过 200 层就判定为死锁。除了主动检测还有一道兜底innodb_lock_wait_timeout默认 50 秒——一个事务等锁超过这个时间就超时放弃。发现死锁后怎么办自动挑一个牺牲品回滚。InnoDB 会自动选一个代价最小的事务回滚掉让别的事务能继续。挑谁的标准大致是改动数据量小的优先回滚改 5 行的 vs 改 100 行的回滚改 5 行的那个因为要还原的东西少。越新的事务越容易被回滚长时间运行的老事务已经干了很多活回滚它更亏。生成的 undo 日志越少回滚成本越低回滚本质就是回放 undo呼应 MVCC 那篇——回滚靠的就是逆向执行 undo 日志INSERT 反向删、DELETE 反向插、UPDATE 还原旧值。只读事务或只有简单查询的事务优先保留。被选中的事务回滚时会回放它的 undo 日志撤销所有改动、释放它持有的全部行锁/表锁/间隙锁、清理对应的 undo 段空间。要注意自动回滚不是万能的——它撤销的是数据库层的改动但业务层已经执行到一半的逻辑不会自动回退所以业务代码必须能识别死锁错误并重试。十、死锁的排查与规避事后排查核心工具是SHOW ENGINE INNODB STATUSSHOWENGINEINNODBSTATUS\G在输出里找LATEST DETECTED DEADLOCK这一段它会完整展示最近一次死锁的现场两个事务各自的 id、线程 id、正在执行的 SQL、以及各自持有和等待的锁。典型的一段长这样简化LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION 12345, thread id 100 UPDATE users SET balance balance - 100 WHERE id 1 WAITING FOR: users 表主键锁 (id1) *** (2) TRANSACTION 67890, thread id 101 UPDATE users SET balance balance 50 WHERE id 2 HOLDS: users 表主键锁 (id2)并在等待其他锁排查三步走① 从日志里读出两个事务分别卡在哪把锁上 → ② 判断哪个事务改动小 / 非核心 → ③ 必要时手动KILL thread_id终止它SHOW ENGINE INNODB STATUS只是分析InnoDB 一般已经自动回滚了其中一个KILL用于处理锁等待但还没构成死锁、卡住不动的情况。终止后可以用SELECT * FROM information_schema.innodb_trx再确认一下事务状态。事前规避才是根本几条最管用的规范索引设计给高频查询/更新的字段建索引避免因为走全表扫描把行锁升级成近似表锁的大范围锁定——这是最容易踩、也最容易改的一条。固定操作顺序让所有事务都按同一个顺序比如按 id 升序访问多个资源直接破坏掉循环等待这个必要条件——四条件破其一死锁就不成立。控制事务粒度拆分大事务、别让一个事务锁着一堆资源跑很久缩短锁的持有时间。降低隔离级别业务允许的话用 RCREAD COMMITTED替代 RR——RC 没有间隙锁能规避一大类由间隙锁引起的死锁代价是要自己接受不可重复读/幻读。设置锁等待超时innodb_lock_wait_timeout调到一个合理值让事务别无限等下去注意它只是超时放弃不能预防死锁。用乐观锁替代显式加锁读多写少的场景用版本号 / CAS压根不加悲观锁也就没有死锁一说。十一、最佳实践最后收敛成几条平时能直接用上的经验优先用行级锁InnoDB 而非 MyISAM把并发度提上去。避免长事务持有锁事务开了就尽快提交锁持有时间越短别人等待越少死锁概率越低。读写分离把读流量分到从库降低主库上的锁冲突概率。能用 MVCC 就别加锁普通查询走快照读天然不加锁、不阻塞写只有真正需要读了就要改、且不许别人插队时才用FOR UPDATE这种当前读。把这篇和前面几篇连起来看一条完整的事务并发控制图景就清晰了redo 保证不丢、undo 支持回滚、MVCC 让读不阻塞写、锁管住写和当前读的正确性、死锁机制兜底处理锁的环形等待——这几套设计各司其职又互相配合共同撑起了 InnoDB 的 ACID。