第10讲:并发场景优化——锁机制与死锁解决

目标: 理解MySQL锁机制,掌握死锁分析和解决方法

开篇:MVCC解决了读写冲突,写写冲突呢?

上一讲我们学了MVCC,读写可以不冲突。但写写冲突 怎么办?两个事务同时修改同一行,必须有先后顺序,这就需要

经典问题:秒杀超卖。应用层"先查后改",两个请求同时查到库存=1,都判断>0,都去扣库存,结果库存变成-1。

今天来看MySQL的锁机制、加锁规则和死锁解决方案。

一、为什么需要锁?

上一讲我们学了MVCC,读写可以不冲突。但写写冲突怎么办?

场景:两个事务同时修改同一行数据

sql 复制代码
-- 初始:stock = 100

-- 事务A
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- 事务B(同时执行)
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- 期望结果:stock = 98

如果没有任何控制,两个UPDATE同时读到stock=100,各自计算100-1=99,最后stock=99,丢失了一次更新。

MySQL的解决方案:行锁

InnoDB在执行UPDATE时,会自动对修改的行加排他锁(X锁)。

为什么需要锁

锁的作用: 让并发的写操作串行执行,事务B必须等事务A提交后才能修改同一行。

关键点:

  • UPDATE/DELETE会自动加行锁,不需要手动加
  • 锁在事务提交或回滚时释放
  • 这就是为什么长事务会阻塞其他事务

二、MySQL锁的三个层级

MySQL的锁从粗到细分三层:全局锁 → 表级锁 → 行级锁。

2.1 全局锁:锁住整个数据库

什么时候用? 全库备份的时候。

sql 复制代码
FLUSH TABLES WITH READ LOCK;  -- 加锁,整个库只读
-- 备份...
UNLOCK TABLES;                -- 释放

问题: 备份期间业务完全不能写,影响太大。

更好的方案: mysqldump的--single-transaction参数,利用MVCC读取一致性快照,不锁表。

2.2 表级锁:锁住整张表

表锁: 锁住整张表,并发性能差,一般不用。

元数据锁(MDL,Metadata Lock):

MDL 是 MySQL 5.5 引入的锁,用于保护表结构,防止查询时表结构被修改。

MDL 的工作方式:

  • 执行 SELECT/INSERT/UPDATE/DELETE 时,自动加 MDL 读锁
  • 执行 ALTER TABLE/DROP TABLE 时,自动加 MDL 写锁
  • 读锁之间不冲突(多个查询可以并发)
  • 读锁和写锁互斥(ALTER 必须等所有查询结束)

为什么需要 MDL?

sql 复制代码
-- 没有 MDL 会出问题:
-- 事务A 正在执行 SELECT * FROM orders WHERE id = 1;
-- 事务B 执行 ALTER TABLE orders DROP COLUMN amount;
-- 事务A 读到一半,表结构变了,崩溃!

MDL 保证:查询期间表结构不会被修改。

MDL 的坑: 长事务持有 MDL 读锁,会阻塞 ALTER TABLE,而 ALTER 又会阻塞后续所有查询(后面 5.1 节会详细讲)。

意向锁:快速判断表中有没有行锁

这是个容易被忽略但很重要的概念。

场景: 事务A锁住了id=1这一行,事务B想锁整张表。

没有意向锁: B需要遍历所有行,检查有没有行锁。表有1000万行,遍历一遍太慢了。

有意向锁: A在锁行之前,先在表上加一个意向锁(IX)。B只需要检查表上有没有IX锁,O(1)时间复杂度。

锁类型 含义 加锁时机
IS(意向共享锁) 表中有行被加了S锁 SELECT ... LOCK IN SHARE MODE
IX(意向排他锁) 表中有行被加了X锁 SELECT ... FOR UPDATE

2.3 行级锁:锁住单行或多行(RR隔离级别)

InnoDB支持行级锁,锁粒度最小,并发性能最好。以下加锁行为基于默认的 RR(可重复读)隔离级别,RC 级别下没有间隙锁。

记录锁(Record Lock): 锁住单行记录。

sql 复制代码
SELECT * FROM orders WHERE id = 1 FOR UPDATE;  -- 只锁住id=1这一行

间隙锁(Gap Lock): 锁住记录之间的"空隙",防止幻读。

为什么需要间隙锁?看这个场景:

sql 复制代码
-- 表中有id: 1, 5, 10
-- 事务A
SELECT * FROM orders WHERE id > 3 FOR UPDATE;  -- 返回id=5, 10

