【高频面试题】分布式锁在项目中的应用

代码分布式锁

一、什么是分布式锁 & 什么时候用

单机项目:synchronized / ReentrantLock 是 JVM 层面锁,只能锁住当前这一台服务实例。

微服务 / 多实例部署:同一个服务部署多份,请求可能打到不同机器,JVM 锁失效,需要分布式锁 ,保证同一时刻只有一个线程执行临界区代码

典型业务场景

  1. 库存扣减(防超卖):多实例同时下单扣库存,防止库存扣成负数
  2. 定时任务防重复执行:定时任务服务部署多实例,避免同一个定时任务在多台机器同时跑
  3. 接口幂等 / 防止重复提交:用户快速重复点击提交、第三方支付重复回调
  4. 共享资源抢占:同一个账号同一时间只能发起一次提现;限制同一设备并发操作
  5. 分布式流水号生成:多服务生成唯一连续编号

核心诉求:互斥性、防止死锁、锁不能被其他线程随意释放、业务超时锁自动处理

生产项目直接使用 Redisson RLock,底层帮我们处理好了各种边界问题,不用自己手写 Redis 锁。

二、Redisson RLock 核心能力

  1. 可重入锁:同一个线程拿到锁之后,可以再次获取同一把锁(方法嵌套调用不会死锁)
  2. 看门狗 WatchDog 自动续期 :如果业务执行时间超过锁默认过期时间,后台线程自动延长锁有效期;业务执行完毕停止续期,锁自动释放。 ⚠️ 只有调用 tryLock(waitTime, TimeUnit) 不指定 leaseTime,看门狗才会生效。
  3. 锁持有者校验:只有加锁的线程才能解锁,其他线程不能释放别人的锁,抛异常保护
  4. 支持锁等待、线程中断
  5. 额外扩展:公平锁、读写锁、红锁(RedLock)

三、项目接入

  1. Maven 依赖(SpringBoot)
XML 复制代码
<!-- redisson springboot starter -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>
  1. 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
  1. 无参 tryLock()
java 复制代码
boolean ok = rLock.tryLock();
  • 逻辑:立刻尝试拿锁,拿不到直接返回 false,不等待
  • 看门狗:✅ 开启(默认 30s,每 10s 续期)
  • 抛出:不会抛 InterruptedException
  • 使用场景:定时任务,抢到就执行,抢不到直接跳过(@Scheduled
  1. 两参数 tryLock(long waitTime, TimeUnit unit)

项目最推荐

java 复制代码
boolean ok = rLock.tryLock(10, TimeUnit.SECONDS);
  • waitTime最大等待锁的时间。在这个时间内循环尝试抢锁;超过这个时间还拿不到,返回 false
  • unit:时间单位 TimeUnit.SECONDS / MILLISECONDS
  • 看门狗:✅ 开启!!(底层源码里 leaseTime=-1,启用看门狗,默认锁有效期 30s,业务没跑完就自动续期)
  • 异常:等待锁过程中线程被中断,抛出 InterruptedException,需要捕获

✅ 业务代码首选,前面库存扣减示例就是这个

  1. 三参数 tryLock(long waitTime, long leaseTime, TimeUnit unit)
java 复制代码
boolean ok = rLock.tryLock(10, 20, TimeUnit.SECONDS);
  • waitTime:最大等待锁时间
  • leaseTime锁租约有效期(锁过期时间) 。一旦拿到锁,leaseTime 时间之后锁自动释放,不会自动续期
  • unit:时间单位
  • 看门狗:❌ 关闭!!只要传了 leaseTime,看门狗直接失效

⚠️ 坑:业务执行时间 > leaseTime,锁会提前释放,并发安全失效! 适用场景:业务执行时长固定,很短,可以预估,不需要看门狗。

参数一句话总结

  1. waitTime:愿意花多久去抢锁,抢不到就放弃;
  2. leaseTime :拿到锁之后,锁多久自动过期。只要指定 leaseTime,看门狗失效;不指定 leaseTime(两参数版本),看门狗开启

配套对比 lock () 方法

java 复制代码
rLock.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 时间内自旋重试获取锁。

代码示例汇总

java 复制代码
RLock 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();
}

