深入理解 MySQL 死锁:从锁机制到实战避坑指南

jxq
1
2026-06-27

前言:一个真实的线上事故

周五下午,电商系统的订单服务突然报警:大量请求超时,数据库连接池耗尽。排查发现,是两笔并发订单在库存扣减时触发了死锁,导致后续请求全部阻塞。

这不是个例。在高并发场景下,MySQL 死锁是每个后端工程师迟早要面对的"坑"。本文将系统讲解 MySQL InnoDB 的锁机制、死锁原理,以及如何在设计与编码层面规避风险。


一、MySQL 锁的全景图

1.1 锁的分类维度

MySQL InnoDB 的锁可以从多个维度划分:

┌─────────────────────────────────────────────────────────────┐
│                      MySQL 锁分类                            │
├─────────────────┬───────────────────────────────────────────┤
│  按 locking 粒度 │  表锁 | 行锁 | 页锁(BDB引擎,已废弃)      │
├─────────────────┼───────────────────────────────────────────┤
│  按锁类型        │  共享锁(S锁) | 排他锁(X锁)                 │
├─────────────────┼───────────────────────────────────────────┤
│  按锁的算法      │  Record Lock | Gap Lock | Next-Key Lock   │
├─────────────────┼───────────────────────────────────────────┤
│  意向锁          │  IS | IX | 意向锁是表级锁                 │
└─────────────────┴───────────────────────────────────────────┘

1.2 行锁:最细粒度的并发控制

行锁(Record Lock)锁定的是索引记录,而非数据行本身。

核心要点

  • InnoDB 的行锁必须通过索引才能生效
  • 如果查询条件无法命中索引,会退化为表锁
  • 锁的是索引项,不是物理数据页
-- 伪代码:行锁示例
SELECT * FROM orders WHERE id = 100 FOR UPDATE;  -- 锁定主键索引记录
SELECT * FROM orders WHERE order_no = 'ORD001' FOR UPDATE;  -- 锁定二级索引 + 主键索引

为什么强调索引?

-- 假设 status 字段无索引
UPDATE orders SET status = 1 WHERE status = 0;
-- 全表扫描 → 所有记录加锁 → 等同于表锁

1.3 间隙锁:防止幻读的利器

间隙锁(Gap Lock)锁定索引记录之间的"间隙",防止其他事务在间隙中插入新记录。

graph LR
    A[记录 10] --> B[间隙 10-20]
    B --> C[记录 20]
    C --> D[间隙 20-30]
    D --> E[记录 30]
    
    style B fill:#f9f,stroke:#333
    style D fill:#f9f,stroke:#333

间隙锁的特点

  • 只在 Repeatable Read (RR) 隔离级别下生效
  • 不与其他间隙锁冲突(间隙锁之间兼容)
  • 只阻止插入,不阻止读取
  • 目的:解决幻读问题
-- 伪代码:间隙锁示例
-- 表中有 id = 10, 20, 30 三条记录
SELECT * FROM orders WHERE id > 15 AND id < 25 FOR UPDATE;
-- 锁定间隙 (10, 20] 和 (20, 30)
-- 其他事务无法插入 id = 15, 18, 22, 28 等记录

1.4 临键锁:行锁 + 间隙锁

Next-Key Lock 是 Record Lock + Gap Lock 的组合,锁定记录本身及其前一个间隙。

记录顺序: 10 → 20 → 30

Next-Key Lock 锁定 id = 20:
├── Record Lock: 锁定 id = 20
└── Gap Lock: 锁定间隙 (10, 20)

结果: 其他事务无法插入 id ∈ (10, 20],也无法更新/删除 id = 20

这是 RR 隔离级别下 InnoDB 的默认锁算法

1.5 意向锁:表锁与行锁的桥梁

意向锁是表级锁,用于协调行锁和表锁的冲突。

意向锁类型 含义 场景
IS (意向共享) 事务打算对某些行加 S 锁 SELECT ... LOCK IN SHARE MODE
IX (意向排他) 事务打算对某些行加 X 锁 SELECT ... FOR UPDATE / UPDATE / DELETE

意向锁的作用

当事务 A 持有某行的 X 锁时,会自动在表上加 IX 锁。此时事务 B 尝试获取表级 X 锁:

事务 A: 行锁 X → 自动加表级意向锁 IX
事务 B: 申请表锁 X → 检查到 IX → 阻塞等待

无需逐行检查是否有行锁冲突,意向锁极大提升了锁冲突检测效率。

兼容性矩阵

        IS    IX    S     X
IS      ✓     ✓     ✓     ✗
IX      ✓     ✓     ✗     ✗
S       ✓     ✗     ✓     ✗
X       ✗     ✗     ✗     ✗

二、死锁的本质

