{T}

锁冲突与死锁案例

数据库线上故障里,最难受的一类问题往往不是单条慢 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)
sql
-- 事务 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锁)

  • 又称写锁,独占资源,其他事务不能读也不能写
  • INSERTUPDATEDELETE 操作自动加排他锁
  • 手动加锁语法:SELECT ... FOR UPDATE
sql
-- 事务 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 将使用表锁
sql
-- 表结构
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 列无索引)

注意: 在实际开发中,务必确保 UPDATEDELETE 语句的 WHERE 条件有合适的索引,否则会导致表锁,严重影响并发性能。

1.3 InnoDB 的间隙锁

间隙锁(Gap Lock) 是 InnoDB 在可重复读隔离级别下,为了解决幻读问题而引入的锁机制。

什么是间隙?

  • 间隙是指索引记录之间的空隙
  • 例如,索引列有值 1、5、10,那么间隙包括:(-∞, 1)、(1, 5)、(5, 10)、(10, +∞)

间隙锁的作用:

  • 锁定一个索引记录之间的间隙
  • 防止其他事务在间隙中插入新记录
  • 只在可重复读隔离级别下生效
sql
-- 表数据: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 是行锁和间隙锁的组合,锁定一个索引记录以及该记录之前的间隙。

示例:

sql
-- 表数据: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): 事务想在某些行上加排他锁

意向锁的兼容性:

锁类型ISIXSX
IS×
IX××
S××
X××××

意向锁的自动获取:

  • 事务获取行级共享锁前,必须先获取表级意向共享锁
  • 事务获取行级排他锁前,必须先获取表级意向排他锁
sql
-- 事务 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):

  • 所有读取都加共享锁
  • 写入加排他锁
  • 完全串行执行,并发性能最低
sql
-- 不同隔离级别下的锁行为示例

