MySQL 锁详解

前言:为什么你必须懂 MySQL 锁?

在单机应用时代,"锁" 可能只是课本里的概念;但在高并发业务场景(如秒杀、订单支付、库存扣减)中,锁是解决 "数据一致性" 的核心武器。多数开发者遇到的 "脏读""重复下单""库存超卖" 问题,本质都是对 MySQL 锁机制理解不透彻。

本文基于 MySQL 8.0 + InnoDB 存储引擎(工业界主流组合),从底层逻辑到实战落地,全方位拆解锁机制:不仅讲 "是什么",更讲 "为什么用""什么时候用""怎么避坑",所有实例均经过 MySQL 8.0 实测,Java 代码基于 JDK 17 + MyBatis-Plus 3.5.5.1 编写,可直接复用。

一、MySQL 锁的核心基础:先搞懂这 3 个核心问题

在深入锁的分类前,必须先明确 3 个底层逻辑 ------ 这是理解所有锁机制的前提,也是区分 "新手" 与 "资深开发者" 的关键。

1.1 锁的本质:解决 "并发资源竞争"

当多个线程(或事务)同时操作同一份数据时,会产生 "资源竞争"。锁的本质就是一种 "并发控制手段",通过 "互斥" 或 "共享" 的规则,保证同一时间只有符合条件的操作能修改数据。

举个通俗例子:食堂打饭时,窗口前的队伍就是 "锁"------ 同一时间只有 1 个人(事务)能打饭(修改数据),其他人必须排队(等待锁释放),避免 "多个人抢同一碗饭"(数据不一致)。

1.2 MySQL 锁的核心载体:InnoDB vs MyISAM

MySQL 不同存储引擎对锁的支持差异极大,其中 InnoDB 支持行锁和事务,MyISAM 仅支持表锁且无事务 ------ 这也是 InnoDB 成为主流的核心原因。

特性 InnoDB MyISAM
锁粒度 表锁 + 行锁(支持) 仅表锁(不支持行锁)
事务支持 支持 ACID 不支持事务
并发性能 高(行锁粒度细,冲突少) 低(表锁粒度粗,冲突多)
适用场景 高并发写业务(订单、库存) 只读业务(博客、新闻)

注意:本文所有内容均基于 InnoDB,MyISAM 因功能局限,仅作对比参考。

1.3 事务隔离级别与锁的关系

MySQL 的事务隔离级别(Read Uncommitted、Read Committed、Repeatable Read、Serializable),本质是通过 "锁的策略" 实现的。不同隔离级别对应不同的锁机制,直接影响数据一致性和并发性能。

核心对应关系(InnoDB 默认隔离级别为 Repeatable Read):

  • Read Committed(RC):通过 "行锁 + 快照读" 避免脏读,不避免幻读;
  • Repeatable Read(RR):通过 "行锁 + Gap 锁 + Next-Key Lock" 避免脏读、不可重复读、幻读;
  • Serializable:通过 "表锁" 强制所有操作串行执行,一致性最高但并发最低。

下面用流程图展示 "事务隔离级别与锁的关联逻辑":

二、MySQL 锁的分类:从 "粒度" 和 "态度" 拆解

MySQL 锁有多种分类方式,最核心的是 "按锁粒度" 和 "按锁态度" 划分 ------ 前者决定 "锁影响的范围",后者决定 "锁的竞争策略"。

2.1 按 "锁粒度" 划分:表锁 vs 行锁

"粒度" 指锁控制的数据范围,粒度越细,并发性能越高(冲突概率低),但锁的管理成本越高。

2.1.1 表锁:最粗粒度的锁,一把锁控制整张表

表锁是 InnoDB 中粒度最粗 的锁,一旦给表加锁,所有操作(读 / 写)都将作用于整张表,其他事务需等待锁释放才能操作该表。

核心特性:
  • 加锁速度快,管理成本低;
  • 并发性能差(所有操作串行化);
  • InnoDB 中表锁通常用于 "全表操作"(如 ALTER TABLE),而非日常业务。
表锁的 2 种类型:
  1. 表读锁(Shared Lock,S 锁)

    • 作用:多个事务可同时加读锁,仅允许 "读操作",禁止 "写操作";
    • 触发方式:LOCK TABLES 表名 READ;
    • 释放方式:UNLOCK TABLES; 或事务结束。
  2. 表写锁(Exclusive Lock,X 锁)

    • 作用:仅允许加锁事务 "读 / 写",其他事务无法加任何锁(读锁也不行);
    • 触发方式:LOCK TABLES 表名 WRITE;
    • 释放方式:UNLOCK TABLES; 或事务结束。
实战实例:表锁的冲突与兼容

以下操作基于 MySQL 8.0,分两个会话(Session A 和 Session B)演示:

Step 1:创建测试表并插入数据

复制代码
-- 创建商品表(InnoDB 引擎)
CREATE TABLE `product` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `name` varchar(100) NOT NULL COMMENT '商品名称',
  `stock` int NOT NULL DEFAULT 0 COMMENT '库存数量',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

