在高并发场景下理解 MySQL 的锁机制是保证数据一致性和提升系统性能的关键。本文将从底层原理出发结合实际案例全面解析 MySQL 的锁机制。一、为什么需要锁在并发环境下多个事务同时访问和修改数据时如果没有合理的控制机制会导致以下问题脏读读到其他事务未提交的数据不可重复读同一事务中多次读取结果不一致幻读同一查询条件下前后读取的行数不一致丢失更新两个事务同时修改同一数据后提交的覆盖先提交的MySQL 通过锁机制和 MVCC多版本并发控制来解决这些问题。二、MySQL 锁的分类2.1 按粒度分类1️⃣ 表锁Table Lock特点锁定整张表粒度最大开销小实现简单并发度低其他事务无法访问该表不会出现死锁使用场景-- 显式加表锁LOCKTABLESusersREAD;-- 读锁共享锁LOCKTABLESusersWRITE;-- 写锁排他锁-- DDL 操作自动加表锁ALTERTABLEusersADDCOLUMNageINT;锁兼容性读锁之间兼容多个事务可以同时持有读锁写锁互斥同一时刻只有一个事务能持有写锁读写互斥持有读锁时不能加写锁反之亦然2️⃣ 行锁Row Lock特点只锁定某一行数据粒度最小并发度高不同事务可以访问不同行开销较大需要维护更多锁信息可能出现死锁关键要点⚠️行锁是通过索引实现的如果不走索引行锁会退化为表锁使用示例-- 正确使用行锁走主键索引BEGIN;SELECT*FROMusersWHEREid1FORUPDATE;-- 获取行锁UPDATEusersSETnamenew_nameWHEREid1;COMMIT;-- 错误使用没有索引退化为表锁SELECT*FROMusersWHEREname张三FORUPDATE;-- 如果 name 字段没有索引会锁全表两种行锁模式FOR UPDATE排他锁X锁其他事务不能读写LOCK IN SHARE MODE共享锁S锁其他事务可以读但不能写2.2 按类型分类3️⃣ 间隙锁Gap Lock概念间隙锁锁定的是索引记录之间的间隙而不是记录本身。它是 InnoDB 在 RR可重复读隔离级别下防止幻读的关键机制。作用范围-- 假设表中有 id1, 3, 5, 7 的记录BEGIN;SELECT*FROMusersWHEREid1ANDid5FORUPDATE;COMMIT;锁定内容✅ 记录锁id3 的记录✅ 间隙锁(1,3) 和 (3,5) 两个间隙❌ 其他事务无法插入 id2 或 id4 的记录重要特性间隙锁只锁定间隙不锁定记录本身4️⃣ Next-Key Lock组成Next-Key Lock 记录锁Row Lock 间隙锁Gap Lock锁定范围左开右闭区间(prev, current]既锁定记录也锁定记录之前的间隙示例-- 数据id 1, 3, 5, 7SELECT*FROMusersWHEREid3FORUPDATE;-- Next-Key Lock 锁定范围(1, 3]-- 即间隙 (1,3) 记录 3作用防止其他事务修改当前记录防止其他事务在间隙中插入新记录彻底解决幻读问题三、MVCC多版本并发控制3.1 什么是 MVCCMVCCMulti-Version Concurrency Control是一种非锁定并发控制机制它的核心思想是读写不冲突读操作不加锁写操作也不阻塞读操作通过维护数据的多个版本来实现并发控制。3.2 MVCC 的实现原理隐藏字段InnoDB 每行记录都包含三个隐藏字段字段说明DB_TRX_ID最近修改该行数据的事务IDDB_ROLL_PTR回滚指针指向 undo log 中的上一个版本DB_ROW_ID隐藏的行ID如果没有定义主键则使用Undo Log 版本链最新版本 (trx_id103) → 旧版本1 (trx_id102) → 旧版本2 (trx_id101) → 初始版本每次修改数据时都会将旧版本写入 undo log并通过回滚指针连接起来形成版本链。3.3 Read ViewMVCC 的核心Read View 的作用决定当前事务能看到哪些版本的数据Read View 的四个关键属性m_ids:创建ReadView时系统中活跃的事务ID列表 min_trx_id:m_ids 中最小的事务IDmax_trx_id:系统中下一个要分配的事务IDcreator_trx_id:创建该ReadView的事务ID可见性判断算法/** * 判断某条记录对当前事务是否可见 */booleanisVisible(Recordrecord,ReadViewreadView){longtrxIdrecord.getTrxId();// 记录的事务ID// 情况1记录的事务ID ReadView的最小活跃事务ID// 说明该事务在ReadView创建前已经提交✅ 可见if(trxIdreadView.getMinTrxId()){returntrue;}// 情况2记录的事务ID ReadView的最大事务ID// 说明该事务在ReadView创建后才启动❌ 不可见if(trxIdreadView.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 ViewSERIALIZABLE不使用 MVCC完全串行化执行关键区别-- RC 级别每次 SELECT 创建新的 Read ViewSETTRANSACTIONISOLATIONLEVELREADCOMMITTED;BEGIN;SELECT*FROMusersWHEREid1;-- 创建 Read View 1-- 此时其他事务修改并提交 id1 的数据SELECT*FROMusersWHEREid1;-- 创建 Read View 2能看到最新数据COMMIT;-- RR 级别第一次 SELECT 创建 Read View后续复用SETTRANSACTIONISOLATIONLEVELREPEATABLEREAD;BEGIN;SELECT*FROMusersWHEREid1;-- 创建 Read View-- 此时其他事务修改并提交 id1 的数据SELECT*FROMusersWHEREid1;-- 复用之前的 Read View看不到最新数据COMMIT;这就是为什么 RC 级别会出现不可重复读而 RR 级别不会的原因四、死锁产生与预防4.1 什么是死锁死锁是指两个或多个事务相互等待对方持有的锁导致所有事务都无法继续执行的情况。死锁产生的四个必要条件互斥条件资源不能被共享占有并等待持有资源的同时等待其他资源不可剥夺已获得的资源不能被迫释放循环等待形成环路等待关系4.2 典型死锁场景-- 事务 ABEGIN;UPDATEaccountsSETbalancebalance-100WHEREid1;-- ① 锁定 id1UPDATEaccountsSETbalancebalance-50WHEREid2;-- ③ 等待 id2 的锁 ❌-- 事务 BBEGIN;UPDATEaccountsSETbalancebalance-200WHEREid2;-- ② 锁定 id2UPDATEaccountsSETbalancebalance-100WHEREid1;-- ④ 等待 id1 的锁 ❌-- 死锁产生-- 事务 A 持有 id1等待 id2-- 事务 B 持有 id2等待 id1-- 形成循环等待时间线分析时刻1: 事务A 锁定 id1 时刻2: 事务B 锁定 id2 时刻3: 事务A 请求 id2 的锁 → 等待事务B释放 时刻4: 事务B 请求 id1 的锁 → 等待事务A释放 结果: 循环等待死锁4.3 预防死锁的最佳实践✅ 方法1固定顺序加锁最推荐-- 始终按 ID 从小到大的顺序获取锁DELIMITER//CREATEPROCEDUREtransfer_funds(INfrom_accountINT,INto_accountINT,INamountDECIMAL(10,2))BEGINDECLAREEXITHANDLERFORSQLEXCEPTIONBEGINROLLBACK;RESIGNAL;END;STARTTRANSACTION;-- 按账户ID大小顺序获取锁避免死锁IFfrom_accountto_accountTHENSELECT*FROMaccountsWHEREidfrom_accountFORUPDATE;SELECT*FROMaccountsWHEREidto_accountFORUPDATE;ELSEIFfrom_accountto_accountTHENSELECT*FROMaccountsWHEREidto_accountFORUPDATE;SELECT*FROMaccountsWHEREidfrom_accountFORUPDATE;ELSESIGNAL SQLSTATE45000SETMESSAGE_TEXTCannot transfer to same account;ENDIF;-- 执行转账UPDATEaccountsSETbalancebalance-amountWHEREidfrom_account;UPDATEaccountsSETbalancebalanceamountWHEREidto_account;COMMIT;END//DELIMITER;原理所有事务都按相同顺序获取锁打破了循环等待条件。✅ 方法2设置锁超时时间-- 设置锁等待超时时间为 10 秒SETinnodb_lock_wait_timeout10;-- 超过10秒未获取到锁事务会自动回滚并抛出异常优点避免事务无限期等待缺点可能导致业务失败需要在应用层处理重试✅ 方法3一次性获取所有锁BEGIN;-- 在事务开始时就把需要的资源都锁定SELECT*FROMaccountsWHEREidIN(1,2)FORUPDATE;-- 执行业务逻辑UPDATEaccountsSETbalancebalance-100WHEREid1;UPDATEaccountsSETbalancebalance100WHEREid2;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_idw.blocking_trx_idINNERJOINinformation_schema.innodb_trx rONr.trx_idw.requesting_trx_id;查看事务信息-- 查看所有活跃事务SELECT*FROMinformation_schema.innodb_trx;-- 查看锁信息MySQL 8.0SELECT*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*FROMusersWHEREage18FORUPDATE;-- ✅ 正确精确定位需要锁定的行SELECT*FROMusersWHEREid1FORUPDATE;5.3 缩短事务时间-- ❌ 错误事务中包含耗时操作BEGIN;SELECT*FROMusersWHEREid1FORUPDATE;-- 调用外部 API耗时 2 秒CALLexternal_api();UPDATEusersSETnamenew_nameWHEREid1;COMMIT;-- ✅ 正确先完成耗时操作再开启事务CALLexternal_api();BEGIN;SELECT*FROMusersWHEREid1FORUPDATE;UPDATEusersSETnamenew_nameWHEREid1;COMMIT;5.4 选择合适的隔离级别隔离级别并发度一致性适用场景READ UNCOMMITTED最高最低几乎不用READ COMMITTED高中等大多数互联网应用REPEATABLE READ中等高金融系统等强一致性场景SERIALIZABLE最低最高极少使用建议优先使用READ COMMITTED在保证一致性的前提下获得更高的并发度。六、实战案例电商库存扣减场景描述电商平台秒杀活动需要扣减商品库存要求保证库存不会扣成负数支持高并发避免超卖方案对比❌ 方案1先查后改有并发问题BEGIN;-- 查询库存SELECTstockFROMproductsWHEREid1;-- 应用层判断if(stock0){-- 扣减库存UPDATEproductsSETstockstock-1WHEREid1;}COMMIT;问题并发情况下可能超卖❌ 方案2使用悲观锁性能差BEGIN;SELECTstockFROMproductsWHEREid1FORUPDATE;-- 行锁if(stock0){UPDATEproductsSETstockstock-1WHEREid1;}COMMIT;问题高并发下大量事务等待性能差✅ 方案3乐观锁推荐-- 使用 CASCompare And Swap机制UPDATEproductsSETstockstock-1,versionversion1WHEREid1ANDstock0ANDversion#{oldVersion};-- 检查影响行数if(affectedRows0){// 扣减失败重试或返回失败}优点无锁设计并发度高通过版本号保证一致性适合读多写少场景✅ 方案4数据库层面原子操作最简单-- 利用数据库的原子性UPDATEproductsSETstockstock-1WHEREid1ANDstock0;-- 检查影响行数if(affectedRows0){// 库存不足}优点简单高效利用数据库自身的原子性无需额外锁机制七、常见问题 FAQQ1什么时候行锁会退化为表锁A当 SQL 语句没有使用索引时InnoDB 无法定位具体的行只能锁定整张表。-- name 字段没有索引SELECT*FROMusersWHEREname张三FORUPDATE;-- 表锁-- 添加索引后ALTERTABLEusersADDINDEXidx_name(name);SELECT*FROMusersWHEREname张三FORUPDATE;-- 行锁Q2间隙锁只在什么隔离级别下生效A间隙锁只在REPEATABLE READ隔离级别下生效。在 READ COMMITTED 级别下没有间隙锁。Q3MVCC 能完全替代锁吗A不能。MVCC 主要解决读写冲突但对于写写冲突仍然需要锁机制。例如UPDATE、DELETE操作需要加排他锁SELECT ... FOR UPDATE需要加锁Q4如何判断当前使用的是行锁还是表锁A使用以下 SQL 查看锁信息-- MySQL 8.0SELECT*FROMperformance_schema.data_locks;-- 或者SHOWENGINEINNODBSTATUS\GQ5死锁发生后MySQL 如何处理AInnoDB 会自动检测死锁并选择一个代价较小的事务进行回滚通常是持有锁较少的事务另一个事务继续执行。被回滚的事务会收到错误Deadlock found when trying to get lock; try restarting transaction应用层需要捕获该异常并进行重试。八、总结核心要点回顾锁的粒度表锁 行锁 间隙锁行锁依赖索引不走索引会退化为表锁间隙锁防幻读只在 RR 级别下生效MVCC 实现快照读通过 Read View 判断数据可见性RC vs RRRC 每次查询创建新 Read ViewRR 复用同一个死锁预防固定顺序加锁是最有效的方法最佳实践清单✅ 为查询条件添加合适的索引✅ 尽量缩短事务持续时间✅ 按固定顺序获取锁✅ 设置合理的锁超时时间✅ 优先使用 READ COMMITTED 隔离级别✅ 监控锁等待和死锁情况✅ 考虑使用乐观锁替代悲观锁参考资料MySQL 官方文档 - InnoDB LockingMySQL 技术内幕InnoDB 存储引擎高性能 MySQL第3版