MySQL 事务与锁优化:解决 “并发死锁”“更新丢失” 的实战方案
今天,我们在谈一下锁的问题,这个问题也是比较复杂,所以想着在谈一次,透彻一点,也是前阵子帮某电商平台紧急排查一个问题:大促期间 “商品超卖” 了 —— 明明库存只有 100 件,却卖出了 105 件,用户投诉不断。查了半天才发现,开发写的扣库存 SQL 没加锁,两个订单同时读库存、同时扣减,导致 “更新丢失”。后来加了一行SELECT ... FOR UPDATE,问题立刻解决。
事务和锁是 MySQL 并发控制的 “左右手”,但很多开发者只知道 “BEGIN...COMMIT” 和 “加索引”,遇到 “死锁”“超卖” 就慌了。这篇是 MySQL 专栏的并发优化实战篇,核心目标是帮你从 “知道事务 ACID” 进阶到 “能在高并发场景下用锁解决实际问题”。
本文用电商 / 金融的真实并发场景贯穿,每个知识点都配 “可复现的 SQL 步骤 + 执行结果 + 优化方案”,看完你不仅能搞懂 InnoDB 锁机制,还能直接把方案用到生产环境,避免 “超卖”“死锁” 这些线上事故。
一、先直面问题:高并发下,事务会遇到哪些坑?
在单线程场景下,事务很少出问题,但一旦多用户同时操作(比如电商大促、金融转账),3 个核心问题会立刻暴露:更新丢失、脏读、幻读。我们先从最常见的 “更新丢失” 切入,用实战案例讲透问题本质。
1. 最致命的坑:更新丢失(电商超卖、金融少记账)
场景:电商商品goods表,stock字段存库存,两个订单同时下单扣库存(库存初始 100)。问题:两个事务都读库存 = 100,都扣减到 99,最终库存变成 99(实际该扣 2 件,变成 98),导致超卖。
实战模拟:复现更新丢失
先准备测试表和数据:
sql
-- 商品表:id=1的商品,库存100
CREATE TABLE goods (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
stock INT NOT NULL DEFAULT 0 -- 库存
);
INSERT INTO goods VALUES (1, 'iPhone 15', 100);
一键获取完整项目代码
用两个会话(Session A、Session B)模拟并发下单:
步骤 Session A(订单 1) Session B(订单 2) 结果(库存)
1 BEGIN; BEGIN; 100
2 SELECT stock FROM goods WHERE id=1; -- 读 100 100
3 SELECT stock FROM goods WHERE id=1; -- 读 100 100
4 UPDATE goods SET stock=99 WHERE id=1; 99
5 UPDATE goods SET stock=99 WHERE id=1; 99(错!)
6 COMMIT; COMMIT; 99
结果:两个订单扣了 2 件库存,最终却剩 99,出现 “更新丢失”—— 这就是电商超卖的根源。
2. 另外两个并发问题:脏读、幻读
脏读:事务 A 读了事务 B 未提交的数据,B 回滚后,A 读的是 “脏数据”。比如:事务 A 查用户余额 = 1000,事务 B 转账扣 100(余额 900)但未提交,A 再查余额 = 900,B 回滚后 A 的 900 就是脏数据。
幻读:同一事务内多次查同一范围,结果集行数变化(比如第一次查 “库存> 0 的商品有 10 个”,第二次查变成 11 个,因为其他事务新增了 1 个)。比如:事务 A 查 “id>5 的商品” 有 3 个,事务 B 新增 1 个 id=6 的商品并提交,A 再查变成 4 个,像 “幻觉” 一样。
二、锁是解决方案:InnoDB 的锁机制详解(从行锁到表锁)
InnoDB 的锁分 “表锁” 和 “行锁”,但实际开发中 90% 的问题都和行锁有关 —— 行锁能精准锁定一行数据,并发度比表锁高 10 倍以上。我们重点讲行锁的 3 种核心类型,以及它们怎么解决上面的并发问题。
1. 行锁的 3 种类型:Record Lock、Gap Lock、Next-Key Lock
InnoDB 的行锁不是 “锁整行数据”,而是 “锁索引上的键值”,具体分 3 种:
(1)Record Lock:锁定具体的一行记录(解决更新丢失)
含义:锁定索引上的某一个具体值(比如锁定goods.id=1这一行),只影响这一行的更新 / 删除。
适用场景:等值查询锁定单行(如WHERE id=1),解决 “更新丢失”。
实战:用 Record Lock 解决更新丢失
还是刚才的 “扣库存” 场景,这次在查询库存时加FOR UPDATE(悲观锁),用 Record Lock 锁定行:
步骤 Session A(订单 1) Session B(订单 2) 结果(库存)
1 BEGIN; BEGIN; 100
2 -- 加 Record Lock 锁定 id=1 的行SELECT stock FROM goods WHERE id=1 FOR UPDATE; -- 读 100 100
3 -- 这里会阻塞,因为 A 已锁 id=1SELECT stock FROM goods WHERE id=1 FOR UPDATE; 阻塞中
4 UPDATE goods SET stock=99 WHERE id=1; 99
5 COMMIT; -- 释放锁 99
6 -- 锁释放后,B 读到 99SELECT stock FROM goods WHERE id=1 FOR UPDATE; 99
7 UPDATE goods SET stock=98 WHERE id=1; 98
8 COMMIT; 98
结果:库存从 100→99→98,没有更新丢失 —— 这就是 Record Lock 的作用:同一时间只允许一个事务修改锁定的行。
(2)Gap Lock:锁定索引间的 “间隙”(解决幻读)
含义:不锁定具体行,而是锁定 “索引键之间的间隙”(比如id=1和id=5之间的间隙),防止其他事务在间隙中插入数据,从而解决幻读。
触发条件:可重复读隔离级别(InnoDB 默认)下,用范围查询(如>, <, BETWEEN)时触发。
实战:Gap Lock 如何阻止幻读
准备测试数据(id 是主键,有 3 行数据):
sql
INSERT INTO goods VALUES (1, 'iPhone 15', 100), (5, '华为Mate 60', 80), (10, '小米14', 60);
一键获取完整项目代码
用两个会话模拟幻读场景,加 Gap Lock 后阻止插入:
步骤 Session A(统计商品) Session B(新增商品) 结果
1 BEGIN; BEGIN;
2 -- 范围查询,触发 Gap LockSELECT * FROM goods WHERE id > 5 FOR UPDATE; -- 查到 id=10 查到 1 行
3 -- 尝试插入 id=6(在 5 和 10 的间隙)INSERT INTO goods VALUES (6, 'OPPO Find X7', 50); 阻塞中(被 Gap Lock 拦截)
4 COMMIT; -- 释放 Gap Lock
5 -- 阻塞解除,插入成功 新增 id=6
原理:Session A 的id>5触发 Gap Lock,锁定了(5, +∞)的间隙(包括 5 和 10 之间、10 之后的间隙),Session B 插入 id=6 时,正好落在这个间隙里,被锁定拦截,直到 A 提交释放锁 —— 这就解决了 “同一事务内多次查行数变化” 的幻读问题。
(3)Next-Key Lock:Record Lock + Gap Lock(默认行锁算法)
含义:InnoDB 默认的行锁算法,等于 “Record Lock(锁具体值)+ Gap Lock(锁间隙)”,既锁定具体行,又锁定间隙,防止更新丢失和幻读。
例子:WHERE id=5 FOR UPDATE,会锁定id=5这一行(Record Lock),以及(1,5)和(5,10)的间隙(Gap Lock),防止插入 id=2、3、4、6、7 等。
2. 表锁:万不得已才用的 “粗粒度锁”
含义:锁定整张表,任何事务都不能修改表数据(读可以),并发度极低。
触发场景:
执行LOCK TABLES goods WRITE;(手动加表锁);
更新语句没走索引(如UPDATE goods SET stock=99 WHERE name='iPhone 15',name没索引),InnoDB 会把行锁升级为表锁。
实战:没索引导致表锁
sql
-- name列没建索引
UPDATE goods SET stock=99 WHERE name='iPhone 15';
一键获取完整项目代码
用SHOW ENGINE INNODB STATUS查看锁信息,会发现TABLE LOCK(表锁)—— 这就是为什么 “更新语句必须走索引”:避免行锁升级为表锁,导致并发崩溃。
三、最头疼的死锁:怎么排查、怎么解决?
死锁是 “两个或多个事务互相等待对方的锁”,比如:
事务 A 持有锁 1,等待锁 2;
事务 B 持有锁 2,等待锁 1;
双方互相等,永远卡住,MySQL 会自动回滚 “代价小” 的事务(通常是持有锁少的那个)。
1. 实战:模拟死锁场景
准备订单表orders(id 为主键):
sql
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL
);
INSERT INTO orders VALUES (1, 100, 500), (2, 200, 800);
一键获取完整项目代码
用两个会话模拟死锁:
步骤 Session A Session B 结果
1 BEGIN; BEGIN;
2 -- 锁定 id=1 的行UPDATE orders SET amount=550 WHERE id=1; 成功
3 -- 锁定 id=2 的行UPDATE orders SET amount=850 WHERE id=2; 成功
4 -- 尝试锁 id=2,被 B 持有UPDATE orders SET amount=550 WHERE id=2; 阻塞中
5 -- 尝试锁 id=1,被 A 持有UPDATE orders SET amount=850 WHERE id=1; 死锁!
结果:Session B 执行步骤 5 时,MySQL 提示死锁,自动回滚 B 的事务:
plaintext
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
一键获取完整项目代码
2. 关键:用死锁日志定位根因
遇到死锁,不要慌,用SHOW ENGINE INNODB STATUS查看死锁日志,里面有详细的 “谁持有锁、谁在等锁” 信息。
查看死锁日志:
sql
SHOW ENGINE INNODB STATUS;
一键获取完整项目代码
日志关键片段解析:
plaintext
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-11-01 14:30:00 0x7f...
*** (1) TRANSACTION:
TRANSACTION 12345, 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 1, OS thread handle 140..., query id 127 localhost root updating
UPDATE orders SET amount=550 WHERE id=2 -- 事务A在等id=2的锁
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`orders` trx id 12345 lock_mode X waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 80000002; asc ;; -- 等待的锁是id=2
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec starting index read
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 2, OS thread handle 140..., query id 128 localhost root updating
UPDATE orders SET amount=850 WHERE id=1 -- 事务B在等id=1的锁
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`orders` trx id 12346 lock_mode X
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 80000002; asc ;; -- 事务B持有id=2的锁
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`orders` trx id 12346 lock_mode X waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 80000001; asc ;; -- 等待的锁是id=1
*** WE ROLL BACK TRANSACTION (1) -- MySQL回滚事务A
一键获取完整项目代码
日志核心信息:
事务 A 持有 id=1 的锁,等待 id=2 的锁;
事务 B 持有 id=2 的锁,等待 id=1 的锁;
形成循环等待,MySQL 回滚事务 A(因为 A 持有 1 个锁,B 持有 1 个锁,回滚 A 代价小)。
3. 死锁解决方案:3 个实战技巧
技巧 1:调整 SQL 执行顺序,避免循环等待
死锁的核心是 “循环等待”,只要让所有事务按相同顺序加锁,就能避免循环。比如刚才的场景,让两个事务都先锁 id=1,再锁 id=2:
步骤 Session A Session B 结果
1 BEGIN; BEGIN;
2 UPDATE orders SET amount=550 WHERE id=1; 成功
3 -- 等 A 释放 id=1 的锁UPDATE orders SET amount=850 WHERE id=1; 阻塞中
4 UPDATE orders SET amount=550 WHERE id=2; 成功
5 COMMIT; -- 释放 id=1、id=2 的锁
6 -- 锁释放后,B 执行 id=1UPDATE orders SET amount=850 WHERE id=1; 成功
7 UPDATE orders SET amount=850 WHERE id=2; 成功
8 COMMIT;
结果:没有死锁,因为两个事务都按 “id=1→id=2” 的顺序加锁,不会循环等待。
技巧 2:缩小锁范围,减少锁冲突
比如 “更新订单时”,只锁需要的行,不要用范围查询锁多行会:
坏:UPDATE orders SET status=2 WHERE user_id>100(锁很多行,容易冲突);
好:UPDATE orders SET status=2 WHERE id=123(只锁 1 行,冲突少)。
技巧 3:设置锁等待超时,避免永久阻塞
InnoDB 默认innodb_lock_wait_timeout=50(50 秒),如果事务等待锁超过 50 秒,会自动回滚。可以根据业务调整,比如电商下单场景设为 5 秒:
sql
-- 临时生效(重启失效)
SET GLOBAL innodb_lock_wait_timeout=5;
-- 永久生效(改my.cnf)
[mysqld]
innodb_lock_wait_timeout=5
一键获取完整项目代码
四、企业级实战:电商扣库存,该用乐观锁还是悲观锁?
前面讲了 “悲观锁”(SELECT ... FOR UPDATE),但实际开发中还有 “乐观锁”(版本号 / 时间戳),两种锁各有适用场景,选错了会导致性能问题。
1. 悲观锁:适合 “写多读少”,强一致性要求高
原理:“悲观” 地认为并发冲突多,所以在修改前先锁定行,阻止其他事务修改(前面的FOR UPDATE就是悲观锁)。
适用场景:电商下单、金融转账(不允许超卖、少记账,强一致性要求高)。
悲观锁扣库存代码(Java+MySQL 示例):
java
运行
// 1. 开启事务
Connection conn = getConnection();
conn.setAutoCommit(false);
try {
// 2. 加悲观锁查询库存(FOR UPDATE)
PreparedStatement pstmt1 = conn.prepareStatement(
"SELECT stock FROM goods WHERE id=? FOR UPDATE"
);
pstmt1.setInt(1, 1);
ResultSet rs = pstmt1.executeQuery();
rs.next();
int stock = rs.getInt("stock");
// 3. 检查库存是否足够
if (stock < 1) {
throw new Exception("库存不足");
}
// 4. 扣库存
PreparedStatement pstmt2 = conn.prepareStatement(
"UPDATE goods SET stock=stock-1 WHERE id=?"
);
pstmt2.setInt(1, 1);
pstmt2.executeUpdate();
// 5. 提交事务
conn.commit();
} catch (Exception e) {
// 6. 回滚事务
conn.rollback();
} finally {
conn.close();
}
一键获取完整项目代码
2. 乐观锁:适合 “读多写少”,并发冲突少
原理:“乐观” 地认为并发冲突少,所以不锁定行,而是在更新时检查 “数据是否被修改过”(用版本号或时间戳)。
实现方式:给表加version字段,更新时判断版本号是否一致:
sql
-- 1. 查库存和版本号
SELECT stock, version FROM goods WHERE id=1;
-- 2. 更新时检查版本号(如果version没变,说明没被修改过)
UPDATE goods
SET stock=stock-1, version=version+1
WHERE id=1 AND version=原版本号;
-- 3. 判断影响行数:如果影响0行,说明版本号变了(被其他事务修改),重试
一键获取完整项目代码
乐观锁扣库存实战:
步骤 Session A(订单 1) Session B(订单 2) 结果(stock/version)
1 BEGIN; BEGIN; 100/1
2 SELECT stock, version FROM goods WHERE id=1; -- 100/1 SELECT stock, version FROM goods WHERE id=1; -- 100/1 100/1
3 -- 版本号 = 1,更新成功UPDATE goods SET stock=99, version=2 WHERE id=1 AND version=1; -- 影响 1 行 99/2
4 COMMIT; 99/2
5 -- 版本号 = 1≠2,更新失败UPDATE goods SET stock=99, version=2 WHERE id=1 AND version=1; -- 影响 0 行 99/2
6 -- 重试:重新查版本号SELECT stock, version FROM goods WHERE id=1; -- 99/2 99/2
7 -- 版本号 = 2,更新成功UPDATE goods SET stock=98, version=3 WHERE id=1 AND version=2; -- 影响 1 行 98/3
8 COMMIT; 98/3
结果:没有更新丢失,且全程没加锁,并发度比悲观锁高。
3. 两种锁的对比与选型
对比维度 悲观锁(FOR UPDATE) 乐观锁(版本号)
并发度 低(锁定行,其他事务等待) 高(不锁定,冲突后重试)
适用场景 写多读少(电商下单、金融转账) 读多写少(商品详情页、库存查询)
一致性 强(不允许冲突) 最终一致(冲突后重试)
实现复杂度 简单(MySQL 层面加锁) 复杂(需要处理重试逻辑)
选型口诀:
写多读少用悲观,强一致不发愁;
读多写少用乐观,高并发不卡顿。
五、避坑清单:事务与锁的 10 个实战禁忌
不要在事务中调用外部接口:比如 “下单事务中调用物流接口”,接口耗时 5 秒,事务持有锁 5 秒,容易导致死锁;
不要用长事务:长事务会长期持有锁,比如 “事务中包含 sleep (10)”,10 秒内其他事务都改不了数据;
更新语句必须走索引:避免行锁升级为表锁(比如UPDATE goods SET stock=99 WHERE name='iPhone',name没索引);
不要用SELECT ... FOR UPDATE查不需要修改的行:比如 “查库存但不扣减”,加锁会阻塞其他事务;
批量更新用CASE WHEN,不要循环UPDATE:循环UPDATE会多次加锁,用CASE WHEN一次更新:
sql
-- 好:一次更新多行,减少锁冲突
UPDATE goods
SET stock = CASE id
WHEN 1 THEN 99
WHEN 2 THEN 79
END
WHERE id IN (1,2);
一键获取完整项目代码
避免在WHERE子句用函数包装索引列:比如WHERE DATE(create_time)='2025-10-01',会导致索引失效,行锁变表锁;
乐观锁要设置重试次数:避免无限重试(比如重试 3 次,失败后返回 “系统繁忙”);
不要在事务中做大量查询:只把 “写操作”(INSERT/UPDATE/DELETE)放事务,查询放事务外;
用READ COMMITTED隔离级别:比默认的REPEATABLE READ少 Gap Lock,减少锁冲突(互联网业务优先选);
定期监控死锁日志:用脚本定时解析SHOW ENGINE INNODB STATUS,发现高频死锁及时优化。
六、总结:事务与锁的核心是 “平衡一致性和并发度”
很多开发者觉得 “锁越严越好”,但实际上 “过严的锁会导致并发崩溃,过松的锁会导致数据不一致”。这篇的核心就是帮你找到平衡:
遇到 “更新丢失”“超卖”,用 Record Lock 锁定单行;
遇到 “幻读”,用 Gap Lock 锁定间隙;
遇到 “死锁”,调整加锁顺序或缩小锁范围;
读多写少用乐观锁,写多读少用悲观锁。
————————————————
版权声明:本文为CSDN博主「码间拾光・菲林斯」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lamehsl/article/details/153959183