分布式场景下的数据竞争问题与解决方案

文章目录

分布式场景下的数据竞争问题与解决方案

一、什么是分布式场景下的数据竞争问题

在分布式系统中,数据竞争(Data Race)是指多个并发请求同时对同一数据进行读写操作,由于操作执行的时序不确定,导致最终的数据状态不可预期。不同于单机环境,分布式场景下的数据竞争面临更加复杂的挑战:

  1. 无共享内存:各服务节点独立,无法像单机那样通过 synchronized 解决

  2. 网络延迟:请求到达顺序不确定,同一时刻可能有多个请求访问同一数据

  3. 数据库并发:多条 SQL 语句可能同时修改同一行或同一批数据

举例来说,库存扣减场景中:两个请求同时读取库存 10,都尝试扣减 5,若不加控制,最终库存可能是 5(正确)或 10(错误),取决于执行时序。

二、解决方案

2.1 乐观锁方案

乐观锁的核心思想是:假设冲突概率较低,先执行操作,冲突时再处理。通过版本号实现,在更新时检查数据是否被其他事务修改。

2.1.1 单数据竞争场景

场景描述:单个商品库存扣减,多个请求同时修改同一条库存记录。

实现原理
数据库 事务T1 数据库 事务T1 newStock = 10 - 5 = 5 更新成功,版本号从1变为2 SELECT * FROM inventory WHERE id = 1 -- version = 1 返回库存数据 (version=1, stock=10) UPDATE inventory SET stock=5, version=2 WHERE id=1 AND version=1 返回更新行数 = 1

  1. 读取数据及版本号
  2. 执行业务逻辑(计算新值)
  3. 更新时检查版本
  4. 结果判断:若更新行数 = 0,说明冲突,抛出异常或重试

代码示例:

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAvailableStock(Long inventoryId, Integer availableStock, 
                                  Integer expectedVersion, Long operatorId) {
    // 使用 WHERE 条件同时检查 ID 和 version,实现乐观锁
    int row = inventoryMapper.update(new LambdaUpdateWrapper<InventoryPO>()
            .set(InventoryPO::getAvailableStock, availableStock)
            .set(InventoryPO::getUpdateUser, operatorId)
            .set(InventoryPO::getVersion, expectedVersion + 1)  // 版本号递增
            .eq(InventoryPO::getId, inventoryId)
            .eq(InventoryPO::getVersion, expectedVersion));     // 核心: 乐观锁条件
    
    // 检查更新是否成功,失败则抛出乐观锁异常
    if (row == 0) {
        throw new OptimisticLockException("更新可用库存版本号冲突");
    }
}

核心要点

  • version 字段:每次更新自增 1
  • WHERE 条件:必须包含 version = expectedVersion
  • 失败信号:row == 0 表示被其他事务修改

适用场景:读多写少、冲突概率低的场景。

2.1.2 多数据竞争场景(死锁问题)

场景描述:批量扣减多个商品库存,多个事务分别锁定不同行,可能形成循环等待导致死锁。

问题根因 :MySQL InnoDB 行锁是逐行获取的,当多个事务以不同顺序访问多行数据时,可能形成循环等待。

死锁示例
数据库 事务T2 事务T1 数据库 事务T2 事务T1 步骤1: 更新sku=100 获取行锁A 步骤1: 更新sku=200 获取行锁B 步骤2: 尝试更新sku=200 等待行锁B 步骤2: 尝试更新sku=100 等待行锁A 💥 死锁! T1持有A等待B,T2持有B等待A UPDATE inventory SET stock=stock-5 WHERE sku_id=100 获取行锁A成功 UPDATE inventory SET stock=stock-3 WHERE sku_id=200 获取行锁B成功 UPDATE inventory SET stock=stock-5 WHERE sku_id=200 等待中... UPDATE inventory SET stock=stock-3 WHERE sku_id=100

  • 事务 T1:先更新 sku=100(获取行锁 A),再更新 sku=200(尝试获取行锁 B)
  • 事务 T2:先更新 sku=200(获取行锁 B),再更新 sku=100(尝试获取行锁 A)
  • 结果:T1 持有 A 等待 B,T2 持有 B 等待 A → 形成循环等待 → 死锁