-- 事务B
INSERT INTO orders(id) VALUES(4);  -- 如果成功,事务A再查就多了一行(幻读)

间隙锁锁住(3, 5)这个间隙,事务B的INSERT会被阻塞,防止幻读。

Next-Key Lock: 记录锁 + 间隙锁,InnoDB默认的行锁算法。

查看锁信息:

sql 复制代码
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

三、加锁规则:不同查询锁的范围不同

这是面试高频考点,也是排查死锁的关键。

核心问题: 执行一条SQL,到底锁了哪些行?

答案取决于两个因素:查询条件 + 索引类型。

3.1 等值查询的加锁规则

主键/唯一索引等值查询:只锁一行

sql 复制代码
-- id是主键,表中有id: 1, 5, 10
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- 只加记录锁,锁住id=5这一行

为什么只锁一行?因为主键/唯一索引保证了只有一条记录匹配,不需要锁间隙。

普通索引等值查询:锁记录+间隙

sql 复制代码
-- idx_user_id是普通索引,表中有user_id: 1, 5, 5, 10
SELECT * FROM orders WHERE user_id = 5 FOR UPDATE;
-- 加Next-Key Lock:(1, 5] + (5, 10)

为什么要锁间隙?因为普通索引可能有多条记录匹配,需要防止其他事务插入新的user_id=5的记录。

3.2 范围查询的加锁规则

sql 复制代码
-- id是主键,表中有id: 1, 5, 10, 15
SELECT * FROM t WHERE id >= 5 AND id < 12 FOR UPDATE;
-- 加锁范围:[5, 10] + (10, 15)

范围查询会锁住所有扫描到的记录和间隙。

3.3 加锁规则总结

查询类型 索引类型 加锁范围
等值查询 主键/唯一索引 记录锁(只锁一行)
等值查询 普通索引 Next-Key Lock + 后间隙
范围查询 主键/唯一索引 记录锁 + 间隙锁
范围查询 普通索引 Next-Key Lock
任意查询 无索引 全表所有行和间隙(等于锁表)

关键点: 没有索引的查询会锁住整张表的所有行和间隙!这是很多死锁和性能问题的根源。

四、死锁:互相等待,谁也走不了

4.1 什么是死锁?

两个事务互相等待对方持有的锁,形成循环等待,谁也无法继续。

sql 复制代码
-- 事务A
BEGIN;
UPDATE orders SET status = 1 WHERE id = 1;  -- 锁住id=1
UPDATE orders SET status = 1 WHERE id = 2;  -- 等待id=2...

-- 事务B
BEGIN;
UPDATE orders SET status = 1 WHERE id = 2;  -- 锁住id=2
UPDATE orders SET status = 1 WHERE id = 1;  -- 等待id=1...
-- 死锁!A等B,B等A

4.2 死锁日志分析实战

遇到死锁怎么排查?看死锁日志。

sql 复制代码
SHOW ENGINE INNODB STATUS\G

真实死锁日志解读:

perl 复制代码
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-01-04 18:02:32 140087116523072

*** (1) TRANSACTION:
TRANSACTION 6103196, ACTIVE 14 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 198162, query id 24473475 223.160.212.163 root updating
UPDATE orders SET status = 1 WHERE id = 2
                                    ↑ 事务1正在执行的SQL

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 15 page no 5 n bits 368 index PRIMARY of table `company_db`.`orders` 
trx id 6103196 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 8000000000000001; asc         ;;
                              ↑ 事务1持有 id=1 的锁(0x01 = 1)

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 15 page no 5 n bits 368 index PRIMARY of table `company_db`.`orders` 
trx id 6103196 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 8000000000000002; asc         ;;
                              ↑ 事务1在等待 id=2 的锁(0x02 = 2)

*** (2) TRANSACTION:
TRANSACTION 6103197, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 198163, query id 24473479 223.160.212.163 root updating
UPDATE orders SET status = 1 WHERE id = 1
                                    ↑ 事务2正在执行的SQL

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 15 page no 5 n bits 368 index PRIMARY of table `company_db`.`orders` 
trx id 6103197 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 8000000000000002; asc         ;;
                              ↑ 事务2持有 id=2 的锁

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 15 page no 5 n bits 368 index PRIMARY of table `company_db`.`orders` 
trx id 6103197 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 8000000000000001; asc         ;;
                              ↑ 事务2在等待 id=1 的锁

