今天,我们在谈一下锁的问题,这个问题也是比较复杂,所以想着在谈一次,透彻一点,也是前阵子帮某电商平台紧急排查一个问题:大促期间 “商品超卖” 了 —— 明明库存只有 100 件,却卖出了 105 件,用户投诉不断。查了半天才发现,开发写的扣库存 SQL 没加锁,两个订单同时读库存、同时扣减,导致 “更新丢失”。后来加了一行,问题立刻解决。
SELECT ... FOR UPDATE
事务和锁是 MySQL 并发控制的 “左右手”,但很多开发者只知道 “BEGIN…COMMIT” 和 “加索引”,遇到 “死锁”“超卖” 就慌了。这篇是 MySQL 专栏的并发优化实战篇,核心目标是帮你从 “知道事务 ACID” 进阶到 “能在高并发场景下用锁解决实际问题”。
本文用电商 / 金融的真实并发场景贯穿,每个知识点都配 “可复现的 SQL 步骤 + 执行结果 + 优化方案”,看完你不仅能搞懂 InnoDB 锁机制,还能直接把方案用到生产环境,避免 “超卖”“死锁” 这些线上事故。
一、先直面问题:高并发下,事务会遇到哪些坑?
在单线程场景下,事务很少出问题,但一旦多用户同时操作(比如电商大促、金融转账),3 个核心问题会立刻暴露:更新丢失、脏读、幻读。我们先从最常见的 “更新丢失” 切入,用实战案例讲透问题本质。
1. 最致命的坑:更新丢失(电商超卖、金融少记账)
场景:电商商品表,
goods字段存库存,两个订单同时下单扣库存(库存初始 100)。问题:两个事务都读库存 = 100,都扣减到 99,最终库存变成 99(实际该扣 2 件,变成 98),导致超卖。
stock
实战模拟:复现更新丢失
先准备测试表和数据:
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 解决更新丢失
还是刚才的 “扣库存” 场景,这次在查询库存时加(悲观锁),用 Record Lock 锁定行:
FOR UPDATE
| 步骤 | 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之间的间隙),防止其他事务在间隙中插入数据,从而解决幻读。触发条件:可重复读隔离级别(InnoDB 默认)下,用范围查询(如
id=5,
>,
<)时触发。
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 的触发 Gap Lock,锁定了
id>5的间隙(包括 5 和 10 之间、10 之后的间隙),Session B 插入 id=6 时,正好落在这个间隙里,被锁定拦截,直到 A 提交释放锁 —— 这就解决了 “同一事务内多次查行数变化” 的幻读问题。
(5, +∞)
(3)Next-Key Lock:Record Lock + Gap Lock(默认行锁算法)
含义:InnoDB 默认的行锁算法,等于 “Record Lock(锁具体值)+ Gap Lock(锁间隙)”,既锁定具体行,又锁定间隙,防止更新丢失和幻读。例子:,会锁定
WHERE id=5 FOR UPDATE这一行(Record Lock),以及
id=5和
(1,5)的间隙(Gap Lock),防止插入 id=2、3、4、6、7 等。
(5,10)
2. 表锁:万不得已才用的 “粗粒度锁”
含义:锁定整张表,任何事务都不能修改表数据(读可以),并发度极低。触发场景:
执行(手动加表锁);更新语句没走索引(如
LOCK TABLES goods WRITE;,
UPDATE goods SET stock=99 WHERE name='iPhone 15'没索引),InnoDB 会把行锁升级为表锁。
name
实战:没索引导致表锁
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. 实战:模拟死锁场景
准备订单表(id 为主键):
orders
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(只锁 1 行,冲突少)。
UPDATE orders SET status=2 WHERE id=123
技巧 3:设置锁等待超时,避免永久阻塞
InnoDB 默认(50 秒),如果事务等待锁超过 50 秒,会自动回滚。可以根据业务调整,比如电商下单场景设为 5 秒:
innodb_lock_wait_timeout=50
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,会导致索引失效,行锁变表锁;乐观锁要设置重试次数:避免无限重试(比如重试 3 次,失败后返回 “系统繁忙”);不要在事务中做大量查询:只把 “写操作”(INSERT/UPDATE/DELETE)放事务,查询放事务外;用
WHERE DATE(create_time)='2025-10-01'隔离级别:比默认的
READ COMMITTED少 Gap Lock,减少锁冲突(互联网业务优先选);定期监控死锁日志:用脚本定时解析
REPEATABLE READ,发现高频死锁及时优化。
SHOW ENGINE INNODB STATUS
六、总结:事务与锁的核心是 “平衡一致性和并发度”
很多开发者觉得 “锁越严越好”,但实际上 “过严的锁会导致并发崩溃,过松的锁会导致数据不一致”。这篇的核心就是帮你找到平衡:
遇到 “更新丢失”“超卖”,用 Record Lock 锁定单行;遇到 “幻读”,用 Gap Lock 锁定间隙;遇到 “死锁”,调整加锁顺序或缩小锁范围;读多写少用乐观锁,写多读少用悲观锁。
