news 2026/9/18 9:41:34

MySQL锁机制全解析:从表锁行锁到死锁排查实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL锁机制全解析:从表锁行锁到死锁排查实战

大半夜被线上告警叫醒,数据库死锁日志刷了一屏,Deadlock found when trying to get lock; try restarting transaction反复出现。这种场面干过后端的人应该都不陌生。锁机制是MySQL里最容易被误解、也最影响系统稳定性的部分——读锁、写锁、表锁、行锁、悲观锁、乐观锁、间隙锁,光名字就一堆,实际用起来更是处处有坑。

这篇文章我会把这七种锁逐个拆开讲清楚,不绕理论,直接说它们在什么场景下起作用、为什么这样设计、用的时候会踩什么坑。文章适合正在做业务开发的工程师、准备数据库面试的同学,以及那些被死锁和锁等待折磨过的DBA或后端负责人。

1. 表锁与行锁:粒度的差异决定了并发天花板

1.1 表锁的真实成本和适用场景

表锁,顾名思义,就是把整张表锁住。MySQL早期的主力存储引擎MyISAM只有表锁,这也是为什么MyISAM在写入频繁的场景下表现很差——它对并发写入的支持约等于零。

表锁的兼容性关系很直白:

锁类型读锁(共享)写锁(排他)
读锁兼容不兼容
写锁不兼容不兼容

也就是说读读不互斥,读写、写写都会互斥。在MyISAM时代,所有写操作必须串行执行,任何一个UPDATEINSERT都会阻塞其他所有读写操作,并发一上来性能就崩了。

但表锁并不是一无是处。它的优点是开销小、加锁快,而且不会产生死锁——因为整张表就一个锁,不存在多个锁资源互相等待的问题。对于单纯读多写极少、或者需要全表批量处理的场景,表锁反而更高效。我见过一些报表系统,每天定时全量更新某张汇总表,这种批量更新如果走行锁,要维护成千上万个锁对象,开销极大,不如直接LOCK TABLE排他锁一把梭。

1.2 InnoDB行锁的真正含义:锁的是索引

InnoDB的行锁才是现代高并发应用的基础。但这里有一个绝大多数人都会忽略的关键点:InnoDB的行锁是加在索引记录上的,不是加在数据行上的。如果一条SQL没有走索引,InnoDB只能扫描主键聚簇索引的所有记录,给扫描过程中碰到的每一条记录都加上锁。

实际操作中我见过一个非常典型的线上事故:

UPDATE user SET age = 18 WHERE name = '张三';

假设name字段上没有索引,这条SQL执行时会全表扫描聚簇索引,对主键索引中的每一行都加锁。虽然最后只更新了一条记录,但加锁的范围是全部记录。此时其他事务想更新任意一行数据,都得等这个事务提交。表面上是行锁,实际效果等同于表锁。

所以判断一个UPDATE是否真正行锁,不要看它更新了多少行,要看它的WHERE条件有没有走索引。

1.3 锁粒度选择的实践建议

我个人的实践原则是:

  • 业务系统核心的读写并发请求,一律用InnoDB的行锁,保证并发度;
  • 对于批处理任务、数据归档、表结构调整这种独占型操作,显式使用表锁或直接LOCK TABLES,避免行锁数量过多消耗内存;
  • 永远不要用无索引字段作为UPDATE/DELETE的过滤条件,这不止是性能问题,更是锁粒度的灾难。

2. 读锁与写锁:共享与排他背后的并发妥协

2.1 S锁与X锁的语义

读锁和写锁不是两种具体技术,而是锁的两种基本性质。InnoDB里的行锁分两类:

  • 共享锁(Shared Lock,S锁):事务读一行数据时加S锁,多个事务可以同时持有同一行数据的S锁。
  • 排他锁(Exclusive Lock,X锁):事务写一行数据时加X锁,X锁与其他任何锁都不兼容。