*** WE ROLL BACK TRANSACTION (2)
    ↑ InnoDB选择回滚事务2(代价较小的那个)

分析三步走:

  1. 找 HOLDS THE LOCK(S):看事务持有什么锁

    • 事务1 持有 id=1 的锁
    • 事务2 持有 id=2 的锁
  2. 找 WAITING FOR THIS LOCK:看事务在等什么锁

    • 事务1 等待 id=2 的锁
    • 事务2 等待 id=1 的锁
  3. 画出等待关系图,确认循环等待

bash 复制代码
事务1 持有 id=1,等待 id=2
事务2 持有 id=2,等待 id=1

事务1 → 等待 → 事务2 → 等待 → 事务1  (形成环 = 死锁)

关键字段速查:

字段 含义
lock_mode X 排他锁
locks rec but not gap 记录锁(不含间隙)
hex 8000000000000001 主键值(去掉最高位8,剩下的是id=1)
WE ROLL BACK TRANSACTION (2) InnoDB回滚了事务2

4.3 死锁预防

原则 说明 示例
统一访问顺序 所有事务按相同顺序访问表和行 都先更新id小的,再更新id大的
缩短事务时间 事务中不要有耗时操作 把API调用放到事务外面
降低隔离级别 RC无间隙锁,减少死锁 如果业务允许,用RC
添加合适索引 避免锁住大量行 确保WHERE条件有索引

五、在线DDL的锁问题

5.1 问题场景

sql 复制代码
-- 给大表加字段,业务卡住了
ALTER TABLE orders ADD COLUMN remark VARCHAR(200);

为什么会卡住?

原因: ALTER TABLE需要获取MDL写锁。如果有长事务持有MDL读锁(比如一个慢查询),ALTER会等待。更糟糕的是,后续所有查询也会等待ALTER,形成连锁阻塞。

5.2 解决方案

方案1:pt-online-schema-change

原理:创建新表 → 复制数据 → 触发器同步增量 → 原子切换表名

bash 复制代码
pt-online-schema-change \
  --alter "ADD COLUMN remark VARCHAR(200)" \
  D=test,t=orders \
  --execute

方案2:gh-ost(GitHub出品)

原理:类似pt-osc,但用binlog同步增量,不用触发器,对主库压力更小。

bash 复制代码
gh-ost \
  --alter="ADD COLUMN remark VARCHAR(200)" \
  --database=test \
  --table=orders \
  --execute

⚠️ 注意: pt-osc 和 gh-ost 都依赖主从复制。如果主从延迟较大,迁移过程会变慢,甚至可能失败。建议在延迟 < 1秒时执行。

方案3:MySQL 8.0 Instant DDL

Instant 的意思是"即时",只修改表的元数据(数据字典),不重建表数据,所以秒级完成。

sql 复制代码
-- MySQL 8.0 会自动选择最优算法,支持 Instant 的操作会自动使用
ALTER TABLE orders ADD COLUMN remark VARCHAR(200);

-- 也可以显式指定,如果不支持会报错(更安全)
ALTER TABLE orders ADD COLUMN remark VARCHAR(200), ALGORITHM=INSTANT;

支持的操作:添加列、修改默认值、重命名列等。不支持修改列类型、加索引。

六、库存超卖:锁的经典应用场景

6.1 问题场景

秒杀场景,库存只有1个,两个请求同时来:

java 复制代码
// 请求A
int stock = query("SELECT stock FROM product WHERE id = 1");  // stock = 1
if (stock > 0) {
    execute("UPDATE product SET stock = stock - 1 WHERE id = 1");
}

// 请求B(同时执行)
int stock = query("SELECT stock FROM product WHERE id = 1");  // stock = 1
if (stock > 0) {
    execute("UPDATE product SET stock = stock - 1 WHERE id = 1");
}
// 结果:库存变成-1,超卖了!

问题根源: "先查后改"不是原子操作,两个请求都查到stock=1,都判断>0,都去扣减。

6.2 解决方案对比

数据库层方案:

方案 SQL 优点 缺点
悲观锁 SELECT ... FOR UPDATE 简单可靠 性能差,锁等待时间长
乐观锁 UPDATE ... WHERE version = ? 无锁等待 高并发时重试多,成功率低
直接扣减 UPDATE ... SET stock = stock - 1 WHERE stock > 0 原子操作,一条SQL 热点行锁竞争

应用层方案(高并发场景):