-- 插入测试数据
INSERT INTO `product` (`name`, `stock`) VALUES ('iPhone 15', 100);

Step 2:Session A 加表读锁,测试读写权限

复制代码
-- Session A:加表读锁
LOCK TABLES product READ;

-- 测试1:读操作(允许)
SELECT * FROM product WHERE id = 1; -- 结果:id=1, name=iPhone 15, stock=100

-- 测试2:写操作(禁止,报错)
UPDATE product SET stock = 99 WHERE id = 1; 
-- 报错信息:Lock wait timeout exceeded; try restarting transaction

Step 3:Session B 测试表读锁的兼容性

复制代码
-- Session B:加表读锁(允许,因为读锁之间兼容)
LOCK TABLES product READ;

-- 测试1:读操作(允许)
SELECT * FROM product WHERE id = 1; -- 正常返回

-- 测试2:加表写锁(禁止,读锁与写锁冲突)
LOCK TABLES product WRITE; 
-- 报错信息:Lock wait timeout exceeded; try restarting transaction

Step 4:释放锁

复制代码
-- Session A 和 Session B 均执行释放锁
UNLOCK TABLES;
2.1.2 行锁:最细粒度的锁,一把锁控制一行数据

行锁是 InnoDB 的核心锁机制,仅锁定 "被操作的行",其他行不受影响 ------ 这也是 InnoDB 支持高并发的关键。

核心特性:
  • 加锁速度慢,管理成本高;
  • 并发性能高(仅冲突行被锁定,其他行可正常操作);
  • 仅在 "事务中" 生效,事务结束后自动释放;
  • 必须通过 "索引" 触发(无索引时会退化为表锁,这是高频坑!)。
行锁的 3 种类型(InnoDB 核心锁):
锁类型 英文缩写 作用 兼容关系(自身 vs 其他锁)
共享锁(读锁) S 锁 允许事务读数据,禁止写数据 S 锁兼容,X 锁冲突
排他锁(写锁) X 锁 允许事务读 / 写数据,禁止其他任何锁 S 锁、X 锁均冲突
意向锁(表级辅助锁) IS/IX 锁 标记 "表中存在行锁",避免表锁与行锁冲突 意向锁之间兼容,与表锁冲突

关键:意向锁(IS/IX)是 "表级锁",但仅作 "标记" 用,不直接控制数据访问 ------ 目的是快速判断表中是否有行锁,避免表锁等待时逐行检查。

实战实例 1:行锁的触发与冲突(基于索引)

以下实例演示 "有索引时行锁的正常生效",分两个会话(Session A 和 Session B):

Step 1:使用之前创建的 product 表(id 为主键索引)

Step 2:Session A 开启事务并加行锁

复制代码
-- Session A:开启事务
START TRANSACTION;

-- 对 id=1 的行加排他锁(写操作默认加 X 锁)
UPDATE product SET stock = 99 WHERE id = 1;
-- 此时 id=1 的行被加 X 锁,其他事务无法操作该行

Step 3:Session B 测试行锁冲突

复制代码
-- Session B:开启事务
START TRANSACTION;

-- 测试1:操作 id=1 的行(冲突,因为 Session A 加了 X 锁)
UPDATE product SET stock = 98 WHERE id = 1; 
-- 结果:阻塞,直到 Session A 提交事务或超时

-- 测试2:操作 id=2 的行(假设存在)(允许,因为仅锁定 id=1)
INSERT INTO product (`name`, `stock`) VALUES ('华为 Mate 60', 200); -- 正常执行
UPDATE product SET stock = 199 WHERE id = 2; -- 正常执行

Step 4:提交事务并释放锁

复制代码
-- Session A:提交事务,释放行锁
COMMIT;

-- Session B:此时之前阻塞的更新会自动执行
-- 结果:id=1 的 stock 变为 98(覆盖 Session A 的 99,因为后提交)
实战实例 2:无索引导致行锁退化为表锁(高频坑!)

如果操作语句中 "没有使用索引",InnoDB 无法定位到具体行,会自动将 "行锁" 升级为 "表锁"------ 这是很多开发者遇到 "莫名锁冲突" 的根源。

Step 1:Session A 开启事务,操作无索引字段

复制代码
-- Session A:开启事务
START TRANSACTION;

-- 注意:name 字段无索引,此时更新会触发表锁
UPDATE product SET stock = 97 WHERE name = 'iPhone 15';
-- 此时 product 表被加表级 X 锁,所有行都被锁定

Step 2:Session B 测试锁冲突(即使操作其他行也阻塞)

复制代码
-- Session B:开启事务
START TRANSACTION;

-- 测试:操作 id=2 的行(华为 Mate 60)(阻塞,因为表锁)
UPDATE product SET stock = 198 WHERE id = 2;
-- 结果:阻塞,直到 Session A 提交事务或超时