解决方案:全局排序 + 乐观锁

核心思路:

  1. 全局排序:确保所有事务以相同顺序访问数据,打破循环等待
  2. 乐观锁更新:每次更新检查版本号,冲突时直接失败

⚠️ 以下代码仅为示例,不可作为生产级代码。生产环境需结合业务场景增加重试机制、降级处理、幂等性保障等。

代码示例:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void batchDeduct(List<SkuDeduct> deductList, Long operatorId) {
    
    // 步骤1: 全局排序,确保锁获取顺序一致(打破循环等待)
    List<SkuDeduct> sortedList = deductList.stream()
            .sorted(Comparator.comparing(SkuDeduct::getSkuId))
            .collect(Collectors.toList());
    
    // 步骤2: 逐条处理,每条使用乐观锁更新
    for (SkuDeduct deduct : sortedList) {
        // 读取当前库存及版本号
        InventoryPO inventory = inventoryRepo.getById(deduct.getInventoryId());
        int currentVersion = inventory.getVersion();
        int currentStock = inventory.getAvailableStock();
        
        // 校验库存是否充足
        if (currentStock < deduct.getQuantity()) {
            throw new StockInsufficientException(
                String.format("SKU[%d] 库存不足,当前库存: %d,需要: %d", 
                    deduct.getSkuId(), currentStock, deduct.getQuantity()));
        }
        
        // 计算新库存值
        int newStock = currentStock - deduct.getQuantity();
        
        // 步骤3: 调用 updateAvailableStock 使用乐观锁更新
        // 核心: WHERE version = currentVersion,冲突时抛出 OptimisticLockException
        inventoryRepo.updateAvailableStock(
            deduct.getInventoryId(),
            newStock,
            currentVersion,
            operatorId
        );
        
        logger.info("SKU[{}] 库存扣减成功,版本号: {} -> {}", 
            deduct.getSkuId(), currentVersion, currentVersion + 1);
    }
}

关键点解析

步骤 代码位置 作用
全局排序 deductList.stream().sorted(...) 确保所有事务按相同顺序获取锁,避免死锁
读取版本 inventoryRepo.getById() 获取当前版本号用于乐观锁
乐观更新 inventoryRepo.updateAvailableStock(...) WHERE 条件包含版本号,冲突抛异常
快速失败 无 try-catch,异常上抛 冲突时直接失败,由上层处理

效果对比

无排序(死锁):
数据库 事务T2 事务T1 数据库 事务T2 事务T1 💥 死锁 UPDATE sku=100 (获取锁A) UPDATE sku=200 (获取锁B) UPDATE sku=200 (等待锁B) UPDATE sku=100 (等待锁A)

排序 + 乐观锁(正常):
数据库 事务T2 (排序:100→200) 事务T1 (排序:100→200) 数据库 事务T2 (排序:100→200) 事务T1 (排序:100→200) 串行执行,无死锁 UPDATE sku=100 (获取锁A) UPDATE sku=100 (等待锁A) UPDATE sku=200 (获取锁B) ✓ 提交,释放锁A、B UPDATE sku=100 (获取锁A) ✓ UPDATE sku=200 (获取锁B) ✓

对比项 无排序 排序 + 乐观锁
T1 执行顺序 sku=100 → sku=200 sku=100 → sku=200
T2 执行顺序 sku=200 → sku=100 sku=100 → sku=200
结果 死锁(T1等T2,T2等T1) 串行执行(T2等待T1释放锁)
并发冲突处理 乐观锁检测 + 快速失败

多数据竞争下的完整方案

  • 第一层:排序 → 确保锁获取顺序一致

    • 作用:打破循环等待,避免死锁
  • 第二层:乐观锁 → 每次更新检查版本

    • 作用:检测并发冲突,冲突时快速失败
  • 组合效果

    • 避免死锁:排序打破循环等待
    • 检测冲突:乐观锁检测并发修改
    • 快速失败:冲突时抛异常,由上层处理