这里有个容易搞混的点:普通SELECT语句在InnoDB下是不加锁的。它走的是MVCC多版本并发控制机制,读取的是某个快照版本,不需要加锁。这个机制让我经常跟团队里的小朋友解释:不是所有读操作都会触发锁机制,只有显式加锁的读(FOR UPDATEFOR SHARE)和写操作(INSERT/UPDATE/DELETE)才会真正动用锁。

2.2 意向锁:表锁与行锁之间的桥梁

讲读锁和写锁,必须提意向锁,不然你查SHOW ENGINE INNODB STATUS看到TABLE LOCK会一头雾水。

意向锁是表级锁,但它的作用是为了配合行锁。一个事务要给某行加X锁,必须先给所在的表加意向排他锁(IX);要给某行加S锁,必须先给表加意向共享锁(IS)。意向锁之间是兼容的,它们只阻塞表级的S锁和X锁请求。

举个例子:事务A对users表id=1这行加了X锁,事务B想对整个users表加表级X锁。如果没有意向锁,MySQL必须遍历users表的所有行,确认没有任何行被加锁才能授予表锁。有意向锁之后,直接检查表上的IX锁就能快速判断有行锁存在,直接阻塞。这就是意向锁存在的意义——用极小的开销维护了表锁和行锁之间的兼容性判断。

2.3 手动加锁的正确姿势

实际业务中,我们经常需要手动加锁,两种方式要区分清楚:

-- 加S锁(读锁) SELECT * FROM account WHERE id = 1 FOR SHARE; -- MySQL 8.0之前的老写法 SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE; -- 加X锁(写锁) SELECT * FROM account WHERE id = 1 FOR UPDATE;

FOR SHARE锁定的是共享读锁,其他事务仍然可以加S锁读取同一行,但不能加X锁修改;FOR UPDATE锁定的是排他写锁,其他事务的读写都会被阻塞。

FOR UPDATE时有一个天坑:必须放在事务里,并且要让事务尽快提交,锁才会释放。我见过有人直接在事务里查出来、做了大量业务计算甚至远程调用,整个链路拖了几秒甚至几十秒再提交,把并发请求全堵在一个锁上。这种问题排查起来特别迷惑,因为SQL本身没问题,问题出在锁的持有时间被严重拉长了。

3. 悲观锁与乐观锁:从SQL到业务的两种并发策略

3.1 悲观锁的典型实现和适用边界

悲观锁的核心思想是:我先拿到锁,再操作数据,别人在我操作期间别想碰这条数据。在MySQL里,悲观锁的落地方式就是前面提到的SELECT ... FOR UPDATE

什么时候该用悲观锁?冲突概率高、重试代价大的场景。比如库存扣减,假设库存只有10件,却有100个并发请求来抢。如果用乐观锁,大部分请求会更新失败需要重试,反而放大压力。这时候用悲观锁让请求排队,反而是稳定的方案。

悲观锁的代码套路是:

# 伪代码示意 def deduct_stock(product_id, quantity): with db.transaction(): # 对目标行加X锁 row = db.query_one( "SELECT stock FROM product WHERE id = %s FOR UPDATE", product_id ) if row.stock < quantity: raise InsufficientStock() db.execute( "UPDATE product SET stock = stock - %s WHERE id = %s", quantity, product_id )

注意FOR UPDATE必须配合事务使用,否则锁会在语句执行完立即释放,悲观锁就形同虚设。另外,FOR UPDATE同样遵循"走索引才能锁行"的规则,如果WHERE条件没走索引,那就是全表加锁,并发直接归零。

3.2 乐观锁的版本号设计与CAS陷阱

乐观锁不锁数据库行,而是靠版本号或时间戳在更新时做校验。它的核心是一个带条件的UPDATE:

UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 5;

执行后如果影响行数为0,说明版本号不匹配,数据在这期间被其他事务改过,需要重新查询、重新计算、再重试。