Step 3:提交事务并释放锁

复制代码
-- Session A:提交事务,释放表锁
COMMIT;

-- Session B:阻塞的更新自动执行

避坑指南:所有涉及 "行锁" 的操作(UPDATE/DELETE/SELECT ... FOR UPDATE),必须通过 "索引字段" 过滤数据,否则会退化为表锁!

2.2 按 "锁态度" 划分:乐观锁 vs 悲观锁

"态度" 指对 "并发冲突" 的预期:悲观锁认为 "冲突一定会发生",提前加锁;乐观锁认为 "冲突很少发生",事后校验。

2.2.1 悲观锁:"先锁后操作",拒绝并发冲突

悲观锁的核心逻辑是 "防患于未然"------ 在操作数据前,先锁定数据,确保只有自己能操作,其他事务必须等待。

核心特性:
  • 依赖数据库原生锁机制(如行锁、表锁);
  • 实现简单,无需额外代码;
  • 并发低时效率高,并发高时会导致 "锁等待",性能下降。
适用场景:
  • 写操作频繁(如库存扣减、订单状态更新);
  • 数据一致性要求极高(如金融交易)。
实战实例:悲观锁在 "库存扣减" 中的应用

业务场景:秒杀活动中,用户下单时需扣减商品库存,避免超卖。

Step 1:SQL 层面实现(使用行锁)

复制代码
-- 开启事务
START TRANSACTION;

-- 1. 查询商品库存并加排他锁(FOR UPDATE 强制加 X 锁)
-- 注意:必须通过 id 索引过滤,避免表锁
SELECT stock FROM product WHERE id = 1 FOR UPDATE;

-- 2. 校验库存(假设库存 >= 1)
-- 3. 扣减库存(实际业务中需判断库存是否足够,不足则回滚)
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- 提交事务,释放锁
COMMIT;

**Step 2:Java 代码实现(基于 JDK 17 + MyBatis-Plus 3.5.5.1)**首先在 pom.xml 中引入核心依赖(最新稳定版):

复制代码
<dependencies>
    <!-- Spring Boot 父依赖 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- MyBatis-Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.5.1</version>
    </dependency>

    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <version>8.3.0</version>
        <scope>runtime</scope>
    </dependency>

    <!-- Lombok(@Slf4j) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.30</version>
        <scope>provided</scope>
    </dependency>

    <!-- Spring Utils(判空) -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-core</artifactId>
        <version>6.1.6</version>
    </dependency>

    <!-- Swagger3(接口文档) -->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-boot-starter</artifactId>
        <version>3.0.0</version>
    </dependency>

    <!-- FastJSON2(JSON 处理) -->
    <dependency>
        <groupId>com.alibaba.fastjson2</groupId>
        <artifactId>fastjson2</artifactId>
        <version>2.0.49</version>
    </dependency>

    <!-- Google Guava(集合工具) -->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>33.0.0-jre</version>
    </dependency>
</dependencies>

编写实体类 Product.java

复制代码
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;

/**
 * 商品实体类
 *
 * @author ken
 */
@Data
@TableName("product")
public class Product implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 商品ID(主键)
     */
    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 商品名称
     */
    private String name;

    /**
     * 库存数量
     */
    private Integer stock;
}

编写 Mapper 接口 ProductMapper.java

复制代码
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Product;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Repository;

/**
 * 商品 Mapper 接口
 *
 * @author ken
 */
@Repository
public interface ProductMapper extends BaseMapper<Product> {

    /**
     * 查询商品库存并加排他锁(悲观锁)
     *
     * @param productId 商品ID
     * @return 库存数量
     */
    @Select("SELECT stock FROM product WHERE id = #{productId} FOR UPDATE")
    Integer selectStockWithPessimisticLock(Long productId);
}

编写 Service 层 ProductService.java

复制代码
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.demo.entity.Product;
import com.example.demo.mapper.ProductMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;

/**
 * 商品服务类(悲观锁实战:库存扣减)
 *
 * @author ken
 */
@Slf4j
@Service
public class ProductService extends ServiceImpl<ProductMapper, Product> {

    private final ProductMapper productMapper;

    public ProductService(ProductMapper productMapper) {
        this.productMapper = productMapper;
    }

