分布式系统中,如何设计实现乐观锁,结合悲观锁


在分布式系统中,如何设计实现乐观锁,并结合悲观锁配合,防止业务场景冲突频繁

在分布式系统中,单独使用乐观锁或悲观锁都有局限:

  • 乐观锁在冲突频繁时,会因大量重试导致性能下降
  • 悲观锁在低冲突场景下会因阻塞导致并发效率降低。

因此,“乐观锁为主,冲突阈值触发悲观锁” 的混合策略成为更优解 —— 低冲突时用乐观锁保持性能,高冲突时自动切换悲观锁避免无效重试。

一、设计思路

  1. 核心策略
    • 先尝试乐观锁更新,记录冲突次数;
    • 当冲突次数超过预设阈值(如 3 次),自动切换为悲观锁,强制锁定资源避免持续冲突。
  2. 技术依赖
    • 乐观锁:基于数据库版本号(version)实现,通过UPDATE ... WHERE version = ?校验;
    • 悲观锁:基于数据库行锁(SELECT ... FOR UPDATE)实现,锁定资源直到事务提交。
  3. 适用场景:电商库存扣减(如秒杀场景:前期库存充足时冲突少,后期库存接近 0 时冲突剧增)、订单状态更新(并发支付时状态频繁变更)等。

二、实现代码(以电商库存扣减为例)

1. 数据库表设计(含版本号)