乐观锁本质上是CAS思想在数据库层的实现。CAS有个臭名昭著的ABA问题——A改成B、B又改成A,值看起来没变但过程变了。MySQL乐观锁通过版本号每次递增,能有效规避ABA问题,因为version一旦从1变成2再变回3,不会回到旧值。

乐观锁的取舍很清晰:

维度悲观锁乐观锁
数据库资源占用高,锁等待阻塞低,不加锁
冲突处理方式排队等待失败重试
适用场景写冲突高写冲突低
性能瓶颈锁竞争重试风暴

一个典型的反面案例:某系统在用户签到接口上用了乐观锁,正常情况下没问题,但运营搞活动时大量用户同一秒签到同一行配置数据,重试请求把数据库打爆了。所以在用什么锁之前,先评估冲突概率,冲突概率低用乐观锁,高用悲观锁,不存在银弹。

3.3 从扣库存场景看两种策略的取舍

扣库存是这两种策略最经典的战场。我之前负责过一个秒杀项目,最初用的是乐观锁:

UPDATE goods SET stock = stock - 1 WHERE id = 123 AND stock > 0;

这个写法实际上把版本号换成了库存量条件,用stock > 0作为校验条件。并发量在每秒几百时效果很好,但到了秒杀瞬间每秒几万的量级,大量请求的UPDATE执行成功但影响行数为0,客户端不断重试,数据库CPU直接冲高。

后来切换到悲观锁方案,请求变成串行执行,虽然吞吐量数字下降了,但每个请求的成功率变得可预测,系统反而稳定。这个经验告诉我:吞吐量和稳定性之间要做权衡,不要只看压测数字

4. 间隙锁与临键锁:防幻读的正面战场

4.1 幻读为什么行锁治不了

先明确幻读的定义:在同一个事务里执行两次范围查询,第二次查询多出了第一次没有的行。

为什么行锁防不了幻读?因为行锁只能锁住已经存在的行,却管不住其他事务往这个范围内插入新行。事务A查id > 10的所有行,事务B插入一条id = 100的记录并提交,事务A再查一次,发现多了一条——这就是幻读。

InnoDB在可重复读隔离级别下,通过MVCC解决了普通SELECT(快照读)的幻读问题,但对于FOR UPDATE这类当前读,必须靠间隙锁和临键锁来兜底。

4.2 间隙锁和临键锁的加锁区间

间隙锁(Gap Lock)锁定的是一个开区间范围,比如两个索引值(5, 10)之间的空隙,它不允许其他事务在这个空隙里插入任何数据。间隙锁之间是互相兼容的——多个事务可以同时持有同一个间隙的间隙锁,这是它和行锁非常不一样的地方。但间隙锁与插入意向锁冲突,所以能阻止新记录插入。

临键锁(Next-Key Lock)是记录锁和间隙锁的合体,锁定的范围是左开右闭区间。假设某索引的值有1、5、10,那么临键锁覆盖的区间是:

(-∞, 1] (1, 5] (5, 10] (10, +∞)

每个区间既包含边界值本身(记录锁),也包含边界之前的空隙(间隙锁)。

这个设计有一个容易被忽略的副作用:即使是完全等值的唯一索引查询,如果记录不存在,也可能产生间隙锁。比如执行:

SELECT * FROM t WHERE id = 100 FOR UPDATE;

如果id=100不存在,事务会获取(上一个值, 100]区间到(100, 下一个值]区间之间的间隙锁,其他事务就无法在附近插入数据了。这意味着一个看似无伤大雅的SELECT,可能阻塞整个区间的写入。

4.3 间隙锁引发的死锁黑天鹅

间隙锁是死锁的重灾区。最典型的案例是:

假设表里有id为1和5的两行记录,两个事务同时执行:

-- 事务A BEGIN; SELECT * FROM t WHERE id BETWEEN 3 AND 4 FOR UPDATE; -- 事务B BEGIN; SELECT * FROM t WHERE id BETWEEN 3 AND 4 FOR UPDATE;

