在 MySQL 事务并发执行场景中,锁是保障数据一致性的核心工具 —— 既要最大化利用数据库并发能力,又要避免脏读、不可重复读、幻读等问题。本文将从锁的核心意义出发,拆解锁的分类、特性及实际应用,所有内容基于 MySQL InnoDB 等引擎的底层逻辑,确保技术准确性与实用性。
一、锁的核心意义:平衡并发与数据一致性
事务并发执行时,最大的挑战是兼顾 “高并发访问” 与 “数据一致性” :当一个事务执行读操作,另一个事务同时执行写操作时,易引发脏读、不可重复读、幻读;若完全禁止并发,又会大幅降低数据库性能。
锁的本质是 “访问控制工具”,通过对数据加锁,限制不同事务对数据的操作权限,从而解决并发冲突。针对 “读 - 写并发” 问题,MySQL 提供两种核心解决方案,适用于不同业务场景:
| 解决方案 | 原理 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| 读操作 MVCC + 写操作加锁 | 读操作通过多版本快照读取(无锁),写操作对数据加锁 | 性能高,并发能力强 | 可能读取到旧版本数据 | 大多数互联网场景(如电商商品查询) |
| 读写操作都加锁 | 读、写操作均对数据加锁,串行化执行 | 每次读取都是最新数据 | 性能低,并发能力弱 | 高安全性场景(如银行账户交易) |
二、锁的基础分类:乐观锁与悲观锁
按 “是否主动加锁” 的逻辑,MySQL 锁可分为乐观锁与悲观锁,二者适用于不同的读写比例场景。
1. 悲观锁:“先上锁,再操作”,预防冲突
- 核心逻辑:每次获取数据时,默认 “别人会修改数据”,因此主动给数据上锁,其他事务需等待锁释放后才能操作该数据,本质是 “预防式” 锁。
- 实现方式:基于 MySQL 的共享锁(S 锁)和排他锁(X 锁)实现(后文详细说明)。
- 适用场景:并发量不大、写多读少的业务(如后台订单修改)—— 写操作频繁时,提前上锁可避免频繁冲突重试。
- 特点:逻辑简单,无需手动处理冲突;但锁等待会导致并发性能降低,若事务执行时间长,易引发锁超时或死锁。
2. 乐观锁:“先操作,后校验”,容忍冲突
- 核心逻辑:默认 “别人不会修改数据”,因此操作时不上锁,仅在提交更新时校验数据是否被修改,本质是 “容忍式” 锁。
- 实现方式:主流方案是数据版本(Version)机制,具体步骤如下:
- 为表新增一个数字类型的version字段,作为版本标识;
- 读数据时,同时读取version值(如SELECT id, name, version FROM user WHERE id=1);
- 写数据时,先判断当前表中version是否与读取时一致(如UPDATE user SET name='new', version=version+1 WHERE id=1 AND version=old_version);
- 若更新行数为 1,说明校验通过,更新成功;若为 0,说明数据已被其他事务修改,需重试或返回失败。
- 优缺点:
-
- 优点:无锁操作,执行性能高,并发能力强;
-
- 缺点:可能出现ABA 问题—— 数据先从 A 改为 B,再改回 A,校验时version一致,但数据实际被修改过(可通过 “版本号 + 时间戳” 双重校验解决)。
- 适用场景:读多写少的业务(如商品详情页浏览)—— 写操作少,冲突概率低,无需频繁重试。
三、锁的粒度分类:从全局到行级,控制锁定范围
按 “锁定数据的范围”(粒度),MySQL 锁可分为全局锁、表级锁、页级锁、行级锁,粒度从大到小,并发能力依次增强,加锁开销依次增大。
1. 全局锁:锁定整个数据库实例
- 定义:对整个 MySQL 实例加锁,加锁后所有库的所有表均处于只读状态(无法执行 INSERT、UPDATE、DELETE、DDL 等写操作)。
- 加锁命令:Flush tables with read lock (FTWRL);释放锁命令:UNLOCK TABLES(客户端断开连接也会自动释放)。
- 核心场景:全库逻辑备份—— 避免备份过程中,其他事务修改数据导致 “备份库与主库数据不一致”(如备份时某表新增数据,备份结果未包含该数据)。
- 注意:若使用SET GLOBAL read_only=1(全局只读)替代,虽能限制写操作,但超级用户(如 root)仍可执行写操作,无法保障备份一致性,因此推荐 FTWRL。
2. 表级锁:锁定整张表
表级锁是粒度较大的锁,支持 InnoDB、MyISAM 等多种引擎,主要分为表锁和元数据锁(MDL) 。
(1)表锁:显式加锁,控制表的读写权限
- 加锁方式:需显式执行命令,释放锁后其他事务才能操作;
-
- 读锁(共享锁):LOCK TABLES 表名 READ [LOCAL]—— 加锁后,当前事务与其他事务均可读该表,但均无法写(写操作会阻塞);
-
- 写锁(排他锁):LOCK TABLES 表名 WRITE—— 加锁后,仅当前事务可读写该表,其他事务的读、写操作均会阻塞。
- 释放方式:UNLOCK TABLES(显式释放)或客户端断开连接(自动释放)。
- 关键特性:LOCK TABLES会 “双向限制”—— 既限制其他线程对表的操作,也限制当前线程的操作范围。
示例:若线程 A 执行LOCK TABLE t1 READ, t2 WRITE,则:
-
- 其他线程:写 t1、读 / 写 t2 会阻塞;
-
- 线程 A:仅能读 t1、读 / 写 t2,无法写 t1,也无法访问其他表(如 t3)。
- 适用场景:MyISAM 引擎(不支持行锁)的表级读写控制;InnoDB 引擎中较少使用(优先用行锁提升并发)。
(2)元数据锁(MDL):自动加锁,保护表结构
- 定义:MDL(Metadata Lock)是 InnoDB 自动加的锁,无需显式命令,用于保护 “表结构” 不被并发修改 —— 确保 DML 操作(增删改查)与 DDL 操作(改表结构)不会冲突。
- 锁类型与触发场景:
-
- MDL 读锁:执行SELECT、INSERT、UPDATE、DELETE等 DML 操作时,自动加读锁;
-
- MDL 写锁:执行ALTER TABLE、DROP TABLE等 DDL 操作时,自动加写锁。
- 互斥规则:
-
- 读锁之间不互斥:多个事务可同时对同一表加 MDL 读锁(支持并发 DML);
-
- 写锁之间互斥:多个事务对同一表加 MDL 写锁会阻塞(避免同时修改表结构);
-
- 读写锁互斥:加 MDL 读锁后,无法加写锁(DML 执行时不能改表结构);加 MDL 写锁后,无法加读锁(改表结构时不能执行 DML)。
- 核心风险:MDL 锁在事务结束后才释放(而非语句结束后),若长事务持有 MDL 读锁,会阻塞后续 DDL 操作,甚至引发 “session 爆满”。
示例:
-
- sessionA 启动长事务,执行SELECT * FROM t1(加 MDL 读锁);
-
- sessionC 执行ALTER TABLE t1 ADD COLUMN age INT(申请 MDL 写锁,因 sessionA 的读锁未释放,阻塞);
-
- 后续所有执行 DML 的 session(如 sessionB 执行SELECT * FROM t1)会申请 MDL 读锁,但因 sessionC 的写锁排队,这些读锁也会阻塞;
-
- 若客户端有 “查询重试机制”,会不断新建 session 申请读锁,最终导致 session 爆满、内存升高。
- 解决方案:避免长事务,及时提交或回滚事务;执行 DDL 前,先检查是否有长事务(如通过INFORMATION_SCHEMA.INNODB_TRX查询),优先终止长事务。
3. 页级锁:锁定相邻的一组记录
- 定义:粒度介于表级锁与行级锁之间,一次锁定 “数据页”(InnoDB 默认数据页大小为 16KB,包含多条记录),是 BDB 引擎的默认锁机制,InnoDB 不支持。
- 特点:平衡 “表级锁的高开销” 与 “行级锁的高并发”—— 锁定一组记录,比表级锁冲突少,比行级锁加锁快;但仍可能出现 “锁冲突”(如修改同一页的不同记录)。
- 适用场景:BDB 引擎的中等并发场景,InnoDB 中无需关注(优先用行锁)。
4. 行级锁:锁定单条记录(InnoDB 核心)
行级锁是 InnoDB 引擎的核心锁机制,粒度最小,冲突概率最低,并发能力最强,主要分为行锁(记录锁) 和意向锁,还衍生出间隙锁、临键锁等特殊锁。
(1)行锁(记录锁):锁定单条索引记录
- 定义:锁定表中某一条具体记录,仅影响该记录的操作,是 InnoDB 解决 “并发修改同一条记录” 的核心锁。
- 核心特性:InnoDB 的行锁锁定的是索引记录,而非物理记录—— 即使两条记录是不同的物理行,若通过相同索引键访问,仍会触发锁冲突。
- 加锁方式:
-
- 隐式加锁:执行INSERT、UPDATE、DELETE时,InnoDB 会自动对涉及的记录加排他锁(X 锁) ;
-
- 显式加锁:执行SELECT ... FOR UPDATE(加排他锁)或SELECT ... LOCK IN SHARE MODE(加共享锁),需在事务中执行(事务结束后释放锁)。
- 锁释放规则:InnoDB 行锁是 “事务级锁”—— 在需要时加锁,但需等到事务提交或回滚后才释放,而非语句执行完释放。
优化建议:若事务中需锁多个行,将 “最可能引发冲突、影响并发” 的锁放在最后(如转账业务,先锁 “转入账户”,再锁 “转出账户”,减少锁等待时间)。
- InnoDB 行锁的关键注意事项:
-
- 无索引条件查询时,行锁退化为表锁:若WHERE条件未使用索引(如SELECT * FROM user WHERE name='test' FOR UPDATE,name无索引),InnoDB 会扫描全表,对所有记录加行锁,最终等效于表锁;
-
- 相同索引键会引发锁冲突:即使修改不同物理记录,若使用相同索引键(如唯一索引id,事务 A 锁id=1,事务 B 误操作id=1),仍会触发锁冲突;
-
- 多索引支持独立锁:表有多个索引时,不同事务可通过不同索引锁定不同行(如事务 A 通过主键id=1锁记录,事务 B 通过唯一索引phone=123锁另一记录)。
(2)意向锁:协调表锁与行锁的 “桥梁”
- 定义:意向锁是 InnoDB 自动加的表级锁,仅用于 “标识表中是否存在行锁”,不直接限制数据操作,只与表锁冲突,与行锁不冲突。
- 核心作用:避免表锁判断时 “遍历全表”—— 当事务 B 申请整个表的写锁时,无需逐行检查是否有行锁,只需判断表是否有意向锁,若有则阻塞,大幅提升性能。
- 锁类型:
-
- 意向共享锁(IS 锁):事务计划对表中某行加共享锁(S 锁)时,先自动对表加 IS 锁;
-
- 意向排他锁(IX 锁):事务计划对表中某行加排他锁(X 锁)时,先自动对表加 IX 锁。
- 互斥规则:意向锁仅与表锁冲突,与其他意向锁、行锁不冲突:
-
- IS 锁:与表的读锁(表锁)兼容,与表的写锁(表锁)冲突;
-
- IX 锁:与表的读锁、写锁(表锁)均冲突;
-
- IS 锁与 IX 锁之间:兼容,无冲突。
四、InnoDB 特殊行锁:间隙锁与临键锁,解决幻读
在 InnoDB 的 “可重复读(RR)” 隔离级别下,为解决幻读问题,衍生出两种特殊行锁:间隙锁(Gap Lock)和临键锁(Next-Key Lock),二者均基于索引间隙生效。
1. 记录锁(Record Lock):基础行锁
- 定义:锁定索引中的 “单条具体记录”,仅限制对该记录的修改,不影响其他记录的插入或修改。
- 作用:解决最基本的并发修改冲突(如两个事务同时修改id=1的记录),无法解决幻读(因不限制间隙插入)。
2. 间隙锁(Gap Lock):锁定索引间隙,防止插入
- 定义:锁定 “索引记录之间的间隙”(不包含记录本身),如id=1与id=3之间的间隙((1,3)),阻止其他事务在该间隙中插入新记录。
- 核心特性:
-
- 仅在RR 隔离级别生效(RC 隔离级别无间隙锁),是 InnoDB 解决幻读的关键;
-
- 即使间隙中无实际数据(如表中只有id=1和id=3,间隙(1,3)无数据),仍会锁定该间隙;
-
- 锁的范围由索引决定:若使用主键或唯一索引,等值查询且记录存在时,间隙锁退化为记录锁;若使用普通索引,无论记录是否存在,都会加间隙锁。
- 示例:表user的id是普通索引,现有记录id=1,3,5,事务 A 执行SELECT * FROM user WHERE id BETWEEN 1 AND 5 FOR UPDATE,会锁定三个间隙:(-∞,1)、(1,3)、(3,5),其他事务无法在这些间隙中插入id=2、4等记录,从而避免幻读。
3. 临键锁(Next-Key Lock):记录锁 + 间隙锁,默认行锁
- 定义:InnoDB 默认的行锁实现方式,是 “记录锁 + 间隙锁” 的组合,锁定 “索引记录及该记录之前的间隙”,范围为 “左开右闭”。
- 作用:同时解决 “并发修改冲突”(记录锁)和 “幻读”(间隙锁)—— 既防止其他事务修改当前记录,也防止在间隙中插入新记录。
- 示例:表user的id是主键(唯一索引),现有记录id=3,事务 A 执行SELECT * FROM user WHERE id=3 FOR UPDATE:
-
- 因是 “唯一索引 + 等值查询 + 记录存在”,临键锁退化为记录锁,仅锁定id=3的记录;
-
- 若id=3不存在,临键锁会锁定间隙(当前最大id, 3](如现有id=2,则锁定(2,3]),阻止其他事务插入id=3。
- 退化为记录锁的条件:需同时满足三个条件 ——唯一索引(主键 / 唯一键)+ 等值查询 + 查询条件匹配到确切记录。
五、总结:MySQL 锁机制的核心应用原则
MySQL 锁机制的设计围绕 “平衡并发与一致性” 展开,实际应用中需遵循以下原则:
- 锁粒度选择:优先用行锁(InnoDB)提升并发,仅在必要时用表锁或全局锁(如全库备份);
- 乐观 / 悲观锁选择:读多写少用乐观锁(Version 机制),写多读少用悲观锁(S/X 锁);
- 避免锁风险:减少长事务