方案 原理 适用场景
Redis 预扣减 库存放 Redis,先扣 Redis 再异步扣 DB 秒杀、高并发
分布式锁 Redis/ZK 加锁,串行处理 并发量中等
消息队列 请求入队,串行消费 削峰填谷

简单场景用直接扣减:

sql 复制代码
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 检查affected_rows,为0说明库存不足

高并发秒杀用 Redis 预扣减:

java 复制代码
// 1. Redis 扣减(原子操作)
Long remain = redis.decr("stock:" + productId);
if (remain < 0) {
    redis.incr("stock:" + productId);  // 回滚
    return "库存不足";
}
// 2. 异步发消息,扣减数据库
mq.send(new OrderMessage(productId, userId));

数据库扛不住高并发写,Redis 单机 10 万 QPS,适合做前置拦截。

七、避坑指南

坑1:长事务持有锁

问题: 事务中调用外部API,持有锁时间过长。

正确做法: 把耗时操作放到事务外面。

坑2:WHERE条件没有索引

问题: 没有索引会锁住整张表。

正确做法: 确保WHERE条件有索引。

坑3:在线DDL阻塞业务

问题: ALTER TABLE等待MDL锁,阻塞所有查询。

正确做法: 使用pt-online-schema-change或gh-ost。

坑4:间隙锁导致死锁

问题: RR隔离级别下,间隙锁容易导致死锁。

正确做法: 如果业务允许,使用RC隔离级别(无间隙锁)。

八、作业

基础题

模拟死锁并分析日志

打开两个MySQL客户端,按以下步骤制造死锁:

sql 复制代码
-- 准备数据
CREATE TABLE test_lock (id INT PRIMARY KEY, val INT);
INSERT INTO test_lock VALUES (1, 100), (2, 200);
步骤 客户端A 客户端B
1 BEGIN; BEGIN;
2 UPDATE test_lock SET val=1 WHERE id=1;
3 UPDATE test_lock SET val=2 WHERE id=2;
4 UPDATE test_lock SET val=1 WHERE id=2; -- 等待B
5 UPDATE test_lock SET val=2 WHERE id=1; -- 死锁!

SHOW ENGINE INNODB STATUS 查看死锁日志,画出等待关系图。

进阶题

分析加锁范围

sql 复制代码
-- 表结构:orders(id PRIMARY KEY, user_id INDEX, status)
-- 数据:id = 1, 5, 10, 15, 20

-- 分析以下SQL在RR隔离级别下的加锁范围:
-- A: SELECT * FROM orders WHERE id = 10 FOR UPDATE;
-- B: SELECT * FROM orders WHERE user_id = 10 FOR UPDATE;
-- C: SELECT * FROM orders WHERE id > 10 FOR UPDATE;
-- D: SELECT * FROM orders WHERE status = 1 FOR UPDATE;  -- status无索引

九、下一讲预告

并发问题解决了,但数据量增长到千万级,单机MySQL撑不住了。

第11讲:主从复制与读写分离架构

下一讲会讲这些:

  • 主从复制原理:Dump线程、IO线程、SQL线程如何协作
  • GTID复制:为什么它是故障切换的救星
  • 读写分离实现:应用层 vs ShardingSphere中间件
  • 主从延迟:原因分析和三种解决方案
  • 主从切换实战:计划内切换步骤 + MHA自动切换

下一讲见!

相关推荐
Bony-3 小时前
Go语言垃圾回收机制详解与图解
开发语言·后端·golang
小句3 小时前
MySQL慢查询日志详细使用指南
数据库·mysql·adb
码农水水4 小时前
中国邮政Java面试被问:容器镜像的多阶段构建和优化
java·linux·开发语言·数据库·mysql·面试·php
JH30738 小时前
SpringBoot自定义启动banner:给项目加个专属“开机画面”
java·spring boot·后端
what丶k8 小时前
深度解析Redis LRU与LFU算法:区别、实现与选型
java·redis·后端·缓存
测试人社区-浩辰9 小时前
AI与区块链结合的测试验证方法
大数据·人工智能·分布式·后端·opencv·自动化·区块链
酉鬼女又兒9 小时前
SQL21 浙江大学用户题目回答情况
数据库·sql·mysql
老友@10 小时前
分布式事务完全演进链:从单体事务到 TCC 、Saga 与最终一致性
分布式·后端·系统架构·事务·数据一致性
wangmengxxw10 小时前
SpringAI-mysql
java·数据库·人工智能·mysql·springai
java1234_小锋11 小时前
Spring里AutoWired与Resource区别?
java·后端·spring