两个事务都成功获得了间隙锁(1, 5)——因为间隙锁之间兼容,互不阻塞。然后:

-- 事务A插入id=3 INSERT INTO t (id) VALUES (3); -- 此时A需要插入意向锁,但B持有间隙锁(1,5),A被阻塞 -- 事务B插入id=3 INSERT INTO t (id) VALUES (3); -- 此时B需要插入意向锁,但A也持有间隙锁(1,5),B被阻塞

死锁形成,MySQL检测到后会选择回滚其中一个事务。

这个案列告诉我们,事务里加了范围锁之后再做插入操作,一定要想到间隙锁互相兼容这个特性。很多死锁不是并发量高触发的,而是两个事务恰好在一个空隙上"默契地"互相等待。

另外注意一个隔离级别的差异性:间隙锁只在REPEATABLE READ隔离级别下生效。如果你的应用不需要严格的RR语义,可以降到READ COMMITTED,这样InnoDB只加记录锁,不加间隙锁,死锁概率会显著下降。很多互联网公司生产环境都用RC,就是为了在并发和一致性之间找平衡。

5. 死锁排查实录:从日志到根因的完整链路

5.1 死锁日志怎么看

先上最常用的三板斧命令:

-- 查看InnoDB引擎状态,重点看LATEST DETECTED DEADLOCK段 SHOW ENGINE INNODB STATUS\G -- 查看当前正在运行的事务 SELECT * FROM information_schema.innodb_trx\G -- MySQL 8.0查看当前锁信息 SELECT * FROM performance_schema.data_locks\G -- MySQL 5.7及之前版本 SELECT * FROM information_schema.innodb_locks\G

SHOW ENGINE INNODB STATUS输出的死锁日志信息量非常大,核心要关注这几点:

  • TRANSACTION编号和状态,判断哪个事务被回滚;
  • WAITING FOR THIS LOCK TO BE GRANTED,这是事务正在等待的锁;
  • HOLD OF THE LOCK或者LOCK HELD,这是事务已经持有的锁;
  • 最终MySQL的裁决:回滚代价较小的事务。

曾经遇到一个真实案例,两个事务做转账:

-- 事务A UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; -- 事务B UPDATE account SET balance = balance - 100 WHERE user_id = 2; UPDATE account SET balance = balance + 100 WHERE user_id = 1;

两个事务同时执行,A锁住了user_id=1的行,B锁住了user_id=2的行,然后A想锁user_id=2、B想锁user_id=1,死锁立即形成。这个案例的根因是加锁顺序不一致

5.2 通过系统表定位问题事务

死锁日志是事后分析,出了死锁MySQL已经帮你回滚了。但更多时候我们面对的是锁等待,不是死锁——一个事务迟迟不提交,其他事务全部卡死,数据库线程池被耗尽。

遇到锁等待,第一件事查information_schema.innodb_trx

SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_running_seconds, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;

重点关注trx_running_seconds很大的事务,它们大概率就是元凶。拿到trx_mysql_thread_id后,可以通过performance_schema.data_locks查这个事务持有和等待的锁明细:

SELECT ENGINE_TRANSACTION_ID as trx_id, OBJECT_NAME as table_name, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks WHERE ENGINE_TRANSACTION_ID = 某事务ID;

LOCK_DATA这个字段会直接告诉你锁在哪一行索引记录上,定位问题的效率非常高。

5.3 根治死锁的几条实际经验

排查完一个又一个死锁之后,我总结出几条在业务里真正管用的经验:

第一,统一加锁顺序。多个事务访问多个资源时,约定一个固定的顺序(比如按主键排序),从根源上消除循环等待。转账场景就按user_id大小排序后再执行UPDATE。

