
在互联网业务开发中并发场景下的数据一致性一直是后端工程师绕不开的核心问题。比如库存扣减、订单状态流转、账户余额变更这些操作一旦被多个线程或进程同时执行就很容易出现数据错乱。MySQL 锁机制正是解决这类问题的基础手段之一。本文将围绕 MySQL 中的锁展开先梳理全局锁、表级锁、行级锁的分类与原理再重点拆解 InnoDB 的行锁实现包括 Record Lock、Gap Lock、Next-Key Lock 以及插入意向锁最后结合锁等待和死锁的排查案例给出生产环境下的最佳实践。无论你是刚接触数据库的初学者还是在项目中处理过并发问题的开发者本文都能提供一套完整、落地的参考思路。1. 先理解为什么需要锁在讲锁之前先思考一个场景某电商系统中有个商品表库存字段stock初始值为 10。两个用户同时下单分别执行UPDATE product SET stock stock - 1 WHERE id 1;如果不加任何控制两个事务都读到 stock10然后各自减一写回最终结果可能是 9而不是正确的 8。这就是典型的“丢失更新”问题。锁的作用就是让这类并发操作变成串行执行从而保证数据的一致性。需要注意锁和事务是紧密相关的。事务的隔离性依赖锁机制实现而锁的释放时机又由事务的提交或回滚决定。理解 MySQL 锁离不开对事务隔离级别的认知。MySQL InnoDB 默认的隔离级别是REPEATABLE READ可重复读在这一级别下行锁、间隙锁、临键锁共同协作既保证了事务隔离又有力地解决了幻读问题。2. MySQL 锁的整体分类从锁的粒度由大到小划分MySQL 锁可以分为三类锁类型说明典型场景全局锁对整个数据库实例加锁全库逻辑备份表级锁对整张表加锁表结构变更、MyISAM 表写操作行级锁对某一行或某一区间加锁InnoDB 并发写操作从锁的模式上看可以分为共享锁Shared LockS 锁和排他锁Exclusive LockX 锁。共享锁之间可以兼容排他锁与其他任何锁都不兼容。从实现策略上看又分为悲观锁和乐观锁。悲观锁认为并发冲突经常发生所以每次操作前先加锁乐观锁则假设冲突很少发生通过版本号或时间戳在更新时校验。下文会逐一拆解。3. 全局锁让整个库只读全局锁最简单粗暴它把整个数据库实例变为只读状态。MySQL 提供了一条命令来加全局锁FLUSH TABLES WITH READ LOCK;加锁之后其他线程的以下语句会被阻塞数据更新语句增删改数据定义语句建表、改表结构更新类事务的提交释放全局锁的命令是UNLOCK TABLES;全局锁的典型使用场景是全库逻辑备份。在备份期间如果业务还在写入数据备份出来的文件可能是不一致的。加上全局锁之后备份期间不会有新写入从而保证备份数据的一致性。不过全局锁的代价也很明显。它会导致业务停摆所有写操作全部阻塞。在 MySQL 8.0 中官方推荐的备份方式是使用mysqldump --single-transaction参数它基于 MVCC多版本并发控制在可重复读隔离级别下生成一致性快照不需要加全局锁也就不影响业务写入。实际项目中建议对全局锁保持谨慎态度能用 MVCC 或备份工具自身机制解决的场景就不要手动加全局锁。4. 表级锁锁住整张表表级锁是 MySQL 早期版本和 MyISAM 存储引擎的主要锁机制。它实现简单开销小但并发能力弱。即使 InnoDB 支持行级锁表级锁依然在某些场景下扮演重要角色尤其要注意元数据锁和意向锁。4.1 表锁Table Lock表锁分为表共享读锁和表独占写锁-- 给表 t_user 加读锁 LOCK TABLES t_user READ; -- 给表 t_user 加写锁 LOCK TABLES t_user WRITE; -- 释放表锁 UNLOCK TABLES;读锁是共享的多个会话可以同时持有读锁写锁是排他的一个会话持有写锁时其他会话既不能读也不能写。表锁最大的问题是粒度太大。一张表只要有一个写锁整张表的读写都受影响在高并发场景下会严重限制吞吐量。4.2 元数据锁Metadata LockMDLMDL 是 MySQL 5.5 版本引入的表级锁它不需要显式调用由 MySQL 自动维护。MDL 的作用是保护表结构定义防止在 DML数据操作执行期间表结构被 DDL数据定义意外修改。当一个事务执行 SELECT、UPDATE、DELETE 时MySQL 会为表加上 MDL 读锁当一个事务执行 ALTER TABLE 等 DDL 语句时会申请 MDL 写锁。MDL 读锁之间兼容写锁与读锁、写锁之间都不兼容。实际开发中MDL 阻塞是非常典型的问题。一个常见场景是事务 A 开启事务执行了一条 SELECT 语句持有表 t_order 的 MDL 读锁。事务 B 执行 ALTER TABLE t_order ADD COLUMN ...等待 MDL 写锁被事务 A 阻塞。事务 C 执行 SELECT 语句发现表被锁也阻塞等待。这个问题的严重性在于MDL 写锁的等待队列会阻塞后续所有操作包括读操作最终导致表不可用。排查时需要查看performance_schema.metadata_locks表或SHOW PROCESSLIST找到持有 MDL 读锁的会话。避免 MDL 阻塞的核心原则是长事务中不要夹杂 DDL 操作DDL 尽量安排在低峰期并设置合理的锁等待超时时间。4.3 意向锁Intention Lock意向锁是 InnoDB 特有的表级锁但它本身不锁定任何真实数据它的作用是为行级锁和表级锁之间建立“沟通桥梁”。当一个事务准备给某一行加行级共享锁行 S 锁时InnoDB 会先自动在该表上加意向共享锁IS 锁准备给某一行加行级排他锁行 X 锁时会先自动加意向排他锁IX 锁。意向锁遵循的兼容规则如下锁类型ISIXSXIS兼容兼容兼容不兼容IX兼容兼容不兼容不兼容S兼容不兼容兼容不兼容X不兼容不兼容不兼容不兼容也就是说意向锁之间互相兼容但意向锁与真实的表级 S/X 锁不兼容。有了意向锁MySQL 在判断“某张表上是否已有行级锁”时就不需要遍历所有行只需要检查表级意向锁即可效率大幅提升。5. 行级锁InnoDB 并发控制的核心InnoDB 的行级锁是本文的重点。它支持更细粒度的并发控制但实现也更复杂。InnoDB 的行锁是基于索引实现的如果 SQL 语句没有走索引行锁会升级为表锁这一点尤其值得注意。5.1 记录锁Record Lock记录锁是作用在索引记录上的锁。它是最基本的行级锁锁定的是某一条具体的索引记录。-- 事务 A BEGIN; UPDATE t_user SET name 张三 WHERE id 1;这条语句会在 id1 的索引记录上加 X 锁。事务 A 提交之前其他事务无法修改 id1 这条记录也无法对其加锁。如果其他事务尝试执行同样条件的 UPDATE会被阻塞直到事务 A 提交。-- 事务 B会被阻塞 BEGIN; UPDATE t_user SET name 李四 WHERE id 1;记录锁的关键在于加锁的对象是索引上的记录而不是数据行本身。如果表结构没有索引InnoDB 会使用隐式的主键来锁定记录。5.2 间隙锁Gap Lock间隙锁锁定的是一个范围而不是某一条具体记录。它锁住的是索引记录之间的“间隙”防止其他事务在这个间隙中插入数据从而解决“幻读”问题。举例说明假设 t_user 表中有 id 为 1、5、10 三条记录BEGIN; SELECT * FROM t_user WHERE id BETWEEN 3 AND 8 FOR UPDATE;这条语句会锁住 id 在 (1, 5) 和 (5, 10) 这两个区间也就是 id2、3、4 以及 6、7、8、9 这些位置。此时另一个事务如果插入 id6 的记录会被阻塞因为 id6 落在了被锁定的间隙中。间隙锁的副作用是可能锁住原本不需要锁住的区间带来额外的阻塞。比如上面的例子中即使业务只需要暂时不允许修改具体某条记录但间隙锁导致整个区间都无法插入数据这会降低并发度。间隙锁的范围大小取决于查询条件和已有索引记录的位置。5.3 临键锁Next-Key Lock临键锁是记录锁和间隙锁的组合它同时锁定一条记录以及该记录前面的间隙。InnoDB 在REPEATABLE READ隔离级别下默认使用临键锁来防止幻读。临键锁的锁定范围是左开右闭区间。假设 t_user 表中有 id 为 1、5、10 三条记录临键锁可能锁定的区间包括(-∞, 1](1, 5](5, 10](10, ∞)当执行SELECT * FROM t_user WHERE id 5 FOR UPDATE;在REPEATABLE READ隔离级别下InnoDB 会在记录 5 上加上临键锁实际锁定的是 (1, 5] 这个区间同时防住其他事务插入 id 为 2、3、4 的记录以及修改 id5 的记录。临键锁是 InnoDB 处理幻读的关键。但如果不需要防止幻读可以考虑将隔离级别调整为READ COMMITTED这个级别下 InnoDB 只使用记录锁不再使用间隙锁和临键锁并发能力会有所提升但需要业务上接受可能出现的幻读。5.4 插入意向锁Insert Intention Lock插入意向锁是间隙锁的一种特殊形式。当一个事务准备插入记录时它会先检查插入位置是否已经被其他事务加了间隙锁如果没有就会生成一个插入意向锁然后执行插入。插入意向锁之间是互相兼容的也就是说多个事务可以在同一个间隙中各自持有插入意向锁只要它们的插入位置不冲突即可。但如果间隙中已经存在间隙锁或临键锁插入事务就需要等待。理解插入意向锁对排查“插入被阻塞”的问题非常有帮助。比如事务 A 执行SELECT * FROM t_user WHERE id BETWEEN 3 AND 8 FOR UPDATE锁住了 (1, 5) 和 (5, 10) 间隙。事务 B 尝试插入 id6 的记录发现目标间隙被锁进入等待状态。事务 A 一直不提交事务 B 就会一直阻塞表现为“插入超时”。6. 悲观锁与乐观锁的应用有了行级锁的基础我们再看两种并发控制策略在业务代码中的落地方式。6.1 悲观锁SELECT ... FOR UPDATE悲观锁的理念是“我操作数据时别人肯定也想操作所以先加锁再说”。在 MySQL 中悲观锁通常通过SELECT ... FOR UPDATE实现。BEGIN; -- 锁定 id1 的商品记录 SELECT * FROM t_product WHERE id 1 FOR UPDATE; -- 业务计算新的库存 -- UPDATE t_product SET stock stock - 1 WHERE id 1; COMMIT;执行SELECT ... FOR UPDATE时InnoDB 会对命中的记录加 X 锁。其他事务想修改这些记录都必须等待当前事务提交。使用悲观锁的注意事项FOR UPDATE必须在事务中执行否则锁没有意义。查询条件必须走索引否则行锁会升级为表锁拖垮并发性能。事务要尽量短加锁后不要再执行耗时的外部调用避免持锁时间过长。6.2 乐观锁版本号机制乐观锁的理念是“冲突是少数情况更新时再校验”。通常做法是在表中增加version字段每次更新时对比当前版本号。-- 第一次读取得到版本号 SELECT id, stock, version FROM t_product WHERE id 1; -- 更新时带上版本号条件 UPDATE t_product SET stock stock - 1, version version 1 WHERE id 1 AND version 1;如果UPDATE影响的行数为 1说明更新成功如果影响行数为 0说明版本号已经变化需要重试或提示用户。乐观锁的优势是并发度高不会因为持锁而阻塞其他事务劣势是可能增加业务重试的复杂度。在电商秒杀、库存扣减等场景中乐观锁是非常常见的方案。7. 锁问题排查从现象到根因锁在高并发下容易引发两类典型问题锁等待超时和死锁。下面通过具体场景演示排查思路。7.1 复现环境准备先用如下 SQL 准备一张简单的表CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(50) NOT NULL, stock INT NOT NULL, version INT DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO t_product (product_name, stock) VALUES (手机, 10);7.2 锁等待超时打开两个终端窗口模拟两个事务同时更新同一行。会话 ABEGIN; UPDATE t_product SET stock stock - 1 WHERE id 1;会话 B此时会被阻塞BEGIN; UPDATE t_product SET stock stock - 1 WHERE id 1;如果会话 A 长时间不提交会话 B 最终会报出类似错误ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction锁等待超时时间由innodb_lock_wait_timeout参数控制默认 50 秒。查看和修改方式如下-- 查看当前锁等待超时时间 SHOW VARIABLES LIKE innodb_lock_wait_timeout; -- 当前会话动态设置为 5 秒 SET SESSION innodb_lock_wait_timeout 5;排查锁等待问题时核心思路是找到谁持有锁、谁在等待锁。可以通过performance_schema下的锁相关表来观察-- 查看当前正在执行的线程 SHOW FULL PROCESSLIST; -- 查看事务信息 SELECT * FROM information_schema.innodb_trx WHERE trx_state RUNNING \G; -- 查看锁等待信息 SELECT * FROM sys.innodb_lock_waits \G;sys.innodb_lock_waits是非常方便的排查视图它会直接给出“阻塞者”和“等待者”的会话信息。7.3 死锁死锁是指两个事务各自持有一把锁同时又在等待对方释放锁形成循环等待。InnoDB 检测到死锁后会选择一个事务作为牺牲者回滚该事务并返回错误。模拟死锁的经典场景如下。会话 ABEGIN; UPDATE t_product SET stock stock - 1 WHERE id 1;会话 BBEGIN; UPDATE t_product SET stock stock - 1 WHERE id 2;此时两个事务都成功执行没有阻塞。接下来会话 A 去更新 id2UPDATE t_product SET stock stock - 1 WHERE id 2;这条语句会被会话 B 持有的锁阻塞。然后会话 B 去更新 id1UPDATE t_product SET stock stock - 1 WHERE id 1;这时死锁就形成了。InnoDB 会立即检测到并返回类似错误ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction发生死锁后可以通过SHOW ENGINE INNODB STATUS查看最近一次死锁的信息SHOW ENGINE INNODB STATUS \G;重点关注输出中的LATEST DETECTED DEADLOCK部分它会列出两个事务执行的 SQL 和持有、等待的锁资源是分析死锁根因最直接的证据。7.4 常见锁问题排查清单问题现象常见原因解决思路锁等待超时长事务持有锁未及时提交优化事务逻辑减少持锁时间检查是否漏了 COMMIT锁等待超时查询条件没走索引行锁升级表锁使用 EXPLAIN 分析 SQL为过滤字段添加索引插入阻塞间隙锁锁住了目标插入区间检查是否有SELECT ... FOR UPDATE或UPDATE锁定了大范围区间死锁多个事务加锁顺序不一致统一加锁顺序按固定字段顺序访问多条记录MDL 阻塞长事务中执行了 DDL避免事务中做表结构变更DDL 放在低峰期事务迟迟不结束应用中未正确提交或回滚检查应用代码确保事务在 finally 中提交或回滚8. 生产环境锁最佳实践结合上面的原理和排查经验这里整理几条在项目开发中直接可以落地的建议。8.1 优先使用更小粒度的锁能用行锁就不要用表锁能用乐观锁就不加悲观锁。每次对锁的选择都要评估并发量和业务接受度。高并发读多写少的场景尽量利用 MVCC 实现无锁读不要一上来就SELECT ... FOR UPDATE。8.2 控制事务执行时间锁的释放依赖事务提交。事务执行时间越长其他事务等待的时间就越长。建议把事务控制在“只包含必要的读写操作”范围内不要在事务中调用远程接口、发送消息、执行批量耗时计算。8.3 所有 DML 尽量走索引行级锁依赖索引。如果UPDATE或DELETE的 WHERE 条件没有索引InnoDB 会扫描全表把扫描到的每一行都加锁等于表锁。开发中养成使用EXPLAIN分析 SQL 的习惯EXPLAIN SELECT * FROM t_product WHERE id 1 FOR UPDATE;重点关注key列确认 SQL 是否使用了索引。8.4 固定多行操作时的加锁顺序如果事务需要更新多条记录多个并发事务必须按相同顺序加锁否则很容易相互等待形成死锁。例如涉及账户 A 和账户 B 的转账所有事务都先更新小 id 的账户再更新大 id 的账户就能有效避免循环等待。8.5 合理设置锁等待超时上线前根据业务特点调整innodb_lock_wait_timeout不要一味使用默认值。如果业务要求快速失败并重试可以把超时时间调小如果业务对等待容忍度高可以适当调大但要警惕长事务带来的风险。8.6 关注表结构变更对线上锁的影响MySQL 8.0 支持了ALGORITHMINSTANT等在线 DDL 能力但并不是所有 DDL 都能在线执行。执行ALTER TABLE前建议先确认该操作是否会在短时间内持有 MDL 写锁同时避开业务高峰。更稳妥的做法是使用专门的表结构变更工具例如pt-online-schema-change在几乎不影响线上读写的情况下完成表结构变更。8.7 监控锁等待与死锁日志生产环境建议开启锁相关监控。MySQL 8.0 中可以通过performance_schema观察锁事件死锁日志通过SHOW ENGINE INNODB STATUS查看。将死锁信息接入监控告警定期分析死锁日志能帮助你及时发现潜在的事务设计问题。9. 总结MySQL 锁机制是数据库并发控制的基础也是事务隔离性、一致性的重要保障。全局锁保护全库备份表级锁中的 MDL 锁和意向锁各司其职InnoDB 行级锁则通过记录锁、间隙锁、临键锁的组合在可重复读隔离级别下有效解决了幻读问题。在实际项目中面对锁问题要养成从现象到根因的排查习惯先看当前进程和事务状态确认是否存在长事务再查锁等待关系找到持锁会话最后结合死锁日志优化事务设计。本文梳理的所有知识点最终都指向一个目标让你的业务在高并发环境下既保持性能又保证数据一致。建议读者在本机搭建一个 MySQL 环境亲自动手复现锁等待和死锁场景这种体验比单纯阅读更容易真正理解锁的本质。