先讲一个真实故障:一个电商项目在晚高峰突然大量接口超时,连接池被占满,后端日志里全是同一个 update 语句超时的报错。排查下来,罪魁祸首是 MySQL 的行锁机制——同一秒几百个请求并发更新同一行记录,所有 update 在锁上排队,排到超时,超时触发重试,重试又继续堆积,最后数据库连接全部耗尽。那次事故之后,我把 MySQL 的锁机制、并发更新、死锁问题彻底梳理了一遍。这篇文章算是一份实战笔记,覆盖四块内容:锁的原理、死锁的排查链路、高并发更新场景的解法、以及日常巡检和避坑经验。无论你是后端开发还是兼职运维,只要业务里出现过“这条 update 卡了很久”“两个事务互相等锁”这类问题,这篇应该对你有用。
1. 先从一次线上事故说起:并发更新为什么会把系统拖垮
1.1 一个典型的并发扣减场景
事故往往长得很朴素,就是一个扣库存的接口。表结构大概这样:
sql复制CREATE TABLE t_stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id VARCHAR(32) NOT NULL,
stock INT NOT NULL,
UNIQUE KEY uk_sku(sku_id)
) ENGINE=InnoDB;
扣减逻辑也简单:
sql复制UPDATE t_stock SET stock = stock - 1 WHERE sku_id = 'A001';
单看这条 SQL,逻辑没毛病。但是“每秒几百个请求同时打同一个 sku”的时候,问题就出来了。MySQL 的 InnoDB 存储引擎默认给这行记录加排他锁,同一时刻只允许一个事务持有这把锁修改这一行。其余事务全部进入锁等待状态,排队等前一个事务提交或回滚。
我拿压测数据来说明排队效应:假设单次 update 从加锁到提交平均耗时 10 毫秒,那么 100 个并发请求全部完成,最坏情况下最后一个请求要等前面的 99 个,也就是接近 1 秒。这还只是单行更新 10 毫秒的情况。如果哪个请求的事务里还查了好几次别的表,锁持有时间拉到 50 毫秒,500 个并发请求堆积,最坏情况就是 25 秒。应用侧的连接池一般等不了这么久,一超时就开始重试,重试又变成新的排队请求,系统很快就进入雪崩状态。
1.2 问题根因:行锁排队与超时
这类事故的本质,是锁等待,而不是死锁。两者的区别要分清:锁等待是多个事务排队竞争同一把锁,总有一个事务在往前推进,只是慢;死锁是两个或多个事务各自持有一把锁,又在互相等对方手里的锁,谁也不让谁,MySQL 只能主动回滚其中一个事务来解开死局。
生产环境里,锁等待往往比死锁更常见,也更隐蔽。因为它不报错,只是让请求变慢。慢查询日志里一堆 update 语句,数据库 CPU 和 IO 看起来都不高,但接口就是一直转圈。如果你在 SHOW PROCESSLIST 里看到很多状态是 updating 的线程,而且执行时间都在几秒以上,大概率就是行锁排队。
这里要记住一个公式:锁等待的最大排队时间 ≈ 前面排队的事务数量 × 每个事务持有锁的时间。所以解决并发更新问题,方向只有两个:减少排队事务的数量,或者缩短每个事务持有锁的时间。后面所有方案都会回到这两个思路上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁的底层逻辑:InnoDB 到底在锁什么
2.1 共享锁、排他锁与意向锁
InnoDB 的锁按类型分为共享锁(S Lock)和排他锁(X Lock)。共享锁之间互相兼容,多个事务可以同时给同一行加共享锁;但共享锁和排他锁互斥,排他锁之间也互斥。用一句话总结读锁和写锁的关系:读读不互斥,读写互斥,写写互斥。
比类型更隐蔽的是意向锁。InnoDB 在给行加锁之前,必须先给表加一个意向锁。意向锁分意向共享锁(IS)和意向排他锁(IX),它们的作用是告诉其他人“这个表里已经有人加了行级锁了”。这样别的事务想给整张表加表级锁时,不需要逐行检查,只要发现表上有意向锁就立刻知道自己该不该等。意向锁之间是互相兼容的,它们只和表级 S/X 锁冲突。
很多初学者会忽略意向锁,但在排查锁等待时会踩坑:比如执行 LOCK TABLES t WRITE 的时候,如果表里的行正在被更新,这条语句会卡住,因为行上的 IX 锁和表级 X 锁冲突。
| 锁类型 | 锁粒度 | 兼容性 | 典型场景 |
|---|---|---|---|
| 共享锁 S | 行 | 与 S 兼容,与 X 互斥 | SELECT ... LOCK IN SHARE MODE |
| 排他锁 X | 行 | 与 S、X 都互斥 | UPDATE、DELETE、SELECT ... FOR UPDATE |
| 意向锁 IS/IX | 表 | 意向锁之间兼容 | 加行锁前自动添加 |
| 表级 S/X 锁 | 表 | 与行锁冲突 | LOCK TABLES 或 DDL 场景 |
2.2 记录锁、间隙锁与临键锁:锁粒度决定并发度
InnoDB 的行锁在索引上实现,所以按锁范围又分三类:记录锁(Record Lock)、间隙锁(Gap Lock)、临键锁(Next-Key Lock)。
记录锁最简单,锁住索引上的一条记录。间隙锁锁的是一个范围,但不锁范围内的具体记录,它锁的是“记录之间的空隙”,目的是防止幻读。临键锁是记录锁和间隙锁的组合,锁住左开右闭的区间,比如索引值在 (10, 20] 这个范围。在 MySQL 默认的 REPEATABLE READ 隔离级别下,普通索引的范围查询和更新都会加临键锁。
举个例子:表里有一列 age 建立了普通索引,已有数据是 10、20、30。一个事务执行:
sql复制SELECT * FROM t_user WHERE age = 20 FOR UPDATE;
此时 InnoDB 不仅锁住 age=20 这条记录,还会锁住 10 和 20 之间的空隙,也会锁住 20 和 30 之间的空隙。这意味着另一个事务想在 age=15 或 age=25 的地方插入新记录,都会被阻塞。这就是间隙锁的代价:它保证了查询范围内不会出现幻读,但也把并发度砍掉一大截。
间隙锁在 READ COMMITTED 隔离级别下通常会被禁用,这就是为什么很多高并发系统愿意把隔离级别调低。后面第 4 章会细说。
2.3 快照读与当前读:普通 SELECT 与 FOR UPDATE 的区别
很多人以为“SELECT 和 UPDATE 一样,都会加锁”,这是常见误区的源头。InnoDB 的 MVCC 机制让普通 SELECT 走的是快照读,不加任何锁,它读的是事务开始时刻的快照数据。也就是说,一个普通 SELECT 不会阻塞别的事务的 UPDATE,同样也不会被别的事务 UPDATE 阻塞。
真正加锁的是当前读。SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE,以及所有 UPDATE、DELETE,都属于当前读,读的是最新版本的数据,同时给读取的记录加锁。
这个区别在排查问题时很关键。如果业务里出现“我查一条数据怎么也会卡住”,第一反应要先确认是不是用了 FOR UPDATE,而不是在那儿怀疑 InnoDB 的锁规则有问题。
2.4 索引与行锁:索引失效真的会“锁表”吗
我经常被问到:“InnoDB 不是行锁吗,为什么我一个 update 把整张表锁住了?”
准确地说,InnoDB 没有真正意义上的表锁参与 DML,但是当你的 UPDATE 没有走索引、走全表扫描的时候,InnoDB 会给扫描到的每一行主键记录都加上锁。数据量大时,等于把所有记录都锁了一遍,对外的表现就是“锁表”。
更麻烦的是,如果是在 REPEATABLE READ 级别下做条件范围扫描,就算没有匹配到任何记录,间隙锁也可能把整个表的所有间隙都锁住。两个并发事务同时对一张表做无索引条件的更新,死锁几乎是必然事件。
所以判断锁范围,先看执行计划。EXPLAIN UPDATE 的 type 字段如果是 ALL,那就是全表扫描,锁范围必然失控。这种情况的修复不是调锁参数,而是赶紧补索引。
3. 死锁是怎么“锁死”的:复现与完整排查链路
3.1 死锁产生的四个必要条件
死锁不是随机发生的,学术界总结过必要条件:互斥、请求与保持、不可剥夺、循环等待。放到 MySQL 场景里翻译一下:每个事务持有一把锁,同时去请求另一把锁;拿到的锁在事务提交或回滚前不会主动释放;当两个事务形成“你等我、我也等你”的循环,死锁就成立了。
最常见的死锁触发模式,就是多个事务以不同的顺序更新多行数据。比如事务 A 先更新 account_id=1,再更新 account_id=2;事务 B 先更新 account_id=2,再更新 account_id=1。两者并发执行时,A 拿到 1 号锁,B 拿到 2 号锁,然后 A 等 2,B 等 1,谁也等不到谁。
3.2 手把手复现一个死锁:两个事务交叉更新
要理解排查过程,最直接的办法是自己先复现一遍。建一张简单账户表:
sql复制CREATE TABLE t_account (
account_id BIGINT PRIMARY KEY,
balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO t_account VALUES (1, 1000), (2, 1000);
开两个 MySQL 会话,按下面的顺序执行:
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | BEGIN; |
BEGIN; |
| 2 | UPDATE t_account SET balance = balance - 100 WHERE account_id = 1; |
UPDATE t_account SET balance = balance - 100 WHERE account_id = 2; |
| 3 | UPDATE t_account SET balance = balance - 100 WHERE account_id = 2; |
UPDATE t_account SET balance = balance - 100 WHERE account_id = 1; |
| 4 | 阻塞中 | 报错:Deadlock found |
步骤 3 时,A 在等 B 持有的 2 号锁,B 在等 A 持有的 1 号锁。MySQL 的死锁检测会在步骤 4 立即触发,回滚其中一个事务,让另一个事务继续执行。注意:死锁并不会让两个事务都卡死在这里,MySQL 会自动牺牲其中一个,回报 ERROR 1213。
3.3 排查链路第一站:看死锁日志
MySQL 把最近一次死锁的详细信息记录在 InnoDB 状态里,用一条命令就能看:
sql复制SHOW ENGINE INNODB STATUS\G
重点看 LATEST DETECTED DEADLOCK 这一节。日志会分别列出两个事务的信息,包括事务 ID、执行的 SQL、持有锁和等待锁的记录位置。刚才那个复现场景的日志大概长这样:
code复制LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 5602, ACTIVE 16 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 9, query id 123 localhost root updating
update t_account set balance = balance - 100 where account_id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3 page no 4 n bits 72 index PRIMARY
of table `test`.`t_account`
lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 5603, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, query id 126 localhost root updating
update t_account set balance = balance - 100 where account_id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 4 page no 5 n bits 72 index PRIMARY
of table `test`.`t_account`
lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3 page no 4 n bits 72 index PRIMARY
of table `test`.`t_account`
lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)
读日志的方法很简单:每个事务段里找 HOLDS THE LOCK(S) 和 WAITING FOR THIS LOCK TO BE GRANTED。事务 1 持有 1 号账户的锁、等待 2 号账户;事务 2 持有 2 号账户的锁、等待 1 号账户,死锁链路一目了然。日志最后一行 WE ROLL BACK TRANSACTION (2) 表示 MySQL 选择了回滚事务 2。
3.4 排查链路第二站:查当前事务与锁等待关系
死锁日志只能看最近一次,但生产中更重要的是查“现在有没有锁等待”。先看当前有哪些长事务:
sql复制SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_runtime,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX
WHERE trx_state = 'RUNNING'
ORDER BY trx_runtime DESC;
如果发现有 trx_state = 'LOCK WAIT' 的事务,接下来要查它在等谁。MySQL 8.0 用 performance_schema 的锁等待表:
sql复制SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx b
ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID
JOIN information_schema.innodb_trx r
ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID;
5.7 及更早版本用 INFORMATION_SCHEMA.INNODB_LOCK_WAITS 和 INNODB_LOCKS。如果服务器开了 sys 库,还有更省事的视图:
sql复制SELECT * FROM sys.innodb_lock_waits\G
这个视图直接告诉你 waiting_pid、waiting_query,以及对应的 blocking_pid、blocking_query。拿到阻塞线程的 thread_id 后,可以用 SHOW FULL PROCESSLIST; 看它在跑什么语句,必要时 KILL <thread_id>; 强制终止长事务。
3.5 快速恢复与后续定位
线上真的出现死锁时,不需要手动处理,MySQL 已经回滚了其中一个事务。应用层会收到 ERROR 1213,这时该做的是记录日志、返回到业务层重试,而不是去数据库里翻文件。
但如果死锁频繁出现,就要回头分析代码了。死锁日志里的 SQL 是线索,真正要改的是业务代码里多行更新或多表更新的顺序。有一种情况例外:如果死锁日志里显示两个事务都在对同一行做条件更新,那大概率不是多行顺序问题,而是间隙锁和插入意向锁的冲突,场景相对复杂,多数要靠调整隔离级别或改写 SQL 解决。
4. 解决并发更新与死锁的实战方案
4.1 统一加锁顺序:让循环等待消失
解决死锁最直接的手段,是让所有事务以相同的顺序访问资源。转账场景最典型,转出账户和转入账户必须按固定顺序加锁,比如总是先锁 account_id 较小的那个,再锁较大的。
sql复制-- 伪代码示例
BEGIN;
-- 先锁小账户,再锁大账户
UPDATE t_account SET balance = balance - 100 WHERE account_id = 1;
UPDATE t_account SET balance = balance + 100 WHERE account_id = 2;
COMMIT;
两个事务都遵守这个顺序,就不会出现 A 等 2、B 等 1 的循环。在代码层面,可以写一个函数把要更新的对象 ID 排序后统一处理。这个方案说起来简单,落地时最容易出问题的是“漏了某条 SQL”,所以排查时一定要把同一个事务里所有可能加锁的语句都拉出来看。
4.2 缩短事务时间:批量操作的控制
锁持有时间越短,锁等待队列越短,并发能力越强。很多慢事务不是慢在 SQL 本身,而是把业务操作全都塞进了事务里。比如事务里调用第三方接口、发消息队列、循环执行大量单条 update,这些都会让事务长期持有锁。
我见过一个真实案例:业务在事务里循环更新 5 万条订单数据,单事务跑了 30 多秒,导致相关表上的所有更新全部排队。正确的做法是分批提交。每次取 1000 个主键,执行一次批量更新,然后提交,再取下一批。虽然总时长变长了,但每把锁的持有时间很短,对并发事务的影响可以忽略。
另外,事务里避免人的因素。凡是需要用户输入确认的流程,不要开着事务等待,否则锁会一直被攥着。
4.3 索引与隔离级别:减少锁范围
这条要展开讲,因为它是“为什么加了索引反而并发更高”的核心原因。更新语句只有走索引,才能把锁的范围收缩到少量记录。如果 WHERE 条件里的列没有索引,或者索引因函数、隐式类型转换失效,InnoDB 只能全表扫描,行锁面积急剧扩大。
排查时用 EXPLAIN 看一眼执行计划,type 至少要达到 ref 或 range,绝不能出现 ALL。对于高频更新的列,索引要精确匹配,避免使用前缀索引;WHERE 条件里不要在索引列上做函数运算,WHERE DATE(create_time) = CURDATE() 这类写法会让索引失效,改成 create_time >= ? AND create_time < ? 才能走索引。
隔离级别方面,如果业务允许,把默认的 REPEATABLE READ 调成 READ COMMITTED 能显著减少间隙锁引发的问题。在 MySQL 5.7+ 且 binlog 使用 ROW 格式时,READ COMMITTED 不会带来复制安全问题,因此很多高并发系统都这么配置:
ini复制[mysqld]
transaction-isolation = READ-COMMITTED
binlog_format = ROW
RC 级别下,InnoDB 基本只保留记录锁,间隙锁被禁用,插入并发度能提升一大截。代价是查询过程中可能出现幻读,对于大多数 OLTP 业务来说,这个风险可以用唯一索引兜住。
4.4 超时与死锁检测参数:控制最坏情况
有些锁等待是业务无法完全避免的,这时候要靠参数兜底。
innodb_lock_wait_timeout 控制事务等待锁的最长时间,默认是 50 秒。对大多数互联网业务来说 50 秒太长了,一个请求等 50 秒,用户早就离开了。常见的做法是调到 3 到 5 秒:
sql复制SET GLOBAL innodb_lock_wait_timeout = 5;
SET SESSION innodb_lock_wait_timeout = 5;
注意 GLOBAL 只对新连接生效,已经存在的连接不会变。如果想让已有连接也生效,需要同时设置 SESSION 或者重连。
innodb_deadlock_detect 控制死锁检测开关,默认开启。开启时,InnoDB 会在事务每次请求锁时检测是否存在循环等待,存在就回滚其中一个事务。这个检测在并发事务数量特别大时会有一定性能开销,因此有的团队在高并发单行更新场景会把它关掉,完全靠锁等待超时兜底。但关闭后,死锁发生时可能要等满 innodb_lock_wait_timeout 才会报错,体验更差。我的建议是不要轻易关,除非你确认并发模型非常清晰,并且做了充分的压测。
4.5 业务层重试:把偶发死锁变成可恢复
死锁发生不可避免,重要的是业务代码能优雅地处理它。MySQL 的死锁报错码是 1213,锁等待超时报错码是 1205。在 DAO 层捕获这两个错误码,做有限次数的重试,是最实用的一招。
以 Python 的 PyMySQL 为例:
python复制import time
import random
import pymysql
MAX_RETRY = 3
def update_with_retry(sql, args):
for attempt in range(MAX_RETRY):
try:
# 执行事务操作
conn.begin()
with conn.cursor() as cursor:
cursor.execute(sql, args)
conn.commit()
return
except pymysql.err.OperationalError as e:
# 1213 死锁,1205 锁等待超时
if e.args[0] in (1213, 1205) and attempt < MAX_RETRY - 1:
time.sleep(random.uniform(0.05, 0.2))
continue
raise
重试时要加随机退避,避免所有请求在同一时刻重试形成新的并发风暴。Java 里对应的是捕获 MySQLTransactionRollbackException,判断错误码后重试。这个机制不解决死锁的根因,但能保证偶发死锁不影响用户体验。
5. 高并发更新场景的落地经验与常见误区
5.1 扣库存的正确打开方式
扣库存是并发更新最典型的场景。很多人第一版写成“先查询库存,再判断,最后 update”,这会在查询和更新之间留出时间窗口,并发高了一定会超卖。
正确的原子扣减写法是:
sql复制UPDATE t_stock
SET stock = stock - 5
WHERE sku_id = 'A001' AND stock >= 5;
利用 stock >= 5 条件做校验,如果影响行数为 0,说明库存不足或已被其他事务扣完。这个方案不需要 SELECT,也不需要版本号,一条语句保证原子性。它的代价是同一行会存在锁竞争,但锁持有时间极短,属于可控范围。
如果热点行竞争已经到了不可接受的程度,可以把单行库存拆成多行,比如分成 10 个子库存,每个子库存对应一个独立的库存行,扣减时随机选一个子库存执行更新:
sql复制UPDATE t_stock_03
SET stock = stock - 1
WHERE sku_id = 'A001' AND stock > 0;
这样把“单行热点”拆成“多行分散”,锁竞争被分散到不同行上。代价是统计总库存时要聚合,业务复杂度会上升。这个方案不要一上来就用,先明确你是否真的遇到了单行热点锁瓶颈。
5.2 唯一键冲突导致的插入死锁
死锁不只出现在 UPDATE 多行记录时,并发 INSERT 可能同样触发死锁,而且更隐蔽。当一个事务插入一条唯一键已存在的记录时,InnoDB 会对已有的唯一索引记录加上共享锁。如果多个事务同时插入相同的唯一键,互相竞争共享锁,再叠加各自的后续更新,死锁就会发生。
更常见的错误写法是“先 DELETE 再 INSERT”。两个事务同时删除同一条记录、同时插入同一条记录,删和插的顺序错开,就可能互相等待。正确做法是尽量避免这种先删后插的模式,改成 INSERT ... ON DUPLICATE KEY UPDATE,或者用业务唯一键做幂等;唯一键冲突时捕获 1062 错误码,按业务规则处理。
5.3 慢查询、长事务与锁等待的联动巡检
锁问题最容易发生在没人注意的时候。我建议把下面几类信息纳入日常巡检:
sql复制-- 查看锁等待关系
SELECT * FROM sys.innodb_lock_waits\G
-- 查看当前长事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_runtime,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_runtime DESC;
-- 查看 InnoDB 行锁等待状态
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_current_waits';
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_time_avg';
慢查询日志里出现大量 UPDATE、DELETE,不要想当然地认为是索引慢,先用 EXPLAIN 看执行计划,再结合 INNODB_TRX 判断是否有锁等待。很多时候慢不是因为扫描行数多,而是因为锁排队排了很久。
5.4 关于锁的几个常见误区
|
