在当今高并发的互联网应用中,处理库存扣减、余额变更、计数器递增等场景已成为常态。这些操作都有一个致命共同点:新值必须依赖旧值计算------ 扣库存要先看当前剩多少,减余额得先确认够不够,连计数器加 1 都要知道上一次是几。可一旦多个请求同时 "抢" 着改同一数据,没做好防护就会出现超卖、少扣、数据错乱等问题。本文带你从问题根源拆解,掌握从基础到分布式场景的完整解决方案,避开那些踩过的坑。
一、问题根源:竞态条件 ------ 为什么 "读 - 算 - 写" 会出乱子?
先看一个真实电商场景的 "翻车现场",理解新值依赖旧值时并发的破坏力:
场景还原
某商品库存 10 件,两个用户同时下单各买 1 件,理想结果是库存剩 8 件。
错误代码(线程不安全)
java
// 线程不安全示例
@Service
public class UnsafeInventoryService {
@Autowired
private ProductRepository productRepository;
public boolean deductStock(Long productId, Integer quantity) {
// 1. 查询商品
Product product = productRepository.findById(productId);
// 2. 检查库存
if (product.getStock() < quantity) {
return false;
}
// 3. 模拟业务处理耗时
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 4. 更新库存
product.setStock(product.getStock() - quantity);
productRepository.update(product);
return true;
}
}
翻车结果 两个线程都读到 "库存 10",各自计算后都写 "9",最终库存剩 9 件 ------ 本该扣 2 件,却只扣了 1 件,出现 "少扣" 漏洞;若场景是秒杀,甚至会出现 "超卖"(库存变负数)。 核心原因:"读取旧值→计算新值→写入新值" 这三步不是原子操作,在 "算" 和 "写" 之间,旧值可能已被其他线程修改,导致新值基于 "过期旧值" 计算,最终数据错乱。
二、解决方案全景图:新值依赖旧值时,如何锁住 "读 - 算 - 写"?
解决并发更新问题,本质是让 "依赖旧值的操作" 变安全,主要分两大技术路线:悲观锁(先锁再操作) 和乐观锁(先操作再验冲突),各有适用场景。
(一)悲观并发控制:先锁死,再计算 ------ 适合冲突多的场景
核心逻辑:既然新值依赖旧值,那就在读旧值时直接 "锁" 住数据,不让其他线程改,直到自己算完新值、写完才算完。
1. 数据库行锁:单服务下的 "简单锁"
用 SELECT ... FOR UPDATE 读旧值时,直接锁定目标行,其他线程想读这行数据会被阻塞,直到当前事务结束。
实现代码
java
@Service
@Transactional
public class PessimisticLockInventoryService {
@Autowired
private ProductRepository productRepository;
public boolean deductStock(Long productId, Integer quantity) {
// 使用 FOR UPDATE 锁定行
Product product = productRepository.findByIdWithLock(productId);
if (product.getStock() < quantity) {
return false;
}
// 安全更新
product.setStock(product.getStock() - quantity);
productRepository.update(product);
return true;
}
}
// Repository 实现
@Repository
public class ProductRepository {
@PersistenceContext
private EntityManager entityManager;
public Product findByIdWithLock(Long id) {
String jpql = "SELECT p FROM Product p WHERE p.id = :id";
return entityManager.createQuery(jpql, Product.class)
.setParameter("id", id)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.getSingleResult();
}
// 或者使用原生SQL
@Query(value = "SELECT * FROM products WHERE id = ? FOR UPDATE", nativeQuery = true)
Product findByIdWithLockNative(Long id);
}
避坑点
- 不要在非事务方法中加锁:
FOR UPDATE需在事务内生效,否则锁会立即释放,等于没加; - 避免锁表风险:若查询条件没走索引(如用 name 查而非 id),会变成 "表锁",所有商品更新都被堵死。
2. 分布式锁:跨服务的 "全局锁"
微服务场景下,多个服务实例都要改同一库存,数据库行锁管不了跨服务的线程,这时需要 Redis、ZooKeeper 等中间件实现 "全局锁"。
基于 Redisson 的实现(自动续期,避免锁超时)
java
@Service
public class DistributedLockInventoryService {
@Autowired
private ProductRepository productRepository;
@Autowired
private RedissonClient redissonClient;
public boolean deductStock(Long productId, Integer quantity) {
String lockKey = "product_lock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,等待5秒,锁超时时间10秒
boolean locked = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("系统繁忙,请重试");
}
// 在锁保护下执行操作
Product product = productRepository.findById(productId);
if (product.getStock() < quantity) {
return false;
}
product.setStock(product.getStock() - quantity);
productRepository.update(product);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("操作被中断", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
避坑点
- 不要用 "Redis setnx" 手动实现锁:没考虑自动续期,若业务耗时超过锁超时,会导致 "锁被误删";
- 锁 key 要粒度适中:用 "商品 id" 而非 "库存模块",否则所有商品更新都抢同一把锁,性能暴跌。
(二)乐观并发控制:先操作,再验冲突 ------ 适合冲突少的场景
核心逻辑:默认冲突很少发生,不用一上来就锁数据,而是在写新值时检查 "旧值有没有被改"------ 如果没被改,就正常写;如果被改了,就重试或返回失败。
1. 版本号控制:给旧值 "打标签"
给数据加个version字段(如库存表加version BIGINT),读旧值时同时读版本号,写新值时要满足 "当前版本号和读时一致" 才更新,更新后版本号 + 1。
实现代码(JPA 自动版 + 手动版)
java
@Entity
@Table(name = "products")
public class Product {
@Id
private Long id;
private String name;
private Integer stock;
@Version
private Long version; // JPA乐观锁版本字段
// getters and setters
}
@Service
@Transactional
public class OptimisticLockInventoryService {
@Autowired
private ProductRepository productRepository;
@Retryable(value = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000))
public boolean deductStock(Long productId, Integer quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("商品不存在"));
if (product.getStock() < quantity) {
return false;
}
// 更新时会自动检查版本号
product.setStock(product.getStock() - quantity);
productRepository.save(product); // JPA会自动处理版本冲突
return true;
}
// 手动版本控制方式
public boolean deductStockManual(Long productId, Integer quantity) {
int updatedRows = productRepository.deductStockWithVersion(
productId, quantity);
if (updatedRows == 0) {
// 版本冲突或库存不足
Product current = productRepository.findById(productId).get();
if (current.getStock() < quantity) {
throw new RuntimeException("库存不足");
} else {
throw new RuntimeException("数据已被修改,请重试");
}
}
return true;
}
}
// Repository 实现
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - :quantity, p.version = p.version + 1 " +
"WHERE p.id = :id AND p.stock >= :quantity AND p.version = :version")
int deductStockWithVersion(@Param("id") Long id,
@Param("quantity") Integer quantity,
@Param("version") Long version);
}
避坑点
- 必须加重试机制:乐观锁冲突时会失败,需用
@Retryable或手动循环重试,否则用户会看到 "操作失败"; - 版本号别用自增主键:自增主键是全局递增,而版本号是 "每行独立递增",用主键会导致所有行冲突。
2. 原子操作:把 "读 - 算 - 写" 压给数据库
最极致的优化:不用在代码里读旧值、算新值,而是把整个逻辑写成一条 SQL,让数据库在内部完成 "读旧值→算新值→写新值",全程原子性。
实现代码(库存扣减 + 余额扣减示例)
java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - :quantity " +
"WHERE p.id = :id AND p.stock >= :quantity")
int deductStock(@Param("id") Long id, @Param("quantity") Integer quantity);
// 原生SQL方式
@Modifying
@Query(value = "UPDATE products SET stock = stock - ?2 WHERE id = ?1 AND stock >= ?2",
nativeQuery = true)
int deductStockNative(Long id, Integer quantity);
}
@Service
public class AtomicOperationInventoryService {
@Autowired
private ProductRepository productRepository;
@Transactional
public boolean deductStock(Long productId, Integer quantity) {
int updatedRows = productRepository.deductStock(productId, quantity);
if (updatedRows > 0) {
// 扣减成功,可以执行后续业务逻辑
return true;
} else {
// 检查具体原因
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("商品不存在"));
if (product.getStock() < quantity) {
throw new RuntimeException("库存不足,当前库存:" + product.getStock());
} else {
throw new RuntimeException("更新失败,请重试");
}
}
}
}
优势
- 性能最高:不用锁,不用重试,数据库内部操作耗时极短;
- 最安全:数据库原生保证原子性,不会出现中间态。
避坑点
- 逻辑别太复杂:原子 SQL 只适合 "简单计算"(如加减乘除),若要先查其他表再算新值(如扣库存前查会员折扣),就不适用了。
三、技术选型指南
用一张表说清四种方案的适用场景,避免盲目选型:
| 方案 | 并发性能 | 适用场景 | 核心优势 | 注意事项 |
|---|---|---|---|---|
| 数据库行锁 | 中等 | 单服务、高冲突(如秒杀) | 强一致、实现简单 | 避免表锁、防死锁 |
| 分布式锁 | 中等 | 微服务、跨服务更新(如多服务扣余额) | 全局一致、跨进程 | 需自动续期、防锁超时 |
| 版本号控制 | 高 | 读多写少(如商品详情页更新库存) | 无锁开销、性能好 | 必须加重试机制 |
| 原子操作 | 极高 | 简单计算(如扣库存、计数器) | 最快、最安全 | 只适合单表简单逻辑 |
选型口诀
- 能原子,不乐观:简单加减用原子 SQL,性能最高;
- 读多写少,用乐观:查询多、更新少,版本号 + 重试更高效;
- 高冲突、单服务,用行锁:秒杀场景冲突多,行锁能防超卖;
- 跨服务、分布式,用全局锁:多服务改同一数据,分布式锁保一致。
四、实战最佳实践
1. 重试机制:乐观锁必须加,否则用户体验差
用Spring Retry时,别只重试版本冲突异常,还要包含ConcurrentUpdateException(数据库并发异常),退避策略用 "指数退避"(避免同时重试导致新冲突):
java
@Service
public class RetryableInventoryService {
@Autowired
private ProductRepository productRepository;
// 使用Spring Retry注解方式
@Retryable(value = {OptimisticLockingException.class, ConcurrentUpdateException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2))
public boolean deductStockWithRetry(Long productId, Integer quantity) {
return doDeductStock(productId, quantity);
}
// 手动重试实现
public boolean deductStockManualRetry(Long productId, Integer quantity) {
int maxRetries = 3;
int attempt = 0;
while (attempt < maxRetries) {
try {
return doDeductStock(productId, quantity);
} catch (OptimisticLockingException e) {
attempt++;
if (attempt >= maxRetries) {
throw new RuntimeException("操作失败,请重试", e);
}
// 指数退避
try {
Thread.sleep(100 * (long) Math.pow(2, attempt - 1));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("操作被中断", ie);
}
}
}
return false;
}
private boolean doDeductStock(Long productId, Integer quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("商品不存在"));
if (product.getStock() < quantity) {
throw new RuntimeException("库存不足");
}
product.setStock(product.getStock() - quantity);
try {
productRepository.save(product);
return true;
} catch (ObjectOptimisticLockingFailureException e) {
throw new OptimisticLockingException("数据版本冲突", e);
}
}
}
// 自定义异常
class OptimisticLockingException extends RuntimeException {
public OptimisticLockingException(String message, Throwable cause) {
super(message, cause);
}
}
2. 多层次防护:代码 + 数据库双保险
即使代码里做了库存校验,也要在数据库加 "库存不能为负" 的约束,防止代码逻辑漏洞(如 quantity 传负数):
java
@Service
@Transactional
public class MultiLayerInventoryService {
@Autowired
private ProductRepository productRepository;
public DeductResult deductStock(Long productId, Integer quantity) {
// 第一层:参数校验
if (quantity <= 0) {
return DeductResult.failure("数量必须大于0");
}
// 第二层:原子操作更新
int updatedRows = productRepository.deductStock(productId, quantity);
// 第三层:结果处理
if (updatedRows > 0) {
// 记录操作日志
logOperation(productId, -quantity, "SUCCESS");
return DeductResult.success();
} else {
// 检查具体失败原因
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("商品不存在"));
if (product.getStock() < quantity) {
logOperation(productId, -quantity, "INSUFFICIENT_STOCK");
return DeductResult.failure("库存不足,当前库存:" + product.getStock());
} else {
logOperation(productId, -quantity, "CONCURRENT_CONFLICT");
return DeductResult.failure("并发冲突,请重试");
}
}
}
private void logOperation(Long productId, Integer quantity, String status) {
// 记录操作日志
}
}
// 返回结果封装
@Data
@AllArgsConstructor
class DeductResult {
private boolean success;
private String message;
public static DeductResult success() {
return new DeductResult(true, "操作成功");
}
public static DeductResult failure(String message) {
return new DeductResult(false, message);
}
}
3. 秒杀场景:Redis 预扣 + 数据库最终确认
高并发秒杀时,直接查数据库会压垮 DB,用 Redis 先 "预扣库存",再异步同步到数据库:
java
@Service
public class PreDeductInventoryService {
@Autowired
private ProductRepository productRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 预扣库存
public boolean preDeductStock(Long productId, Integer quantity, String orderToken) {
String key = "pre_deduct:" + productId;
// 使用Redis原子操作预扣
Long remaining = redisTemplate.opsForValue().increment(key, -quantity);
if (remaining != null && remaining >= 0) {
// 预扣成功,记录预扣关系
String userKey = "user_deduct:" + orderToken;
redisTemplate.opsForHash().put(userKey, "productId", productId);
redisTemplate.opsForHash().put(userKey, "quantity", quantity);
redisTemplate.expire(userKey, 30, TimeUnit.MINUTES); // 30分钟有效期
return true;
} else {
// 库存不足,恢复
redisTemplate.opsForValue().increment(key, quantity);
return false;
}
}
// 实际扣减
@Transactional
public boolean confirmDeduct(String orderToken) {
String userKey = "user_deduct:" + orderToken;
Long productId = (Long) redisTemplate.opsForHash().get(userKey, "productId");
Integer quantity = (Integer) redisTemplate.opsForHash().get(userKey, "quantity");
if (productId == null || quantity == null) {
return false;
}
// 实际数据库扣减
int updatedRows = productRepository.deductStock(productId, quantity);
if (updatedRows > 0) {
// 清理预扣记录
redisTemplate.delete("pre_deduct:" + productId);
redisTemplate.delete(userKey);
return true;
}
return false;
}
}