2.1 死锁的四个必要条件

死锁必须同时满足以下四个条件(缺一不可):

graph TB
    A[互斥条件<br/>资源同一时刻只能被一个事务持有] --> E[死锁]
    B[占有且等待<br/>持有资源同时请求其他资源] --> E
    C[不可剥夺<br/>已持有资源不能被强制抢占] --> E
    D[循环等待<br/>事务间形成循环等待关系] --> E

只要打破其中任意一个,死锁就不会发生

2.2 典型死锁场景

场景一:交叉更新

-- 初始数据
-- accounts: id=1 余额=1000, id=2 余额=2000

-- 事务 A                     -- 事务 B
BEGIN;                        BEGIN;
UPDATE accounts 
SET balance = balance - 100
WHERE id = 1;  -- A 持有 id=1 的 X 锁

                              UPDATE accounts 
                              SET balance = balance - 100
                              WHERE id = 2;  -- B 持有 id=2 的 X 锁

UPDATE accounts 
SET balance = balance + 100
WHERE id = 2;  -- A 等待 id=2 的锁

                              UPDATE accounts 
                              SET balance = balance + 100
                              WHERE id = 1;  -- B 等待 id=1 的锁
                              -- 💥 死锁产生!

等待图

事务 A: 持有 lock(1) → 等待 lock(2)
事务 B: 持有 lock(2) → 等待 lock(1)

形成循环: A → lock(2) → B → lock(1) → A

场景二:间隙锁死锁

-- 表中只有 id = 10, 30 两条记录

-- 事务 A
INSERT INTO orders (id) VALUES (20);  -- 等待间隙锁 (10, 30)

-- 事务 B  
INSERT INTO orders (id) VALUES (25);  -- 也等待间隙锁 (10, 30)

-- 两个事务都持有插入意向锁,都在等待对方的间隙锁释放
-- 某些情况下可能触发死锁

场景三:唯一索引冲突

-- 表有唯一索引 uk_email

-- 事务 A
INSERT INTO users (email) VALUES ('a@example.com');  
-- 成功,持有唯一索引记录锁

-- 事务 B
INSERT INTO users (email) VALUES ('a@example.com');
-- 等待事务 A 释放锁

-- 事务 A(回滚或重复插入)
ROLLBACK;
-- 事务 B 可能与事务 C 形成死锁(如果事务 C 也在操作相同记录)

三、MySQL 死锁检测机制

3.1 innodb_deadlock_detect

InnoDB 内置了死锁检测机制:

-- 查看配置
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- 默认 ON

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

检测原理:InnoDB 维护一个 等待图(Wait-for Graph),每当有锁等待发生时,检查图中是否存在环。如果存在环,则判定为死锁。

3.2 超时机制:innodb_lock_wait_timeout

当死锁检测关闭或检测失败时,超时机制作为兜底:

SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 默认 50 秒

-- 修改超时时间
SET innodb_lock_wait_timeout = 10;

3.3 死锁处理策略

当检测到死锁时,InnoDB 会:

  1. 选择牺牲者:选择回滚代价最小的事务
  2. 回滚牺牲者:释放其持有的所有锁
  3. 返回错误:向客户端返回 ER_LOCK_DEADLOCK(错误码 1213)
-- 捕获死锁错误(伪代码)
try {
    executeTransaction();
} catch (DeadlockException e) {
    // 重试逻辑
    retry();
}

3.4 查看死锁信息

-- 查看最近的死锁信息
SHOW ENGINE INNODB STATUS\G

-- 在 LATEST DETECTED DEADLOCK 部分可以看到详细信息:
-- (1) 哪些事务参与
-- (2) 持有哪些锁
-- (3) 等待哪些锁
-- (4) 哪个事务被选为牺牲者

开启死锁监控日志

-- MySQL 8.0+
SET GLOBAL innodb_status_output = ON;
SET GLOBAL innodb_status_output_locks = ON;

四、死锁预防与最佳实践

4.1 统一访问顺序

核心原则:所有事务按相同顺序访问资源。

-- ✅ 正确做法:按主键顺序更新
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 即使并发多个事务,都按 1→2 的顺序,不会形成循环等待
// 伪代码:转账前先排序
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // 按ID排序,保证加锁顺序一致
    Long[] ids = {fromId, toId};
    Arrays.sort(ids);
    
    // 按 ID 顺序加锁
    lockAccount(ids[0]);
    lockAccount(ids[1]);
    
    // 执行转账逻辑
    doTransfer(fromId, toId, amount);
}

4.2 缩小事务范围

原则:事务越短,持有锁的时间越短,死锁概率越低。