    /**
     * 扣减商品库存(悲观锁实现)
     *
     * @param productId 商品ID
     * @param quantity  扣减数量
     * @return 扣减结果(true:成功,false:失败)
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean deductStockPessimistic(Long productId, Integer quantity) {
        // 1. 参数校验
        if (ObjectUtils.isEmpty(productId) || ObjectUtils.isEmpty(quantity) || quantity <= 0) {
            log.error("扣减库存参数无效:productId={}, quantity={}", productId, quantity);
            return false;
        }

        // 2. 查询库存并加排他锁(悲观锁核心:FOR UPDATE)
        Integer currentStock = productMapper.selectStockWithPessimisticLock(productId);
        if (ObjectUtils.isEmpty(currentStock)) {
            log.error("商品不存在或库存未初始化:productId={}", productId);
            return false;
        }

        // 3. 校验库存是否足够
        if (currentStock < quantity) {
            log.error("商品库存不足:productId={}, currentStock={}, quantity={}", 
                     productId, currentStock, quantity);
            return false;
        }

        // 4. 扣减库存
        Product product = new Product();
        product.setId(productId);
        product.setStock(currentStock - quantity);
        boolean updateResult = updateById(product);
        if (updateResult) {
            log.info("库存扣减成功:productId={}, 扣减前={}, 扣减后={}", 
                     productId, currentStock, currentStock - quantity);
        } else {
            log.error("库存扣减失败:productId={}", productId);
        }

        return updateResult;
    }
}

编写 Controller 层 ProductController.java(集成 Swagger3):

复制代码
import com.example.demo.service.ProductService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * 商品控制器(悲观锁实战)
 *
 * @author ken
 */
@RestController
@RequestMapping("/api/product")
@Api(tags = "商品管理接口(悲观锁实战)")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    /**
     * 扣减商品库存(悲观锁实现)
     *
     * @param productId 商品ID
     * @param quantity  扣减数量
     * @return 扣减结果
     */
    @PostMapping("/deduct-stock/pessimistic")
    @ApiOperation(value = "扣减商品库存(悲观锁)", notes = "基于 MySQL 行锁实现,避免库存超卖")
    public ResponseEntity<Boolean> deductStockPessimistic(
            @ApiParam(value = "商品ID", required = true, example = "1") 
            @RequestParam Long productId,
            @ApiParam(value = "扣减数量", required = true, example = "1") 
            @RequestParam Integer quantity) {
        boolean result = productService.deductStockPessimistic(productId, quantity);
        return ResponseEntity.ok(result);
    }
}
2.2.2 乐观锁:"先操作后校验",容忍并发冲突

乐观锁的核心逻辑是 "乐观预期"------ 认为并发冲突很少发生,因此不提前加锁,而是在 "提交事务时" 通过 "版本号" 或 "时间戳" 校验数据是否被修改,若被修改则重试。

核心特性:
  • 不依赖数据库原生锁,通过业务逻辑实现;
  • 无锁等待,并发高时性能优于悲观锁;
  • 需处理 "重试逻辑",避免冲突导致操作失败;
  • 不适合写操作频繁的场景(会导致大量重试)。
实现方式(2 种主流方案):
  1. 版本号机制 (推荐):在表中增加 version 字段,每次更新时版本号 + 1,提交时校验版本号是否与查询时一致;
  2. 时间戳机制 :在表中增加 update_time 字段,提交时校验时间戳是否与查询时一致(精度可能不足,不如版本号可靠)。
实战实例:乐观锁在 "库存扣减" 中的应用

业务场景与悲观锁一致,但通过 "版本号" 实现并发控制。

Step 1:修改表结构,增加版本号字段

复制代码
-- 给 product 表增加 version 字段(乐观锁版本号)
ALTER TABLE `product` 
ADD COLUMN `version` int NOT NULL DEFAULT 1 COMMENT '乐观锁版本号(每次更新+1)' AFTER `stock`;

-- 更新现有数据的版本号
UPDATE product SET version = 1;

Step 2:SQL 层面实现(版本号校验)

复制代码
-- 开启事务
START TRANSACTION;

-- 1. 查询商品信息(包含版本号)
SELECT stock, version FROM product WHERE id = 1;
-- 假设查询结果:stock=98, version=1

-- 2. 校验库存(足够)
-- 3. 扣减库存并校验版本号(核心:WHERE 条件包含 version)
UPDATE product 
SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND version = 1;

-- 4. 判断更新行数(若为 0,说明版本号已变,数据被修改)
SELECT ROW_COUNT(); -- 1:成功,0:失败(需重试)

-- 提交事务
COMMIT;

Step 3:Java 代码实现(MyBatis-Plus 自带乐观锁插件) MyBatis-Plus 提供了 OptimisticLockerInnerInterceptor 插件,可自动处理版本号逻辑,无需手动写 UPDATE 语句。

首先配置乐观锁插件 MyBatisPlusConfig.java

复制代码
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * MyBatis-Plus 配置类(乐观锁插件 + 分页插件)
 *
 * @author ken
 */
@Configuration
public class MyBatisPlusConfig {

    /**
     * 注册 MyBatis-Plus 插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 1. 乐观锁插件(核心)
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        
        // 2. 分页插件(可选,按需添加)
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        
        return interceptor;
    }
}

修改实体类 Product.java,给 version 字段加 @Version 注解:

复制代码
import com.baomidou.mybatisplus.annotation.Version;
// 其他导入省略...

@Data
@TableName("product")
public class Product implements Serializable {
    private static final long serialVersionUID = 1L;

    // 其他字段省略...

    /**
     * 乐观锁版本号(MyBatis-Plus 乐观锁插件依赖)
     */
    @Version
    private Integer version;
}