七、项目开发中的注意事项(面试高频)

  1. 锁 key 设计 :尽量粒度细,比如按商品 id lock:stock:1001,不要全局一把大锁,会严重降低并发。
  2. finally 释放锁 + isHeldByCurrentThread 判断 : 如果业务执行时间很长,锁自动过期释放,此时unlock()会抛出异常;所以释放锁前判断是否是当前线程持有锁。
  3. 看门狗原理:默认锁有效期 30s;开启看门狗后,后台定时任务每 10s 检查,如果业务线程还持有锁,就重新把锁过期时间重置为 30s。一旦业务代码执行完毕,停止续期,锁到期自动释放。
  4. 主从切换风险 :普通 RLock 是基于 Redis 主从,主节点加锁成功,还没同步到从节点 master 宕机,slave 升为主,会出现锁丢失。 如果业务对一致性要求极高(金融交易),使用 redissonClient.getMultiLock() 红锁 RedLock。绝大多数普通业务,主从模式 RLock 足够。
  5. 不要把锁放在方法外共享 RLock 对象 :每个业务请求,redissonClient.getLock(lockKey) 获取锁对象,局部使用。

八、精简版

Q:项目里分布式锁怎么用的?

A:

mm项目微服务多实例部署,定时任务、库存扣减场景需要分布式锁,使用 Redisson 的 RLock。

RLock 是可重入锁,支持看门狗自动续期,解决业务执行超过锁过期时间导致锁失效问题。

代码中使用 tryLock 尝试获取锁,finally 里判断当前线程持有锁才释放锁,避免误解锁。锁 key 按业务维度细分,减少锁竞争。普通业务用单机主从 RLock;金融强一致性场景使用 RedLock 红锁。
前置区分:

  • 数据库锁分为两类:悲观锁乐观锁 很多面试题会把这两个统称为数据库分布式锁(多服务实例共用同一数据库,所以具备分布式能力)

单元测试:模拟多线程并发,验证 Redisson RLock 互斥效果

说明:

  1. 使用 SpringBootTest + Junit5,配合 Redisson;
  2. 开多个线程并发执行扣库存逻辑,不加锁会出现超卖,加了 RLock 就不会;
  3. 测试前保证 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);
    }
}

测试现象说明

  1. testDeductStockWithLock(带 RLock) 多线程争抢同一把锁,同一时间只有 1 个线程进入临界区;库存从 10 递减到 0,后续线程提示库存不足,不会超卖
  2. testDeductStockWithoutLock(无锁版本) 所有线程几乎同时读到库存 = 10,一起扣减,最终库存大概率变成负数,复现超卖

测试思路

验证分布式锁是否生效,一般用 CountDownLatch 开启大量并发线程,共享一个库存变量。对比有无锁两种场景:无锁会超卖,使用 RLock 互斥访问临界区,保证不会出现并发安全问题。

小坑提醒

  1. 测试时 Redis 不要有脏数据,锁 key 冲突会影响结果;
  2. Thread.sleep(200) 用来模拟业务耗时,放大并发竞争;
  3. 生产环境不要用静态变量模拟 DB,只是单元测试方便演示;真实场景要操作数据库。

数据库锁

一、数据库悲观锁(数据库分布式锁 - 互斥锁)

原理

利用 select ... for update行级锁

在事务内执行 select * from table where id=? for update,数据库会锁住这一行记录。 其他事务再来查询这一行,会阻塞等待;直到当前事务提交 / 回滚,锁释放。

前提条件:

  1. MySQL InnoDB 引擎
  2. 查询条件必须命中唯一索引 / 主键索引 ,才是行锁;没命中索引会升级为表锁,锁整张表,灾难!
  3. 必须在事务中使用。

业务场景

并发量不高,强一致性,对数据安全要求高,能接受锁等待。 比如:资金账务、少量库存扣减。