// ❌ 错误:事务中包含外部调用
@Transactional
public void placeOrder(Order order) {
    orderMapper.insert(order);       // 持有锁
    inventoryService.deduct(order);  // 仍然持有锁
    paymentService.charge(order);    // 可能慢,仍然持有锁
    notificationService.send(order); // 调用外部服务,超时风险高!
}

// ✅ 正确:事务最小化
public void placeOrder(Order order) {
    // 非事务操作
    validateOrder(order);
    
    // 事务范围最小化
    transactionTemplate.execute(status -> {
        orderMapper.insert(order);
        inventoryService.deduct(order);
        return null;
    });
    
    // 事务外操作
    notificationService.send(order);
}

4.3 合理使用索引

-- ❌ 问题:无索引字段更新,退化为表锁
UPDATE orders SET status = 1 WHERE remark = 'urgent';

-- ✅ 解决:添加索引
ALTER TABLE orders ADD INDEX idx_remark (remark);

4.4 降低隔离级别

某些场景下,可以将隔离级别从 RR 降为 RC,避免间隙锁:

-- 查看当前隔离级别
SELECT @@transaction_isolation;

-- 修改为 READ COMMITTED(当前会话)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

权衡:RC 无法防止幻读,需要业务层保证数据一致性。

4.5 乐观锁替代悲观锁

对于冲突率低的场景,乐观锁更高效:

-- 伪代码:CAS 思想
UPDATE products 
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;

-- 如果 affected_rows = 0,说明版本已变化,重试

4.6 批量操作优化

-- ❌ 单条循环更新
for id in ids:
    UPDATE orders SET status = 1 WHERE id = id;

-- ✅ 批量更新,减少锁持有时间
UPDATE orders SET status = 1 WHERE id IN (1, 2, 3, 4, 5);

五、实战案例分析

案例:电商库存扣减死锁

业务场景:用户下单时扣减商品库存。

表结构

CREATE TABLE inventory (
    sku_id BIGINT PRIMARY KEY,
    stock INT NOT NULL,
    version INT DEFAULT 0
);

问题代码

@Transactional
public boolean deductStock(Long skuId1, Long skuId2, int qty1, int qty2) {
    // 按传入顺序加锁,可能与其他事务顺序相反
    inventoryMapper.deductStock(skuId1, qty1);
    inventoryMapper.deductStock(skuId2, qty2);
    return true;
}

死锁复现

T1: deductStock(100, 200, ...)  → 锁顺序: 100 → 200
T2: deductStock(200, 100, ...)  → 锁顺序: 200 → 100
→ 交叉持有,形成死锁

解决方案

@Transactional
public boolean deductStock(Long skuId1, Long skuId2, int qty1, int qty2) {
    // 统一按 skuId 排序
    List<StockDeduction> deductions = Arrays.asList(
        new StockDeduction(skuId1, qty1),
        new StockDeduction(skuId2, qty2)
    );
    
    // 按 skuId 升序排序
    deductions.sort(Comparator.comparing(StockDeduction::getSkuId));
    
    // 按统一顺序扣减
    for (StockDeduction d : deductions) {
        inventoryMapper.deductStock(d.getSkuId(), d.getQty());
    }
    return true;
}

进一步优化:单条 SQL 批量更新:

UPDATE inventory SET stock = stock - CASE sku_id
    WHEN 100 THEN 10
    WHEN 200 THEN 5
END
WHERE sku_id IN (100, 200);

六、死锁排查 Checklist

当线上出现死锁时,按以下步骤排查:

□ 1. 查看 SHOW ENGINE INNODB STATUS,定位死锁事务
□ 2. 分析涉及的表和 SQL 语句
□ 3. 检查是否有索引缺失导致表锁
□ 4. 检查事务中是否有长时间阻塞操作
□ 5. 检查是否有交叉更新场景
□ 6. 检查隔离级别是否合理
□ 7. 检查是否有序访问问题

常用查询 SQL

-- 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 查看当前锁
SELECT * FROM performance_schema.data_locks;

-- 查看当前事务
SELECT * FROM information_schema.INNODB_TRX;

总结

关键点 说明
锁的本质 InnoDB 行锁基于索引,间隙锁防止幻读
死锁条件 互斥、占有且等待、不可剥夺、循环等待
检测机制 Wait-for Graph + 超时兜底
预防核心 统一访问顺序 + 缩小事务范围 + 合理索引
处理策略 自动回滚牺牲者,业务层可重试

记住:死锁不是"是否会发生"的问题,而是"何时发生"的问题。理解锁机制,在设计和编码层面预防,才能构建高可用的并发系统。


📚 参考资料

  • MySQL 8.0 Reference Manual - InnoDB Locking
  • 《高性能MySQL》第三版
  • MySQL 官方技术博客

本文首发于 小虾的技术博客,转载请注明出处。

动物装饰