CREATE TABLE `product_stock` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `stock` int NOT NULL COMMENT '当前库存',
  `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT '商品库存表';

-- 初始化数据:商品1001,库存100
INSERT INTO `product_stock` (`id`, `stock`, `version`) VALUES (1001, 100, 0);

2. 核心代码实现(Java + Spring + MyBatis)

(1)实体类与 Mapper 接口
// 库存实体类
@Data
public class ProductStock {
    private Long id;
    private Integer stock;
    private Integer version; // 乐观锁版本号
}

// MyBatis Mapper接口
public interface ProductStockMapper {
    // 乐观锁查询:获取当前库存和版本号(无锁)
    ProductStock selectForOptimistic(Long id);

    // 乐观锁扣减:WHERE条件校验版本号和库存
    int deductByOptimistic(
        @Param("id") Long id, 
        @Param("deductNum") int deductNum, 
        @Param("version") int version
    );

    // 悲观锁查询:加行锁(FOR UPDATE),确保后续更新独占
    @Select("SELECT id, stock, version FROM product_stock WHERE id = #{id} FOR UPDATE")
    ProductStock selectForPessimistic(Long id);

    // 悲观锁扣减:无需版本号(已通过行锁保证独占)
    int deductByPessimistic(
        @Param("id") Long id, 
        @Param("deductNum") int deductNum
    );
}
(2)Mapper XML 实现(核心 SQL)
<!-- 乐观锁扣减SQL -->
<update id="deductByOptimistic">
    UPDATE product_stock
    SET stock = stock - #{deductNum}, version = version + 1
    WHERE id = #{id} 
      AND version = #{version}  <!-- 版本号校验:确保未被其他线程修改 -->
      AND stock >= #{deductNum}  <!-- 库存充足校验 -->
</update>

<!-- 悲观锁扣减SQL(无需版本号,依赖行锁) -->
<update id="deductByPessimistic">
    UPDATE product_stock
    SET stock = stock - #{deductNum}, version = version + 1  <!-- 版本号仍递增,便于后续乐观锁使用 -->
    WHERE id = #{id} 
      AND stock >= #{deductNum}  <!-- 库存充足校验 -->
</update>
(3)服务层实现(混合锁策略)
@Service
public class StockService {
    @Autowired
    private ProductStockMapper stockMapper;

    // 乐观锁最大重试次数(超过则切换悲观锁)
    private static final int OPTIMISTIC_MAX_RETRY = 3;

    /**
     * 库存扣减主方法:乐观锁重试 -> 阈值触发悲观锁
     */
    public boolean deductStock(Long productId, int deductNum) {
        // 1. 先尝试乐观锁扣减
        int retryCount = 0;
        while (retryCount < OPTIMISTIC_MAX_RETRY) {
            // 查库存和版本号(无锁)
            ProductStock stock = stockMapper.selectForOptimistic(productId);
            if (stock == null) {
                throw new RuntimeException("商品不存在");
            }
            // 提前校验库存(减少无效更新)
            if (stock.getStock() < deductNum) {
                System.out.println("库存不足,当前库存:" + stock.getStock());
                return false;
            }
            // 乐观锁更新
            int affectRows = stockMapper.deductByOptimistic(productId, deductNum, stock.getVersion());
            if (affectRows > 0) {
                System.out.println("乐观锁扣减成功,剩余库存:" + (stock.getStock() - deductNum));
                return true;
            }
            // 冲突,重试计数+1
            retryCount++;
            System.out.println("乐观锁冲突,重试次数:" + retryCount);
        }

        // 2. 乐观锁重试达阈值,切换悲观锁
        System.out.println("乐观锁重试超限,切换悲观锁");
        return deductByPessimistic(productId, deductNum);
    }

    /**
     * 悲观锁扣减(独立方法,方便事务控制)
     */
    @Transactional(rollbackFor = Exception.class) // 悲观锁必须在事务中,否则锁会提前释放
    public boolean deductByPessimistic(Long productId, int deductNum) {
        // 加行锁查询(锁定当前行,其他线程需等待事务提交)
        ProductStock stock = stockMapper.selectForPessimistic(productId);
        if (stock == null) {
            throw new RuntimeException("商品不存在");
        }
        // 校验库存
        if (stock.getStock() < deductNum) {
            System.out.println("库存不足,当前库存:" + stock.getStock());
            return false;
        }
        // 扣减库存(无需版本号校验,因行锁已保证独占)
        int affectRows = stockMapper.deductByPessimistic(productId, deductNum);
        if (affectRows > 0) {
            System.out.println("悲观锁扣减成功,剩余库存:" + (stock.getStock() - deductNum));
            return true;
        }
        return false;
    }
}

三、关键逻辑解析

  1. 乐观锁阶段
    • 无锁查询库存和版本号,通过UPDATE ... WHERE version = ?实现并发控制;
    • 若更新失败(版本号不匹配),说明发生冲突,重试计数递增,直到达到阈值。
  2. 悲观锁阶段
    • 当乐观锁重试 3 次仍失败,触发悲观锁逻辑;
    • 通过SELECT ... FOR UPDATE对目标行加行锁(InnoDB 引擎下,基于主键查询会触发行锁,避免表锁);
    • 事务内完成 “查询 - 校验 - 更新” 全流程,确保期间其他线程无法修改该记录,彻底避免冲突。
  3. 事务控制
    • 悲观锁必须在事务中执行(@Transactional),否则FOR UPDATE的锁会在查询后立即释放,失去锁定意义;
    • 乐观锁无需事务(或用短事务),减少锁持有时间,提升并发效率。

四、优势与注意事项

  1. 优势
    • 低冲突场景:乐观锁无阻塞,性能优于纯悲观锁;
    • 高冲突场景:自动切换悲观锁,避免乐观锁的无效重试风暴,保证稳定性。
  2. 注意事项
    • 阈值设置:OPTIMISTIC_MAX_RETRY需根据业务压测调整(冲突率高则阈值设小,如 2 次;冲突率低则设大,如 5 次);
    • 悲观锁范围:SELECT ... FOR UPDATE需基于主键 / 唯一索引查询,避免因索引失效导致表锁,影响并发;
    • 事务粒度:悲观锁事务需尽可能短,减少锁持有时间,降低阻塞风险。

这种混合策略兼顾了乐观锁的高性能和悲观锁的高可靠性,是分布式高并发场景下的实用方案。

JAVA-技能点
多线程