-- 读已提交(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 提供了参数控制等待时间:

sql
-- 查看锁等待超时时间(默认 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 图解死锁

code
事务 A: 持有锁(资源 1) → 等待锁(资源 2)
                              ↑
                              |
事务 B: 持有锁(资源 2) ← 等待锁(资源 1)

死锁的四个必要条件:

  1. 互斥条件: 资源同一时间只能被一个事务占用
  2. 请求与保持条件: 事务持有资源的同时请求新资源
  3. 不剥夺条件: 已分配的资源不能被强制剥夺
  4. 循环等待条件: 存在事务的循环等待链

3.3 MySQL 的死锁检测

MySQL 提供了参数控制死锁检测:

sql
-- 查看死锁检测开关(默认开启)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';

-- 关闭死锁检测(不推荐)
SET GLOBAL innodb_deadlock_detect = OFF;

死锁检测的工作原理:

  • InnoDB 内部维护了一个锁等待图(Wait-for Graph)
  • 定期检测图中是否存在环
  • 发现环后,选择一个事务回滚(通常选择插入/更新最少行的事务)

为什么有人关闭死锁检测?

  • 在高并发场景下,死锁检测本身有性能开销
  • 如果业务能接受锁等待超时,可以关闭死锁检测
  • 不推荐关闭,应该优化业务逻辑避免死锁

四、常见锁冲突场景

4.1 场景一:长事务占锁

一个事务里同时包含查询、更新、远程调用、循环处理、消息发送,导致锁持有时间被业务逻辑拉长。

典型症状:

  • SHOW PROCESSLIST 出现大量 Waiting for lock
  • 数据库连接池被占满,请求开始级联超时
  • 上游服务重试后,进一步放大数据库压力

危险写法示例:

sql
BEGIN;
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 这里夹杂 RPC、库存检查、优惠计算、发送消息
-- 耗时可能长达数秒
UPDATE orders SET status = 'PAID' WHERE id = 1001;
COMMIT;

问题分析:

  • 问题不在 FOR UPDATE 本身,而在于事务里放了太多非数据库动作
  • 锁持有时间 = 业务逻辑执行时间,可能长达数秒
  • 在高并发下,会导致严重的锁等待

优化方案:

sql
-- 方案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 场景二:范围更新导致锁面扩大

sql
UPDATE orders
SET status = 'CANCELLED'
WHERE user_id = 1001 AND status = 'INIT';

问题分析: 如果 user_id, status 没有合适的联合索引,数据库可能需要扫描大量记录,最终锁住更大范围。结果是:

  • 本意只想取消用户的一部分订单
  • 实际却把同一范围内其他并发更新也堵住

示例:

sql
-- 表结构
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;

优化方案:

sql
-- 方案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 场景三:热点行竞争

库存、账户余额、优惠券计数、全局序号等数据经常会落到单行高频更新:

sql
UPDATE stock
SET available = available - 1
WHERE sku_id = 9001 AND available > 0;

在秒杀、抢券、扣库存场景下,大量事务会竞争同一行,导致:

  • 行锁等待时间快速累积
  • 应用侧出现明显抖动
  • 数据库层吞吐受单行串行更新限制

性能瓶颈分析:

  • MySQL 单行更新的 QPS 上限约 2000-5000(取决于硬件)
  • 秒杀场景下可能达到数万甚至数十万 TPS
  • 单行热点成为系统瓶颈

优化方案:

方案1:库存分桶

sql
-- 创建多个库存桶
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:使用缓存预扣减

java
// 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:乐观锁

sql
-- 查询当前库存
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:令牌桶限流

java
// 使用 Guava RateLimiter 限流
RateLimiter rateLimiter = RateLimiter.create(1000); // 每秒 1000 次

public boolean deductStock(Long skuId) {
    if (!rateLimiter.tryAcquire()) {
        return false; // 被限流
    }
    
    // 执行数据库扣减
    return doDeductStock(skuId);
}

4.4 场景四:唯一索引冲突导致的锁等待

sql
-- 表结构
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 IGNOREON DUPLICATE KEY UPDATE 处理冲突
sql
-- 使用 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:

sql
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 执行其他业务逻辑
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

事务 B(同时执行):

sql
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2;
-- 执行其他业务逻辑
UPDATE account SET balance = balance + 50 WHERE id = 1;
COMMIT;

死锁分析:

  1. 事务 A 先锁住 id = 1
  2. 事务 B 先锁住 id = 2
  3. 事务 A 尝试锁 id = 2,等待 B 释放
  4. 事务 B 尝试锁 id = 1,等待 A 释放
  5. 形成循环等待 → 死锁

解决方案:

sql
-- 统一加锁顺序:都按 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:

sql
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(同时执行):

sql
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 排序获取锁
  • 两个事务锁定记录的顺序不同
  • 可能出现交叉等待 → 死锁

解决方案:

sql
-- 方案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(插入订单):

sql
BEGIN;
INSERT INTO orders(id, user_id, status, amount) 
VALUES(1001, 100, 'INIT', 100.00);
COMMIT;

事务 B(更新订单状态):

sql
BEGIN;
UPDATE orders SET status = 'CANCELLED' 
WHERE user_id = 100 AND status = 'INIT';
COMMIT;

死锁分析:

  • 事务 A 插入新记录,需要获取插入位置的间隙锁
  • 事务 B 更新范围,需要获取 Next-Key Lock
  • 两者可能互相等待 → 死锁

解决方案:

sql
-- 方案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 案例四:外键约束导致的死锁

场景: 父子表关联更新

sql
-- 表结构
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 锁子表,等待检查父表
  • 可能形成死锁

解决方案:

sql
-- 方案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 案例五:自增主键间隙锁死锁

场景: 并发插入数据

sql
-- 表结构
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)

死锁分析:

  • 自增主键分配和间隙锁之间存在竞争
  • 多个插入事务可能同时获取同一间隙的锁
  • 可能形成复杂的锁等待关系

解决方案:

sql
-- 方案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
// Java 应用捕获死锁异常
try {
    orderService.processOrder(orderId);
} catch (DeadlockLoserDataAccessException e) {
    // 死锁异常,可以重试
    log.warn("Deadlock detected, retrying...", e);
    orderService.processOrder(orderId); // 重试
}

锁等待特征:

  • 更常见的是 Lock wait timeout exceeded
  • 等待超时后报错
  • 错误码:1205
java
// Java 应用捕获锁等待超时异常
try {
    orderService.processOrder(orderId);
} catch (LockTimeoutException e) {
    // 锁等待超时
    log.error("Lock wait timeout", e);
    throw new BusinessException("系统繁忙,请稍后重试");
}

排查优先级:

  • 死锁: 先看循环依赖链,优化事务逻辑
  • 锁等待: 先找到谁长时间持锁不放,优化长事务

6.2 使用 SHOW ENGINE INNODB STATUS

排查死锁时先执行:

sql
SHOW ENGINE INNODB STATUS\G

重点关注信息:

code
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)

