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 种类型:
-
表读锁(Shared Lock,S 锁):
- 作用:多个事务可同时加读锁,仅允许 “读操作”,禁止 “写操作”;
- 触发方式:
LOCK TABLES 表名 READ; - 释放方式:
UNLOCK TABLES;或事务结束。
-
表写锁(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 种主流方案):
- 版本号机制(推荐):在表中增加
version字段,每次更新时版本号 + 1,提交时校验版本号是否与查询时一致; - 时间戳机制:在表中增加
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 什么是幻读?
幻读指 “同一事务中,多次执行相同的查询语句,返回的结果集行数不一致”。例如:
- 事务 A 执行
SELECT * FROM product WHERE stock > 50,返回 3 条数据; - 事务 B 插入 1 条
stock=60的数据并提交; - 事务 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]区间; - 包含两部分:
- Gap 锁:锁定 (1,3) 和 (3,5) 间隙;
- 行锁:锁定 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 个条件,缺一不可:
- 互斥条件:资源(锁)只能被一个事务占用;
- 持有并等待条件:事务持有部分资源,同时等待其他资源;
- 不可剥夺条件:事务已持有的资源(锁)不能被强制剥夺;
- 循环等待条件:多个事务形成 “互相等待” 的循环链。
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 秒)检查是否存在 “循环等待” 的事务链;
- 死锁处理:一旦检测到死锁,选择 “代价最小” 的事务(如修改行数最少、事务时间最短)进行回滚,释放锁,让其他事务继续执行;
- 锁等待超时:若未检测到死锁(如循环链过长),则等待
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 个技巧:
-
统一加锁顺序:所有事务对 “多资源” 的加锁顺序保持一致(如先锁 id 小的行,再锁 id 大的行)。示例:事务 A 和 B 都先锁 id=1,再锁 id=2,避免交叉等待。
-
缩小锁持有时间:尽量缩短事务执行时间,在 “必要时才加锁”,加锁后快速完成操作并提交事务。反例:事务中包含 “查询 + 业务逻辑 + 写操作”,业务逻辑耗时过长,导致锁持有时间久。
-
避免范围查询加锁:范围查询(如
BETWEEN、>)会触发 Gap 锁,扩大锁范围,增加死锁概率。尽量用 “等值查询”(如WHERE id = ?)。 -
设置合理的锁等待超时:将
innodb_lock_wait_timeout设为较小值(如 10-30 秒),避免事务长时间阻塞。 -
使用乐观锁替代悲观锁:乐观锁无锁等待,从根本上避免死锁(适合读多写少场景)。
五、MySQL 锁的实战总结:从理论到业务落地
掌握锁机制后,关键是 “在正确的场景用正确的锁”,以下是核心总结和业务落地建议:
5.1 锁机制全景图(一张图看懂所有锁)

5.2 高频业务场景的锁选型建议
| 业务场景 | 推荐锁类型 | 核心原因 | 注意事项 |
|---|---|---|---|
| 商品库存扣减(秒杀) | 悲观锁 | 写操作频繁,需强一致性,避免超卖 | 必须通过索引加锁,避免表锁 |
| 订单状态更新(支付 / 取消) | 悲观锁 | 状态修改需原子性,避免并发修改导致状态错乱 | 缩小事务范围,减少锁持有时间 |
| 商品详情查询(读多写少) | 乐观锁 | 读操作频繁,冲突少,无需锁等待 | 增加重试逻辑,避免冲突导致查询失败 |
| 用户信息修改(低频写) | 乐观锁 | 写操作少,乐观锁性能更高 | 版本号字段需加索引,提升校验效率 |
| 全表数据迁移(批量操作) | 表锁 | 全表操作,行锁效率低,表锁更高效 | 选择业务低峰期执行,避免影响正常业务 |
5.3 避坑指南:90% 开发者会踩的 5 个锁问题
-
无索引导致行锁退化为表锁:所有行锁操作必须通过索引过滤,否则 InnoDB 会自动升级为表锁。验证方法:执行
EXPLAIN查看 SQL 是否走索引,若type为ALL(全表扫描),则会触发表锁。 -
Gap 锁导致莫名锁冲突:RR 级别下,范围查询会触发 Gap 锁,即使操作 “不存在的行” 也会锁定间隙。解决方法:非必要不使用范围查询,或改用 RC 隔离级别。
-
事务未提交导致锁未释放:事务中加锁后,若未提交或回滚,锁会一直持有,导致其他事务阻塞。排查方法:执行
SHOW ENGINE INNODB STATUS;查看未提交的事务和锁信息。 -
乐观锁未处理重试逻辑:乐观锁冲突时若不重试,会直接返回失败,影响用户体验。解决方法:增加重试逻辑(如最多重试 3 次),重试前可短暂休眠,减少 CPU 占用。
-
死锁未做降级处理:死锁发生后,若不捕获异常并降级(如返回 “系统繁忙,请稍后再试”),会导致用户操作失败。解决方法:Java 代码中捕获
DeadlockLoserDataAccessException,增加降级策略。
结尾:如何进一步验证和学习 MySQL 锁?
- 查看锁信息:执行
SHOW ENGINE INNODB STATUS;查看当前锁等待、死锁日志、事务信息; - 监控锁等待:通过 MySQL 监控工具(如 Prometheus + Grafana)监控
innodb_row_lock_waits(行锁等待次数)等指标; - 官方文档参考:InnoDB 锁机制的权威来源是 MySQL 官方文档。
