关于锁机制,我先说一个结论:大部分性能事故,本质上不是SQL写得差,而是锁用得不对。
我在接手一个订单系统的性能排查时,遇到过这样的场景:白天高峰期,一个简单的UPDATE orders SET status = 'paid' WHERE order_id = 123竟然卡了5秒才返回。业务方第一反应是"数据库慢查询",结果EXPLAIN一看,主键查询、命中索引、扫描行数是1,怎么看都不像一个"慢SQL"。最后翻information_schema.innodb_trx才发现,另一条事务早就锁住了这一行,后到的更新一直在等待锁释放。
这种"SQL不慢,但就是执行不动"的案例,比慢查询更隐蔽,也更容易让人摸不着头脑。要真正搞懂并发控制,绕不开MySQL锁机制——全局锁、表级锁、行级锁、间隙锁、意向锁、MDL锁,它们是怎么配合的,为什么会产生锁等待,以及怎么从优化策略层面把锁冲突降下来。这篇文章不准备从教科书定义讲起,而是从一次真实的"update卡住"开始,把锁机制的整体框架、底层原理、排查链路和优化思路完整串一遍。
1. 为什么数据库并发最终都要绕回"锁"这件事
1.1 从一次update卡住说起
那次线上事故,问题表面是"应用接口超时",真正的根因链条是这样的:
- 上午11:00,运营后台发起了一个批量状态更新任务,在一个大事务里逐条
UPDATE订单表,事务一直没提交。 - 11:01,用户在前台点击"支付成功",应用服务执行
UPDATE orders SET status = 'paid' WHERE order_id = ?,被大事务持有的行锁挡住。 - 11:05,后续越来越多的请求堆积,连接池被占满,数据库线程数飙升,接口大面积超时。
你看,所有环节里,SQL本身都没问题,全是"锁等待"在作祟。所以理解锁机制,不是为了应付面试,而是为了能快速定位这种"看似正常但实际卡死"的问题。
1.2 并发控制的三条路线:锁、多版本、事务隔离
数据库要处理并发,本质上是在回答一个问题:多个事务同时读写同一份数据时,如何保证结果正确且性能可接受?
业界答案基本可以归成三类:锁(Lock)、多版本并发控制(MVCC)、事务隔离级别。三者不是并列关系,而是协作者——锁负责"写写互斥",MVCC负责"读写不互斥",隔离级别则是前两者的"总调度规则"。
MySQL的InnoDB引擎之所以表现优异,就是因为它在"锁 + 多版本"上做得足够精细。行级锁让并发写互不干扰,MVCC让读操作不用等待写锁释放,二者结合才支撑起了高并发场景下的吞吐量。
1.3 一个基本原则:锁是成本,不是福利
很多刚接触事务的开发者容易陷入一个误区:锁越多越安全。实际上,锁是并发系统里最昂贵的协调机制——每拿到一把锁,都要做加锁、检测、排队、释放这一整套动作,而排队就意味着等待。
真正成熟的方案不是"一锁了之",而是在数据一致性和并发度之间找平衡点。比如:读多写少的场景用MVCC就比加读锁高效;短事务比长事务更容易减少锁持有时间;能走索引的UPDATE才不会把行锁升级成表锁。这些会在后面每个章节展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从粒度到尺度:MySQL锁家族的层次与结构
MySQL的锁可以从"锁什么"来分层:全局锁锁整个实例,表级锁锁一张表,行级锁锁记录,意向锁则在表级别登记"我要在行级别加锁"。理解这个家族谱系,才能在排查时快速缩小范围。
2.1 全局锁:最彻底的串行化
全局锁的命令是FLUSH TABLES WITH READ LOCK(简称FTWRL)。它会让整个实例进入只读状态,所有DML和DDL全部阻塞。全局锁的典型应用场景是全库逻辑备份——为了保证备份数据的一致性,需要一个时间点快照,不允许任何写入。
但全局锁几乎是个"核武器",生产环境用不好就宕机。它会阻塞所有业务请求,没有任何并发可言。好在InnoDB有了MVCC之后,逻辑备份工具mysqldump --single-transaction已经可以不依赖FTWRL,而是利用一致性快照来备份。所以现在的生产环境里,FTWRL很少出现在日常操作中,除非你要做某些需要严格静止点的运维操作。
2.2 表级锁:MySQL Server层的"粗犷派"
表级锁是MySQL Server层的概念,在MyISAM时代是主流,存储引擎本身不提供行级锁,所有写操作都要独占整张表。切换到InnoDB后,表级锁不再是主角,但它依然存在,只是换了面孔。
第一类是显式表锁,比如直接执行LOCK TABLES table_name WRITE。这种锁一旦加上,其他会话对这个表的所有读写都会被阻塞。通常只有做批量数据整理、结构变更时才可能用到,正常业务代码不会碰它。
第二类是MDL锁(Metadata Lock),这个要重点讲。MySQL从5.5版本开始引入MDL锁,它的作用是保护表结构定义(元数据)不被并发修改。任何DML语句在执行时,都会先获取该表的MDL读锁;任何DDL语句(ALTER TABLE、DROP TABLE)需要获取MDL写锁。MDL读锁之间可以共享,但读锁和写锁互斥。
MDL锁引发的生产事故很常见:一个长事务在事务里执行了SELECT没提交,持有MDL读锁;此时一个ALTER TABLE语句排队等待MDL写锁;后续所有对这个表的SELECT和UPDATE全部被阻塞——因为它们在等MDL读锁,而读锁排在写锁后面。这就是"一条DDL卡死整个表"的底层逻辑。
2.3 行级锁:InnoDB领地的精雕细琢
真正让InnoDB碾压MyISAM的,是行级锁。它允许多个事务同时更新一张表中不同的行,并发度大大提升。
InnoDB的行锁有三个变体,分别对应不同的加锁方式:
| 锁类型 | 英文名 | 锁定范围 | 典型场景 |
|---|---|---|---|
| 记录锁 | Record Lock | 索引上的单条记录 | 主键/唯一键等值查询 |
| 间隙锁 | Gap Lock | 索引记录之间的间隙 | 范围查询、等值查询未命中记录 |
| 临键锁 | Next-Key Lock | 记录+间隙的组合 | 默认隔离级别下的范围查询 |
记录锁很好理解,就是把某一行锁住,其他事务不能修改这一行。间隙锁和临键锁相对复杂,它们是InnoDB用来解决"幻读"问题的关键——后面单独讲。
有一点必须强调:InnoDB的行锁是建立在索引上的,如果WHERE条件没有走索引,MySQL只能全表扫描,意味着每一行都有可能被扫描到并加锁,结果就是"行锁实际升级成了表锁"。这是我在实际业务中见过最多的锁性能问题,没有之一。
2.4 意向锁存在的意义
很多人不理解意向锁(Intention Lock)是干嘛的——它不直接锁数据,却在表级别登记了"某个事务准备在行级别加锁"的意图。
为什么需要它?想象一个场景:事务A要给表里的某一行加写锁,事务B想给整张表加表级写锁。如果B不知道A持有行锁,B直接加表锁就会和A的行锁冲突。为了避免每次加表锁都要逐行扫描判断锁冲突,InnoDB引入了意向锁这个"信号灯":
- 事务A要在行上加共享锁,先要在表上加意向共享锁(IS)
- 事务A要在行上加排他锁,先要在表上加意向排他锁(IX)
这样表级锁在判断兼容性时,不用关心具体锁了哪些行,只看意向锁即可。意向锁之间是互相兼容的,因为它只是"意图预告",真正互斥的是表锁和行锁的冲突。
2.5 锁兼容性矩阵
把常见的锁放在一张兼容性表格里,可以一目了然地看出哪些组合会阻塞:
| 锁类型 | 共享锁(S) | 排他锁(X) | 意向共享锁(IS) | 意向排他锁(IX) |
|---|---|---|---|---|
| 共享锁(S) | 兼容 | 冲突 | 兼容 | 冲突 |
| 排他锁(X) | 冲突 | 冲突 | 冲突 | 冲突 |
| 意向共享锁(IS) | 兼容 | 冲突 | 兼容 | 兼容 |
| 意向排他锁(IX) | 冲突 | 冲突 | 兼容 | 兼容 |
这张表可以当排查工具用:如果看到两个事务互相不兼容的锁都在等待,八成是死锁或锁等待;如果只是读读模式,则完全不必担心阻塞。
3. 快照隔离与锁的联合:InnoDB事务隔离级别的底层账本
锁机制的最终呈现,是由事务隔离级别来编排的。同样一条SELECT或UPDATE,在不同隔离级别下,加锁的粒度和范围完全不同。这一章把InnoDB的账本翻开,看看隔离级别和锁是怎么联动的。
3.1 四种隔离级别:各自锁什么
SQL标准定义了四个隔离级别,InnoDB都支持:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 加锁特征 |
|---|---|---|---|---|
| 读未提交(READ UNCOMMITTED) | 可能 | 可能 | 可能 | 读不加锁,写加行锁 |
| 读已提交(READ COMMITTED) | 避免 | 可能 | 可能 | 写加行锁,读用快照 |
| 可重复读(REPEATABLE READ) | 避免 | 避免 | 避免 | 写加行锁+间隙锁,读用快照 |
| 串行化(SERIALIZABLE) | 避免 | 避免 | 避免 | 读写都加锁 |
InnoDB默认隔离级别是可重复读。但在同样是从"可重复读"这个级别,MySQL和其他数据库(比如PostgreSQL)对幻读的处理方式不一样——MySQL靠临键锁来"物理上禁止插入",PostgreSQL则依靠快照机制。理解这层差异,才能解释为什么MySQL在可重复读下反而对范围更新更谨慎。
3.2 当前读与快照读
说到MVCC,必须把"读"拆成两类:快照读和当前读。
- 快照读:普通的
SELECT语句,读取的是事务开始时的数据快照,不加锁,不阻塞任何写操作。这是MVCC给高并发带来的最大红利——读和写不互斥。 - 当前读:
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT,读的是数据的最新版本,并且会加锁。当前读的加锁范围和隔离级别、索引是否命中强相关。
很多人说"MySQL的读不加锁",这个说法只对了一半——只有快照读不加锁,当前读是要加锁的。所以一个事务里的普通SELECT不会阻塞别人,但如果同一段业务代码里跑了一条SELECT ... FOR UPDATE,它就会持有锁直到事务结束。
3.3 间隙锁与临键锁:防止幻读的代价
可重复读级别下,InnoDB使用**间隙锁(Gap Lock)和临键锁(Next-Key Lock)**来阻止"幻读"——也就是防止其他事务在查询范围内插入新记录。
想象一个场景:事务A执行SELECT * FROM orders WHERE amount > 100 FOR UPDATE,它先把满足条件的记录加上了记录锁,但"amount > 100"这个范围区间还有空隙——比如100到200之间目前没有记录,如果事务B在这个空隙插入一条amount=150的记录,事务A再次查询时就会多出一行(幻读)。
为了杜绝这种情况,InnoDB不仅锁住满足条件的记录,还对索引范围内的"空隙"加锁。事务B想插入记录时必须先获得插入意向锁,而插入意向锁和间隙锁冲突,于是B被阻塞——幻读被"物理隔离"了。
间隙锁的代价很直观:间隙锁不是针对某一行的锁,而是一个范围区间锁,它会导致并发插入互相阻塞。比如一个表里主键范围是1、5、10,事务A锁定了主键5这一行,间隙锁可能覆盖(1,5)和(5,10)两个区间,其他事务想插入主键为3或7的记录,都会被阻塞。所以间隙锁是"范围性"的锁,比行锁更容易引发锁冲突。
3.4 隔离级别的取舍:默认的未必是最优的
MySQL默认可重复读,好处是业务写起来方便——事务内多次读取结果一致,不容易出现逻辑混乱。但在高并发写入场景下,间隙锁可能让锁冲突雪上加霜。
如果你的业务可以接受"同一个事务内两次读取结果可能略有变化",读已提交通常能有更好的并发度:它只加记录锁,不加间隙锁,所以插入不会被范围性阻塞。
实际案例:我在一个订单分发系统中,把隔离级别从可重复读调整成读已提交后,INSERT的锁等待次数下降了约60%,吞吐量明显提升——因为系统原本使用可重复读,但业务对即时一致性不敏感,读已提交的快照机制完全够用,还省掉了间隙锁的开销。
不过这个调整要谨慎:需要确认业务是否依赖"事务内可重复读"。如果在一个事务里先查出某个值,基于这个值更新另一张表,然后又回查这张表,可重复读会给你"一致性视图",读已提交则可能看到其他事务的已提交修改。这种差别在某些账务、库存类业务里是不能接受的。
4. 锁等待与死锁:一线排查的完整链路
这部分是实战中最值钱的内容。我不会直接给结论,而是带你走一遍完整的排查链路——就像我们在线上做复盘时那样,从现象开始,一层层剥到根因。
4.1 第一步:先看事务和锁等待快照
遇到"数据库响应慢但SQL不慢"的情况,第一步不是改SQL,而是查当前有没有事务堆积和锁等待。
事务列表
sql复制SELECT * FROM information_schema.innodb_trx\G
重点看这几个字段:trx_started(事务开始时间)、trx_state(RUNNING/LOCK WAIT)、trx_query(事务最近执行的SQL)。如果一个事务开了很久还没结束,它很可能就是锁的"持有者"。
锁等待明细
sql复制SELECT * FROM performance_schema.data_lock_waits\G
SELECT * FROM performance_schema.data_locks\G
data_locks显示当前所有锁,data_lock_waits显示锁等待关系。MySQL 8.0里,performance_schema.data_lock_waits比老版的INNODB_LOCK_WAITS信息更全,能直接看到哪个锁阻塞了哪个锁。
阻塞关系查询(这是我在现场最常用的SQL):
sql复制SELECT
r.trx_id AS waiting_trx_id,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_query AS blocking_query,
b.trx_started
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r
ON w.REQUESTING_ENGINE_TRANSACTION_ID = r.trx_id
JOIN information_schema.innodb_trx b
ON w.BLOCKING_ENGINE_TRANSACTION_ID = b.trx_id;
这条SQL输出了"谁在等谁、等的是哪个事务、等待方执行的SQL是什么"——基本就是定位的第一手证据。
如果用的是MySQL 5.7及以下,可以查information_schema.innodb_lock_waits和innodb_locks,字段稍老,但同样能看出等待关系。
4.2 第二步:完整复现一个"锁等待"场景
为了让你对排查链路有肌肉记忆,这里模拟一个最常见的场景。
表结构:
sql复制CREATE TABLE `user_account` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`balance` DECIMAL(10,2) NOT NULL,
UNIQUE KEY `uk_user_id` (`user_id`)
);
事务A:
sql复制BEGIN;
UPDATE user_account SET balance = balance - 100 WHERE user_id = 1001;
-- 不提交,持有 user_id=1001 这一行的行锁
事务B:
sql复制BEGIN;
UPDATE user_account SET balance = balance + 50 WHERE user_id = 1001;
-- 等待事务A释放锁
事务B执行后,innodb_trx里会看到一条trx_state = 'LOCK WAIT'的记录,data_lock_waits里则能看到它在等事务A持有的锁。定位到持有者事务后,下一步要做的就是:判断事务A是业务逻辑需要长时间持有锁,还是出现了"事务忘记提交"之类的编程错误。
在真正的高并发场景里,锁等待的持有者往往不止一个事务,而是串成了一根等待链——A等B,B等C,C等A,这就演变成了死锁。
4.3 第三步:死锁的现场勘查
死锁是锁等待的极端形态:两个或多个事务互相持有对方想要的锁,又都在等对方释放。InnoDB检测到死锁后,会选择一个代价最小的事务回滚,让其他事务继续执行。这个机制本身是保护机制,但如果死锁频繁出现,说明应用层的事务设计有缺陷。
查看最近一次死锁日志:
sql复制SHOW ENGINE INNODB STATUS\G
输出里的LATEST DETECTED DEADLOCK段落会包含死锁发生时的事务信息、持有的锁、等待的锁、执行过的SQL语句。下面是一个典型的死锁日志摘要(做了脱敏):
code复制*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 0 sec
MySQL thread id 100, OS thread handle 12345
UPDATE user_account SET balance = balance - 100 WHERE user_id = 1001
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 4 n bits 72 index uk_user_id
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 0 sec
UPDATE user_account SET balance = balance + 50 WHERE user_id = 1002
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 4 n bits 72 index uk_user_id
日志显示事务1要更新user_id=1001,事务2要更新user_id=1002,表面上看互不相关。但继续往下看,会发现两个事务都还在执行其他更新,它们的加锁顺序恰好交叉,就触发了死锁。
4.4 第四步:一个线上死锁案例的完整复盘
我接手过的一个真实case:一个"账户转账"接口,业务逻辑是"先扣减转出方余额,再增加转入方余额"。两个用户互相转账时,事务A扣A加B,事务B扣B加A,恰好形成交叉:
- 事务A:锁住A → 等B
- 事务B:锁住B → 等A
于是死锁发生。这个case的根因是加锁顺序没有统一。解决方案也很直接:所有涉及多个账户更新的操作,先按user_id排序,统一了加锁顺序,死锁就基本消失了。
这个案例里有三个经验值得记下来:
- 死锁日志里的SQL不是唯一线索,要结合业务代码看完整的事务范围。
- 死锁不一定非要有循环依赖的对象,锁顺序交叉就足够触发。
- 应用程序层面要做死锁重试,捕获
Deadlock found when trying to get lock异常后,随机延迟后重试一次,通常就能成功。
4.5 排查流程模板:直接抄作业
把上面的思路固化成一张流程清单,遇到锁问题照着走:
- 确认现象:应用超时?监控里数据库活跃连接数升高?慢查询数量突增?
- 查看全局状态:
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_current_waits';,快速判断当前是否有行锁等待。 - 查事务:
SELECT * FROM information_schema.innodb_trx;,看有没有疑似长事务。 - 查锁等待:用
performance_schema.data_lock_waits关联事务表,定位阻塞链。 - 验证索引:对卡住的查询跑
EXPLAIN,确认是否走索引、扫描行数是否正常。 - 查看死锁日志:如果最近有死锁,用
SHOW ENGINE INNODB STATUS\G提取LATEST DETECTED DEADLOCK段。 - 定位代码:把事务ID对应到应用日志里的事务ID(或者根据SQL语句找代码位置),分析事务范围。
- 制定修复方案:优化SQL、加索引、缩短事务、调整加锁顺序、改隔离级别,选一个或多个组合。
5. 降低锁竞争的实用优化策略
优化锁竞争,核心目标只有一个:让锁的粒度和持有时间尽可能小。下面这些策略按优先级排,都是可以在线上直接落地的。
5.1 索引是行锁精准度的基础,没有之一
这是最基础也最容易被忽略的一点。InnoDB行锁依赖索引,如果UPDATE或DELETE的WHERE条件无法命中索引,数据库会扫描全表并对每条记录尝试加锁,最终锁范围从"几行"扩大到"整张表"。
验证方法是EXPLAIN,看type列和key列。如果type显示ALL或key为NULL,说明没走索引,行锁很可能退化成表锁。
一个实际例子:
sql复制-- 为 user_account 表的 user_id 加索引前
UPDATE user_account SET balance = balance - 100 WHERE user_id = 1001;
-- type=ALL,扫描全表,锁全表记录
-- 为 user_id 添加唯一索引后
UPDATE user_account SET balance = balance - 100 WHERE user_id = 1001;
-- type=const,精准命中一行,锁一行
加索引后,并发更新不同user_id的事务互相不阻塞,吞吐量直接翻倍。我见过不少案例,"数据库性能差"的根因就是一个UPDATE条件没索引。
5.2 缩短事务窗口:锁的租期越短越好
一个锁持有多久,取决于事务从开始到提交/回滚的时间。事务越长,其他事务等待的时间就越长。优化思路是:把事务里不必要的工作挪出去。
- 不要在事务里做远程RPC调用、HTTP请求、复杂计算。这些操作和数据库无关,却拉长了锁的持有时间。
- 提前计算好需要的值,事务里只做必要的更新和插入。
- 避免在事务里执行用户交互操作(比如"等待用户确认后再提交"),这种设计几乎必然产生长事务。
举个例子,原来一个"下单"事务里做了:查库存、锁库存、减库存、生成订单、调用支付接口、发货通知。后来改成:先算好一切,把支付和通知放到事务外,事务里只保留减库存和生成订单,锁等待明显下降。
5.3 批量操作要拆批,不要一次性提交
大批量UPDATE或DELETE,如果放在一个事务里,会持有大量行锁长达数秒甚至数分钟。正确做法是拆成小批量,每批提交一次。
推荐模板:
sql复制-- 每次只更新100条,循环执行,直到影响行数为0
UPDATE orders SET status = 'processed'
WHERE status = 'pending'
AND id BETWEEN 1 AND 100;
UPDATE orders SET status = 'processed'
WHERE status = 'pending'
AND id BETWEEN 101 AND 200;
拆批有两个好处:一是每个事务持有的锁数量少、时间短;二是如果中途出错,已经提交的批次不会回滚,重试成本低。拆批间隔可以选择加SLEEP(0.1),给其他事务让路,但要注意别让总耗时超出业务容忍范围。
5.4 统一加锁顺序:消除死锁的黄金法则
死锁的根源是加锁顺序不一致。拿上面"账户转账"的例子:A转账给B、B转账给A,两个事务如果都按"先锁转出方、再锁转入方",在交叉转账时就会互相等待。
解决办法是在代码里统一一个全局性的加锁顺序。比如两个账户ID分别是1001和1002,不管谁给谁转账,都先锁MIN(user_id)、再锁MAX(user_id)。这样一来,所有事务都按同一顺序拿锁,就不会出现交叉等待,死锁从设计上被根除。
实现上,可以在更新SQL里显式排序,也可以在应用层先查出来排好序再逐条更新。后者更灵活,适用于涉及多张表的复杂事务。
5.5 隔离级别的精准取舍:不是所有业务都需要可重复读
前面讲过,可重复读的间隙锁可能引发范围性阻塞。在业务允许的范围内,把隔离级别改成读已提交,可以有效减少间隙锁冲突。
MySQL配置方式:
sql复制SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
修改后要关注业务是否有依赖"可重复读"的查询逻辑。如果不确定,先在压测环境模拟跑一轮,对比锁等待变化和功能正确性,再上生产。
5.6 SQL执行计划层面的微调
有些锁竞争不完全是锁本身的问题,而是SQL的执行计划不够优。比如:
- 使用
FOR UPDATE锁定时,如果查询条件范围过大,锁的范围也大。尽量用精确的等值条件代替范围条件。 - 导入大量数据时,先用临时表整理数据,再
INSERT INTO ... SELECT,减少逐条加锁的窗口。 - 对于"读取最新值并更新"的操作,可以改用
SELECT ... FOR UPDATE的当前读,避免"先快照读、再更新"导致的不一致和额外加锁。
6. 压测验证与监控:优化效果不能靠感觉
锁优化做完了,怎么确认有效?答案是:在受控环境下压测,用监控数据说话。
6.1 监控指标与阈值参考
以下几个指标是我在日常和异常时都会关注的:
| 指标 | 获取方式 | 含义 | 参考经验值 |
|---|---|---|---|
| 当前行锁等待数 | SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_current_waits' |
正在等待行锁的事务数 | 正常应接近0 |
| 行锁等待总时长 | SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_time' |
累计等待总毫秒数 | 对比历史数据看趋势 |
| 行锁等待次数 | SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_waits' |
累计等待次数 | 持续增长需关注 |
| 当前活跃事务数 | information_schema.innodb_trx |
事务总览 | 与连接池上限匹配 |
| 锁等待类事件 | performance_schema.events_waits_summary_global_by_event_name |
锁等待明细 | 按事件名聚合 |
建议把这些指标接入数据库监控大盘,设置阈值告警。比如Innodb_row_lock_current_waits持续大于5,就要人工介入查看事务情况。
6.2 压测流量模型设计
压测不是简单把并发数拉到最大,而是要模拟真实的锁竞争场景。我常用的模型有三种:
- 热点行竞争模型:所有并发都更新同一行(模拟秒杀扣减库存),测试锁等待时间、吞吐量。
- 范围扫描模型:大量并发执行
UPDATE条件命中同一个索引范围,观察间隙锁的影响。 - 混合模型:读写混合,部分事务执行
SELECT ... FOR UPDATE,部分事务执行普通INSERT,验证快照读和当前读的配合。
压测前后的数据对比能直观证明优化效果。拿上面的"账户转账"例子来说,压测前并发100个用户互转,每秒大概能完成20笔;统一加锁顺序并改读已提交后,每秒完成笔数提升到68,死锁日志从"每10分钟一次"降到"一整天都没出现"。这种量化结果比任何口头描述都更有说服力。
6.3 经验复盘:几个容易被忽略的坑
最后一个部分,复盘几个我在实际优化中踩过的坑,每个都对应了真实的生产事故:
坑一:只优化SQL,不优化事务边界。 很多人看到锁等待,第一反应是"这条UPDATE写得不走索引"。但有时候索引没问题,问题出在事务开启后做了太多无关操作。锁等待的根因可能不在SQL本身,而在"事务什么时候开始、什么时候提交"。
坑二:忽视连接池对锁等待的放大效应。 数据库连接池是共享资源。当一批锁等待发生时,连接池里的连接被占满,后续请求无法获取连接,表现为"整个应用卡死",但此时数据库负载未必很高。排查时要看连接池活跃连接数,才能还原完整的阻塞链路。
坑三:读已提交的副作用没评估清楚。 把隔离级别从可重复读改成读已提交后,有业务团队发现"事务内两次读取结果不一致",因为他们依赖可重复读来做"先读后写"的防重逻辑。所以在改隔离级别前,一定要梳理所有事务的读取语义。
坑四:加索引后没有清理旧方案。 有个系统为了支持更新条件,给大表加了一个索引。索引加了,更新快了,但插入性能下降——因为每次INSERT都要同步维护索引。这也是优化策略里要权衡的:索引不是越多越好,要针对高频更新条件建索引,而不是把所有WHERE字段都加上。
写在最后
锁机制这个话题,网上讲原理的文章很多,但真正把它从"概念"变成"排查工具",需要一个从理论到实战的完整闭环。我的建议是:先用SHOW ENGINE INNODB STATUS和performance_schema把线上常见的锁等待现场看一遍,再结合业务代码梳理事务边界,最后用压测数据验证优化效果。
如果你现在正被"数据库偶尔卡一下"的问题困扰,先别急着买机器、升配置——打开innodb_trx,看看有没有长事务,再给卡住的SQL跑一遍EXPLAIN。很多时候,一条索引、一个事务提交时机,就能解决90%的锁竞争问题。