解读关键信息:

  1. 事务信息:

    • TRANSACTION 12345: 事务 ID
    • ACTIVE 2 sec: 事务已执行时间
    • starting index read: 当前正在执行的操作
  2. 锁信息:

    • lock_mode X: 排他锁
    • locks rec but not gap: 记录锁,不是间隙锁
    • waiting: 正在等待锁
  3. 关键问题:

    • 事务 1 等待事务 2 持有的锁
    • 事务 2 也在等待其他锁
    • 形成循环等待

实际阅读顺序:

  1. 先看两个事务分别执行了哪条 SQL
  2. 再看它们各自持有和等待的锁对象
  3. 最后回到业务代码,对照事务入口和更新顺序

6.3 查看当前锁等待情况

查询锁等待关系:

sql
-- 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;

查看长时间运行的事务:

sql
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;

查看当前活跃的锁:

sql
-- 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

sql
SHOW FULL PROCESSLIST;

-- 关注以下状态:
-- Waiting for table metadata lock: 等待元数据锁
-- Waiting for table level lock: 等待表锁
-- Waiting for lock: 等待行锁
-- Sending data: 可能是大查询
-- Statistics: 可能在计算执行计划

2. 慢查询日志

sql
-- 开启慢查询日志
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.log

3. 业务日志

java
// 在业务代码中记录事务执行时间
@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. 索引设计检查

sql
-- 检查表的索引
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

bash
# 安装 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

监控脚本示例:

bash
#!/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 缩短事务

核心原则: 事务内只保留必要的数据库操作,其他操作移到事务外。

优化示例:

java
// × 错误示例:长事务
@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);
}

批量处理优化:

java
// × 错误示例:大批量一次性处理
@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 统一加锁顺序

核心原则: 涉及多行、多表资源时,统一按照相同顺序访问。

实现方案:

java
// × 错误示例:不同业务逻辑,加锁顺序不一致
// 业务 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);
    }
}

使用框架强制顺序:

java
// 使用 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 用索引缩小锁范围

核心原则: 确保更新条件有合适的索引,避免全表扫描和大规模锁。

索引设计最佳实践:

sql
-- × 错误示例:更新条件无索引
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';
-- 使用索引,只锁住满足条件的记录

索引选择原则:

  1. 区分度高的列放在前面

    • 例如:user_id 区分度高,status 区分度低
    • 联合索引:(user_id, status) √
    • 联合索引:(status, user_id) ×
  2. 覆盖索引减少回表

sql
-- 创建覆盖索引
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';
-- 不需要回表,性能更高
  1. 避免索引失效
sql
-- × 索引失效示例
-- 使用函数
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 处理热点行

核心策略: 降低单行热点并发,分散锁竞争。

方案对比:

方案适用场景优点缺点
库存分桶库存、计数器简单有效,提升并发需要定期同步桶数据
缓存预扣秒杀、抢购性能极高,减少数据库压力需要保证数据一致性
乐观锁低并发冲突无锁等待,实现简单高冲突时重试频繁
令牌桶流量削峰简单易用,保护系统不解决根本问题

库存分桶完整示例:

sql
-- 创建库存桶表
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 做好重试机制

核心原则: 死锁被回滚后可以重试,但前提是业务具备幂等性。

重试实现:

java
// 使用 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);
}

幂等性保证:

java
// 使用幂等键保证重试安全
@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);
        // 重复请求,直接返回
    }
}

重试最佳实践:

  1. 设置合理的重试次数

    • 一般 2-3 次,避免无限重试
    • 重试间隔使用指数退避(100ms → 200ms → 400ms)
  2. 只重试可重试的异常

    • 死锁:可重试
    • 锁等待超时:可重试
    • 业务异常:不可重试
    • 违反约束:不可重试
  3. 记录重试日志

    • 记录重试原因、重试次数
    • 监控重试频率,过高说明需要优化
  4. 设置兜底机制

    • 重试全部失败后,记录到待处理队列
    • 后续人工处理或定时任务重试
