文章目录
- 分布式场景下的数据竞争问题与解决方案
-
- 一、什么是分布式场景下的数据竞争问题
- 二、解决方案
-
- [2.1 乐观锁方案](#2.1 乐观锁方案)
-
- [2.1.1 单数据竞争场景](#2.1.1 单数据竞争场景)
- [2.1.2 多数据竞争场景(死锁问题)](#2.1.2 多数据竞争场景(死锁问题))
- [2.2 悲观锁方案(Redis 分布式锁)](#2.2 悲观锁方案(Redis 分布式锁))
-
- [2.2.1 锁 Key 设计](#2.2.1 锁 Key 设计)
- [2.2.2 单数据竞争场景](#2.2.2 单数据竞争场景)
- [2.2.3 多数据竞争场景(MultiLock)](#2.2.3 多数据竞争场景(MultiLock))
- 三、总结
分布式场景下的数据竞争问题与解决方案
一、什么是分布式场景下的数据竞争问题
在分布式系统中,数据竞争(Data Race)是指多个并发请求同时对同一数据进行读写操作,由于操作执行的时序不确定,导致最终的数据状态不可预期。不同于单机环境,分布式场景下的数据竞争面临更加复杂的挑战:
-
无共享内存:各服务节点独立,无法像单机那样通过 synchronized 解决
-
网络延迟:请求到达顺序不确定,同一时刻可能有多个请求访问同一数据
-
数据库并发:多条 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
- 读取数据及版本号
- 执行业务逻辑(计算新值)
- 更新时检查版本
- 结果判断:若更新行数 = 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 → 形成循环等待 → 死锁
解决方案:全局排序 + 乐观锁
核心思路:
- 全局排序:确保所有事务以相同顺序访问数据,打破循环等待
- 乐观锁更新:每次更新检查版本号,冲突时直接失败
⚠️ 以下代码仅为示例,不可作为生产级代码。生产环境需结合业务场景增加重试机制、降级处理、幂等性保障等。
代码示例:
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 分布式锁 | 写多读少 | 简单直接 | 并发度低 |
工程实践中,建议:
- 优先乐观锁:大部分场景下性能更好
- 多数据竞争场景:务必做好全局排序(乐观锁)或使用 MultiLock(悲观锁),避免死锁
- 生产环境 :
- 乐观锁:增加重试机制、幂等性保障
- 悲观锁:合理设置锁超时时间、防止锁泄漏