编写 Service 层(增加乐观锁实现,含重试逻辑):

复制代码
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.demo.entity.Product;
import com.example.demo.mapper.ProductMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;

import java.util.concurrent.TimeUnit;

/**
 * 商品服务类(乐观锁实战:库存扣减)
 *
 * @author ken
 */
@Slf4j
@Service
public class ProductService extends ServiceImpl<ProductMapper, Product> {

    // 其他代码省略...

    /**
     * 扣减商品库存(乐观锁实现,含重试逻辑)
     *
     * @param productId 商品ID
     * @param quantity  扣减数量
     * @param maxRetry  最大重试次数(避免无限重试)
     * @return 扣减结果(true:成功,false:失败)
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean deductStockOptimistic(Long productId, Integer quantity, int maxRetry) {
        // 1. 参数校验
        if (ObjectUtils.isEmpty(productId) || ObjectUtils.isEmpty(quantity) || quantity <= 0 || maxRetry < 0) {
            log.error("扣减库存参数无效:productId={}, quantity={}, maxRetry={}", productId, quantity, maxRetry);
            return false;
        }

        // 2. 重试逻辑(核心:失败后重试)
        int retryCount = 0;
        while (retryCount < maxRetry) {
            try {
                // 2.1 查询商品信息(包含版本号)
                LambdaQueryWrapper<Product> queryWrapper = new LambdaQueryWrapper<>();
                queryWrapper.eq(Product::getId, productId);
                Product product = getOne(queryWrapper);
                if (ObjectUtils.isEmpty(product)) {
                    log.error("商品不存在:productId={}", productId);
                    return false;
                }

                // 2.2 校验库存
                Integer currentStock = product.getStock();
                if (currentStock < quantity) {
                    log.error("商品库存不足:productId={}, currentStock={}, quantity={}", 
                             productId, currentStock, quantity);
                    return false;
                }

                // 2.3 扣减库存(MyBatis-Plus 自动处理版本号校验)
                product.setStock(currentStock - quantity);
                boolean updateResult = updateById(product);
                if (updateResult) {
                    log.info("库存扣减成功(乐观锁):productId={}, 扣减前={}, 扣减后={}, 版本号={}", 
                             productId, currentStock, currentStock - quantity, product.getVersion());
                    return true;
                } else {
                    // 2.4  updateResult 为 false,说明版本号冲突,重试
                    retryCount++;
                    log.warn("库存扣减冲突,重试中:productId={}, 重试次数={}/{}", 
                             productId, retryCount, maxRetry);
                    
                    // 可选:重试前休眠,减少CPU占用(避免自旋重试)
                    TimeUnit.MILLISECONDS.sleep(100);
                }
            } catch (InterruptedException e) {
                log.error("库存扣减重试异常:productId={}", productId, e);
                Thread.currentThread().interrupt();
                return false;
            }
        }

        // 3. 超过最大重试次数,返回失败
        log.error("库存扣减失败(超过最大重试次数):productId={}, maxRetry={}", productId, maxRetry);
        return false;
    }
}

编写 Controller 层(增加乐观锁接口):

复制代码
import io.swagger.annotations.ApiParam;
// 其他导入省略...

@RestController
@RequestMapping("/api/product")
@Api(tags = "商品管理接口(乐观锁实战)")
public class ProductController {

    // 其他代码省略...

    /**
     * 扣减商品库存(乐观锁实现)
     *
     * @param productId 商品ID
     * @param quantity  扣减数量
     * @param maxRetry  最大重试次数(默认3次)
     * @return 扣减结果
     */
    @PostMapping("/deduct-stock/optimistic")
    @ApiOperation(value = "扣减商品库存(乐观锁)", notes = "基于版本号机制,含重试逻辑,适合读多写少场景")
    public ResponseEntity<Boolean> deductStockOptimistic(
            @ApiParam(value = "商品ID", required = true, example = "1") 
            @RequestParam Long productId,
            @ApiParam(value = "扣减数量", required = true, example = "1") 
            @RequestParam Integer quantity,
            @ApiParam(value = "最大重试次数", required = false, example = "3") 
            @RequestParam(required = false, defaultValue = "3") int maxRetry) {
        boolean result = productService.deductStockOptimistic(productId, quantity, maxRetry);
        return ResponseEntity.ok(result);
    }
}
2.2.3 乐观锁 vs 悲观锁:怎么选?

很多开发者纠结 "哪种锁更好",其实没有绝对答案,关键看业务场景。以下是核心对比和选型建议:

对比维度 乐观锁 悲观锁
核心逻辑 先操作后校验(版本号) 先锁后操作(行锁 / 表锁)
并发性能 高(无锁等待) 低(锁等待阻塞)
实现复杂度 高(需处理重试) 低(依赖数据库原生锁)
数据一致性 最终一致(可能重试) 强一致(锁定期间数据不被修改)
适用场景 读多写少(如商品详情、用户信息) 写多读少(如库存扣减、订单支付)

