Administrator
发布于 2019-05-31 / 1181 阅读
29

InnoDB 行锁与间隙锁:一次死锁日志分析

凌晨两点的死锁告警

5 月 28 号凌晨,库存服务的告警响了:批量扣减任务报死锁,一晚上 217 次。错误是客户端收到的:

com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException:
Deadlock found when trying to get lock; try restarting transaction
; SQL []; nested exception is ...

MySQL 有死锁检测,发现之后会挑一个事务回滚,所以数据没出问题,但任务失败了要重试,白白浪费时间。之前我对死锁的认识只停留在"两个事务互相等",真到看日志的时候,发现里面讲的东西比我以为的多得多。

读懂 show engine innodb status

排查死锁唯一的入口就是它(MySQL 5.7 也可以用 innodb_print_all_deadlocks 把所有死锁写进 error log,只保留最近一条是不够的):

mysql> SHOW ENGINE INNODB STATUS\G

输出里找 LATEST DETECTED DEADLOCK 这一段:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-05-28 02:14:37 0x7f8c1c0d9700
*** (1) TRANSACTION:
TRANSACTION 4218937, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 2
MySQL thread id 88341, OS thread handle 140240123467520, query id 2983741 10.0.1.23 appuser updating
UPDATE t_inventory SET qty = qty - 5 WHERE sku_id = 10086 AND warehouse_id = 3

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 342 page no 5 n bits 80 index idx_sku_wh of table `wms`.`t_inventory`
trx id 4218937 lock_mode X waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80002766; asc   'f;;
 1: len 4; hex 80000003; asc     ;;
 2: len 8; hex 0000000000001f40; asc       @;;

*** (2) TRANSACTION:
TRANSACTION 4218938, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 1
MySQL thread id 88342, OS thread handle 140240124011264, query id 2983745 10.0.1.24 appuser updating
UPDATE t_inventory SET qty = qty - 3 WHERE sku_id = 10010 AND warehouse_id = 3

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 342 page no 5 n bits 80 index idx_sku_wh of table `wms`.`t_inventory`
trx id 4218938 lock_mode X

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 342 page no 5 n bits 80 index idx_sku_wh of table `wms`.`t_inventory`
trx id 4218938 lock_mode X waiting

*** WE ROLL BACK TRANSACTION (2)

几个字段的读法:

  • lock_mode X waiting:X 是排他锁,waiting 表示在等。(S 是共享锁)
  • hex 80002766:索引键值。bigint 类型在 InnoDB 内部存储时最高位做了翻转,0x80002766 去掉符号位 0x80000000 就是 10086。
  • undo log entries 2:这个事务已经改了 2 行,回滚它的代价更大,InnoDB 挑事务时会参考这个。
  • *** WE ROLL BACK TRANSACTION (2):明确说了回滚哪个。

第一类死锁:更新顺序不一致

这一类最好懂,代码长这样:

@Transactional
public void batchDeduct(List<DeductItem> items) {
    for (DeductItem item : items) {
        inventoryMapper.deduct(item.getSkuId(), item.getWarehouseId(), item.getQty());
    }
}

items 是从上游 MQ 消费下来的,顺序取决于消息到达的先后。两个事务拿到顺序相反的商品列表:

事务 A:先改 sku 10086,再改 sku 10010
事务 B:先改 sku 10010,再改 sku 10086

t1  A 锁住 10086
t2  B 锁住 10010
t3  A 请求 10010 → 等 B
t4  B 请求 10086 → 等 A
    → 循环等待,死锁

解决办法毫无悬念:固定获取锁的顺序。在 Java 代码里排序,不在 SQL 层改:

@Transactional
public void batchDeduct(List<DeductItem> items) {
    // 按 sku_id 升序,保证所有事务以相同顺序加锁
    items.sort(Comparator.comparingLong(DeductItem::getSkuId));
    for (DeductItem item : items) {
        inventoryMapper.deduct(item.getSkuId(), item.getWarehouseId(), item.getQty());
    }
}

这个改动上线后,死锁从每天 217 次降到每天 30 多次。剩下的那 30 多次是另一类,也是这篇真正想写的部分。

第二类:不存在的记录引发的间隙锁死锁

剩下的死锁日志样式完全不同,两条 SQL 都是 INSERT:

*** (1) TRANSACTION:
TRANSACTION 4220114, ACTIVE 1 sec inserting
INSERT INTO t_inventory (sku_id, warehouse_id, qty)
VALUES (10025, 3, 0) ON DUPLICATE KEY UPDATE qty = qty
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index idx_sku_wh ... lock_mode X insert intention waiting

*** (2) TRANSACTION:
TRANSACTION 4220115, ACTIVE 1 sec inserting
INSERT INTO t_inventory (sku_id, warehouse_id, qty)
VALUES (10026, 3, 0) ON DUPLICATE KEY UPDATE qty = qty
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... index idx_sku_wh ... lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index idx_sku_wh ... lock_mode X insert intention waiting

两条 INSERT 插的是不同的 sku_id(10025 和 10026),都能死锁,这就不是顺序问题了。

看业务代码,插入之前有一次查询:

@Transactional
public void initInventory(Long skuId, Integer warehouseId) {
    Inventory inv = inventoryMapper.selectBySkuAndWh(skuId, warehouseId);
    if (inv == null) {
        // 不存在才插,这个判断制造了间隙锁
        inventoryMapper.insertInit(skuId, warehouseId, 0);
    }
}

