锁冲突与死锁案例
数据库线上故障里,最难受的一类问题往往不是单条慢 SQL,而是锁冲突把请求链路拖成排队系统。它的典型表现是:
- 应用接口 RT 突增,但数据库 CPU 并不一定高
- 线程池、连接池、消息消费线程持续堆积
- 少量热点写请求把大量后续请求堵住
这类问题必须结合事务边界、索引命中、加锁顺序和业务流量一起看,不能只盯 SQL 语法。
一、数据库锁的类型详解
1.1 锁的基本分类
数据库锁从不同维度可以分为多种类型:
按锁的粒度划分
表级锁(Table Lock)
- 锁定整张表,粒度最大
- 开销小,加锁快,不会出现死锁
- 并发度低,容易出现锁冲突
- MyISAM 存储引擎使用表锁
行级锁(Row Lock)
- 只锁定被操作的数据行,粒度最小
- 开销大,加锁慢,会出现死锁
- 并发度高,锁冲突概率低
- InnoDB 存储引擎支持行锁
页面锁(Page Lock)
- 锁定一组相邻的数据行
- 开销和并发度介于表锁和行锁之间
- BDB 存储引擎使用页锁
按锁的类型划分
共享锁(Shared Lock,S锁)
- 又称读锁,允许多个事务同时读取同一资源
- 一个事务获取共享锁后,其他事务只能获取共享锁,不能获取排他锁
- 语法:
SELECT ... LOCK IN SHARE MODE(MySQL 8.0 后推荐使用FOR SHARE)
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE id = 1001 LOCK IN SHARE MODE;
-- 此时可以读取,但不能修改
-- 事务 B(同时执行)
BEGIN;
SELECT * FROM orders WHERE id = 1001 LOCK IN SHARE MODE; -- 成功,共享锁兼容
UPDATE orders SET status = 'PAID' WHERE id = 1001; -- 阻塞,需要排他锁
COMMIT;排他锁(Exclusive Lock,X锁)
- 又称写锁,独占资源,其他事务不能读也不能写
INSERT、UPDATE、DELETE操作自动加排他锁- 手动加锁语法:
SELECT ... FOR UPDATE
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 此时其他事务不能读也不能写该行
-- 事务 B(同时执行)
BEGIN;
SELECT * FROM orders WHERE id = 1001 FOR UPDATE; -- 阻塞,等待 A 释放锁
COMMIT;1.2 InnoDB 的行锁实现
InnoDB 通过给索引上的索引项加锁来实现行锁,这意味着:
- 只有通过索引条件检索数据,InnoDB 才使用行级锁
- 否则,InnoDB 将使用表锁
-- 表结构
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
INDEX idx_age (age)
);
-- 情况1:通过主键更新,只锁定一行
UPDATE users SET name = 'Alice' WHERE id = 1; -- 行锁
-- 情况2:通过索引更新,锁定索引记录
UPDATE users SET name = 'Bob' WHERE age = 25; -- 行锁(锁定满足条件的索引项)
-- 情况3:无索引更新,锁定整个表
UPDATE users SET name = 'Charlie' WHERE name = 'David'; -- 表锁(name 列无索引)注意: 在实际开发中,务必确保 UPDATE、DELETE 语句的 WHERE 条件有合适的索引,否则会导致表锁,严重影响并发性能。
1.3 InnoDB 的间隙锁
间隙锁(Gap Lock) 是 InnoDB 在可重复读隔离级别下,为了解决幻读问题而引入的锁机制。
什么是间隙?
- 间隙是指索引记录之间的空隙
- 例如,索引列有值 1、5、10,那么间隙包括:(-∞, 1)、(1, 5)、(5, 10)、(10, +∞)
间隙锁的作用:
- 锁定一个索引记录之间的间隙
- 防止其他事务在间隙中插入新记录
- 只在可重复读隔离级别下生效
-- 表数据:id = 1, 5, 10
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id > 3 AND id < 8 FOR UPDATE;
-- 锁定间隙 (1, 5) 和 (5, 10),以及 id=5 这一行
-- 事务 B(同时执行)
BEGIN;
INSERT INTO users VALUES(2, 'test'); -- 阻塞,因为 id=2 在间隙 (1, 5) 内
INSERT INTO users VALUES(8, 'test'); -- 阻塞,因为 id=8 在间隙 (5, 10) 内
COMMIT;间隙锁的特点:
- 间隙锁之间不冲突(多个事务可以同时对同一间隙加间隙锁)
- 间隙锁与行锁组合形成 Next-Key Lock
- 目的是防止幻读,但会降低并发性能
1.4 Next-Key Lock
Next-Key Lock 是行锁和间隙锁的组合,锁定一个索引记录以及该记录之前的间隙。
示例:
-- 表数据:id = 1, 5, 10
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id <= 5 FOR UPDATE;
-- Next-Key Lock 锁定:(-∞, 1]、(1, 5]
-- 包括:间隙 (-∞, 1)、行 1、间隙 (1, 5)、行 5
-- 事务 B
BEGIN;
INSERT INTO users VALUES(0, 'test'); -- 阻塞
INSERT INTO users VALUES(2, 'test'); -- 阻塞
INSERT INTO users VALUES(5, 'test'); -- 阻塞(行锁冲突)
INSERT INTO users VALUES(10, 'test'); -- 成功
COMMIT;Next-Key Lock 的退化:
- 使用唯一索引且查询条件是等值查询时,退化为行锁
- 例如:
SELECT * FROM users WHERE id = 5 FOR UPDATE(id 是主键),只锁定 id=5 这一行
1.5 意向锁
意向锁(Intention Lock) 是表级锁,用于协调行锁和表锁的关系。
为什么需要意向锁?
- 当一个事务想获取表锁时,需要检查表中是否有行锁
- 如果没有意向锁,需要扫描整张表的每一行
- 有了意向锁,只需要检查是否有对应的意向锁即可
意向锁的类型:
- 意向共享锁(Intention Shared Lock,IS): 事务想在某些行上加共享锁
- 意向排他锁(Intention Exclusive Lock,IX): 事务想在某些行上加排他锁
意向锁的兼容性:
| 锁类型 | IS | IX | S | X |
|---|---|---|---|---|
| IS | √ | √ | √ | × |
| IX | √ | √ | × | × |
| S | √ | × | √ | × |
| X | × | × | × | × |
意向锁的自动获取:
- 事务获取行级共享锁前,必须先获取表级意向共享锁
- 事务获取行级排他锁前,必须先获取表级意向排他锁
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 自动获取:意向排他锁(表级) + 排他行锁(行级)
-- 事务 B(同时执行)
BEGIN;
LOCK TABLE orders WRITE; -- 阻塞,因为表有意向排他锁
COMMIT;1.6 不同隔离级别下的锁行为
读未提交(READ UNCOMMITTED):
- 读取不加锁,写入加行锁
- 可能出现脏读、不可重复读、幻读
读已提交(READ COMMITTED):
- 普通读取不加锁(快照读)
- 写入加行锁
- 使用记录锁(Record Lock),不使用间隙锁
- 可能出现不可重复读、幻读
可重复读(REPEATABLE READ) - InnoDB 默认:
- 普通读取使用 MVCC(快照读)
- 写入加行锁 + 间隙锁(Next-Key Lock)
- 解决脏读、不可重复读、幻读
串行化(SERIALIZABLE):
- 所有读取都加共享锁
- 写入加排他锁
- 完全串行执行,并发性能最低
-- 不同隔离级别下的锁行为示例
-- 读已提交(RC)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT * FROM users WHERE id > 5 FOR UPDATE;
-- 只锁定 id > 5 的已存在记录(id=10)
-- 其他事务可以在 (5, 10) 和 (10, +∞) 插入新记录
-- 可重复读(RR)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT * FROM users WHERE id > 5 FOR UPDATE;
-- 锁定 (5, +∞) 的所有间隙和记录
-- 其他事务不能在 (5, +∞) 范围内插入新记录二、InnoDB 为什么会出现锁冲突
InnoDB 为了保证事务隔离,会在读写过程中加不同类型的锁。实际排障里最常遇到的是:
- 行锁: 命中索引记录时锁住具体行
- 间隙锁: 锁住索引区间,避免幻读
- Next-Key Lock: 行锁和间隙锁的组合
- 意向锁: 表级协调锁,用来表达"事务即将在某些行上加锁"
所以"看起来只更新一行"的 SQL,如果索引没命中,或者命中了范围条件,最后锁住的可能不是一行,而是一段索引范围。
2.1 锁冲突的本质
锁冲突的本质是资源竞争: 多个事务同时请求同一资源的锁,且锁类型不兼容。
锁兼容性矩阵:
| 锁类型 | 共享锁(S) | 排他锁(X) |
|---|---|---|
| 共享锁(S) | 兼容 | 冲突 |
| 排他锁(X) | 冲突 | 冲突 |
2.2 锁等待与超时
当一个事务请求锁但被阻塞时,会等待锁释放。MySQL 提供了参数控制等待时间:
-- 查看锁等待超时时间(默认 50 秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 设置锁等待超时时间
SET innodb_lock_wait_timeout = 30; -- 30 秒
-- 超时后报错
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction优化建议:
- 根据业务实际情况设置合理的超时时间
- 超时时间太短可能导致事务频繁失败
- 超时时间太长可能导致请求堆积
三、锁冲突和死锁的区别
3.1 概念对比
锁冲突:
- 一个事务拿到锁,其他事务只能等待
- 是正常的并发现象
- MySQL 不会自动干预,除非超时
死锁:
- 多个事务彼此等待对方释放锁,形成循环依赖
- 是异常情况
- MySQL 会自动检测并回滚其中一个事务
3.2 图解死锁
事务 A: 持有锁(资源 1) → 等待锁(资源 2)
↑
|
事务 B: 持有锁(资源 2) ← 等待锁(资源 1)死锁的四个必要条件:
- 互斥条件: 资源同一时间只能被一个事务占用
- 请求与保持条件: 事务持有资源的同时请求新资源
- 不剥夺条件: 已分配的资源不能被强制剥夺
- 循环等待条件: 存在事务的循环等待链
3.3 MySQL 的死锁检测
MySQL 提供了参数控制死锁检测:
-- 查看死锁检测开关(默认开启)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- 关闭死锁检测(不推荐)
SET GLOBAL innodb_deadlock_detect = OFF;死锁检测的工作原理:
- InnoDB 内部维护了一个锁等待图(Wait-for Graph)
- 定期检测图中是否存在环
- 发现环后,选择一个事务回滚(通常选择插入/更新最少行的事务)
为什么有人关闭死锁检测?
- 在高并发场景下,死锁检测本身有性能开销
- 如果业务能接受锁等待超时,可以关闭死锁检测
- 不推荐关闭,应该优化业务逻辑避免死锁
四、常见锁冲突场景
4.1 场景一:长事务占锁
一个事务里同时包含查询、更新、远程调用、循环处理、消息发送,导致锁持有时间被业务逻辑拉长。
典型症状:
SHOW PROCESSLIST出现大量Waiting for lock- 数据库连接池被占满,请求开始级联超时
- 上游服务重试后,进一步放大数据库压力
危险写法示例:
BEGIN;
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 这里夹杂 RPC、库存检查、优惠计算、发送消息
-- 耗时可能长达数秒
UPDATE orders SET status = 'PAID' WHERE id = 1001;
COMMIT;问题分析:
- 问题不在
FOR UPDATE本身,而在于事务里放了太多非数据库动作 - 锁持有时间 = 业务逻辑执行时间,可能长达数秒
- 在高并发下,会导致严重的锁等待
优化方案:
-- 方案1:缩小事务范围,只包含必要的数据库操作
BEGIN;
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
UPDATE orders SET status = 'PAID' WHERE id = 1001;
COMMIT;
-- 将 RPC、消息发送等移到事务外
-- 方案2:使用乐观锁,减少锁持有时间
SELECT version, status FROM orders WHERE id = 1001;
-- 执行业务逻辑(无锁)
BEGIN;
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE id = 1001 AND version = 查询到的version;
COMMIT;4.2 场景二:范围更新导致锁面扩大
UPDATE orders
SET status = 'CANCELLED'
WHERE user_id = 1001 AND status = 'INIT';问题分析:
如果 user_id, status 没有合适的联合索引,数据库可能需要扫描大量记录,最终锁住更大范围。结果是:
- 本意只想取消用户的一部分订单
- 实际却把同一范围内其他并发更新也堵住
示例:
-- 表结构
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
status VARCHAR(20),
amount DECIMAL(10, 2),
INDEX idx_user (user_id) -- 只有 user_id 的单列索引
);
-- 数据:用户 1001 有 1000 个订单
-- 事务 A
BEGIN;
UPDATE orders SET status = 'CANCELLED'
WHERE user_id = 1001 AND status = 'INIT';
-- 需要扫描 user_id=1001 的所有订单
-- 在可重复读隔离级别下,可能锁定大量记录和间隙
-- 事务 B(同时执行)
BEGIN;
UPDATE orders SET status = 'PAID'
WHERE user_id = 1001 AND id = 500;
-- 阻塞,因为被事务 A 的锁覆盖
COMMIT;优化方案:
-- 方案1:创建联合索引
CREATE INDEX idx_user_status ON orders(user_id, status);
-- 方案2:分批更新
-- 每次只更新一小批,减少锁范围
UPDATE orders
SET status = 'CANCELLED'
WHERE user_id = 1001 AND status = 'INIT'
LIMIT 100;
-- 方案3:使用主键范围更新
-- 先查询出需要更新的主键
SELECT id FROM orders
WHERE user_id = 1001 AND status = 'INIT';
-- 然后按主键更新
UPDATE orders SET status = 'CANCELLED' WHERE id IN (1001, 1002, ...);4.3 场景三:热点行竞争
库存、账户余额、优惠券计数、全局序号等数据经常会落到单行高频更新:
UPDATE stock
SET available = available - 1
WHERE sku_id = 9001 AND available > 0;在秒杀、抢券、扣库存场景下,大量事务会竞争同一行,导致:
- 行锁等待时间快速累积
- 应用侧出现明显抖动
- 数据库层吞吐受单行串行更新限制
性能瓶颈分析:
- MySQL 单行更新的 QPS 上限约 2000-5000(取决于硬件)
- 秒杀场景下可能达到数万甚至数十万 TPS
- 单行热点成为系统瓶颈
优化方案:
方案1:库存分桶
-- 创建多个库存桶
CREATE TABLE stock_bucket (
id INT PRIMARY KEY,
sku_id INT,
bucket_no INT, -- 桶编号
available INT,
INDEX idx_sku_bucket (sku_id, bucket_no)
);
-- 初始化:sku_id=9001 的库存分散到 10 个桶
INSERT INTO stock_bucket VALUES
(1, 9001, 1, 100),
(2, 9001, 2, 100),
...
(10, 9001, 10, 100);
-- 扣库存时随机选择一个桶
UPDATE stock_bucket
SET available = available - 1
WHERE sku_id = 9001 AND bucket_no = FLOOR(1 + RAND() * 10)
AND available > 0;
-- 查询总库存时汇总
SELECT SUM(available) FROM stock_bucket WHERE sku_id = 9001;方案2:使用缓存预扣减
// Redis 预扣库存
public boolean deductStock(Long skuId) {
String key = "stock:" + skuId;
Long remaining = redisTemplate.opsForValue().decrement(key);
if (remaining < 0) {
redisTemplate.opsForValue().increment(key);
return false; // 库存不足
}
// 异步同步到数据库
asyncUpdateDatabase(skuId);
return true;
}
// 定期异步同步到数据库
@Scheduled(fixedRate = 1000)
public void syncStockToDatabase() {
// 批量更新数据库
batchUpdateStock();
}方案3:乐观锁
-- 查询当前库存
SELECT id, available, version FROM stock WHERE sku_id = 9001;
-- 扣减库存(使用版本号)
UPDATE stock
SET available = available - 1, version = version + 1
WHERE sku_id = 9001 AND version = 查询到的version AND available > 0;
-- 如果影响行数为 0,说明并发冲突,业务重试方案4:令牌桶限流
// 使用 Guava RateLimiter 限流
RateLimiter rateLimiter = RateLimiter.create(1000); // 每秒 1000 次
public boolean deductStock(Long skuId) {
if (!rateLimiter.tryAcquire()) {
return false; // 被限流
}
// 执行数据库扣减
return doDeductStock(skuId);
}4.4 场景四:唯一索引冲突导致的锁等待
-- 表结构
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE,
email VARCHAR(100)
);
-- 事务 A
BEGIN;
INSERT INTO users(username, email) VALUES('alice', 'alice@example.com');
-- 未提交
-- 事务 B(同时执行)
BEGIN;
INSERT INTO users(username, email) VALUES('alice', 'bob@example.com');
-- 阻塞,等待唯一索引锁
COMMIT;问题分析:
- InnoDB 的唯一索引检查需要锁住间隙
- 即使插入的值不同,如果落在同一间隙,也会阻塞
- 这是 InnoDB 防止幻读的机制
优化方案:
- 应用层先检查唯一性(注意并发问题)
- 使用
INSERT IGNORE或ON DUPLICATE KEY UPDATE处理冲突
-- 使用 INSERT IGNORE 忽略重复
INSERT IGNORE INTO users(username, email) VALUES('alice', 'bob@example.com');
-- 使用 ON DUPLICATE KEY UPDATE 更新
INSERT INTO users(username, email) VALUES('alice', 'bob@example.com')
ON DUPLICATE KEY UPDATE email = VALUES(email);五、典型死锁案例
5.1 案例一:更新顺序不一致
场景: 转账业务,两个用户互相转账
事务 A:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 执行其他业务逻辑
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;事务 B(同时执行):
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2;
-- 执行其他业务逻辑
UPDATE account SET balance = balance + 50 WHERE id = 1;
COMMIT;死锁分析:
- 事务 A 先锁住
id = 1 - 事务 B 先锁住
id = 2 - 事务 A 尝试锁
id = 2,等待 B 释放 - 事务 B 尝试锁
id = 1,等待 A 释放 - 形成循环等待 → 死锁
解决方案:
-- 统一加锁顺序:都按 id 从小到大加锁
BEGIN;
-- 先获取两个账户的锁,按 id 排序
SELECT * FROM account WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- 然后执行转账
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;优化建议:
- 涉及多行更新时,统一按主键顺序加锁
- 或者在业务层保证操作顺序一致
- 使用分布式锁在应用层串行化操作
5.2 案例二:先查后改与批量处理混用
事务 A:
BEGIN;
SELECT id FROM orders
WHERE status = 'INIT'
ORDER BY id
LIMIT 100
FOR UPDATE;
-- 处理这批订单
UPDATE orders SET status = 'PROCESSING' WHERE id IN (选中的id);
COMMIT;事务 B(同时执行):
BEGIN;
SELECT id FROM orders
WHERE status = 'INIT'
ORDER BY create_time -- 不同的排序字段
LIMIT 100
FOR UPDATE;
-- 处理这批订单
UPDATE orders SET status = 'PROCESSING' WHERE id IN (选中的id);
COMMIT;死锁分析:
- 事务 A 按 id 排序获取锁
- 事务 B 按 create_time 排序获取锁
- 两个事务锁定记录的顺序不同
- 可能出现交叉等待 → 死锁
解决方案:
-- 方案1:统一排序规则
-- 所有批量查询都使用相同的排序字段(如主键)
SELECT id FROM orders
WHERE status = 'INIT'
ORDER BY id
LIMIT 100
FOR UPDATE;
-- 方案2:使用分布式锁
-- 在应用层加分布式锁,保证同一时间只有一个批量任务执行
String lockKey = "batch_process_orders";
if (redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS)) {
try {
// 执行批量处理
} finally {
redisLock.unlock(lockKey);
}
}
-- 方案3:跳过锁策略
-- 使用 SKIP LOCKED 跳过已被锁定的记录(MySQL 8.0+)
SELECT id FROM orders
WHERE status = 'INIT'
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;5.3 案例三:插入与更新冲突
场景: 订单插入与状态更新并发执行
事务 A(插入订单):
BEGIN;
INSERT INTO orders(id, user_id, status, amount)
VALUES(1001, 100, 'INIT', 100.00);
COMMIT;事务 B(更新订单状态):
BEGIN;
UPDATE orders SET status = 'CANCELLED'
WHERE user_id = 100 AND status = 'INIT';
COMMIT;死锁分析:
- 事务 A 插入新记录,需要获取插入位置的间隙锁
- 事务 B 更新范围,需要获取 Next-Key Lock
- 两者可能互相等待 → 死锁
解决方案:
-- 方案1:缩小更新范围
-- 查询出具体的订单 id,然后按主键更新
SELECT id FROM orders
WHERE user_id = 100 AND status = 'INIT';
UPDATE orders SET status = 'CANCELLED' WHERE id IN (具体id列表);
-- 方案2:调整隔离级别
-- 使用读已提交隔离级别,避免间隙锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 方案3:业务层面控制
-- 插入和批量更新错峰执行5.4 案例四:外键约束导致的死锁
场景: 父子表关联更新
-- 表结构
CREATE TABLE orders (
id INT PRIMARY KEY,
status VARCHAR(20),
KEY idx_status (status)
);
CREATE TABLE order_items (
id INT PRIMARY KEY,
order_id INT,
amount DECIMAL(10, 2),
FOREIGN KEY (order_id) REFERENCES orders(id)
);
-- 事务 A
BEGIN;
UPDATE orders SET status = 'CANCELLED' WHERE id = 1001;
-- 需要检查子表 order_items 是否有关联记录
COMMIT;
-- 事务 B
BEGIN;
INSERT INTO order_items(id, order_id, amount) VALUES(1, 1001, 100.00);
-- 需要检查父表 orders 对应记录
COMMIT;死锁分析:
- 外键约束会在父表和子表之间自动加锁
- 事务 A 锁父表,等待检查子表
- 事务 B 锁子表,等待检查父表
- 可能形成死锁
解决方案:
-- 方案1:避免使用外键约束
-- 在应用层维护数据一致性
-- 适合高并发场景
-- 方案2:统一操作顺序
-- 先操作父表,再操作子表
-- 所有涉及父子表的事务都遵循这个顺序
-- 方案3:使用存储过程封装操作
-- 在存储过程中使用事务和锁保证顺序
DELIMITER //
CREATE PROCEDURE update_order_with_items(
IN p_order_id INT,
IN p_status VARCHAR(20)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
UPDATE orders SET status = p_status WHERE id = p_order_id;
-- 其他操作
COMMIT;
END //
DELIMITER ;5.5 案例五:自增主键间隙锁死锁
场景: 并发插入数据
-- 表结构
CREATE TABLE logs (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT,
action VARCHAR(50),
KEY idx_user (user_id)
);
-- 数据:id = 1, 5, 10
-- 事务 A
BEGIN;
INSERT INTO logs(user_id, action) VALUES(100, 'login');
-- 在间隙 (1, 5) 插入,获取 id=2
-- 事务 B
BEGIN;
INSERT INTO logs(user_id, action) VALUES(200, 'logout');
-- 也在间隙 (1, 5) 插入,获取 id=3
-- 事务 C
BEGIN;
SELECT * FROM logs WHERE id > 1 AND id < 5 FOR UPDATE;
-- 尝试锁定间隙 (1, 5)死锁分析:
- 自增主键分配和间隙锁之间存在竞争
- 多个插入事务可能同时获取同一间隙的锁
- 可能形成复杂的锁等待关系
解决方案:
-- 方案1:使用顺序自增
-- innodb_autoinc_lock_mode = 0 (传统模式)
-- 所有插入都串行,避免间隙锁冲突(性能低)
-- 方案2:使用 UUID 或雪花算法生成主键
-- 避免自增主键的间隙问题
CREATE TABLE logs (
id BIGINT PRIMARY KEY, -- 使用雪花算法生成
user_id INT,
action VARCHAR(50)
);
-- 方案3:批量插入使用单个事务
-- 减少并发插入的事务数量
BEGIN;
INSERT INTO logs(user_id, action) VALUES
(100, 'login'), (200, 'logout'), (300, 'click');
COMMIT;六、实战排查思路
6.1 先判断是锁等待还是死锁
死锁特征:
- 应用日志通常直接报
Deadlock found when trying to get lock - MySQL 会回滚其中一个事务
- 错误码:1213
// Java 应用捕获死锁异常
try {
orderService.processOrder(orderId);
} catch (DeadlockLoserDataAccessException e) {
// 死锁异常,可以重试
log.warn("Deadlock detected, retrying...", e);
orderService.processOrder(orderId); // 重试
}锁等待特征:
- 更常见的是
Lock wait timeout exceeded - 等待超时后报错
- 错误码:1205
// Java 应用捕获锁等待超时异常
try {
orderService.processOrder(orderId);
} catch (LockTimeoutException e) {
// 锁等待超时
log.error("Lock wait timeout", e);
throw new BusinessException("系统繁忙,请稍后重试");
}排查优先级:
- 死锁: 先看循环依赖链,优化事务逻辑
- 锁等待: 先找到谁长时间持锁不放,优化长事务
6.2 使用 SHOW ENGINE INNODB STATUS
排查死锁时先执行:
SHOW ENGINE INNODB STATUS\G重点关注信息:
LATEST DETECTED DEADLOCK
------------------------
2026-03-30 10:30:15 0x700012345000
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 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 100, OS thread handle 123456, query id 1000 localhost root updating
UPDATE account SET balance = balance - 100 WHERE id = 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `test`.`account`
trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 101, OS thread handle 123457, query id 1001 localhost root updating
UPDATE account SET balance = balance - 50 WHERE id = 2
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `test`.`account`
trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 5 n bits 72 index PRIMARY of table `test`.`account`
trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
*** WE ROLL BACK TRANSACTION (1)解读关键信息:
-
事务信息:
TRANSACTION 12345: 事务 IDACTIVE 2 sec: 事务已执行时间starting index read: 当前正在执行的操作
-
锁信息:
lock_mode X: 排他锁locks rec but not gap: 记录锁,不是间隙锁waiting: 正在等待锁
-
关键问题:
- 事务 1 等待事务 2 持有的锁
- 事务 2 也在等待其他锁
- 形成循环等待
实际阅读顺序:
- 先看两个事务分别执行了哪条 SQL
- 再看它们各自持有和等待的锁对象
- 最后回到业务代码,对照事务入口和更新顺序
6.3 查看当前锁等待情况
查询锁等待关系:
-- MySQL 8.0+ 使用 performance_schema
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
-- MySQL 5.7 及以下
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;查看长时间运行的事务:
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;查看当前活跃的锁:
-- MySQL 8.0+
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
THREAD_ID,
PROCESSLIST_ID
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'your_database';
-- MySQL 5.7 及以下
SELECT * FROM information_schema.innodb_locks;6.4 配套排查动作
除了 SHOW ENGINE INNODB STATUS,还要一起看:
1. SHOW PROCESSLIST
SHOW FULL PROCESSLIST;
-- 关注以下状态:
-- Waiting for table metadata lock: 等待元数据锁
-- Waiting for table level lock: 等待表锁
-- Waiting for lock: 等待行锁
-- Sending data: 可能是大查询
-- Statistics: 可能在计算执行计划2. 慢查询日志
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2; -- 超过 2 秒记录
SET GLOBAL log_queries_not_using_indexes = 'ON'; -- 记录未使用索引的查询
-- 查看慢查询日志
-- Linux: /var/log/mysql/slow.log
-- 或使用 mysqldumpslow 工具分析
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log3. 业务日志
// 在业务代码中记录事务执行时间
@Around("execution(* com.example.service.*.*(..))")
public Object logTransactionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long duration = System.currentTimeMillis() - start;
if (duration > 1000) { // 超过 1 秒记录
log.warn("Slow transaction: {} took {} ms",
joinPoint.getSignature(), duration);
}
}
}4. 索引设计检查
-- 检查表的索引
SHOW INDEX FROM orders;
-- 使用 EXPLAIN 分析 SQL
EXPLAIN UPDATE orders SET status = 'CANCELLED'
WHERE user_id = 1001 AND status = 'INIT';
-- 检查索引使用情况
SELECT
TABLE_SCHEMA,
TABLE_NAME,
INDEX_NAME,
CARDINALITY,
SEQ_IN_INDEX
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'your_database'
ORDER BY TABLE_NAME, INDEX_NAME;6.5 使用工具辅助排查
MySQL Enterprise Monitor
- 提供图形化界面监控锁等待
- 实时显示锁冲突情况
- 支持历史数据分析
Percona Toolkit
# 安装 Percona Toolkit
yum install percona-toolkit
# 使用 pt-deadlock-logger 持续监控死锁
pt-deadlock-logger h=localhost,u=root,p=password --daemonize --log /var/log/deadlock.log
# 使用 pt-query-digest 分析慢查询
pt-query-digest /var/log/mysql/slow.log监控脚本示例:
#!/bin/bash
# monitor_locks.sh - 监控锁等待情况
while true; do
echo "=== $(date) ==="
mysql -uroot -ppassword -e "
SELECT
r.trx_id waiting_trx_id,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_query blocking_query,
TIMESTAMPDIFF(SECOND, r.trx_started, NOW()) wait_seconds
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id
WHERE TIMESTAMPDIFF(SECOND, r.trx_started, NOW()) > 5;
"
sleep 10
done七、治理方式
7.1 缩短事务
核心原则: 事务内只保留必要的数据库操作,其他操作移到事务外。
优化示例:
// × 错误示例:长事务
@Transactional
public void processOrder(Long orderId) {
// 1. 查询订单
Order order = orderRepository.findById(orderId);
// 2. 检查库存(可能耗时)
boolean stockAvailable = inventoryService.checkStock(order.getSkuId());
if (!stockAvailable) {
throw new BusinessException("库存不足");
}
// 3. 调用支付接口(可能耗时,可能失败)
PaymentResult result = paymentService.pay(order);
// 4. 发送消息通知(可能耗时)
notificationService.sendNotification(order);
// 5. 更新订单状态
order.setStatus("PAID");
orderRepository.save(order);
}
// √ 正确示例:缩短事务
public void processOrder(Long orderId) {
// 事务外操作
Order order = orderRepository.findById(orderId);
boolean stockAvailable = inventoryService.checkStock(order.getSkuId());
if (!stockAvailable) {
throw new BusinessException("库存不足");
}
PaymentResult result = paymentService.pay(order);
// 只在事务内执行必要的数据库操作
updateOrderStatus(orderId, "PAID");
// 异步发送通知
notificationService.sendNotificationAsync(order);
}
@Transactional
public void updateOrderStatus(Long orderId, String status) {
orderRepository.updateStatus(orderId, status);
}批量处理优化:
// × 错误示例:大批量一次性处理
@Transactional
public void batchUpdateOrders(List<Long> orderIds) {
List<Order> orders = orderRepository.findByIds(orderIds);
for (Order order : orders) {
order.setStatus("PROCESSING");
// 其他复杂逻辑
}
orderRepository.saveAll(orders);
}
// √ 正确示例:分批处理
public void batchUpdateOrders(List<Long> orderIds) {
// 分批处理,每批 100 条
Lists.partition(orderIds, 100).forEach(batch -> {
processBatch(batch);
});
}
@Transactional
public void processBatch(List<Long> batch) {
List<Order> orders = orderRepository.findByIds(batch);
orders.forEach(order -> order.setStatus("PROCESSING"));
orderRepository.saveAll(orders);
}7.2 统一加锁顺序
核心原则: 涉及多行、多表资源时,统一按照相同顺序访问。
实现方案:
// × 错误示例:不同业务逻辑,加锁顺序不一致
// 业务 A:从账户 1 转账到账户 2
public void transferA(Long fromId, Long toId, BigDecimal amount) {
accountRepository.deductBalance(fromId, amount);
accountRepository.addBalance(toId, amount);
}
// 业务 B:从账户 2 转账到账户 1
public void transferB(Long fromId, Long toId, BigDecimal amount) {
accountRepository.deductBalance(fromId, amount);
accountRepository.addBalance(toId, amount);
}
// √ 正确示例:统一加锁顺序
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 按 ID 排序,保证加锁顺序一致
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
// 先锁定 ID 小的账户
accountRepository.lockById(firstId);
// 再锁定 ID 大的账户
accountRepository.lockById(secondId);
// 执行转账逻辑
if (fromId < toId) {
accountRepository.deductBalance(fromId, amount);
accountRepository.addBalance(toId, amount);
} else {
accountRepository.addBalance(toId, amount);
accountRepository.deductBalance(fromId, amount);
}
}使用框架强制顺序:
// 使用 AOP 强制按顺序加锁
@Aspect
@Component
public class LockOrderAspect {
@Around("@annotation(orderedLock)")
public Object enforceLockOrder(ProceedingJoinPoint joinPoint, OrderedLock orderedLock) throws Throwable {
// 获取方法参数中的 ID 列表
Object[] args = joinPoint.getArgs();
List<Long> ids = extractIds(args);
// 排序后按顺序加锁
Collections.sort(ids);
for (Long id : ids) {
lockManager.lock(id);
}
try {
return joinPoint.proceed();
} finally {
// 按相反顺序释放锁
Collections.reverse(ids);
for (Long id : ids) {
lockManager.unlock(id);
}
}
}
}
// 使用注解标记需要按顺序加锁的方法
@OrderedLock
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 业务逻辑
}7.3 用索引缩小锁范围
核心原则: 确保更新条件有合适的索引,避免全表扫描和大规模锁。
索引设计最佳实践:
-- × 错误示例:更新条件无索引
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
status VARCHAR(20),
create_time DATETIME
);
UPDATE orders SET status = 'CANCELLED'
WHERE user_id = 1001 AND status = 'INIT';
-- 需要全表扫描,锁住大量记录
-- √ 正确示例:创建合适的索引
CREATE INDEX idx_user_status ON orders(user_id, status);
UPDATE orders SET status = 'CANCELLED'
WHERE user_id = 1001 AND status = 'INIT';
-- 使用索引,只锁住满足条件的记录索引选择原则:
-
区分度高的列放在前面
- 例如:user_id 区分度高,status 区分度低
- 联合索引:(user_id, status) √
- 联合索引:(status, user_id) ×
-
覆盖索引减少回表
-- 创建覆盖索引
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
-- 查询可以使用索引覆盖
SELECT id, create_time FROM orders
WHERE user_id = 1001 AND status = 'INIT';
-- 不需要回表,性能更高- 避免索引失效
-- × 索引失效示例
-- 使用函数
UPDATE orders SET status = 'CANCELLED'
WHERE DATE(create_time) = '2026-03-30';
-- 隐式类型转换
UPDATE orders SET status = 'CANCELLED'
WHERE user_id = '1001'; -- user_id 是 INT,字符串会导致索引失效
-- 使用 OR
UPDATE orders SET status = 'CANCELLED'
WHERE user_id = 1001 OR status = 'INIT';
-- √ 正确示例
-- 使用范围查询
UPDATE orders SET status = 'CANCELLED'
WHERE create_time >= '2026-03-30 00:00:00'
AND create_time < '2026-03-31 00:00:00';
-- 类型匹配
UPDATE orders SET status = 'CANCELLED'
WHERE user_id = 1001;
-- 使用 UNION 替代 OR
UPDATE orders SET status = 'CANCELLED' WHERE user_id = 1001
UNION ALL
UPDATE orders SET status = 'CANCELLED' WHERE status = 'INIT';7.4 处理热点行
核心策略: 降低单行热点并发,分散锁竞争。
方案对比:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 库存分桶 | 库存、计数器 | 简单有效,提升并发 | 需要定期同步桶数据 |
| 缓存预扣 | 秒杀、抢购 | 性能极高,减少数据库压力 | 需要保证数据一致性 |
| 乐观锁 | 低并发冲突 | 无锁等待,实现简单 | 高冲突时重试频繁 |
| 令牌桶 | 流量削峰 | 简单易用,保护系统 | 不解决根本问题 |
库存分桶完整示例:
-- 创建库存桶表
CREATE TABLE stock_bucket (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
bucket_no INT NOT NULL,
available INT NOT NULL DEFAULT 0,
total INT NOT NULL DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_sku_bucket (sku_id, bucket_no),
KEY idx_sku (sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 初始化库存(分 10 个桶)
DELIMITER //
CREATE PROCEDURE init_stock_buckets(
IN p_sku_id BIGINT,
IN p_total_stock INT,
IN p_bucket_count INT
)
BEGIN
DECLARE i INT DEFAULT 1;
DECLARE per_bucket INT;
SET per_bucket = CEIL(p_total_stock / p_bucket_count);
WHILE i <= p_bucket_count DO
INSERT INTO stock_bucket(sku_id, bucket_no, available, total)
VALUES(p_sku_id, i, per_bucket, per_bucket);
SET i = i + 1;
END WHILE;
END //
DELIMITER ;
-- 初始化 sku_id=9001,总库存 1000,分 10 个桶
CALL init_stock_buckets(9001, 1000, 10);
-- 扣减库存(随机选择桶)
DELIMITER //
CREATE FUNCTION deduct_stock(
p_sku_id BIGINT,
p_quantity INT
) RETURNS BOOLEAN
DETERMINISTIC
BEGIN
DECLARE v_bucket_no INT;
DECLARE v_available INT;
DECLARE v_affected INT DEFAULT 0;
DECLARE retry_count INT DEFAULT 0;
DECLARE max_retry INT DEFAULT 3;
retry_loop: LOOP
-- 随机选择一个桶
SET v_bucket_no = FLOOR(1 + RAND() * 10);
-- 检查库存
SELECT available INTO v_available
FROM stock_bucket
WHERE sku_id = p_sku_id AND bucket_no = v_bucket_no;
IF v_available >= p_quantity THEN
-- 扣减库存
UPDATE stock_bucket
SET available = available - p_quantity
WHERE sku_id = p_sku_id
AND bucket_no = v_bucket_no
AND available >= p_quantity;
SET v_affected = ROW_COUNT();
IF v_affected > 0 THEN
RETURN TRUE;
END IF;
END IF;
SET retry_count = retry_count + 1;
IF retry_count >= max_retry THEN
RETURN FALSE;
END IF;
END LOOP;
RETURN FALSE;
END //
DELIMITER ;
-- 查询总库存
SELECT sku_id, SUM(available) AS total_available
FROM stock_bucket
GROUP BY sku_id;
-- 库存同步(定期将各桶库存同步到主表)
DELIMITER //
CREATE PROCEDURE sync_stock_to_main()
BEGIN
-- 同步到主库存表
INSERT INTO stock(sku_id, available, total)
SELECT sku_id, SUM(available), SUM(total)
FROM stock_bucket
GROUP BY sku_id
ON DUPLICATE KEY UPDATE
available = VALUES(available),
total = VALUES(total);
END //
DELIMITER ;7.5 做好重试机制
核心原则: 死锁被回滚后可以重试,但前提是业务具备幂等性。
重试实现:
// 使用 Spring Retry 实现自动重试
@Retryable(
value = {DeadlockLoserDataAccessException.class, LockTimeoutException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
order.setStatus("PROCESSING");
orderRepository.save(order);
}
// 重试失败后的兜底处理
@Recover
public void recover(DeadlockLoserDataAccessException e, Long orderId) {
log.error("Deadlock retry failed for order: {}", orderId, e);
// 发送告警
alertService.sendAlert("Deadlock retry failed: " + orderId);
// 记录到待处理队列
pendingQueueService.add(orderId);
}幂等性保证:
// 使用幂等键保证重试安全
@Transactional
public void processOrder(String idempotentKey, Long orderId) {
// 检查幂等键是否已处理
if (idempotentKeyRepository.existsByKey(idempotentKey)) {
log.info("Order already processed: {}", idempotentKey);
return;
}
// 执行业务逻辑
Order order = orderRepository.findById(orderId);
order.setStatus("PROCESSING");
orderRepository.save(order);
// 记录幂等键
idempotentKeyRepository.save(new IdempotentKey(idempotentKey, orderId));
}
// 或者使用数据库唯一约束
@Transactional
public void processOrder(String idempotentKey, Long orderId) {
try {
// 插入幂等键(唯一约束)
idempotentKeyRepository.insert(idempotentKey, orderId);
// 执行业务逻辑
Order order = orderRepository.findById(orderId);
order.setStatus("PROCESSING");
orderRepository.save(order);
} catch (DuplicateKeyException e) {
log.info("Order already processed: {}", idempotentKey);
// 重复请求,直接返回
}
}重试最佳实践:
-
设置合理的重试次数
- 一般 2-3 次,避免无限重试
- 重试间隔使用指数退避(100ms → 200ms → 400ms)
-
只重试可重试的异常
- 死锁:可重试
- 锁等待超时:可重试
- 业务异常:不可重试
- 违反约束:不可重试
-
记录重试日志
- 记录重试原因、重试次数
- 监控重试频率,过高说明需要优化
-
设置兜底机制
- 重试全部失败后,记录到待处理队列
- 后续人工处理或定时任务重试
// 完整的重试和兜底机制
@Service
@Slf4j
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PendingQueueService pendingQueueService;
@Autowired
private AlertService alertService;
private static final int MAX_RETRY = 3;
public void processOrderWithRetry(Long orderId) {
int retryCount = 0;
while (retryCount < MAX_RETRY) {
try {
processOrder(orderId);
return; // 成功,直接返回
} catch (DeadlockLoserDataAccessException e) {
retryCount++;
log.warn("Deadlock detected for order: {}, retry count: {}", orderId, retryCount);
if (retryCount >= MAX_RETRY) {
// 重试失败,记录到待处理队列
log.error("Max retry reached for order: {}", orderId);
pendingQueueService.add(orderId, "DEADLOCK_RETRY_FAILED");
alertService.sendAlert("Order process failed after retry: " + orderId);
return;
}
// 指数退避
try {
Thread.sleep(100 * (1 << retryCount));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
} catch (Exception e) {
// 其他异常,不重试
log.error("Unexpected error for order: {}", orderId, e);
throw new BusinessException("处理失败");
}
}
}
@Transactional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
order.setStatus("PROCESSING");
orderRepository.save(order);
}
}八、常见误区
8.1 误区一:死锁说明数据库不稳定
错误认知:
- 死锁是数据库的 bug 或性能问题
- 数据库应该避免所有死锁
正确理解:
- 死锁是并发控制的正常现象
- 只要资源竞争存在,就可能出现死锁
- MySQL 有死锁检测机制,会自动处理
- 应该优化业务逻辑,减少死锁概率
合理目标:
- 将死锁频率控制在可接受范围(如每天 < 10 次)
- 通过监控告警及时发现死锁
- 建立重试机制,保证业务连续性
8.2 误区二:行锁一定只影响一行
错误认知:
- 使用行级锁就只会锁定一行
UPDATE ... WHERE id = 100只锁 id=100 这一行
正确理解:
-- 情况1:唯一索引等值查询,只锁一行
UPDATE users SET name = 'Alice' WHERE id = 100;
-- 只锁 id=100 这一行
-- 情况2:范围查询,锁定多行和间隙
UPDATE users SET status = 'ACTIVE' WHERE id > 100 AND id < 200;
-- 锁定 id 在 (100, 200) 的所有行和间隙
-- 情况3:无索引,锁定整个表
UPDATE users SET status = 'ACTIVE' WHERE name = 'Alice';
-- name 列无索引,锁定整个表
-- 情况4:间隙锁,锁定间隙
SELECT * FROM users WHERE id > 100 FOR UPDATE;
-- 锁定 id > 100 的所有行和间隙排查方法:
-- 查看当前事务持有的锁
SELECT * FROM performance_schema.data_locks
WHERE THREAD_ID = CONNECTION_ID();8.3 误区三:只要加重试就够了
错误认知:
- 所有异常都可以通过重试解决
- 重试次数越多越好
正确理解:
重试适用场景:
- 死锁:可重试
- 锁等待超时:可重试
- 网络超时:可重试
重试不适用场景:
- 业务异常(如余额不足)
- 违反唯一约束
- 外键约束失败
- SQL 语法错误
重试的风险:
- 无限重试可能导致雪崩
- 重试风暴加剧锁冲突
- 幂等性问题导致重复执行
最佳实践:
// √ 正确示例:有限重试 + 幂等保证
@Retryable(
value = {DeadlockLoserDataAccessException.class},
maxAttempts = 3, // 最多 3 次
backoff = @Backoff(delay = 100, multiplier = 2) // 指数退避
)
@Transactional
public void processOrder(String idempotentKey, Long orderId) {
// 幂等性检查
if (isProcessed(idempotentKey)) {
return;
}
// 业务逻辑
doProcess(orderId);
// 记录幂等键
markAsProcessed(idempotentKey);
}8.4 误区四:把所有业务都包进事务最安全
错误认知:
- 事务范围越大,数据越安全
- 把所有操作都放在事务里可以保证一致性
正确理解:
大事务的问题:
- 延长锁持有时间,加剧锁冲突
- 增加死锁概率
- 占用数据库连接时间长
- 回滚代价大
合理的事务范围:
// × 错误示例:事务包含所有操作
@Transactional
public void processOrder(Long orderId) {
// 1. 查询订单
Order order = orderRepository.findById(orderId);
// 2. 调用第三方接口(可能耗时数秒)
PaymentResult result = paymentService.pay(order);
// 3. 发送邮件通知
emailService.sendNotification(order);
// 4. 更新订单状态
order.setStatus("PAID");
orderRepository.save(order);
}
// √ 正确示例:缩小事务范围
public void processOrder(Long orderId) {
// 事务外操作
Order order = orderRepository.findById(orderId);
PaymentResult result = paymentService.pay(order);
emailService.sendNotification(order);
// 只在事务内执行必要的数据库操作
updateOrderStatus(orderId, "PAID");
}
@Transactional
public void updateOrderStatus(Long orderId, String status) {
orderRepository.updateStatus(orderId, status);
}最终一致性方案:
对于跨服务的业务,使用最终一致性方案:
// 使用消息队列实现最终一致性
public void processOrder(Long orderId) {
// 1. 本地事务:创建订单,发送消息(事务消息)
Order order = createOrder(orderId);
// 2. 发送消息到 MQ
messageQueueService.send("order.paid", order);
// 3. 其他服务消费消息,执行相应逻辑
// 库存服务:扣减库存
// 通知服务:发送通知
}
// 库存服务消费消息
@RabbitListener(queues = "order.paid")
public void handleOrderPaid(Order order) {
inventoryService.deductStock(order.getSkuId(), order.getQuantity());
}8.5 误区五:读操作不会加锁
错误认知:
- 只有写操作才会加锁
- 读操作永远不会导致锁冲突
正确理解:
读操作也会加锁的情况:
-- 1. LOCK IN SHARE MODE(共享锁)
SELECT * FROM orders WHERE id = 1001 LOCK IN SHARE MODE;
-- 加共享锁,会阻塞写操作
-- 2. FOR UPDATE(排他锁)
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 加排他锁,会阻塞所有操作
-- 3. 串行化隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM orders WHERE id = 1001;
-- 自动加共享锁快照读与当前读:
-- 快照读(不加锁)
SELECT * FROM orders WHERE id = 1001;
-- 读的是快照数据,不加锁
-- 当前读(加锁)
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 读的是最新数据,加排他锁
UPDATE orders SET status = 'PAID' WHERE id = 1001;
-- 当前读,加排他锁读取导致死锁的案例:
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE id = 1001 LOCK IN SHARE MODE;
-- 加共享锁
-- 事务 B
BEGIN;
SELECT * FROM orders WHERE id = 1001 LOCK IN SHARE MODE;
-- 也加共享锁,兼容
-- 事务 A 继续
UPDATE orders SET status = 'PAID' WHERE id = 1001;
-- 需要排他锁,等待 B 释放共享锁
-- 事务 B 继续
UPDATE orders SET status = 'CANCELLED' WHERE id = 1001;
-- 也需要排他锁,等待 A 释放共享锁
-- 死锁!8.6 误区六:关闭死锁检测能提升性能
错误认知:
- 死锁检测有性能开销,关闭可以提升性能
- 用锁等待超时替代死锁检测即可
正确理解:
关闭死锁检测的风险:
- 死锁会一直等待,直到锁等待超时(默认 50 秒)
- 事务长时间占用连接,导致连接池耗尽
- 请求堆积,系统雪崩
死锁检测的开销:
- InnoDB 的死锁检测算法已经优化得很好
- 开销通常可以忽略不计
- 只有在极高并发场景下才有明显开销
合理方案:
-- √ 保持死锁检测开启
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- Value: ON
-- √ 设置合理的锁等待超时时间
SET innodb_lock_wait_timeout = 10; -- 10 秒
-- √ 在应用层做好重试
-- 见 7.5 节重试机制何时考虑关闭死锁检测:
- 系统已经优化到极致,仍有性能瓶颈
- 死锁检测确实成为瓶颈(通过性能分析确认)
- 有完善的监控和重试机制
- 业务可以接受锁等待超时
九、面试要点
9.1 基础知识类
1. MySQL 有哪些锁类型?分别是什么作用?
答案:
按粒度划分:
- 表级锁: 锁定整张表,开销小,并发度低,MyISAM 使用
- 行级锁: 锁定数据行,开销大,并发度高,InnoDB 使用
- 页面锁: 锁定一组数据行,介于表锁和行锁之间
按类型划分:
- 共享锁(S锁): 允许多个事务同时读,但禁止写
- 排他锁(X锁): 独占资源,禁止其他事务读写
InnoDB 特有:
- 间隙锁: 锁定索引记录之间的间隙,防止幻读
- Next-Key Lock: 行锁 + 间隙锁的组合
- 意向锁: 表级锁,协调行锁和表锁
2. InnoDB 的行锁是如何实现的?
答案:
InnoDB 通过给索引上的索引项加锁来实现行锁:
- 只有通过索引条件检索数据,才使用行级锁
- 否则,使用表锁
- 这意味着 UPDATE/DELETE 的 WHERE 条件必须有合适的索引
示例:
-- 通过主键更新:行锁
UPDATE users SET name = 'Alice' WHERE id = 1;
-- 无索引更新:表锁
UPDATE users SET name = 'Alice' WHERE name = 'Bob';3. 什么是间隙锁?为什么需要间隙锁?
答案:
间隙锁(Gap Lock)是 InnoDB 在可重复读隔离级别下,为了解决幻读问题而引入的锁机制。
作用:
- 锁定索引记录之间的间隙
- 防止其他事务在间隙中插入新记录
- 只在可重复读隔离级别下生效
示例:
-- 表数据:id = 1, 5, 10
BEGIN;
SELECT * FROM users WHERE id > 3 AND id < 8 FOR UPDATE;
-- 锁定间隙 (1, 5) 和 (5, 10)
INSERT INTO users VALUES(2, 'test'); -- 阻塞
INSERT INTO users VALUES(8, 'test'); -- 阻塞4. 什么是意向锁?为什么需要意向锁?
答案:
意向锁是表级锁,用于协调行锁和表锁的关系。
为什么需要:
- 当事务想获取表锁时,需要检查表中是否有行锁
- 如果没有意向锁,需要扫描整张表的每一行
- 有了意向锁,只需检查是否有对应的意向锁即可
类型:
- 意向共享锁(IS):事务想在某些行上加共享锁
- 意向排他锁(IX):事务想在某些行上加排他锁
自动获取:
- 事务获取行级共享锁前,自动获取表级 IS 锁
- 事务获取行级排他锁前,自动获取表级 IX 锁
9.2 技术深度类
5. 死锁的四个必要条件是什么?如何破坏?
答案:
四个必要条件:
- 互斥条件: 资源同一时间只能被一个事务占用
- 请求与保持条件: 事务持有资源的同时请求新资源
- 不剥夺条件: 已分配的资源不能被强制剥夺
- 循环等待条件: 存在事务的循环等待链
破坏方法:
- 破坏互斥条件: 不太可能,锁的本质就是互斥
- 破坏请求与保持条件: 一次性获取所有需要的锁
- 破坏不剥夺条件: 设置锁等待超时
- 破坏循环等待条件: 统一加锁顺序
实际应用:
- 统一加锁顺序是最有效的方法
- 一次性获取锁可以避免部分死锁
- 锁等待超时是兜底方案
6. MySQL 如何检测和处理死锁?
答案:
死锁检测:
- InnoDB 内部维护了一个锁等待图(Wait-for Graph)
- 定期检测图中是否存在环
- 发现环后,选择一个事务回滚
选择回滚事务的策略:
- 通常选择插入/更新最少行的事务
- 或者选择持有锁最少的事务
- 目标是减少回滚代价
配置参数:
-- 查看死锁检测开关
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- 关闭死锁检测(不推荐)
SET GLOBAL innodb_deadlock_detect = OFF;查看死锁信息:
SHOW ENGINE INNODB STATUS\G
-- 查看 LATEST DETECTED DEADLOCK 部分7. 不同隔离级别下的锁行为有什么区别?
答案:
| 隔离级别 | 读操作 | 写操作 | 间隙锁 | 问题 |
|---|---|---|---|---|
| 读未提交 | 不加锁 | 行锁 | 无 | 脏读、不可重复读、幻读 |
| 读已提交 | 不加锁(快照读) | 行锁 | 无 | 不可重复读、幻读 |
| 可重复读 | 不加锁(快照读) | 行锁+间隙锁 | 有 | 无 |
| 串行化 | 共享锁 | 排他锁 | 有 | 无 |
示例:
-- 读已提交(RC):只锁记录
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM users WHERE id > 5 FOR UPDATE;
-- 只锁 id > 5 的已存在记录
-- 可重复读(RR):锁记录+间隙
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM users WHERE id > 5 FOR UPDATE;
-- 锁定 (5, +∞) 的所有间隙和记录8. 为什么长事务会放大锁冲突?
答案:
原因:
- 延长了锁持有时间,扩大了并发碰撞窗口
- 锁持有期间,其他事务只能等待
- 高并发下,等待事务快速累积
示例:
-- 长事务:持有锁 5 秒
BEGIN;
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 执行业务逻辑(5 秒)
UPDATE orders SET status = 'PAID' WHERE id = 1001;
COMMIT;
-- 影响:
-- 假设 QPS=100,5 秒内有 500 个请求
-- 这 500 个请求都会被阻塞优化:
- 缩小事务范围,只包含必要的数据库操作
- 将 RPC、消息发送等移到事务外
- 分批处理大批量操作
9. 为什么统一更新顺序能减少死锁?
答案:
原理:
- 统一加锁顺序可以破坏循环等待条件
- 所有事务按相同顺序获取锁,不会形成环
示例:
-- × 不同顺序:可能死锁
-- 事务 A
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 事务 B
UPDATE account SET balance = balance - 50 WHERE id = 2;
UPDATE account SET balance = balance + 50 WHERE id = 1;
-- √ 统一顺序:不会死锁
-- 都按 id 从小到大
SELECT * FROM account WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- 然后执行转账10. SHOW ENGINE INNODB STATUS 应该重点看什么?
答案:
重点关注:
LATEST DETECTED DEADLOCK: 最近一次死锁信息- 事务执行的 SQL:了解业务逻辑
- 持有的锁:哪些资源被锁定
- 等待的锁:哪些资源在等待
阅读顺序:
- 先看两个事务分别执行了哪条 SQL
- 再看它们各自持有和等待的锁对象
- 最后回到业务代码,对照事务入口和更新顺序
示例输出解读:
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec
UPDATE account SET balance = balance - 100 WHERE id = 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X ... waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 1 sec
UPDATE account SET balance = balance - 50 WHERE id = 2
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... lock_mode X
-- 解读:
-- 事务 1 执行 UPDATE id=1,等待锁
-- 事务 2 执行 UPDATE id=2,持有锁
-- 需要看后续信息确认循环等待9.3 实战场景类
11. 热点行竞争如何优化?
答案:
问题:
- 单行高频更新导致锁竞争激烈
- 单行更新 QPS 上限约 2000-5000
优化方案:
-
库存分桶
- 将库存分散到多个桶
- 扣减时随机选择桶
- 提升并发能力
-
缓存预扣减
- Redis 预扣库存
- 异步同步到数据库
- 减少数据库压力
-
乐观锁
- 使用版本号控制
- 减少锁等待
- 适合低冲突场景
-
令牌桶限流
- 控制并发请求数
- 保护数据库
- 不解决根本问题
选择建议:
- 高并发场景:库存分桶 + 缓存预扣
- 中等并发:乐观锁
- 所有场景:限流保护
12. 如何排查线上的锁冲突问题?
答案:
排查步骤:
-
判断锁等待还是死锁
- 死锁:错误码 1213
- 锁等待超时:错误码 1205
-
查看锁等待情况
sqlSELECT * FROM information_schema.innodb_lock_waits; SELECT * FROM information_schema.innodb_trx; -
分析死锁信息
sqlSHOW ENGINE INNODB STATUS\G -- 查看 LATEST DETECTED DEADLOCK -
查看长时间事务
sqlSELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) FROM information_schema.innodb_trx ORDER BY trx_started ASC; -
检查索引设计
sqlSHOW INDEX FROM 表名; EXPLAIN UPDATE ... WHERE ...; -
结合业务日志
- 找到事务入口
- 分析事务范围
- 优化业务逻辑
13. 如何设计一个高并发的库存扣减系统?
答案:
架构设计:
-
前端层
- 限流(令牌桶)
- 答题/验证码防刷
-
应用层
- 库存预热到 Redis
- Redis 原子扣减
- 异步同步数据库
-
数据库层
- 库存分桶
- 乐观锁兜底
- 定期同步桶数据
完整流程:
1. 用户请求 → 限流检查
2. Redis 扣库存 → 成功/失败
3. 成功 → 创建订单(异步)
4. 定时任务 → 同步 Redis 到数据库
5. 数据库分桶 → 提升并发能力关键点:
- Redis 库存与数据库库存的一致性
- 异步创建订单的可靠性(消息队列)
- 库存同步的实时性
- 防超卖机制
14. 如何保证多表更新的一致性和避免死锁?
答案:
方案:
-
统一加锁顺序
java// 按 ID 排序后加锁 List<Long> ids = Arrays.asList(orderId, paymentId, inventoryId); Collections.sort(ids); for (Long id : ids) { lockManager.lock(id); } -
使用分布式锁
java// 业务层先获取分布式锁 if (redisLock.tryLock("process_order:" + orderId, 30, TimeUnit.SECONDS)) { try { // 执行多表更新 updateOrder(orderId); updatePayment(paymentId); updateInventory(inventoryId); } finally { redisLock.unlock("process_order:" + orderId); } } -
使用事务管理器
java// Spring 事务管理 @Transactional public void processOrder(Long orderId) { // 统一在事务内执行 // Spring 保证原子性 } -
最终一致性方案
java// 使用消息队列 // 1. 更新订单表(本地事务) // 2. 发送消息到 MQ // 3. 其他服务消费消息,更新各自表
选择建议:
- 单体应用:统一加锁顺序 + 数据库事务
- 微服务:分布式锁 + 最终一致性
15. 如何监控和告警数据库锁问题?
答案:
监控指标:
- 锁等待次数
- 锁等待时间
- 死锁次数
- 长事务数量
实现方案:
-
MySQL Performance Schema
sql-- 启用锁监控 UPDATE performance_schema.setup_instruments SET ENABLED = 'YES', TIMED = 'YES' WHERE NAME LIKE 'wait/lock%'; -- 查询锁统计 SELECT * FROM performance_schema.events_waits_summary_by_instance WHERE EVENT_NAME LIKE 'wait/lock%'; -
自定义监控脚本
bash#!/bin/bash # 定期检查锁等待 while true; do mysql -e " SELECT COUNT(*) AS lock_wait_count FROM information_schema.innodb_lock_waits; " | mail -s "Lock Wait Alert" admin@example.com sleep 60 done -
Prometheus + Grafana
yaml# prometheus.yml - job_name: 'mysql' static_configs: - targets: ['localhost:9104'] # Grafana Dashboard - 锁等待次数图表 - 锁等待时间图表 - 死锁次数图表 -
告警规则
- 锁等待次数 > 10/min:警告
- 锁等待次数 > 50/min:严重
- 死锁次数 > 5/min:警告
- 死锁次数 > 20/min:严重
十、最佳实践总结
10.1 设计层面
-
合理设计索引
- UPDATE/DELETE 的 WHERE 条件必须有合适的索引
- 联合索引遵循最左前缀原则
- 避免索引失效
-
控制事务范围
- 事务内只包含必要的数据库操作
- RPC、消息发送等移到事务外
- 大批量操作分批处理
-
统一加锁顺序
- 涉及多表、多行更新时,统一加锁顺序
- 按主键从小到大排序
- 在应用层强制执行
10.2 开发层面
-
避免长事务
java// √ 推荐:小事务 @Transactional public void updateOrderStatus(Long orderId, String status) { orderRepository.updateStatus(orderId, status); } // × 避免:大事务 @Transactional public void processOrder(Long orderId) { // 包含大量业务逻辑 } -
使用乐观锁
java// 低冲突场景使用乐观锁 @Transactional public boolean deductStock(Long skuId, Integer quantity) { Stock stock = stockRepository.findById(skuId); if (stock.getAvailable() < quantity) { return false; } int affected = stockRepository.updateWithVersion( skuId, quantity, stock.getVersion() ); return affected > 0; } -
做好重试机制
java@Retryable( value = DeadlockLoserDataAccessException.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2) ) @Transactional public void processOrder(Long orderId) { // 业务逻辑 }
10.3 运维层面
-
监控锁指标
- 锁等待次数
- 锁等待时间
- 死锁次数
- 长事务数量
-
设置合理参数
sql-- 锁等待超时时间 SET innodb_lock_wait_timeout = 10; -- 死锁检测(保持开启) SET innodb_deadlock_detect = ON; -- 事务隔离级别 SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -
定期排查
- 定期检查慢查询日志
- 定期检查长事务
- 定期优化索引
10.4 架构层面
-
热点数据分散
- 库存分桶
- 计数器分片
- 用户分表
-
读写分离
- 读操作走从库
- 减少主库锁压力
-
异步处理
- 非核心操作异步化
- 使用消息队列削峰
-
最终一致性
- 跨服务操作使用最终一致性
- 避免分布式事务
参考资料
- MySQL 官方文档:https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
- 《高性能 MySQL》
- 《MySQL 技术内幕:InnoDB 存储引擎》
- InnoDB 锁机制详解:https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
- MySQL 死锁检测:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html
版本差异(MySQL 5.7 → 8.0/8.4)
| 特性 | 旧版(本文编写时,MySQL 5.7) | 当前(MySQL 8.0/8.4 LTS) |
|---|---|---|
| 默认字符集 | utf8(需显式配置 utf8mb4) | utf8mb4(MySQL 8.0 起默认) |
| 索引 | 普通 B+Tree | 降序索引、隐藏索引、函数索引(8.0+) |
| SQL 能力 | 常规查询 | 递归 CTE、窗口函数(8.0+) |
| 版本策略 | 5.7 | 8.0(主流)/ 8.4 LTS / 9.x(创新版) |
| Java 驱动 | mysql-connector-java 5.x/8.0 | mysql-connector-j 8.x/9.x |
本文基于 MySQL 5.7 编写,核心概念(索引、事务、锁、MVCC、InnoDB)在 8.0/8.4 中依然适用;8.0 的默认字符集、隐藏索引与 SQL 增强(CTE/窗口函数)是升级后的主要差异。