选型口诀:"读多写少用乐观,写多读少用悲观;一致性要求高用悲观,并发性能要求高用乐观"。

三、InnoDB 进阶锁机制:Gap 锁与 Next-Key Lock(解决幻读)

在 Repeatable Read(RR)隔离级别下,InnoDB 引入了 Gap 锁Next-Key Lock,核心目的是解决 "幻读" 问题 ------ 这是 InnoDB 锁机制的难点,也是面试高频考点。

3.1 什么是幻读?

幻读指 "同一事务中,多次执行相同的查询语句,返回的结果集行数不一致"。例如:

  1. 事务 A 执行 SELECT * FROM product WHERE stock > 50,返回 3 条数据;
  2. 事务 B 插入 1 条 stock=60 的数据并提交;
  3. 事务 A 再次执行相同查询,返回 4 条数据 ------ 这就是幻读。

注意:幻读的核心是 "行数变化",与 "不可重复读"(同一行数据值变化)不同。

3.2 Gap 锁:锁定 "间隙",防止插入新数据

Gap 锁(间隙锁)是 锁定 "索引区间中的间隙",不锁定具体数据行,目的是防止其他事务在 "间隙中插入新数据",从而避免幻读。

核心特性:
  • 仅作用于 "索引区间",不锁定实际数据;
  • 仅在 RR 隔离级别下生效(RC 级别下无 Gap 锁);
  • 触发条件:通过 "范围查询"(如 >, <, BETWEEN)操作索引字段。
实战实例:Gap 锁的触发与效果

假设 product 表有以下数据(id 为主键索引):

id name stock version
1 iPhone 15 98 2
3 华为 Mate 60 198 2
5 小米 14 150 1

Step 1:Session A 开启事务,执行范围查询(触发 Gap 锁)

复制代码
-- Session A:开启事务(RR 隔离级别)
START TRANSACTION;

-- 范围查询:查询 id > 1 且 id < 5 的数据,触发 Gap 锁
-- 锁定的间隙:(1, 3) 和 (3, 5)(注意:不包含 1、3、5 本身)
SELECT * FROM product WHERE id BETWEEN 2 AND 4 FOR UPDATE;

Step 2:Session B 尝试在间隙中插入数据(被阻塞)

复制代码
-- Session B:开启事务
START TRANSACTION;

-- 尝试插入 id=2 的数据(属于 (1,3) 间隙,被 Gap 锁阻塞)
INSERT INTO product (`name`, `stock`, `version`) VALUES ('OPPO Find X7', 120, 1);
-- 结果:阻塞,直到 Session A 提交事务或超时

-- 尝试插入 id=4 的数据(属于 (3,5) 间隙,同样被阻塞)
INSERT INTO product (`name`, `stock`, `version`) VALUES ('vivo X100', 130, 1);
-- 结果:阻塞

Step 3:Session B 尝试插入非间隙数据(允许)

复制代码
-- 插入 id=6 的数据(不属于 (1,3) 或 (3,5) 间隙,允许)
INSERT INTO product (`name`, `stock`, `version`) VALUES ('荣耀 Magic6', 110, 1);
-- 结果:正常执行

Step 4:Session A 提交事务,释放 Gap 锁

复制代码
-- Session A:提交事务
COMMIT;

-- Session B:之前阻塞的插入操作自动执行
-- 结果:id=2 和 id=4 的数据成功插入

3.3 Next-Key Lock:Gap 锁 + 行锁的组合

Next-Key Lock 是 Gap 锁 + 行锁的组合,既锁定 "间隙",又锁定 "间隙边界的行数据"------ 这是 InnoDB 在 RR 级别下默认的行锁策略(范围查询时)。

核心逻辑:
  • 锁定范围为 "左开右闭" 区间,例如 id BETWEEN 2 AND 4 会锁定 (1,5] 区间;
  • 包含两部分:
    1. Gap 锁:锁定 (1,3) 和 (3,5) 间隙;
    2. 行锁:锁定 id=3 和 id=5 的行数据(若存在)。
实战实例:Next-Key Lock 的效果

使用与 Gap 锁实例相同的数据(id=1、3、5)。

Step 1:Session A 开启事务,执行范围查询(触发 Next-Key Lock)

复制代码
-- Session A:开启事务(RR 隔离级别)
START TRANSACTION;

-- 范围查询:查询 id > 1 且 id <=5 的数据,触发 Next-Key Lock
-- 锁定区间:(1,5](包含 Gap 锁 (1,3)、(3,5) 和行锁 id=3、5)
SELECT * FROM product WHERE id > 1 AND id <=5 FOR UPDATE;

Step 2:Session B 测试锁冲突

复制代码
-- Session B:开启事务

-- 测试1:修改 id=3 的行(被行锁阻塞)
UPDATE product SET stock = 197 WHERE id = 3;
-- 结果:阻塞

