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 锁定间隙;遇到 “死锁”,调整加锁顺序或缩小锁范围;读多写少用乐观锁,写多读少用悲观锁。

© 版权声明

相关文章

暂无评论

none
暂无评论...