在高并发场景下,理解 MySQL 的锁机制是保证数据一致性和提升系统性能的关键。本文将从底层原理出发,结合实际案例,全面解析 MySQL 的锁机制。
一、为什么需要锁?
在并发环境下,多个事务同时访问和修改数据时,如果没有合理的控制机制,会导致以下问题:
- 脏读:读到其他事务未提交的数据
- 不可重复读:同一事务中多次读取结果不一致
- 幻读:同一查询条件下,前后读取的行数不一致
- 丢失更新:两个事务同时修改同一数据,后提交的覆盖先提交的
MySQL 通过锁机制和 MVCC(多版本并发控制)来解决这些问题。
二、MySQL 锁的分类
2.1 按粒度分类
1️⃣ 表锁(Table Lock)
特点:
- 锁定整张表,粒度最大
- 开销小,实现简单
- 并发度低,其他事务无法访问该表
- 不会出现死锁
使用场景:
-- 显式加表锁LOCKTABLESusersREAD;-- 读锁(共享锁)LOCKTABLESusersWRITE;-- 写锁(排他锁)-- DDL 操作自动加表锁ALTERTABLEusersADDCOLUMNageINT;锁兼容性:
- 读锁之间兼容:多个事务可以同时持有读锁
- 写锁互斥:同一时刻只有一个事务能持有写锁
- 读写互斥:持有读锁时不能加写锁,反之亦然
2️⃣ 行锁(Row Lock)
特点:
- 只锁定某一行数据,粒度最小
- 并发度高,不同事务可以访问不同行
- 开销较大,需要维护更多锁信息
- 可能出现死锁
关键要点:
⚠️行锁是通过索引实现的!如果不走索引,行锁会退化为表锁!
使用示例:
-- 正确使用行锁(走主键索引)BEGIN;SELECT*FROMusersWHEREid=1FORUPDATE;-- 获取行锁UPDATEusersSETname='new_name'WHEREid=1;COMMIT;-- 错误使用(没有索引,退化为表锁)SELECT*FROMusersWHEREname='张三'FORUPDATE;-- 如果 name 字段没有索引,会锁全表!两种行锁模式:
FOR UPDATE:排他锁(X锁),其他事务不能读写LOCK IN SHARE MODE:共享锁(S锁),其他事务可以读但不能写
2.2 按类型分类
3️⃣ 间隙锁(Gap Lock)
概念:
间隙锁锁定的是索引记录之间的"间隙",而不是记录本身。它是 InnoDB 在 RR(可重复读)隔离级别下防止幻读的关键机制。
作用范围:
-- 假设表中有 id=1, 3, 5, 7 的记录BEGIN;SELECT*FROMusersWHEREid>1ANDid<5FORUPDATE;COMMIT;锁定内容:
- ✅ 记录锁:id=3 的记录
- ✅ 间隙锁:(1,3) 和 (3,5) 两个间隙
- ❌ 其他事务无法插入 id=2 或 id=4 的记录
重要特性:
🔑间隙锁只锁定间隙,不锁定记录本身!
4️⃣ Next-Key Lock
组成:
Next-Key Lock = 记录锁(Row Lock)+ 间隙锁(Gap Lock)锁定范围:
- 左开右闭区间:(prev, current]
- 既锁定记录,也锁定记录之前的间隙
示例:
-- 数据:id = 1, 3, 5, 7SELECT*FROMusersWHEREid=3FORUPDATE;-- Next-Key Lock 锁定范围:(1, 3]-- 即:间隙 (1,3) + 记录 3作用:
- 防止其他事务修改当前记录
- 防止其他事务在间隙中插入新记录
- 彻底解决幻读问题
三、MVCC:多版本并发控制
3.1 什么是 MVCC?
MVCC(Multi-Version Concurrency Control)是一种非锁定并发控制机制,它的核心思想是:
读写不冲突:读操作不加锁,写操作也不阻塞读操作,通过维护数据的多个版本来实现并发控制。
3.2 MVCC 的实现原理
隐藏字段
InnoDB 每行记录都包含三个隐藏字段:
| 字段 | 说明 |
|---|---|
DB_TRX_ID | 最近修改该行数据的事务ID |
DB_ROLL_PTR | 回滚指针,指向 undo log 中的上一个版本 |
DB_ROW_ID | 隐藏的行ID,如果没有定义主键则使用 |
Undo Log 版本链
最新版本 (trx_id=103) → 旧版本1 (trx_id=102) → 旧版本2 (trx_id=101) → 初始版本每次修改数据时,都会将旧版本写入 undo log,并通过回滚指针连接起来,形成版本链。
3.3 Read View:MVCC 的核心
Read View 的作用:
🎯决定当前事务能看到哪些版本的数据
Read View 的四个关键属性
m_ids:创建ReadView时,系统中活跃的事务ID列表 min_trx_id:m_ids 中最小的事务IDmax_trx_id:系统中下一个要分配的事务IDcreator_trx_id:创建该ReadView的事务ID可见性判断算法
/** * 判断某条记录对当前事务是否可见 */booleanisVisible(Recordrecord,ReadViewreadView){longtrxId=record.getTrxId();// 记录的事务ID// 情况1:记录的事务ID < ReadView的最小活跃事务ID// 说明该事务在ReadView创建前已经提交,✅ 可见if(trxId<readView.getMinTrxId()){returntrue;}// 情况2:记录的事务ID >= ReadView的最大事务ID// 说明该事务在ReadView创建后才启动,❌ 不可见if(trxId>=readView.getMaxTrxId()){returnfalse;}// 情况3:记录的事务ID在活跃事务列表中// 说明该事务还在运行中,未提交,❌ 不可见if(readView.getMIds().contains(trxId)){returnfalse;}// 情况4:记录的事务ID不在活跃列表中,且介于min和max之间// 说明该事务在ReadView创建前已提交,✅ 可见returntrue;}3.4 不同隔离级别下 Read View 的创建时机
| 隔离级别 | Read View 创建时机 | 特点 |
|---|---|---|
| READ UNCOMMITTED | 不创建 | 总是读取最新版本,可能读到未提交数据 |
| READ COMMITTED | 每次 SELECT 都创建 | 能读到其他事务已提交的数据 |
| REPEATABLE READ | 第一次 SELECT 时创建 | 整个事务期间使用同一个 Read View |
| SERIALIZABLE | 不使用 MVCC | 完全串行化执行 |
关键区别:
-- RC 级别:每次 SELECT 创建新的 Read ViewSETTRANSACTIONISOLATIONLEVELREADCOMMITTED;BEGIN;SELECT*FROMusersWHEREid=1;-- 创建 Read View 1-- 此时其他事务修改并提交 id=1 的数据SELECT*FROMusersWHEREid=1;-- 创建 Read View 2,能看到最新数据COMMIT;-- RR 级别:第一次 SELECT 创建 Read View,后续复用SETTRANSACTIONISOLATIONLEVELREPEATABLEREAD;BEGIN;SELECT*FROMusersWHEREid=1;-- 创建 Read View-- 此时其他事务修改并提交 id=1 的数据SELECT*FROMusersWHEREid=1;-- 复用之前的 Read View,看不到最新数据COMMIT;💡这就是为什么 RC 级别会出现不可重复读,而 RR 级别不会的原因!
四、死锁:产生与预防
4.1 什么是死锁?
死锁是指两个或多个事务相互等待对方持有的锁,导致所有事务都无法继续执行的情况。
死锁产生的四个必要条件:
- 互斥条件:资源不能被共享
- 占有并等待:持有资源的同时等待其他资源
- 不可剥夺:已获得的资源不能被迫释放
- 循环等待:形成环路等待关系
4.2 典型死锁场景
-- 事务 ABEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;-- ① 锁定 id=1UPDATEaccountsSETbalance=balance-50WHEREid=2;-- ③ 等待 id=2 的锁 ❌-- 事务 BBEGIN;UPDATEaccountsSETbalance=balance-200WHEREid=2;-- ② 锁定 id=2UPDATEaccountsSETbalance=balance-100WHEREid=1;-- ④ 等待 id=1 的锁 ❌-- 💥 死锁产生!-- 事务 A 持有 id=1,等待 id=2-- 事务 B 持有 id=2,等待 id=1-- 形成循环等待时间线分析:
时刻1: 事务A 锁定 id=1 时刻2: 事务B 锁定 id=2 时刻3: 事务A 请求 id=2 的锁 → 等待事务B释放 时刻4: 事务B 请求 id=1 的锁 → 等待事务A释放 结果: 循环等待,死锁!4.3 预防死锁的最佳实践
✅ 方法1:固定顺序加锁(最推荐)
-- 始终按 ID 从小到大的顺序获取锁DELIMITER//CREATEPROCEDUREtransfer_funds(INfrom_accountINT,INto_accountINT,INamountDECIMAL(10,2))BEGINDECLAREEXITHANDLERFORSQLEXCEPTIONBEGINROLLBACK;RESIGNAL;END;STARTTRANSACTION;-- 按账户ID大小顺序获取锁,避免死锁IFfrom_account<to_accountTHENSELECT*FROMaccountsWHEREid=from_accountFORUPDATE;SELECT*FROMaccountsWHEREid=to_accountFORUPDATE;ELSEIFfrom_account>to_accountTHENSELECT*FROMaccountsWHEREid=to_accountFORUPDATE;SELECT*FROMaccountsWHEREid=from_accountFORUPDATE;ELSESIGNAL SQLSTATE'45000'SETMESSAGE_TEXT='Cannot transfer to same account';ENDIF;-- 执行转账UPDATEaccountsSETbalance=balance-amountWHEREid=from_account;UPDATEaccountsSETbalance=balance+amountWHEREid=to_account;COMMIT;END//DELIMITER;原理:所有事务都按相同顺序获取锁,打破了"循环等待"条件。
✅ 方法2:设置锁超时时间
-- 设置锁等待超时时间为 10 秒SETinnodb_lock_wait_timeout=10;-- 超过10秒未获取到锁,事务会自动回滚并抛出异常优点:避免事务无限期等待
缺点:可能导致业务失败,需要在应用层处理重试
✅ 方法3:一次性获取所有锁
BEGIN;-- 在事务开始时就把需要的资源都锁定SELECT*FROMaccountsWHEREidIN(1,2)FORUPDATE;-- 执行业务逻辑UPDATEaccountsSETbalance=balance-100WHEREid=1;UPDATEaccountsSETbalance=balance+100WHEREid=2;COMMIT;优点:避免逐步加锁导致的死锁
缺点:可能锁定不必要的资源,降低并发度
✅ 方法4:使用低隔离级别
-- 使用 READ COMMITTED 隔离级别SETTRANSACTIONISOLATIONLEVELREADCOMMITTED;原理:RC 级别下没有间隙锁,减少了锁的范围,降低死锁概率。
注意:需要权衡数据一致性要求。
4.4 监控和排查死锁
查看当前锁等待情况
-- 查看锁等待关系SELECTr.trx_idASwaiting_trx_id,r.trx_mysql_thread_idASwaiting_thread,r.trx_queryASwaiting_query,b.trx_idASblocking_trx_id,b.trx_mysql_thread_idASblocking_thread,b.trx_queryASblocking_query,w.requested_lock_mode,w.blocking_lock_modeFROMinformation_schema.innodb_lock_waits wINNERJOINinformation_schema.innodb_trx bONb.trx_id=w.blocking_trx_idINNERJOINinformation_schema.innodb_trx rONr.trx_id=w.requesting_trx_id;查看事务信息
-- 查看所有活跃事务SELECT*FROMinformation_schema.innodb_trx;-- 查看锁信息(MySQL 8.0+)SELECT*FROMperformance_schema.data_locks;SELECT*FROMperformance_schema.data_lock_waits;查看死锁日志
-- 查看最近的死锁信息SHOWENGINEINNODBSTATUS\G输出中包含LATEST DETECTED DEADLOCK部分,详细记录了死锁的发生过程。
五、锁的性能优化建议
5.1 索引优化
-- ❌ 错误:没有索引,行锁退化为表锁SELECT*FROMusersWHEREname='张三'FORUPDATE;-- ✅ 正确:添加索引,使用行锁ALTERTABLEusersADDINDEXidx_name(name);SELECT*FROMusersWHEREname='张三'FORUPDATE;5.2 缩小锁范围
-- ❌ 错误:锁定过多行SELECT*FROMusersWHEREage>18FORUPDATE;-- ✅ 正确:精确定位需要锁定的行SELECT*FROMusersWHEREid=1FORUPDATE;5.3 缩短事务时间
-- ❌ 错误:事务中包含耗时操作BEGIN;SELECT*FROMusersWHEREid=1FORUPDATE;-- 调用外部 API(耗时 2 秒)CALLexternal_api();UPDATEusersSETname='new_name'WHEREid=1;COMMIT;-- ✅ 正确:先完成耗时操作,再开启事务CALLexternal_api();BEGIN;SELECT*FROMusersWHEREid=1FORUPDATE;UPDATEusersSETname='new_name'WHEREid=1;COMMIT;5.4 选择合适的隔离级别
| 隔离级别 | 并发度 | 一致性 | 适用场景 |
|---|---|---|---|
| READ UNCOMMITTED | 最高 | 最低 | 几乎不用 |
| READ COMMITTED | 高 | 中等 | 大多数互联网应用 |
| REPEATABLE READ | 中等 | 高 | 金融系统等强一致性场景 |
| SERIALIZABLE | 最低 | 最高 | 极少使用 |
建议:优先使用READ COMMITTED,在保证一致性的前提下获得更高的并发度。
六、实战案例:电商库存扣减
场景描述
电商平台秒杀活动,需要扣减商品库存,要求:
- 保证库存不会扣成负数
- 支持高并发
- 避免超卖
方案对比
❌ 方案1:先查后改(有并发问题)
BEGIN;-- 查询库存SELECTstockFROMproductsWHEREid=1;-- 应用层判断if(stock>0){-- 扣减库存UPDATEproductsSETstock=stock-1WHEREid=1;}COMMIT;问题:并发情况下可能超卖
❌ 方案2:使用悲观锁(性能差)
BEGIN;SELECTstockFROMproductsWHEREid=1FORUPDATE;-- 行锁if(stock>0){UPDATEproductsSETstock=stock-1WHEREid=1;}COMMIT;问题:高并发下大量事务等待,性能差
✅ 方案3:乐观锁(推荐)
-- 使用 CAS(Compare And Swap)机制UPDATEproductsSETstock=stock-1,version=version+1WHEREid=1ANDstock>0ANDversion=#{oldVersion};-- 检查影响行数if(affectedRows==0){// 扣减失败,重试或返回失败}优点:
- 无锁设计,并发度高
- 通过版本号保证一致性
- 适合读多写少场景
✅ 方案4:数据库层面原子操作(最简单)
-- 利用数据库的原子性UPDATEproductsSETstock=stock-1WHEREid=1ANDstock>0;-- 检查影响行数if(affectedRows==0){// 库存不足}优点:
- 简单高效
- 利用数据库自身的原子性
- 无需额外锁机制
七、常见问题 FAQ
Q1:什么时候行锁会退化为表锁?
A:当 SQL 语句没有使用索引时,InnoDB 无法定位具体的行,只能锁定整张表。
-- name 字段没有索引SELECT*FROMusersWHEREname='张三'FORUPDATE;-- 表锁-- 添加索引后ALTERTABLEusersADDINDEXidx_name(name);SELECT*FROMusersWHEREname='张三'FORUPDATE;-- 行锁Q2:间隙锁只在什么隔离级别下生效?
A:间隙锁只在REPEATABLE READ隔离级别下生效。在 READ COMMITTED 级别下,没有间隙锁。
Q3:MVCC 能完全替代锁吗?
A:不能。MVCC 主要解决读写冲突,但对于写写冲突仍然需要锁机制。例如:
UPDATE、DELETE操作需要加排他锁SELECT ... FOR UPDATE需要加锁
Q4:如何判断当前使用的是行锁还是表锁?
A:使用以下 SQL 查看锁信息:
-- MySQL 8.0+SELECT*FROMperformance_schema.data_locks;-- 或者SHOWENGINEINNODBSTATUS\GQ5:死锁发生后,MySQL 如何处理?
A:InnoDB 会自动检测死锁,并选择一个代价较小的事务进行回滚(通常是持有锁较少的事务),另一个事务继续执行。
被回滚的事务会收到错误:
Deadlock found when trying to get lock; try restarting transaction应用层需要捕获该异常并进行重试。
八、总结
核心要点回顾
- 锁的粒度:表锁 > 行锁 > 间隙锁
- 行锁依赖索引:不走索引会退化为表锁
- 间隙锁防幻读:只在 RR 级别下生效
- MVCC 实现快照读:通过 Read View 判断数据可见性
- RC vs RR:RC 每次查询创建新 Read View,RR 复用同一个
- 死锁预防:固定顺序加锁是最有效的方法
最佳实践清单
- ✅ 为查询条件添加合适的索引
- ✅ 尽量缩短事务持续时间
- ✅ 按固定顺序获取锁
- ✅ 设置合理的锁超时时间
- ✅ 优先使用 READ COMMITTED 隔离级别
- ✅ 监控锁等待和死锁情况
- ✅ 考虑使用乐观锁替代悲观锁
参考资料
- MySQL 官方文档 - InnoDB Locking
- MySQL 技术内幕:InnoDB 存储引擎
- 高性能 MySQL(第3版)