-- 测试2:插入 id=2 的数据(被 Gap 锁阻塞)
INSERT INTO product (`name`, `stock`, `version`) VALUES ('OPPO Find X7', 120, 1);
-- 结果:阻塞

-- 测试3:插入 id=6 的数据(不属于 (1,5] 区间,允许)
INSERT INTO product (`name`, `stock`, `version`) VALUES ('荣耀 Magic6', 110, 1);
-- 结果:正常执行

Step 3:Session A 提交事务,释放锁

复制代码
-- Session A:提交事务
COMMIT;

-- Session B:阻塞的操作自动执行

3.4 为什么 RC 级别没有 Gap 锁?

RC(Read Committed)级别下,InnoDB 会关闭 Gap 锁和 Next-Key Lock,仅保留行锁 ------ 原因是 RC 级别 "不保证避免幻读",通过 "快照读"(一致性非锁定读)减少锁冲突,换取更高的并发性能。

两者对比:

隔离级别 锁机制 幻读防护 并发性能
RR 行锁 + Gap 锁 + Next-Key Lock 防护
RC 仅行锁 不防护

避坑指南:如果业务不需要 "避免幻读"(如普通订单查询),可将隔离级别设为 RC,减少 Gap 锁带来的锁冲突;若需要强一致性(如金融交易),则必须用 RR 级别。

四、MySQL 死锁:产生原因、检测与解决

死锁是 "多个事务互相等待对方释放锁" 的僵局,例如:事务 A 持有行锁 1,等待事务 B 的行锁 2;事务 B 持有行锁 2,等待事务 A 的行锁 1------ 此时两者都无法继续,形成死锁。

4.1 死锁的产生条件(必要且充分)

根据 "操作系统死锁理论",死锁的产生必须满足以下 4 个条件,缺一不可:

  1. 互斥条件:资源(锁)只能被一个事务占用;
  2. 持有并等待条件:事务持有部分资源,同时等待其他资源;
  3. 不可剥夺条件:事务已持有的资源(锁)不能被强制剥夺;
  4. 循环等待条件:多个事务形成 "互相等待" 的循环链。

4.2 实战实例:死锁的产生过程

以下实例演示两个事务因 "交叉加锁" 导致死锁:

Step 1:使用 product 表(id=1 和 id=2 存在数据)

Step 2:Session A 开启事务,锁定 id=1 的行

复制代码
-- Session A:开启事务
START TRANSACTION;

-- 锁定 id=1 的行(加 X 锁)
UPDATE product SET stock = stock - 1 WHERE id = 1;
-- 结果:成功,stock=97

Step 3:Session B 开启事务,锁定 id=2 的行

复制代码
-- Session B:开启事务
START TRANSACTION;

-- 锁定 id=2 的行(加 X 锁)
UPDATE product SET stock = stock - 1 WHERE id = 2;
-- 结果:成功,stock=197

Step 4:Session A 尝试锁定 id=2 的行(等待 Session B 释放锁)

复制代码
-- Session A:尝试锁定 id=2 的行
UPDATE product SET stock = stock - 1 WHERE id = 2;
-- 结果:阻塞,等待 Session B 释放 id=2 的锁

Step 5:Session B 尝试锁定 id=1 的行(触发死锁)

复制代码
-- Session B:尝试锁定 id=1 的行
UPDATE product SET stock = stock - 1 WHERE id = 1;
-- 结果:MySQL 检测到死锁,自动回滚 Session B 的事务
-- 报错信息:Deadlock found when trying to get lock; try restarting transaction

4.3 MySQL 如何检测和处理死锁?

InnoDB 内置了 "死锁检测机制",核心逻辑如下:

  1. 定时检测:每隔一段时间(默认 1 秒)检查是否存在 "循环等待" 的事务链;
  2. 死锁处理:一旦检测到死锁,选择 "代价最小" 的事务(如修改行数最少、事务时间最短)进行回滚,释放锁,让其他事务继续执行;
  3. 锁等待超时 :若未检测到死锁(如循环链过长),则等待 innodb_lock_wait_timeout(默认 50 秒)后,自动回滚超时的事务。

通过以下 SQL 查看和修改死锁相关参数:

复制代码
-- 查看死锁检测开关(1:开启,0:关闭)
SHOW VARIABLES LIKE 'innodb_deadlock_detect'; -- 默认 1