第二,缩短事务时间。锁的持有时间决定了阻塞范围,事务里的远程调用、外部接口、复杂计算全部移到事务外面。我曾见过一个事务里调用短信接口,超时3秒,期间持有100行数据的写锁,整个系统的写请求都跟着遭殃。

第三,确保更新条件走索引。这条怎么强调都不为过——不只是性能问题,还关系到行锁是否退化成表锁。

第四,设置合理的锁等待超时innodb_lock_wait_timeout默认是50秒,对OLTP系统来说太长了。我一般建议设置为3到5秒,宁可让请求快速失败,也不要无限阻塞拖垮整个实例。

第五,业务层必须做好重试。死锁无法100%避免,MySQL的死锁检测器会回滚其中一个事务,但应用层不捕获异常直接报错的话,用户体验就是"操作失败"。正确的做法是捕获死锁错误码(MySQL是1213),做有限次数的重试。

我这几年处理过好几次严重的锁问题,最大的体会是:锁机制不是背熟了八种锁的定义就能玩转的,真正的难点在于搞清楚"一个SQL执行时到底锁了哪些范围、锁了多久、和谁冲突"。每次写完SQL,先EXPLAIN看有没有走索引,再想一想这个SQL在RR隔离级别下会不会锁住额外的区间,最后把事务精简到最小。养成这个习惯之后,线上锁故障至少能少一半。后面有时间我再写一篇MVCC和锁是怎么配合保证隔离级别的,那部分同样有很多反直觉的设计。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 9:41:33

从零实现桌面鱼缸 MiroFish:Boids 鱼群模拟与透明窗口性能优化

1. MiroFish 这个"看着没什么用"的项目&#xff0c;为什么值得认真做一遍第一次把 MiroFish 挂在桌面上&#xff0c;我盯着屏幕里那十几条鱼看了大概十分钟&#xff0c;然后才想起来自己本来是要去开会的。它就是这么一个东西&#xff1a;一个常驻在桌面上、没有边框…

作者头像 李华
网站建设 2026/9/18 9:38:04

Agent-Reach 工程实践:工具调用、记忆分层与多 Agent 协作

把 Agent 接进真实业务的第一天&#xff0c;十有八九会遇到这种场面&#xff1a;本地 Demo 里工具调用、记忆检索、多轮规划全都跑得通&#xff0c;一上线就出现工具选错、参数拼错、循环停不下来、上下文爆掉。问题往往不在模型&#xff0c;而在模型和外部世界之间那一层——我…

作者头像 李华
网站建设 2026/9/18 9:37:20

Windows服务管理实战:用sc命令与批处理脚本实现自动化运维

Windows下折腾服务&#xff0c;我第一个想到的命令就是sc。它是系统自带的Service Control&#xff0c;不需要额外装任何软件&#xff0c;安装、开启、配置、关闭甚至删除windows服务&#xff0c;一行命令就能搞定&#xff1b;配合bat批处理之后&#xff0c;更是能把“手动开服…

作者头像 李华
网站建设 2026/9/18 9:36:08

基于Flask的医院挂号与质控系统开发实战

1. 医院挂号与质控系统开发实战&#xff1a;基于Flask的全栈解决方案在医院信息化建设中&#xff0c;挂号系统与医疗质量监控是两大核心需求。去年我参与某三甲医院系统升级项目时&#xff0c;深刻体会到传统手工排班和纸质质控报告的痛点——医生排班冲突频发、质控数据滞后一…

作者头像 李华
网站建设 2026/9/18 9:36:01

基于Node.js+Vue的自习室座位预约签到系统实战解析

自习室座位签到预约系统&#xff0c;这六个字背后其实是大多数自习室管理者的真实痛点&#xff1a;座位靠“占”、来了没座、人走位空&#xff0c;管理全靠吼。用Node.js加Vue做一套预约签到系统&#xff0c;本质上就是把“占座”从线下冲突变成线上契约&#xff0c;让每一个座…

作者头像 李华