2.2 悲观锁方案(Redis 分布式锁)

悲观锁的核心思想是:假设冲突概率较高,先加锁再操作。使用 Redis 分布式锁(如 Redisson)实现,适用于写多读少、冲突概率高的场景。

2.2.1 锁 Key 设计

设计原则

  • 唯一性:确保不同业务、不同 SKU 的锁互不冲突
  • 可读性:便于问题排查和监控
  • 业务相关性:Key 与业务场景紧密关联

单数据场景锁 Key

java 复制代码
// 参考设计:业务前缀 + 业务标识
String lockKey = String.format("BUSINESS-SITE-GOODS-STOCK-SYNC:%s:%s", businessSiteNo, skuId);
// 示例: BUSINESS-SITE-GOODS-STOCK-SYNC:BS001:SKU12345

多数据场景锁 Key(批量场景):

java 复制代码
// 批量场景下的锁 Key 设计
List<RLock> rLockList = new ArrayList<>();
for (String businessSiteNO : syncEnableBusinessSiteNoList) {
    for (String skuId : relationProductCodeList) {
        // 格式:业务场景:网点编号:商品ID
        String lockKey = String.format("BUSINESS-SITE-GOODS-STOCK-SYNC:%s:%s", businessSiteNO, skuId);
        RLock lock = redissonClient.getLock(lockKey);
        rLockList.add(lock);
    }
}

设计要点

  • 维度组合:多数据竞争时,通常需要多维度标识(如 网点 + SKU)
  • 避免热点:确保锁的粒度合适,既能互斥又不会过度竞争
  • 批量场景:每个 SKU 生成一个锁,所有锁组成 MultiLock
2.2.2 单数据竞争场景

场景描述:单个商品库存扣减,多个请求需要互斥访问。

实现原理
Redis/Redisson 请求T2 请求T1 Redis/Redisson 请求T2 请求T1 执行库存扣减业务 执行库存扣减业务 tryLock(30s) 获取锁成功 ✓ tryLock(30s) 等待锁... unlock() 释放锁 获取锁成功 ✓

代码示例(基于 Redisson):

java 复制代码
@Autowired
private RedissonClient redissonClient;