-- 查看锁等待超时时间(单位:秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 默认 50

-- 临时修改锁等待超时时间(重启后失效)
SET GLOBAL innodb_lock_wait_timeout = 30;

-- 永久修改(需修改 my.cnf 并重启 MySQL)
-- innodb_deadlock_detect = 1
-- innodb_lock_wait_timeout = 30

4.4 如何避免死锁?(5 个实战技巧)

死锁的核心是 "循环等待",因此避免死锁的关键是 "打破死锁的 4 个条件",以下是工业界常用的 5 个技巧:

  1. 统一加锁顺序:所有事务对 "多资源" 的加锁顺序保持一致(如先锁 id 小的行,再锁 id 大的行)。示例:事务 A 和 B 都先锁 id=1,再锁 id=2,避免交叉等待。

  2. 缩小锁持有时间:尽量缩短事务执行时间,在 "必要时才加锁",加锁后快速完成操作并提交事务。反例:事务中包含 "查询 + 业务逻辑 + 写操作",业务逻辑耗时过长,导致锁持有时间久。

  3. 避免范围查询加锁 :范围查询(如 BETWEEN>)会触发 Gap 锁,扩大锁范围,增加死锁概率。尽量用 "等值查询"(如 WHERE id = ?)。

  4. 设置合理的锁等待超时 :将 innodb_lock_wait_timeout 设为较小值(如 10-30 秒),避免事务长时间阻塞。

  5. 使用乐观锁替代悲观锁:乐观锁无锁等待,从根本上避免死锁(适合读多写少场景)。

五、MySQL 锁的实战总结:从理论到业务落地

掌握锁机制后,关键是 "在正确的场景用正确的锁",以下是核心总结和业务落地建议:

5.1 锁机制全景图(一张图看懂所有锁)

5.2 高频业务场景的锁选型建议

业务场景 推荐锁类型 核心原因 注意事项
商品库存扣减(秒杀) 悲观锁 写操作频繁,需强一致性,避免超卖 必须通过索引加锁,避免表锁
订单状态更新(支付 / 取消) 悲观锁 状态修改需原子性,避免并发修改导致状态错乱 缩小事务范围,减少锁持有时间
商品详情查询(读多写少) 乐观锁 读操作频繁,冲突少,无需锁等待 增加重试逻辑,避免冲突导致查询失败
用户信息修改(低频写) 乐观锁 写操作少,乐观锁性能更高 版本号字段需加索引,提升校验效率
全表数据迁移(批量操作) 表锁 全表操作,行锁效率低,表锁更高效 选择业务低峰期执行,避免影响正常业务

5.3 避坑指南:90% 开发者会踩的 5 个锁问题

  1. 无索引导致行锁退化为表锁 :所有行锁操作必须通过索引过滤,否则 InnoDB 会自动升级为表锁。验证方法:执行 EXPLAIN 查看 SQL 是否走索引,若 typeALL(全表扫描),则会触发表锁。

  2. Gap 锁导致莫名锁冲突:RR 级别下,范围查询会触发 Gap 锁,即使操作 "不存在的行" 也会锁定间隙。解决方法:非必要不使用范围查询,或改用 RC 隔离级别。

  3. 事务未提交导致锁未释放 :事务中加锁后,若未提交或回滚,锁会一直持有,导致其他事务阻塞。排查方法:执行 SHOW ENGINE INNODB STATUS; 查看未提交的事务和锁信息。

  4. 乐观锁未处理重试逻辑:乐观锁冲突时若不重试,会直接返回失败,影响用户体验。解决方法:增加重试逻辑(如最多重试 3 次),重试前可短暂休眠,减少 CPU 占用。

  5. 死锁未做降级处理 :死锁发生后,若不捕获异常并降级(如返回 "系统繁忙,请稍后再试"),会导致用户操作失败。解决方法:Java 代码中捕获 DeadlockLoserDataAccessException,增加降级策略。

结尾:如何进一步验证和学习 MySQL 锁?

  1. 查看锁信息 :执行 SHOW ENGINE INNODB STATUS; 查看当前锁等待、死锁日志、事务信息;
  2. 监控锁等待 :通过 MySQL 监控工具(如 Prometheus + Grafana)监控 innodb_row_lock_waits(行锁等待次数)等指标;
  3. 官方文档参考 :InnoDB 锁机制的权威来源是 MySQL 官方文档
相关推荐
沐伊~2 小时前
mysql 安装
数据库·mysql
成为你的宁宁2 小时前
Ubuntu安装mysql5.7及常见错误问题
linux·mysql·ubuntu
努力学习的小廉3 小时前
初识MYSQL —— 复合查询
android·数据库·mysql
熙客14 小时前
Kubernetes是如何保证有状态应用数据安全和快速恢复的
mysql·云原生·容器·kubernetes
倔强的石头10616 小时前
KingbaseES:从兼容到超越,详解超越MySQL的权限隔离与安全增强
数据库·mysql·安全·金仓数据库
小鸡毛程序员16 小时前
我在CSDN学MYSQL之----数据库基本概念和基本知识(下)
数据库·mysql
米花町的小侦探16 小时前
解决 GORM + MySQL 5.7 报错:Error 1067: Invalid default value for ‘updated_at‘
mysql
老衲提灯找美女19 小时前
MySQL数据库基础操作:
数据库·mysql·oracle
ヾChen19 小时前
头歌MySQL——复杂查询
数据库·物联网·学习·mysql·头歌