关键在于:表里有 sku_id 为 10020 和 10030 的记录,10025 和 10026 都不存在。当隔离级别是 RR 时,SELECT ... WHERE sku_id = 10025(走的是普通索引 idx_sku_wh)会对 (10020, 10030) 这个区间加间隙锁,防止别的事务在这个区间里插入新记录。

于是:

t1  事务 A 查 10025,在 (10020, 10030) 上加 gap lock
t2  事务 B 查 10026,在同一个间隙加 gap lock    ← gap lock 之间互相兼容,都成功了
t3  事务 A 插 10025,需要 insert intention lock,被 B 的 gap lock 挡住
t4  事务 B 插 10026,需要 insert intention lock,被 A 的 gap lock 挡住
    → 死锁

这个场景的隐蔽之处在于:加锁的那条 SQL 是 SELECT,不是 INSERT。InnoDB 的死锁日志只显示正在等待的那条 SQL(这里是 INSERT),前面的 SELECT 不显示,所以日志看起来像是两条无关的 INSERT 互相打架。

InnoDB 到底加了什么锁

把这块理清楚之后,很多之前想不明白的现象就通了。MySQL 5.7 默认隔离级别是 REPEATABLE-READ,有以下几种锁类型:

锁类型锁定范围作用
Record Lock单条索引记录锁住已经存在的行
Gap Lock两条索引记录之间的开区间阻止在区间内插入,解决幻读
Next-Key Lock左开右闭区间(前一条, 当前]Record + Gap 的组合,RR 下默认加这种
Insert Intention Lock插入位置的间隙插入前申请,和 Gap Lock 冲突

加锁规则可以总结成四条(针对 RR 下的当前读):

  • 唯一索引上的等值查询,命中了 → 退化成 Record Lock
  • 唯一索引上的等值查询,没命中 → Gap Lock(锁住它该在的那个间隙)
  • 非唯一索引上的等值查询 → Next-Key Lock,向右扫描到第一个不满足条件的记录后退化成 Gap Lock
  • 范围查询 → 一路加 Next-Key Lock,包含右端点

举个具体的例子,表里 idx_sku 上有 10、20、30 三条记录,执行:

-- 走普通索引,等值查询,命中
SELECT * FROM t_inventory WHERE sku_id = 20 FOR UPDATE;
-- 锁定的区间:(10, 20] 的 next-key lock + (20, 30) 的 gap lock
-- 也就是 (10, 30) 开区间,20 本身被 record lock 锁住

这就是 RR 能防止幻读的原因:它不光锁住已存在的记录,还把可能插入新记录的间隙也锁上了,另一个事务没法在这个范围里插新数据。

顺便说一个我之前搞混的点:间隙锁只在 RR 及以上存在,RC 下没有(RC 只有 Record Lock)。这也是很多公司把隔离级别改成 RC 的直接原因之一。

我们的四条修改

一、去掉"先查后插",改用唯一索引兜底

造成间隙锁的是那次 SELECT,直接把它去掉:

@Transactional
public void initInventory(Long skuId, Integer warehouseId) {
    // 不查,直接插。唯一索引冲突时走 UPDATE 分支
    inventoryMapper.insertOnDuplicate(skuId, warehouseId, 0);
}
<insert id="insertOnDuplicate">
    INSERT INTO t_inventory (sku_id, warehouse_id, qty)
    VALUES (#{skuId}, #{warehouseId}, #{qty})
    ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
</insert>

前提是 (sku_id, warehouse_id) 上有唯一索引。这条语句只加行锁,不加间隙锁。

二、缩短事务,减少锁的持有时间

原来一个事务里要处理 200 个 SKU,中间还夹着两次 RPC 调用。改成每 50 个 SKU 一批,RPC 挪到事务外面。单个事务的持锁时间从平均 1.8 秒降到 120 毫秒。

三、给关键操作加降级路径

批量任务的隔离级别单独降到 RC(只对这一个数据源),彻底避开间隙锁:

@Transactional(isolation = Isolation.READ_COMMITTED)
public void batchInit(List<InventoryInit> list) { ... }

注意这个改动要和业务确认:RC 下可能出现不可重复读和幻读,批量初始化这种"幂等写入"的场景没问题,涉及金额对账的不能用。

四、加重试

死锁是分布式系统的正常现象,不可能完全消灭。加了一层重试:

// Spring Retry,只对死锁异常重试,最多 3 次,指数退避
@Retryable(value = {DeadlockLoserDataAccessException.class},
           maxAttempts = 3,
           backoff = @Backoff(delay = 100, multiplier = 2))
public void batchDeductWithRetry(List<DeductItem> items) {
    batchDeduct(items);
}

这里要注意重试的方法必须是被代理调用的(又是 AOP 那个坑),而且整个事务要在重试范围内,否则回滚了一半的数据再重试会重复处理。

效果

指标修改前修改后
死锁次数(每天)2170
单事务持锁时间1.8 秒0.12 秒
批量任务总耗时(10 万 SKU)26 分钟7 分钟
锁等待超时(50 秒)每天 30 多次0

就写到这。如果哪天你也被《InnoDB 行锁与间隙锁:一次死锁日志分析》里同一个坑绊住,回来翻这篇,能省半小时。

参考