代码分布式锁
一、什么是分布式锁 & 什么时候用
单机项目:
synchronized/ReentrantLock是 JVM 层面锁,只能锁住当前这一台服务实例。微服务 / 多实例部署:同一个服务部署多份,请求可能打到不同机器,JVM 锁失效,需要分布式锁 ,保证同一时刻只有一个线程执行临界区代码。
典型业务场景
- 库存扣减(防超卖):多实例同时下单扣库存,防止库存扣成负数
- 定时任务防重复执行:定时任务服务部署多实例,避免同一个定时任务在多台机器同时跑
- 接口幂等 / 防止重复提交:用户快速重复点击提交、第三方支付重复回调
- 共享资源抢占:同一个账号同一时间只能发起一次提现;限制同一设备并发操作
- 分布式流水号生成:多服务生成唯一连续编号
核心诉求:互斥性、防止死锁、锁不能被其他线程随意释放、业务超时锁自动处理
生产项目直接使用 Redisson RLock,底层帮我们处理好了各种边界问题,不用自己手写 Redis 锁。
二、Redisson RLock 核心能力
- 可重入锁:同一个线程拿到锁之后,可以再次获取同一把锁(方法嵌套调用不会死锁)
- 看门狗 WatchDog 自动续期 :如果业务执行时间超过锁默认过期时间,后台线程自动延长锁有效期;业务执行完毕停止续期,锁自动释放。 ⚠️ 只有调用
tryLock(waitTime, TimeUnit)不指定 leaseTime,看门狗才会生效。 - 锁持有者校验:只有加锁的线程才能解锁,其他线程不能释放别人的锁,抛异常保护
- 支持锁等待、线程中断
- 额外扩展:公平锁、读写锁、红锁(RedLock)
三、项目接入
- Maven 依赖(SpringBoot)
XML
<!-- redisson springboot starter -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.5</version>
</dependency>
- Redis 配置(application.yml)
Groovy
spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
Redisson 会自动读取 spring.redis 配置,也可以单独 redisson 配置文件做更复杂的集群、哨兵配置。
RLock.tryLock () 三个重载方法
java// 重载1:无参 boolean tryLock() // 重载2:2个参数 boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException // 重载3:3个参数 boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
- 无参
tryLock()
javaboolean ok = rLock.tryLock();
- 逻辑:立刻尝试拿锁,拿不到直接返回 false,不等待
- 看门狗:✅ 开启(默认 30s,每 10s 续期)
- 抛出:不会抛 InterruptedException
- 使用场景:定时任务,抢到就执行,抢不到直接跳过(
@Scheduled)
- 两参数
tryLock(long waitTime, TimeUnit unit)【项目最推荐】
javaboolean ok = rLock.tryLock(10, TimeUnit.SECONDS);
waitTime:最大等待锁的时间。在这个时间内循环尝试抢锁;超过这个时间还拿不到,返回 falseunit:时间单位TimeUnit.SECONDS / MILLISECONDS- 看门狗:✅ 开启!!(底层源码里 leaseTime=-1,启用看门狗,默认锁有效期 30s,业务没跑完就自动续期)
- 异常:等待锁过程中线程被中断,抛出
InterruptedException,需要捕获✅ 业务代码首选,前面库存扣减示例就是这个
- 三参数
tryLock(long waitTime, long leaseTime, TimeUnit unit)
javaboolean ok = rLock.tryLock(10, 20, TimeUnit.SECONDS);
waitTime:最大等待锁时间leaseTime:锁租约有效期(锁过期时间) 。一旦拿到锁,leaseTime 时间之后锁自动释放,不会自动续期unit:时间单位- 看门狗:❌ 关闭!!只要传了 leaseTime,看门狗直接失效
⚠️ 坑:业务执行时间 > leaseTime,锁会提前释放,并发安全失效! 适用场景:业务执行时长固定,很短,可以预估,不需要看门狗。
参数一句话总结
- waitTime:愿意花多久去抢锁,抢不到就放弃;
- leaseTime :拿到锁之后,锁多久自动过期。只要指定 leaseTime,看门狗失效;不指定 leaseTime(两参数版本),看门狗开启。
配套对比 lock () 方法
javarLock.lock(); // 一直阻塞,死等锁,不可中断,看门狗开启 rLock.lockInterruptibly(); // 阻塞等待,可以响应线程中断,看门狗开启 rLock.lock(10, TimeUnit.SECONDS); // 指定leaseTime,看门狗关闭高频面试题
Q:
tryLock(10, TimeUnit.SECONDS)和tryLock(10,30,TimeUnit.SECONDS)的区别?A:
两参数:不指定 leaseTime,看门狗生效,锁默认 30s,业务运行期间持续续期;
三参数:指定 leaseTime=30s,看门狗关闭,30 秒之后锁直接过期,不会续期; waitTime 都是最多等待 10s 抢锁。
Q:tryLock () 无参和两参数区别?A:无参不等待,一次尝试;两参数会在 waitTime 时间内自旋重试获取锁。
代码示例汇总
javaRLock rLock = redissonClient.getLock("lock:stock:1001"); // 1. 无参,不等待,看门狗开启 boolean b1 = rLock.tryLock(); // 2. 两参数,最多等10s抢锁,看门狗开启【推荐业务使用】 boolean b2 = rLock.tryLock(10, TimeUnit.SECONDS); //3. 三参数:最多等10s抢锁,拿到锁后20s过期,看门狗关闭 boolean b3 = rLock.tryLock(10,20, TimeUnit.SECONDS);
四、业务代码示例 1:库存扣减场景
java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Service
public class StockService {
// 注入redisson客户端
@Resource
private RedissonClient redissonClient;
/**
* 扣减商品库存,分布式锁防止超卖
* @param productId 商品ID
*/
public void deductStock(Long productId) {
// 定义锁key:不同商品使用不同锁,提升并发粒度,不要全用同一个锁
String lockKey = "lock:stock:" + productId;
// 获取锁对象,不是马上加锁
RLock rLock = redissonClient.getLock(lockKey);
boolean acquireLock;
try {
// tryLock(最大等待锁时间,时间单位)
// 不传入leaseTime,看门狗自动开启,默认30s,每10s续期
acquireLock = rLock.tryLock(10, TimeUnit.SECONDS);
if (!acquireLock) {
// 获取锁失败,直接抛出异常或者返回提示
throw new RuntimeException("服务器繁忙,请稍后重试");
}
// =================临界区代码:只有拿到锁才会执行=================
// 1. 查询商品库存
int stock = getStockByProductId(productId);
if (stock <= 0) {
throw new RuntimeException("商品库存不足");
}
// 2. 扣减库存
updateStock(productId, stock - 1);
System.out.println("库存扣减成功,剩余库存:" + (stock - 1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断", e);
} finally {
// ✅ 释放锁前必须判断:当前线程持有锁才解锁!
// 防止锁已经过期释放,其他线程拿到锁,当前线程误释放别人的锁
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
// 模拟DB查询库存
private int getStockByProductId(Long productId) {
return 10;
}
// 模拟DB更新库存
private void updateStock(Long productId, int newStock) {
}
}
五、业务代码示例 2:分布式定时任务,防止多实例重复执行
java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Component
public class TaskJob {
@Resource
private RedissonClient redissonClient;
/**
* 定时任务:每30秒执行一次
* 多实例部署时,保证只有一台机器执行任务
*/
@Scheduled(fixedRate = 30000)
public void runTask() {
String lockKey = "lock:task:clearExpireOrder";
RLock rLock = redissonClient.getLock(lockKey);
boolean getLock;
try {
// 最多等待0秒,抢不到直接放弃,不需要排队等待
getLock = rLock.tryLock(0, TimeUnit.SECONDS);
if (!getLock) {
// 其他实例已经抢到锁,当前实例直接跳过任务
return;
}
// =========临界区,执行定时任务逻辑=========
System.out.println("开始清理过期订单任务");
// cleanExpireOrder();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
}
六、RLock 几个重要 API 说明
java
// 1. 尝试获取锁,最多等待10秒,看门狗开启(推荐业务使用)
boolean tryLock = rLock.tryLock(10, TimeUnit.SECONDS);
// 2. 尝试获取锁,等待10s,锁持有时间20s,指定leaseTime → 看门狗关闭!不会自动续期
boolean tryLock = rLock.tryLock(10, 20, TimeUnit.SECONDS);
// 3. 阻塞拿锁,一直等直到拿到锁,支持中断
rLock.lockInterruptibly();
// 4. 释放锁,必须放在finally,并且判断 isHeldByCurrentThread()
if(rLock.isHeldByCurrentThread()){
rLock.unlock();
}
七、项目开发中的注意事项(面试高频)
- 锁 key 设计 :尽量粒度细,比如按商品 id
lock:stock:1001,不要全局一把大锁,会严重降低并发。 - finally 释放锁 + isHeldByCurrentThread 判断 : 如果业务执行时间很长,锁自动过期释放,此时
unlock()会抛出异常;所以释放锁前判断是否是当前线程持有锁。 - 看门狗原理:默认锁有效期 30s;开启看门狗后,后台定时任务每 10s 检查,如果业务线程还持有锁,就重新把锁过期时间重置为 30s。一旦业务代码执行完毕,停止续期,锁到期自动释放。
- 主从切换风险 :普通 RLock 是基于 Redis 主从,主节点加锁成功,还没同步到从节点 master 宕机,slave 升为主,会出现锁丢失。 如果业务对一致性要求极高(金融交易),使用
redissonClient.getMultiLock()红锁 RedLock。绝大多数普通业务,主从模式 RLock 足够。 - 不要把锁放在方法外共享 RLock 对象 :每个业务请求,
redissonClient.getLock(lockKey)获取锁对象,局部使用。
八、精简版
Q:项目里分布式锁怎么用的?
A:
mm项目微服务多实例部署,定时任务、库存扣减场景需要分布式锁,使用 Redisson 的 RLock。
RLock 是可重入锁,支持看门狗自动续期,解决业务执行超过锁过期时间导致锁失效问题。
代码中使用 tryLock 尝试获取锁,finally 里判断当前线程持有锁才释放锁,避免误解锁。锁 key 按业务维度细分,减少锁竞争。普通业务用单机主从 RLock;金融强一致性场景使用 RedLock 红锁。
前置区分:
- 数据库锁分为两类:悲观锁 、乐观锁 很多面试题会把这两个统称为数据库分布式锁(多服务实例共用同一数据库,所以具备分布式能力)
单元测试:模拟多线程并发,验证 Redisson RLock 互斥效果
说明:
- 使用 SpringBootTest + Junit5,配合 Redisson;
- 开多个线程并发执行扣库存逻辑,不加锁会出现超卖,加了 RLock 就不会;
- 测试前保证 Redis 服务正常启动。
1. 依赖(补充 Junit5)
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
2. 业务 Service(复用上面,方便测试)
java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Service
public class StockService {
@Resource
private RedissonClient redissonClient;
// 模拟数据库库存,静态变量,多线程共享
public static int dbStock = 10;
/**
* 扣减商品库存,分布式锁防止超卖
* @param productId 商品ID
*/
public void deductStock(Long productId) {
String lockKey = "lock:stock:" + productId;
RLock rLock = redissonClient.getLock(lockKey);
boolean acquireLock;
try {
// 最多等待10s,不指定leaseTime,看门狗开启
acquireLock = rLock.tryLock(10, TimeUnit.SECONDS);
if (!acquireLock) {
throw new RuntimeException("获取锁失败,请稍后重试");
}
// =========临界区代码=========
if (dbStock <= 0) {
System.out.println(Thread.currentThread().getName() + ":库存不足");
return;
}
// 模拟业务耗时,放大并发问题
Thread.sleep(200);
dbStock = dbStock - 1;
System.out.println(Thread.currentThread().getName() + "扣减成功,剩余库存:" + dbStock);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断", e);
} finally {
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
// 【对比方法】不带分布式锁版本,用来复现超卖问题
public void deductStockWithoutLock() throws InterruptedException {
if (dbStock <= 0) {
System.out.println(Thread.currentThread().getName() + ":库存不足");
return;
}
Thread.sleep(200);
dbStock = dbStock - 1;
System.out.println(Thread.currentThread().getName() + "扣减成功,剩余库存:" + dbStock);
}
}
3. Junit5 并发测试类
java
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.Resource;
import java.util.concurrent.CountDownLatch;
@SpringBootTest
public class StockServiceTest {
@Resource
private StockService stockService;
/**
* 测试:带RLock分布式锁,15个线程并发扣库存,初始库存10
* 预期:只有10个线程扣减成功,不会出现库存负数
*/
@Test
void testDeductStockWithLock() throws InterruptedException {
// 重置库存
StockService.dbStock = 10;
// 线程数量:15个并发请求
int threadNum = 15;
CountDownLatch countDownLatch = new CountDownLatch(threadNum);
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
try {
stockService.deductStock(1L);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + "异常:" + e.getMessage());
} finally {
countDownLatch.countDown();
}
}, "线程-" + i).start();
}
// 主线程等待所有子线程执行完成
countDownLatch.await();
System.out.println("====全部执行完毕,最终库存:" + StockService.dbStock);
}
/**
* 【对比测试】不加分布式锁,15线程并发扣库存,会出现超卖
* 预期:库存会扣到负数
*/
@Test
void testDeductStockWithoutLock() throws InterruptedException {
StockService.dbStock = 10;
int threadNum = 15;
CountDownLatch countDownLatch = new CountDownLatch(threadNum);
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
try {
stockService.deductStockWithoutLock();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}, "线程-" + i).start();
}
countDownLatch.await();
System.out.println("====全部执行完毕,最终库存:" + StockService.dbStock);
}
}
测试现象说明
testDeductStockWithLock(带 RLock) 多线程争抢同一把锁,同一时间只有 1 个线程进入临界区;库存从 10 递减到 0,后续线程提示库存不足,不会超卖。testDeductStockWithoutLock(无锁版本) 所有线程几乎同时读到库存 = 10,一起扣减,最终库存大概率变成负数,复现超卖。
测试思路
验证分布式锁是否生效,一般用 CountDownLatch 开启大量并发线程,共享一个库存变量。对比有无锁两种场景:无锁会超卖,使用 RLock 互斥访问临界区,保证不会出现并发安全问题。
小坑提醒
- 测试时 Redis 不要有脏数据,锁 key 冲突会影响结果;
Thread.sleep(200)用来模拟业务耗时,放大并发竞争;- 生产环境不要用静态变量模拟 DB,只是单元测试方便演示;真实场景要操作数据库。
数据库锁
一、数据库悲观锁(数据库分布式锁 - 互斥锁)
原理
利用 select ... for update,行级锁。
在事务内执行 select * from table where id=? for update,数据库会锁住这一行记录。 其他事务再来查询这一行,会阻塞等待;直到当前事务提交 / 回滚,锁释放。
前提条件:
- MySQL InnoDB 引擎
- 查询条件必须命中唯一索引 / 主键索引 ,才是行锁;没命中索引会升级为表锁,锁整张表,灾难!
- 必须在事务中使用。
业务场景
并发量不高,强一致性,对数据安全要求高,能接受锁等待。 比如:资金账务、少量库存扣减。
示例代码(SpringBoot + MyBatis)
java
@Service
public class StockDbPessimisticService {
@Resource
private StockMapper stockMapper;
/**
* 数据库悲观锁:for update
*/
@Transactional(rollbackFor = Exception.class)
public void deductStockPessimistic(Long productId) {
// 重点:for update,加行悲观锁
Stock stock = stockMapper.selectByIdForUpdate(productId);
if (stock == null || stock.getNum() <= 0) {
throw new RuntimeException("库存不足");
}
// 扣减库存
stock.setNum(stock.getNum() - 1);
stockMapper.updateById(stock);
}
}
Mapper 接口
java
public interface StockMapper extends BaseMapper<Stock> {
@Select("select id, num from t_stock where id = #{productId} for update")
Stock selectByIdForUpdate(@Param("productId") Long productId);
}
建表语句
sql
CREATE TABLE `t_stock` (
`id` bigint NOT NULL COMMENT '商品id',
`num` int DEFAULT '0' COMMENT '库存数量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
悲观锁优缺点
✅优点:
- 不用引入中间件(Redis),数据库自带能力;
- 锁由数据库管理,宕机后事务回滚自动释放锁,不会死锁;
- 强互斥,一致性好。
❌缺点:
- 并发高时大量线程阻塞等待,数据库压力巨大,性能差;
- 没走索引会升级成表锁,直接锁全表;
- 长事务会长时间占用行锁,容易引发数据库锁等待、锁超时;
- 数据库主从架构:如果读从库不能用 for update,只能在主库加锁。
二、数据库乐观锁(数据库分布式锁,无阻塞)
原理
不加数据库锁,通过版本号做冲突校验。 在库存表增加 version 版本字段。 读取数据拿到 version,更新的时候带上 version 条件: update t_stock set num = num -1, version = version +1 where id=? and version=oldVersion
- 如果影响行数 = 1:更新成功
- 如果影响行数 = 0:说明版本已经变了,别的线程已经修改,更新失败,需要重试。
本质:不加锁,提交的时候检查冲突,失败就放弃。
适用场景
并发中等,允许重试,不想数据库长时间锁行。比如订单库存扣减。
代码示例
java
@Service
public class StockDbOptimisticService {
@Resource
private StockMapper stockMapper;
/**
* 乐观锁,循环重试
*/
public void deductStockOptimistic(Long productId) {
// 重试次数,防止死循环
int retryTimes = 3;
while (retryTimes-- > 0) {
Stock stock = stockMapper.selectById(productId);
if (stock == null || stock.getNum() <= 0) {
throw new RuntimeException("库存不足");
}
// 更新:版本号必须匹配
int rows = stockMapper.updateStockWithVersion(
productId,
stock.getNum() - 1,
stock.getVersion()
);
if (rows > 0) {
System.out.println("扣减成功");
return;
}
// 更新行数0,版本冲突,重试
System.out.println("版本冲突,重试");
}
throw new RuntimeException("多次重试失败,请稍后重试");
}
}
Mapper
java
@Update("update t_stock set num=#{newNum}, version=version+1 where id=#{id} and version=#{oldVersion}")
int updateStockWithVersion(@Param("id") Long id,
@Param("newNum") Integer newNum,
@Param("oldVersion") Integer oldVersion);
表结构增加 version 字段:
sql
ALTER TABLE t_stock ADD COLUMN version INT DEFAULT 0;
乐观锁优缺点
✅优点:
- 无数据库行锁,不会阻塞,并发性能比悲观锁好;
- 不需要中间件,只依赖数据库;
- 不存在死锁。
❌缺点:
- 高并发场景下大量更新失败,大量重试,CPU、数据库压力上涨;
- 只适合写冲突不那么激烈的场景;
- 大量重试会占用连接,极端场景需要限流。
注意:乐观锁不是真正意义上的锁,是一种 CAS 思想,很多面试会问这个点。
三、三大方案横向对比(面试核心表格)
| 方案 | 原理 | 性能 | 一致性 | 适用场景 | 缺点 |
|---|---|---|---|---|---|
| 数据库悲观锁 for update | 行锁,事务内互斥 | 低 | 强一致 | 低并发资金、账务 | 高并发容易锁等待、长事务风险,容易产生数据库阻塞 |
| 数据库乐观锁 version | CAS 版本号,无锁 | 中 | 最终一致 | 中等并发库存,允许重试 | 高并发大量失败重试,自旋消耗资源 |
| Redisson RLock Redis 分布式锁 | Redis + 看门狗可重入锁 | 高 | 最终一致(主从有极小丢锁风险) | 高并发库存、定时任务、重复提交 | 依赖 Redis;主从切换有极小概率锁丢失;金融强一致可用 RedLock |
四、项目选型思路
- 并发很低,不想引入 Redis 中间件,强一致 → 数据库悲观锁
- 并发中等,不想引入 Redis,可以接受重试 → 数据库乐观锁
- 并发高,需要高性能,多实例定时任务、防重复提交 → Redisson RLock(Redis 分布式锁)
- 金融核心交易,极高一致性:Redisson RedLock 或者数据库悲观锁
面试常见追问:
Q:数据库乐观锁和悲观锁是分布式锁吗?
A:
是的,因为多个服务实例共用同一个数据库,锁可以跨 JVM,属于分布式锁。
悲观锁是互斥锁;乐观锁是 CAS 无锁方案。
Q:生产上为什么高并发不用数据库悲观锁?
A:并发上来,大量请求阻塞等待行锁,数据库连接耗尽,容易拖垮数据库。
五、数据库悲观锁、乐观锁 并发单元测试
前置说明:
- 基于 SpringBootTest + JUnit5 + MyBatis-Plus + MySQL(InnoDB)
- 模拟多线程并发请求,多个线程操作同一条库存记录
- 悲观锁依赖数据库事务 + 行锁;乐观锁依靠版本号 CAS 校验
- 注意:数据库测试和 Redis 不一样,多线程共用数据库连接,会真实触发数据库锁等待
- 表沿用前面的
t_stock
sql
CREATE TABLE `t_stock` (
`id` bigint NOT NULL COMMENT '商品id',
`num` int DEFAULT '0' COMMENT '库存数量',
`version` int DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 初始化一条数据
INSERT INTO t_stock(id,num,version) VALUES (1,10,0);
- Mapper 接口 StockMapper.java
java
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface StockMapper extends BaseMapper<Stock> {
/**
* 悲观锁查询,for update
*/
@Select("select id, num, version from t_stock where id = #{productId} for update")
Stock selectByIdForUpdate(@Param("productId") Long productId);
/**
* 乐观锁更新,版本号匹配才更新
*/
@Update("update t_stock set num=#{newNum}, version=version+1 where id=#{id} and version=#{oldVersion}")
int updateStockWithVersion(@Param("id") Long id,
@Param("newNum") Integer newNum,
@Param("oldVersion") Integer oldVersion);
}
- 实体类 Stock.java
java
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
@Data
public class Stock {
@TableId
private Long id;
private Integer num;
private Integer version;
}
- Service 层:悲观锁 + 乐观锁业务代码
java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class StockDbService {
@Resource
private StockMapper stockMapper;
// ========== 悲观锁 for update ==========
@Transactional(rollbackFor = Exception.class)
public void deductPessimistic(Long productId) {
// 事务内加行悲观锁
Stock stock = stockMapper.selectByIdForUpdate(productId);
if (stock == null || stock.getNum() <= 0) {
System.out.println(Thread.currentThread().getName() + ":库存不足");
return;
}
// 模拟业务耗时
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
stock.setNum(stock.getNum() - 1);
stockMapper.updateById(stock);
System.out.println(Thread.currentThread().getName() + "扣减成功,剩余库存:" + stock.getNum());
}
// ========== 乐观锁 version版本号 ==========
public void deductOptimistic(Long productId) {
int maxRetry = 3;
while (maxRetry-- > 0) {
Stock stock = stockMapper.selectById(productId);
if (stock == null || stock.getNum() <= 0) {
System.out.println(Thread.currentThread().getName() + ":库存不足");
return;
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
int rows = stockMapper.updateStockWithVersion(productId, stock.getNum() - 1, stock.getVersion());
if (rows > 0) {
System.out.println(Thread.currentThread().getName() + "扣减成功");
return;
}
System.out.println(Thread.currentThread().getName() + "版本冲突,进行重试,剩余重试次数:" + maxRetry);
}
throw new RuntimeException("乐观锁多次重试失败");
}
}
- JUnit5 并发测试类
java
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.Resource;
import java.util.concurrent.CountDownLatch;
@SpringBootTest
public class StockDbLockTest {
@Resource
private StockDbService stockDbService;
/**
* 测试数据库悲观锁 for update
* 初始库存10,15个并发线程
* 预期:最多成功扣减10次,不会超卖;线程串行排队执行
*/
@Test
void testPessimisticLock() throws InterruptedException {
int threadCount = 15;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
stockDbService.deductPessimistic(1L);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + "异常:" + e.getMessage());
} finally {
latch.countDown();
}
}, "悲观锁线程-" + i).start();
}
latch.await();
System.out.println("====悲观锁测试全部结束====");
}
/**
* 测试数据库乐观锁 version版本号
* 初始库存10,15并发线程
* 预期:只有10次更新成功;其余线程版本冲突,触发重试,重试耗尽后抛出异常
*/
@Test
void testOptimisticLock() throws InterruptedException {
int threadCount = 15;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
stockDbService.deductOptimistic(1L);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + "异常:" + e.getMessage());
} finally {
latch.countDown();
}
}, "乐观锁线程-" + i).start();
}
latch.await();
System.out.println("====乐观锁测试全部结束====");
}
}
测试现象 & 原理讲解
① 悲观锁 testPessimisticLock
- 现象:线程排队阻塞 ,同一时刻只有一个线程拿到这一行的行锁,其他线程卡在
select ... for update等待; - 直到持有锁的线程事务提交,锁释放,下一个线程继续执行;
- 结果:库存扣减到 0,不会超卖。
⚠️坑:如果查询不走索引,会升级为表锁,整张表锁住,所有请求排队,性能灾难。事务不能写太长,长事务会一直持有锁,引发锁等待超时。
② 乐观锁 testOptimisticLock
- 现象:所有线程几乎同时读到库存 = 10,并发执行 update;
- 只有最先提交的那条更新
rows=1成功;剩下的线程更新返回 0,版本冲突,进入重试; - 重试耗尽则抛出异常。
乐观锁没有数据库行锁 ,不会阻塞线程,靠版本号做 CAS 校验;高并发场景大量冲突重试,会增加数据库压力。
额外补充面试追问点
Q:悲观锁是分布式锁吗?
是的。多个微服务实例共用同一个 MySQL,事务行锁可以跨 JVM 互斥,属于数据库实现的分布式锁。前提:使用 InnoDB,命中索引,事务环境。
Q:乐观锁算不算分布式锁?
严格来说乐观锁不是锁,是 CAS 无锁方案,依靠版本号做冲突检测。业务上经常归类到分布式并发控制方案,面试要把这点说清楚。
Q:悲观锁和 Redisson RLock 怎么选型?
低并发、强一致性、不想引入中间件选悲观锁;高并发,追求性能,选 Redisson RLock。
测试注意事项
- 每次测试前手动重置数据库数据:
update t_stock set num=10,version=0 where id=1; - 数据库连接池要调大一点,多线程并发会占用多个连接,连接不够会直接报错
- 悲观锁测试时能在 MySQL 看到锁等待,可以执行
show engine innodb status;查看行锁信息
六、业务落地避坑
数据库悲观锁坑点
- ❗️❗️❗️
for update必须走索引,否则表锁;❗️❗️❗️ - 一定要加**
@Transactional**;事务必须尽快提交,不要在事务里面写远程调用、sleep;事务越长,持有锁越久。
数据库乐观锁坑点
- 重试次数不能无限,设置最大重试次数;
- 不能解决 ABA 问题(可以加时间戳版本号优化);
Redis RLock 坑点
- 不指定 leaseTime 看门狗才生效;
- 释放锁必须判断
isHeldByCurrentThread(); - Redis 主从异步复制存在极小丢锁风险。
总结:三大分布式锁 测试对比汇总
| 方案 | 并发现象 | 是否阻塞 | 超卖 |
|---|---|---|---|
| Redisson RLock | 多线程抢 Redis 锁,抢到才执行 | 等待期间自旋 | 不会超卖 |
| MySQL 悲观锁 for update | 线程卡在 select for update 排队等待行锁 | ✅阻塞 | 不会超卖 |
| MySQL 乐观锁 version | 全部同时读取,更新阶段判断版本冲突 | ❌不阻塞 | 不会超卖,但是大量失败重试 |