java
// 完整的重试和兜底机制
@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 这一行

正确理解:

sql
-- 情况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 的所有行和间隙

排查方法:

sql
-- 查看当前事务持有的锁
SELECT * FROM performance_schema.data_locks 
WHERE THREAD_ID = CONNECTION_ID();

8.3 误区三:只要加重试就够了

错误认知:

  • 所有异常都可以通过重试解决
  • 重试次数越多越好

正确理解:

重试适用场景:

  • 死锁:可重试
  • 锁等待超时:可重试
  • 网络超时:可重试

重试不适用场景:

  • 业务异常(如余额不足)
  • 违反唯一约束
  • 外键约束失败
  • SQL 语法错误

重试的风险:

  • 无限重试可能导致雪崩
  • 重试风暴加剧锁冲突
  • 幂等性问题导致重复执行

最佳实践:

java
// √ 正确示例:有限重试 + 幂等保证
@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 误区四:把所有业务都包进事务最安全

错误认知:

  • 事务范围越大,数据越安全
  • 把所有操作都放在事务里可以保证一致性

正确理解:

大事务的问题:

  • 延长锁持有时间,加剧锁冲突
  • 增加死锁概率
  • 占用数据库连接时间长
  • 回滚代价大

合理的事务范围:

java
// × 错误示例:事务包含所有操作
@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);
}

最终一致性方案:

对于跨服务的业务,使用最终一致性方案:

java
// 使用消息队列实现最终一致性
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 误区五:读操作不会加锁

错误认知:

  • 只有写操作才会加锁
  • 读操作永远不会导致锁冲突

正确理解:

读操作也会加锁的情况:

sql
-- 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;
-- 自动加共享锁

快照读与当前读:

sql
-- 快照读(不加锁)
SELECT * FROM orders WHERE id = 1001;
-- 读的是快照数据,不加锁

-- 当前读(加锁)
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 读的是最新数据,加排他锁

UPDATE orders SET status = 'PAID' WHERE id = 1001;
-- 当前读,加排他锁

读取导致死锁的案例:

sql
-- 事务 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 的死锁检测算法已经优化得很好
  • 开销通常可以忽略不计
  • 只有在极高并发场景下才有明显开销

合理方案:

sql
-- √ 保持死锁检测开启
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 条件必须有合适的索引

示例:

sql
-- 通过主键更新:行锁
UPDATE users SET name = 'Alice' WHERE id = 1;

-- 无索引更新:表锁
UPDATE users SET name = 'Alice' WHERE name = 'Bob';

3. 什么是间隙锁?为什么需要间隙锁?

答案:

间隙锁(Gap Lock)是 InnoDB 在可重复读隔离级别下,为了解决幻读问题而引入的锁机制。

作用:

  • 锁定索引记录之间的间隙
  • 防止其他事务在间隙中插入新记录
  • 只在可重复读隔离级别下生效

示例:

sql
-- 表数据: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. 死锁的四个必要条件是什么?如何破坏?

答案:

四个必要条件:

  1. 互斥条件: 资源同一时间只能被一个事务占用
  2. 请求与保持条件: 事务持有资源的同时请求新资源
  3. 不剥夺条件: 已分配的资源不能被强制剥夺
  4. 循环等待条件: 存在事务的循环等待链

破坏方法:

  • 破坏互斥条件: 不太可能,锁的本质就是互斥
  • 破坏请求与保持条件: 一次性获取所有需要的锁
  • 破坏不剥夺条件: 设置锁等待超时
  • 破坏循环等待条件: 统一加锁顺序

实际应用:

  • 统一加锁顺序是最有效的方法
  • 一次性获取锁可以避免部分死锁
  • 锁等待超时是兜底方案

6. MySQL 如何检测和处理死锁?

答案:

死锁检测:

  • InnoDB 内部维护了一个锁等待图(Wait-for Graph)
  • 定期检测图中是否存在环
  • 发现环后,选择一个事务回滚

选择回滚事务的策略:

  • 通常选择插入/更新最少行的事务
  • 或者选择持有锁最少的事务
  • 目标是减少回滚代价

