当前位置: 首页 > news >正文

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 成为主流的核心原因。

特性InnoDBMyISAM
锁粒度表锁 + 行锁(支持)仅表锁(不支持行锁)
事务支持支持 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 插件*/@Beanpublic 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 乐观锁插件依赖)*/@Versionprivate 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 为主键索引):

idnamestockversion
1iPhone 15982
3华为 Mate 601982
5小米 141501

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 是否走索引,若 type 为 ALL(全表扫描),则会触发表锁。

  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 官方文档。
http://www.dtcms.com/a/573831.html

相关文章:

  • Spring AOP和事物
  • 系列文章<九>(从LED显示屏的偏色问题问题到手机影像):从LED冬奥会、奥运会及春晚等大屏,到手机小屏,快来挖一挖里面都有什么
  • linux上从 MySQL 官方二进制包安装 MySQL
  • 网络通信---OSI七层模型
  • 淘宝客如何做淘宝客网站网站特色分析
  • 问题:编译jetson-inference,找不到-lnpymath
  • redis集群下如何使用lua脚本
  • 剪贴板管理工具,高效管理复制内容
  • 2.1 python装饰器基础:从语法糖到高阶函数
  • 什么是网站维护中珠宝 网站欣赏
  • 《投资-162》谨慎参与大涨之后的大跌的反弹。
  • 【区块链】一、原理与起源
  • LeetCode算法日记 - Day 94: 最长的斐波那契子序列的长度
  • 站长之家特效网站成都记者留言网站
  • 从咨询师到产品创造者:当AI让‘重复劳动‘变成创业金矿
  • 浅学Java-设计模式
  • Java Vector集合全面解析:线程安全的动态数组
  • FIB为什么要用液态镓来做离子源?
  • zabbix深度监控之邮件告警、微信群和微信告警
  • h5手机网站建设网页制作开版费
  • 企业做网站需要多少钱江西省住房建设厅统计网站
  • 游戏登录接口被爆破?从限流到 AI 防护的完整防御方案
  • 【训练技巧】优化器adam和adamw的公式推导详解及区别
  • 网易云网站开发网页开发的基本过程
  • 网站建设实训报告作业惠民网站建设
  • 上海华亮建设集团网站河南省城乡与住房建设厅网站首页
  • 【计算机视觉目标检测算法对比:R-CNN、YOLO与SSD全面解析】
  • 如何判断一个需求是“必须做”还是“可以等”?
  • 网站的主机做网站有发展吗
  • 力扣.84柱状图中最大矩形 力扣.134加油站牛客.abb(hard 动态规划+哈希表)牛客.哈夫曼编码