示例代码(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;

悲观锁优缺点

✅优点:

  1. 不用引入中间件(Redis),数据库自带能力;
  2. 锁由数据库管理,宕机后事务回滚自动释放锁,不会死锁
  3. 强互斥,一致性好。

❌缺点:

  1. 并发高时大量线程阻塞等待,数据库压力巨大,性能差;
  2. 没走索引会升级成表锁,直接锁全表;
  3. 长事务会长时间占用行锁,容易引发数据库锁等待、锁超时;
  4. 数据库主从架构:如果读从库不能用 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;

乐观锁优缺点

✅优点:

  1. 无数据库行锁,不会阻塞,并发性能比悲观锁好;
  2. 不需要中间件,只依赖数据库;
  3. 不存在死锁。

❌缺点:

  1. 高并发场景下大量更新失败,大量重试,CPU、数据库压力上涨;
  2. 只适合写冲突不那么激烈的场景;
  3. 大量重试会占用连接,极端场景需要限流。

注意:乐观锁不是真正意义上的锁,是一种 CAS 思想,很多面试会问这个点。

三、三大方案横向对比(面试核心表格)

方案 原理 性能 一致性 适用场景 缺点
数据库悲观锁 for update 行锁,事务内互斥 强一致 低并发资金、账务 高并发容易锁等待、长事务风险,容易产生数据库阻塞
数据库乐观锁 version CAS 版本号,无锁 最终一致 中等并发库存,允许重试 高并发大量失败重试,自旋消耗资源
Redisson RLock Redis 分布式锁 Redis + 看门狗可重入锁 最终一致(主从有极小丢锁风险) 高并发库存、定时任务、重复提交 依赖 Redis;主从切换有极小概率锁丢失;金融强一致可用 RedLock

四、项目选型思路

  1. 并发很低,不想引入 Redis 中间件,强一致 → 数据库悲观锁
  2. 并发中等,不想引入 Redis,可以接受重试 → 数据库乐观锁
  3. 并发高,需要高性能,多实例定时任务、防重复提交 → Redisson RLock(Redis 分布式锁)
  4. 金融核心交易,极高一致性:Redisson RedLock 或者数据库悲观锁

面试常见追问:

Q:数据库乐观锁和悲观锁是分布式锁吗?

A:

是的,因为多个服务实例共用同一个数据库,锁可以跨 JVM,属于分布式锁。

悲观锁是互斥锁;乐观锁是 CAS 无锁方案。

Q:生产上为什么高并发不用数据库悲观锁?

A:并发上来,大量请求阻塞等待行锁,数据库连接耗尽,容易拖垮数据库。

五、数据库悲观锁、乐观锁 并发单元测试

前置说明:

  1. 基于 SpringBootTest + JUnit5 + MyBatis-Plus + MySQL(InnoDB)
  2. 模拟多线程并发请求,多个线程操作同一条库存记录
  3. 悲观锁依赖数据库事务 + 行锁;乐观锁依靠版本号 CAS 校验
  4. 注意:数据库测试和 Redis 不一样,多线程共用数据库连接,会真实触发数据库锁等待
  5. 表沿用前面的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);
  1. 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);
}
  1. 实体类 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;
}
  1. 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("乐观锁多次重试失败");
    }
}
  1. 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。

测试注意事项

  1. 每次测试前手动重置数据库数据:update t_stock set num=10,version=0 where id=1;
  2. 数据库连接池要调大一点,多线程并发会占用多个连接,连接不够会直接报错
  3. 悲观锁测试时能在 MySQL 看到锁等待,可以执行 show engine innodb status; 查看行锁信息

六、业务落地避坑

数据库悲观锁坑点

  1. ❗️❗️❗️for update 必须走索引,否则表锁;❗️❗️❗️
  2. 一定要加**@Transactional**;事务必须尽快提交,不要在事务里面写远程调用、sleep;事务越长,持有锁越久。

数据库乐观锁坑点

  1. 重试次数不能无限,设置最大重试次数;
  2. 不能解决 ABA 问题(可以加时间戳版本号优化);

Redis RLock 坑点

  1. 不指定 leaseTime 看门狗才生效;
  2. 释放锁必须判断 isHeldByCurrentThread()
  3. Redis 主从异步复制存在极小丢锁风险。

总结:三大分布式锁 测试对比汇总

方案 并发现象 是否阻塞 超卖
Redisson RLock 多线程抢 Redis 锁,抢到才执行 等待期间自旋 不会超卖
MySQL 悲观锁 for update 线程卡在 select for update 排队等待行锁 ✅阻塞 不会超卖
MySQL 乐观锁 version 全部同时读取,更新阶段判断版本冲突 ❌不阻塞 不会超卖,但是大量失败重试
相关推荐
CoderYanger1 小时前
Java EE 进阶:2.3 JavaScript
java·开发语言·前端·javascript·css·职场和发展·java-ee
凤山老林1 小时前
Spring Boot + OpenSearch 实战:搞定全文检索、向量召回与混合排序
spring boot·后端·全文检索·向量·opensearch·全文索引
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之前后端分离多模块工程配置
java·spring·状态模式
梅梅绵绵冰1 小时前
SpringCloud网关
java·spring·spring cloud
敲代码的嘎仔1 小时前
互动问答系统实战:两级评论模型、ES 搜索集成、Caffeine 多级缓存全记录
java·开发语言·数据库·elasticsearch·缓存·mybatis·高并发
IT_陈寒1 小时前
Vite的HMR怎么突然罢工了?原来是我漏了这个配置
前端·人工智能·后端
BingoGo1 小时前
一个ChatGPT 超级省额度方案!用 TaskQuay 连接网页 ChatGPT 和本地 Codex
人工智能·后端
QQ_21696290962 小时前
基于微服务架构的店铺管理系统的设计与实现
大数据·spring boot·后端·spring·微服务·小程序·架构