配置参数:

sql
-- 查看死锁检测开关
SHOW VARIABLES LIKE 'innodb_deadlock_detect';

-- 关闭死锁检测(不推荐)
SET GLOBAL innodb_deadlock_detect = OFF;

查看死锁信息:

sql
SHOW ENGINE INNODB STATUS\G
-- 查看 LATEST DETECTED DEADLOCK 部分

7. 不同隔离级别下的锁行为有什么区别?

答案:

隔离级别读操作写操作间隙锁问题
读未提交不加锁行锁脏读、不可重复读、幻读
读已提交不加锁(快照读)行锁不可重复读、幻读
可重复读不加锁(快照读)行锁+间隙锁
串行化共享锁排他锁

示例:

sql
-- 读已提交(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. 为什么长事务会放大锁冲突?

答案:

原因:

  • 延长了锁持有时间,扩大了并发碰撞窗口
  • 锁持有期间,其他事务只能等待
  • 高并发下,等待事务快速累积

示例:

sql
-- 长事务:持有锁 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. 为什么统一更新顺序能减少死锁?

答案:

原理:

  • 统一加锁顺序可以破坏循环等待条件
  • 所有事务按相同顺序获取锁,不会形成环

示例:

sql
-- × 不同顺序:可能死锁
-- 事务 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:了解业务逻辑
  • 持有的锁:哪些资源被锁定
  • 等待的锁:哪些资源在等待

阅读顺序:

  1. 先看两个事务分别执行了哪条 SQL
  2. 再看它们各自持有和等待的锁对象
  3. 最后回到业务代码,对照事务入口和更新顺序

示例输出解读:

code
*** (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

优化方案:

  1. 库存分桶

    • 将库存分散到多个桶
    • 扣减时随机选择桶
    • 提升并发能力
  2. 缓存预扣减

    • Redis 预扣库存
    • 异步同步到数据库
    • 减少数据库压力
  3. 乐观锁

    • 使用版本号控制
    • 减少锁等待
    • 适合低冲突场景
  4. 令牌桶限流

    • 控制并发请求数
    • 保护数据库
    • 不解决根本问题

选择建议:

  • 高并发场景:库存分桶 + 缓存预扣
  • 中等并发:乐观锁
  • 所有场景:限流保护

12. 如何排查线上的锁冲突问题?

答案:

排查步骤:

  1. 判断锁等待还是死锁

    • 死锁:错误码 1213
    • 锁等待超时:错误码 1205
  2. 查看锁等待情况

    sql
    SELECT * FROM information_schema.innodb_lock_waits;
    SELECT * FROM information_schema.innodb_trx;
  3. 分析死锁信息

    sql
    SHOW ENGINE INNODB STATUS\G
    -- 查看 LATEST DETECTED DEADLOCK
  4. 查看长时间事务

    sql
    SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW())
    FROM information_schema.innodb_trx
    ORDER BY trx_started ASC;
  5. 检查索引设计

    sql
    SHOW INDEX FROM 表名;
    EXPLAIN UPDATE ... WHERE ...;
  6. 结合业务日志

    • 找到事务入口
    • 分析事务范围
    • 优化业务逻辑

13. 如何设计一个高并发的库存扣减系统?

答案:

架构设计:

  1. 前端层

    • 限流(令牌桶)
    • 答题/验证码防刷
  2. 应用层

    • 库存预热到 Redis
    • Redis 原子扣减
    • 异步同步数据库
  3. 数据库层

    • 库存分桶
    • 乐观锁兜底
    • 定期同步桶数据

完整流程:

code
1. 用户请求 → 限流检查
2. Redis 扣库存 → 成功/失败
3. 成功 → 创建订单(异步)
4. 定时任务 → 同步 Redis 到数据库
5. 数据库分桶 → 提升并发能力

关键点:

  • Redis 库存与数据库库存的一致性
  • 异步创建订单的可靠性(消息队列)
  • 库存同步的实时性
  • 防超卖机制

14. 如何保证多表更新的一致性和避免死锁?

答案:

方案:

  1. 统一加锁顺序

    java
    // 按 ID 排序后加锁
    List<Long> ids = Arrays.asList(orderId, paymentId, inventoryId);
    Collections.sort(ids);
    
    for (Long id : ids) {
        lockManager.lock(id);
    }
  2. 使用分布式锁

    java
    // 业务层先获取分布式锁
    if (redisLock.tryLock("process_order:" + orderId, 30, TimeUnit.SECONDS)) {
        try {
            // 执行多表更新
            updateOrder(orderId);
            updatePayment(paymentId);
            updateInventory(inventoryId);
        } finally {
            redisLock.unlock("process_order:" + orderId);
        }
    }
  3. 使用事务管理器

    java
    // Spring 事务管理
    @Transactional
    public void processOrder(Long orderId) {
        // 统一在事务内执行
        // Spring 保证原子性
    }
  4. 最终一致性方案

    java
    // 使用消息队列
    // 1. 更新订单表(本地事务)
    // 2. 发送消息到 MQ
    // 3. 其他服务消费消息,更新各自表

选择建议:

  • 单体应用:统一加锁顺序 + 数据库事务
  • 微服务:分布式锁 + 最终一致性

15. 如何监控和告警数据库锁问题?

答案:

监控指标:

  • 锁等待次数
  • 锁等待时间
  • 死锁次数
  • 长事务数量

实现方案:

  1. 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%';
  2. 自定义监控脚本

    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
  3. Prometheus + Grafana

    yaml
    # prometheus.yml
    - job_name: 'mysql'
      static_configs:
        - targets: ['localhost:9104']
    
    # Grafana Dashboard
    - 锁等待次数图表
    - 锁等待时间图表
    - 死锁次数图表
  4. 告警规则

    • 锁等待次数 > 10/min:警告
    • 锁等待次数 > 50/min:严重
    • 死锁次数 > 5/min:警告
    • 死锁次数 > 20/min:严重

十、最佳实践总结

10.1 设计层面

  1. 合理设计索引

    • UPDATE/DELETE 的 WHERE 条件必须有合适的索引
    • 联合索引遵循最左前缀原则
    • 避免索引失效
  2. 控制事务范围

    • 事务内只包含必要的数据库操作
    • RPC、消息发送等移到事务外
    • 大批量操作分批处理
  3. 统一加锁顺序

    • 涉及多表、多行更新时,统一加锁顺序
    • 按主键从小到大排序
    • 在应用层强制执行

10.2 开发层面

  1. 避免长事务

    java
    // √ 推荐:小事务
    @Transactional
    public void updateOrderStatus(Long orderId, String status) {
        orderRepository.updateStatus(orderId, status);
    }
    
    // × 避免:大事务
    @Transactional
    public void processOrder(Long orderId) {
        // 包含大量业务逻辑
    }
  2. 使用乐观锁

    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;
    }
  3. 做好重试机制

    java
    @Retryable(
        value = DeadlockLoserDataAccessException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100, multiplier = 2)
    )
    @Transactional
    public void processOrder(Long orderId) {
        // 业务逻辑
    }