public void deductStock(Long skuId, Integer quantity) {
    String lockKey = "stock:lock:" + skuId;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 尝试获取锁,等待 30 秒,锁有效期 10 秒
        boolean isLock = lock.tryLock(30, 10, TimeUnit.SECONDS);
        if (!isLock) {
            throw new RuntimeException("获取分布式锁失败");
        }
        
        // 获取锁成功后执行业务逻辑
        // ... 库存扣减逻辑
        
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("获取锁被中断", e);
    } finally {
        // 释放锁
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

核心要点

  • redissonClient.getLock(lockKey):获取分布式锁对象
  • tryLock(waitTime, leaseTime, unit):等待时间 + 锁有效期
  • isHeldByCurrentThread():确保只释放自己持有的锁
  • 锁自动续期:Redisson 提供 watchdog,默认 30 秒续期
2.2.3 多数据竞争场景(MultiLock)

场景描述:批量扣减多个商品库存,需要同时锁定多个 key。

问题分析:如果按顺序单个加锁,可能出现部分锁定的情况:
Redis 事务T1 Redis 事务T1 已锁定 sku:100 已锁定 sku:100, sku:200 ⚠️ 潜在问题: 如果在加锁过程中失败 可能导致部分锁定状态 lock("sku:100") ✓ lock("sku:200") ✓

解决方案:Redisson MultiLock

MultiLock 将多个锁视为一个整体,要么全部获取成功,要么失败

代码示例

java 复制代码
@Autowired
private RedissonClient redissonClient;

@Transactional
public void batchDeduct(List<SkuDeduct> deductList, Long operatorId) {
    
    // 步骤1: 构建多个锁对象
    List<RLock> rLockList = new ArrayList<>();
    for (SkuDeduct deduct : deductList) {
        String lockKey = "stock:lock:" + deduct.getSkuId();
        RLock lock = redissonClient.getLock(lockKey);
        rLockList.add(lock);
    }
    
    // 步骤2: 创建 MultiLock(将多个锁视为一个整体)
    RLock[] lockArray = rLockList.toArray(new RLock[0]);
    RLock multiLock = redissonClient.getMultiLock(lockArray);
    
    boolean isLock = false;
    try {
        // 步骤3: 一次性尝试获取所有锁
        // 参数: waitTime=30秒等待获取, leaseTime=-1表示使用watchdog自动续期(默认30秒)
        isLock = multiLock.tryLock(30, TimeUnit.SECONDS);
        if (!isLock) {
            throw new RuntimeException("获取分布式锁失败");
        }
        
        // 步骤4: 获取锁成功后,执行业务逻辑
        for (SkuDeduct deduct : deductList) {
            // ... 库存扣减逻辑
        }
        
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("获取锁被中断", e);
    } catch (Exception e) {
        throw new RuntimeException("库存扣减失败", e);
    } finally {
        // 步骤5: 一次性释放所有锁
        if (multiLock != null && multiLock.isHeldByCurrentThread()) {
            multiLock.unlock();
        }
    }
}

为什么要用 MultiLock 而不是单个加锁?

对比项 单个加锁 MultiLock
原子性 ❌ 部分成功 ✅ 全部成功或全部失败
失败处理 ❌ 需手动释放已获取的锁 ✅ 自动处理
死锁风险 ⚠️ 加锁过程中可能发生 ✅ 避免部分锁定
代码复杂度 需手动管理多个锁 统一管理
释放一致性 可能漏释放某个锁 一次性释放所有锁

关键点解析

步骤 代码位置 作用
构建锁列表 redissonClient.getLock(lockKey) 为每个 SKU 创建独立锁
创建 MultiLock redissonClient.getMultiLock(...) 将多个锁组合为单个锁
一次性获取 multiLock.tryLock(...) 全部获取或全部失败
一次性释放 multiLock.unlock() 保证所有锁都被释放

三、总结

方案 核心思想 实现方式 适用场景 优点 缺点
乐观锁 先操作,冲突检测 数据库 version 字段 读多写少 并发度高 冲突时需重试
悲观锁 先加锁,再操作 Redis 分布式锁 写多读少 简单直接 并发度低

工程实践中,建议

  1. 优先乐观锁:大部分场景下性能更好
  2. 多数据竞争场景:务必做好全局排序(乐观锁)或使用 MultiLock(悲观锁),避免死锁
  3. 生产环境
    • 乐观锁:增加重试机制、幂等性保障
    • 悲观锁:合理设置锁超时时间、防止锁泄漏
相关推荐
甘露s4 小时前
分布式与可重入性的一些问题
分布式
juniperhan4 小时前
Flink 系列第 3 篇:核心概念精讲|分布式缓存 + 重启策略 + 并行度 底层原理 + 代码实战 + 生产规范
大数据·分布式·缓存·flink
想你依然心痛4 小时前
HarmonyOS 5.0 IoT开发实战:构建分布式智能设备控制中枢与边缘计算网关
分布式·物联网·harmonyos
lifallen4 小时前
如何保证 Kafka 的消息顺序性?
java·大数据·分布式·kafka
橙露4 小时前
大数据处理:PySpark 入门与分布式数据分析实战
分布式·数据挖掘·数据分析
时光追逐者4 小时前
分享四款开源且实用的 Kafka 管理工具
分布式·kafka·开源
IT枫斗者4 小时前
AI Agent 设计模式全景解析:从单体智能到分布式协作的架构演进
人工智能·redis·分布式·算法·spring·缓存·设计模式
☞遠航☜7 小时前
kafka快速上手
分布式·kafka·linq
2501_9333295517 小时前
技术架构深度解析:Infoseek舆情监测系统的全链路设计与GEO时代的技术实践
开发语言·人工智能·分布式·架构