MySQL 间隙锁 (Gap Lock) 与 Next-Key Lock 导致的线上死锁分析实战

MySQL 间隙锁 (Gap Lock) 与 Next-Key Lock 导致的线上死锁分析实战

MySQL 间隙锁 (Gap Lock) 与 Next-Key Lock 导致的线上死锁分析实战

某日促销峰值期间,线上“优惠券发放与扣减”服务频繁触发报警,监控显示数据库出现大量 Deadlock found when trying to get lock; try restarting transaction (Error 1213) 异常,导致订单创建失败率飙升至 5%。

经过排查,该死锁并非发生在常规的“同一行记录交叉更新”场景,而是由非唯一索引上的 UPDATE 与 INSERT 混合并发触发。本文将通过真实的 SHOW ENGINE INNODB STATUS 日志,深入剖析 InnoDB 引擎中的 Gap Lock(间隙锁) 与 Next-Key Lock 作用机制,并给出可落地的重构方案。


一、问题背景与业务痛点

在优惠券业务中,存在如下逻辑:当用户领取优惠券时,系统需要更新当前用户已有的“待使用”优惠券状态(如锁定或作废),随后插入一条新的优惠券明细记录。

简化后的表结构定义如下(注意 idx_user_status 为非唯一复合索引):

CREATE TABLE `user_coupon` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `coupon_code` varchar(64) NOT NULL COMMENT '券码',
  `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态: 0-未激活, 1-可使用, 2-已锁定',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_user_status` (`user_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户优惠券表';

分析线上抓取到的日志片段:

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 2847591, ACTIVE 0 sec starting index read lock
MYSQL thread id 102, OS thread handle 13982312, query id 89201 updating
UPDATE user_coupon SET status = 2 WHERE user_id = 10086 AND status = 0

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 5 n bits 72 index idx_user_status of table `shop`.`user_coupon` 
trx id 2847591 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0

*** (2) TRANSACTION:
TRANSACTION 2847592, ACTIVE 0 sec inserting
INSERT INTO user_coupon(user_id, coupon_code, status) VALUES (10086, 'CP_999', 0)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 42 page no 5 n bits 72 index idx_user_status of table `shop`.`user_coupon` 
trx id 2847592 lock_mode X locks gap before rec

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 5 n bits 72 index idx_user_status of table `shop`.`user_coupon` 
trx id 2847592 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (1)

分析结论:两个事务在并发执行时,均持有 idx_user_status 上的 Gap Lock(间隙锁),随后同时尝试申请 Insert Intention Lock(插入意向锁)。由于间隙锁与插入意向锁互斥,导致双方互相等待对方释放间隙锁,形成了循环依赖,最终触发死锁回滚。


二、

MyBatis 核心执行链路与动态插件拦截器执行原理
▲ 权威参考图:MyBatis 核心执行链路与动态插件拦截器执行原理 (已转存博客园图床)

核心设计与解决思路

1. InnoDB 锁机制解密

在 MySQL 可重复读(Repeatable Read, 简称 RR)隔离级别下,InnoDB 默认采用 Next-Key Lock(临键锁)来进行范围查询和更新,以此解决幻读问题。

  • Record Lock:行锁,仅锁定单个索引记录。
  • Gap Lock:间隙锁,锁定索引记录之间的间隙,不包含记录本身(左开右开 (A, B))。
  • Next-Key Lock:Record Lock + Gap Lock,锁定记录本身及其前面的间隙(左开右闭 (A, B])。
  • Insert Intention Lock:插入意向锁,在 INSERT 之前设置的一种特殊的 Gap 锁。多个事务插入同一个 Gap 时,只要插入的位置不重合,插入意向锁之间不会阻塞;但插入意向锁会被其他事务持有的 Gap Lock/Next-Key Lock 阻塞。

2. 系统分层与锁竞争拓扑图

下图展示了从业务网关到 InnoDB 锁引擎的调用链路,以及死锁发生的物理区域:

flowchart TD
    Client[客户端并发请求] --> NettyGateway[API 网关]
    NettyGateway --> BizService[优惠券业务服务 Cluster]

    subgraph Spring Boot 业务逻辑
        BizService --> Tx1[事务 1: 线程 A]
        BizService --> Tx2[事务 2: 线程 B]
    end

    subgraph MySQL InnoDB 存储引擎 (RR 隔离级别)
        Tx1 -->|1. UPDATE 申请 | Lock1[idx_user_status 间隙锁 Gap Lock]
        Tx2 -->|2. UPDATE 申请 | Lock2[idx_user_status 间隙锁 Gap Lock]

        Tx1 -->|3. INSERT 申请 | Wait2[等待 Tx2 释放 Gap Lock]
        Tx2 -->|4. INSERT 申请 | Wait1[等待 Tx1 释放 Gap Lock]

        Wait2 <-->|互斥等待: 死锁检测引擎检测到环路| Wait1
    end

    Wait1 -->|触发回滚| Rollback1[回滚 事务 1]

3. 端到端请求执行时序图

假设当前数据库中存在 user_id = 10086, status = 0 的记录(ID=10),以及下一条记录(ID=20)。

当事务 A 和事务 B 并发执行 UPDATE 与 INSERT 时,时间轴上的死锁形成过程如下:

▲ 时序图 2:端到端请求处理与调用时序链路
▲ 时序图 2:端到端请求处理与调用时序链路

4. 解决方案技术选型对比

优化方案 技术原理 优点 缺点 适用场景
方案一:隔离级别降级 (RC) 将 DB 隔离级别由 RR 调整为 Read Committed (RC) 彻底消除 Gap Lock,极大地减少死锁概率 需保障 Binlog 为 ROW 格式,存在不可重复读现象 中大型业务通用,推荐
方案二:主键/唯一索引更新 先查出 Primary Key,再精准基于 WHERE id = ? 更新 将锁范围缩小为单个 Record Lock,不再锁定 Gap 增加一次 DB 查询开销 业务改造成本低
方案三:Redis 分布式锁 将 user_id 维度的并发强行转化为串行执行 保证 DB 层绝无死锁 依赖外部缓存,增加系统复杂度和延迟 超高并发、写冲突极为频繁的场景

三、完整实战代码与配置

下面基于 Spring Boot 3.x + Java 17 + MyBatis-Plus,展示优化前后的代码及配置。

1. 产生死锁的错误示例代码

以下代码在并发时极易引发死锁:

// UserCouponServiceImpl.java (错误示范)
package com.example.coupon.service.impl;

import com.example.coupon.entity.UserCoupon;
import com.example.coupon.mapper.UserCouponMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl {

    private final UserCouponMapper userCouponMapper;

    @Transactional(rollbackFor = Exception.class)
    public void issueNewCouponVulnerable(Long userId, String newCouponCode) {
        // 1. 基于非唯一索引批量更新(加 X 方向的 Next-Key Lock / Gap Lock)
        userCouponMapper.updateStatusByUserIdAndStatus(userId, 0, 2);

        // 2. 插入新记录(申请 Insert Intention Lock,触发死锁)
        UserCoupon newCoupon = new UserCoupon();
        newCoupon.setUserId(userId);
        newCoupon.setCouponCode(newCouponCode);
        newCoupon.setStatus(0);
        userCouponMapper.insert(newCoupon);
    }
}

2. 改造后的最佳实践代码

我们采用 “先查主键,再走主键更新 + Redis 锁控制” 的组合拳对代码进行重构。

UserCouponOptServiceImpl.java:

package com.example.coupon.service.impl;

import com.example.coupon.entity.UserCoupon;
import com.example.coupon.mapper.UserCouponMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.List;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserCouponOptServiceImpl {

    private final UserCouponMapper userCouponMapper;
    private final RedissonClient redissonClient;

    /**
     * 安全发放新优惠券,规避间隙锁死锁
     */
    public void issueNewCouponSafe(Long userId, String newCouponCode) {
        String lockKey = "lock:user:coupon:" + userId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 1. 业务加锁,将同一个 user_id 的并发写操作串行化
            if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
                try {
                    // 调用事务方法
                    executeIssueTransaction(userId, newCouponCode);
                } finally {
                    lock.unlock();
                }
            } else {
                throw new RuntimeException("系统繁忙,请稍后再试");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("加锁中断", e);
        }
    }

    @Transactional(rollbackFor = Exception.class)
    public void executeIssueTransaction(Long userId, String newCouponCode) {
        // 2. 先查询匹配的主键列表(避免直接对非唯一索引范围加锁)
        List<Long> needUpdateIds = userCouponMapper.selectList(
                new LambdaQueryWrapper<UserCoupon>()
                        .select(UserCoupon::getId)
                        .eq(UserCoupon::getUserId, userId)
                        .eq(UserCoupon::getStatus, 0)
        ).stream().map(UserCoupon::getId).toList();

        // 3. 根据主键精准更新,锁粒度降级为精确的 Record Lock (行锁)
        if (!needUpdateIds.isEmpty()) {
            userCouponMapper.updateStatusByIds(needUpdateIds, 2);
        }

        // 4. 执行插入操作,不再产生锁冲突
        UserCoupon newCoupon = new UserCoupon();
        newCoupon.setUserId(userId);
        newCoupon.setCouponCode(newCouponCode);
        newCoupon.setStatus(0);
        userCouponMapper.insert(newCoupon);
    }
}

3. XML 批量主键更新 SQL 配置

UserCouponMapper.xml:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.coupon.mapper.UserCouponMapper">

    <!-- 精准基于主键 ID 批量更新,仅触发行锁(Record Lock) -->
    <update id="updateStatusByIds">
        UPDATE user_coupon
        SET status = #{targetStatus}
        WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </update>
</mapper>

4. 数据库及应用配置信息

针对 MySQL 及 Spring Boot 隔离级别的调整如下:

bootstrap.yml:

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root_password
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      # 推荐显式声明隔离级别为 READ_COMMITTED
      transaction-isolation: TRANSACTION_READ_COMMITTED

logging:
  level:
    com.example.coupon.mapper: debug

MySQL 数据库全域配置文件建议:

my.cnf:

[mysqld]
# 隔离级别推荐设置为 RC,能够显著避免绝大多数 Gap Lock 死锁
transaction-isolation = READ-COMMITTED
# 必须使用 ROW 格式的 Binlog,确保主从同步的数据一致性
binlog_format = ROW
# 开启死锁检测 (默认开启)
innodb_deadlock_detect = ON

四、避坑指南与总结验证

1. 线上避坑指南

  1. 切忌使用“不存在的值”做带有锁定意图的条件更新:
    在 RR 隔离级别下,若使用非唯一索引去 SELECT ... FOR UPDATE 或 UPDATE 一条不存在的记录,InnoDB 会锁定记录可能插入的整个“区间”。并发下极易触发死锁。
  2. 尽量使用主键(Primary Key)或唯一索引(Unique Index)更新:
    若条件为主键,且记录存在,InnoDB 会将锁降级为 Record Lock(单行记录锁),不加 Gap Lock;若使用的是非唯一索引,无论记录是否存在,均会加 Gap Lock 或 Next-Key Lock。
  3. 隔离级别降级至 Read Committed (RC):
    互联网高并发业务场景下,推荐设置 transaction-isolation = READ-COMMITTED,配合 binlog_format = ROW。RC 模式下无幻读保护的 Gap Lock,能消除 90% 以上的 InnoDB 死锁概率。
  4. 统一多表/多行资源的加锁顺序:
    如果事务内部需要同时更新多行记录,必须在业务层先对 ID 等主键进行正序排序(List.sort()),再顺序执行更新,防止因交叉竞争导致的死锁。

2. 压测效果验证

我们使用 Apache JMeter 对改造前后的接口进行了 100 线程并发压测(每秒 2000 个请求):

  • 改造前(RR 隔离级别 + 非唯一索引更新):
  • TPS:~450
  • Error Rate:4.8%(抛出大量的 Deadlock exception)
  • 数据库 CPU 使用率:90%+(死锁检测线程频繁耗用 CPU 资源)
  • 改造后(RC 隔离级别/Redis锁 + 主键更新):
  • TPS:~1850
  • Error Rate:0%
  • 平均响应时间 (RT):从 180ms 降至 22ms,系统吞吐量提升约 4 倍。
posted @ 2026-10-05 15:15  丨吴丨  阅读(5)  评论(0)    收藏  举报