前言:一个真实的线上事故
周五下午,电商系统的订单服务突然报警:大量请求超时,数据库连接池耗尽。排查发现,是两笔并发订单在库存扣减时触发了死锁,导致后续请求全部阻塞。
这不是个例。在高并发场景下,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 会:
- 选择牺牲者:选择回滚代价最小的事务
- 回滚牺牲者:释放其持有的所有锁
- 返回错误:向客户端返回
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 官方技术博客
本文首发于 小虾的技术博客,转载请注明出处。