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


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

思路:

在分布式系统中,乐观锁的核心设计思想是 “假设冲突概率低,通过版本控制避免显式加锁”,适用于并发更新频繁但冲突较少的场景(如库存扣减、订单状态更新等)。

与悲观锁(如分布式锁)通过阻塞控制并发不同,乐观锁通过 “版本校验” 实现无阻塞并发,性能更优。

一、分布式乐观锁的核心设计思路

  1. 版本标识机制:在共享资源(通常是数据库记录)中添加一个 “版本字段”(如versionupdate_time),用于标记资源的当前状态。
    • 每次读取资源时,同时获取版本号;
    • 更新资源时,校验当前版本号是否与读取时一致:
      • 一致:说明期间无其他线程修改,允许更新,并将版本号 + 1;
      • 不一致:说明资源已被修改,更新失败,需重试或降级。
  2. 分布式环境适配:版本号必须存储在分布式共享存储中(如 MySQL、Redis),确保所有节点(服务实例)能访问到同一版本信息。

二、典型应用场景

电商库存扣减为例:

  • 场景描述:多用户同时购买同一商品,需保证库存不超卖(最终库存 ≥ 0)。
  • 冲突特点:高并发读,但实际扣减冲突集中在库存接近 0 时,大部分场景无冲突,适合乐观锁。

三、具体实现(以 MySQL 为例)

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 '商品库存表';

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

2. 代码实现(Java + MyBatis)

核心逻辑:查询库存与版本号 → 校验库存是否充足 → 带版本号更新库存 → 校验更新结果(失败则重试)。

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

// Mapper接口(MyBatis)
public interface ProductStockMapper {
    // 查询商品库存(含版本号)
    ProductStock selectById(Long id);

    // 扣减库存(带版本号校验)
    // SQL:UPDATE product_stock SET stock = stock - #{deductNum}, version = version + 1 
    // WHERE id = #{id} AND version = #{version} AND stock >= #{deductNum}
    int deductStock(@Param("id") Long id, @Param("deductNum") int deductNum, @Param("version") int version);
}
(2)业务逻辑(含重试机制)
@Service
public class StockService {
    @Autowired
    private ProductStockMapper productStockMapper;

    // 最大重试次数(避免无限重试)
    private static final int MAX_RETRY = 3;

    /**
     * 扣减商品库存
     * @param productId 商品ID
     * @param deductNum 扣减数量
     * @return 是否成功
     */
    public boolean deductStock(Long productId, int deductNum) {
        int retryCount = 0;
        while (retryCount < MAX_RETRY) {
            // 1. 查询当前库存与版本号(分布式共享数据)
            ProductStock stock = productStockMapper.selectById(productId);
            if (stock == null) {
                throw new RuntimeException("商品不存在");
            }

            // 2. 提前校验库存是否充足(减少无效更新)
            if (stock.getStock() < deductNum) {
                System.out.println("库存不足,当前库存:" + stock.getStock());
                return false;
            }

            // 3. 带版本号更新库存(核心:乐观锁校验)
            int affectRows = productStockMapper.deductStock(productId, deductNum, stock.getVersion());
            if (affectRows > 0) {
                // 更新成功:版本号已递增,其他线程将读取到新版本
                System.out.println("库存扣减成功,剩余库存:" + (stock.getStock() - deductNum));
                return true;
            }

            // 4. 更新失败(版本号不匹配,说明被其他线程修改),重试
            retryCount++;
            System.out.println("扣减冲突,重试次数:" + retryCount);
        }

        // 超过最大重试次数,降级处理(如返回失败,由用户重试)
        System.out.println("超过最大重试次数,扣减失败");
        return false;
    }
}

3. 核心 SQL 解析(乐观锁的关键)

扣减库存的 SQL 语句是乐观锁的核心,通过WHERE条件同时校验版本号和库存充足性:

UPDATE product_stock 
SET stock = stock - #{deductNum}, version = version + 1 
WHERE id = #{id} 
  AND version = #{version}  -- 版本号校验:确保期间未被修改
  AND stock >= #{deductNum}  -- 库存充足性校验
  • version与查询时一致,且库存充足,则更新成功(影响行数 = 1);
  • version已被其他线程修改(版本号不匹配),或库存不足,则更新失败(影响行数 = 0)。

四、分布式环境下的注意事项

  1. 重试策略设计:冲突时需重试,但需限制重试次数(如 3 次),避免高冲突场景下的无限循环导致性能雪崩。可结合退避策略(如重试间隔递增)降低并发压力。

  2. 版本号的原子性:版本号的更新必须与业务操作(如库存扣减)在同一事务中,确保原子性(依赖数据库事务 ACID)。

  3. 与悲观锁的配合:若业务场景冲突频繁(如秒杀最后几件商品),乐观锁的重试可能导致性能下降,此时可降级为悲观锁(如SELECT ... FOR UPDATE),避免无效重试。

  4. 非数据库场景的适配:若共享资源存储在 Redis 中,可通过WATCH命令实现乐观锁(监控版本号键,事务中校验版本号是否变化):

    // Redis乐观锁伪代码
    String key = "product:stock:1001";
    String versionKey = "product:version:1001";
    int deductNum = 1;
    
    Jedis jedis = new Jedis("localhost");
    try {
        jedis.watch(versionKey); // 监控版本号
        int currentVersion = Integer.parseInt(jedis.get(versionKey));
        int currentStock = Integer.parseInt(jedis.get(key));
    
        if (currentStock < deductNum) {
            return false;
        }
    
        // 事务中更新库存和版本号
        Transaction tx = jedis.multi();
        tx.decrBy(key, deductNum);
        tx.incr(versionKey);
        List<Object> result = tx.exec(); // 若versionKey被修改,result为null
    
        return result != null && !result.isEmpty();
    } finally {
        jedis.unwatch();
        jedis.close();
    }
    

总结

分布式乐观锁通过 “版本号校验 + 无阻塞重试” 实现高效并发控制,适合冲突较少的场景。核心是依赖共享存储的版本标识,确保所有节点能感知资源状态变化,同时通过应用层重试解决冲突。相比分布式悲观锁(如 Redis 分布式锁),乐观锁避免了锁竞争导致的阻塞,性能更优,但需合理设计重试策略以应对高冲突场景。

JAVA-技能点
多线程