10.3 运维层面

  1. 监控锁指标

    • 锁等待次数
    • 锁等待时间
    • 死锁次数
    • 长事务数量
  2. 设置合理参数

    sql
    -- 锁等待超时时间
    SET innodb_lock_wait_timeout = 10;
    
    -- 死锁检测(保持开启)
    SET innodb_deadlock_detect = ON;
    
    -- 事务隔离级别
    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
  3. 定期排查

    • 定期检查慢查询日志
    • 定期检查长事务
    • 定期优化索引

10.4 架构层面

  1. 热点数据分散

    • 库存分桶
    • 计数器分片
    • 用户分表
  2. 读写分离

    • 读操作走从库
    • 减少主库锁压力
  3. 异步处理

    • 非核心操作异步化
    • 使用消息队列削峰
  4. 最终一致性

    • 跨服务操作使用最终一致性
    • 避免分布式事务

参考资料

版本差异(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.78.0(主流)/ 8.4 LTS / 9.x(创新版)
Java 驱动mysql-connector-java 5.x/8.0mysql-connector-j 8.x/9.x

本文基于 MySQL 5.7 编写,核心概念(索引、事务、锁、MVCC、InnoDB)在 8.0/8.4 中依然适用;8.0 的默认字符集、隐藏索引与 SQL 增强(CTE/窗口函数)是升级后的主要差异。