新值依赖旧值?并发更新的“坑”

在当今高并发的互联网应用中,处理库存扣减、余额变更、计数器递增等场景已成为常态。这些操作都有一个致命共同点:新值必须依赖旧值计算------ 扣库存要先看当前剩多少,减余额得先确认够不够,连计数器加 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 只适合 "简单计算"(如加减乘除),若要先查其他表再算新值(如扣库存前查会员折扣),就不适用了。

三、技术选型指南

用一张表说清四种方案的适用场景,避免盲目选型:

方案 并发性能 适用场景 核心优势 注意事项
数据库行锁 中等 单服务、高冲突(如秒杀) 强一致、实现简单 避免表锁、防死锁
分布式锁 中等 微服务、跨服务更新(如多服务扣余额) 全局一致、跨进程 需自动续期、防锁超时
版本号控制 读多写少(如商品详情页更新库存) 无锁开销、性能好 必须加重试机制
原子操作 极高 简单计算(如扣库存、计数器) 最快、最安全 只适合单表简单逻辑

选型口诀

  1. 能原子,不乐观:简单加减用原子 SQL,性能最高;
  2. 读多写少,用乐观:查询多、更新少,版本号 + 重试更高效;
  3. 高冲突、单服务,用行锁:秒杀场景冲突多,行锁能防超卖;
  4. 跨服务、分布式,用全局锁:多服务改同一数据,分布式锁保一致。

四、实战最佳实践

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;
    }
}
相关推荐
_处女座程序员的日常3 小时前
如何预览常见格式word、excel、ppt、图片等格式的文档
前端·javascript·word·excel·开源软件
wangbing11254 小时前
layui窗口标题
前端·javascript·layui
qq_398586544 小时前
Utools插件实现Web Bluetooth
前端·javascript·electron·node·web·web bluetooth
李剑一4 小时前
mitt和bus有什么区别
前端·javascript·vue.js
soda_yo5 小时前
JavaScripe中你所不知道的"变量提升"
javascript
www_stdio5 小时前
JavaScript 执行机制详解:从 V8 引擎到执行上下文
前端·javascript
我命由我123455 小时前
HTML - 换行标签的 3 种写法(<br>、<br/>、<br />)
前端·javascript·css·html·css3·html5·js
icebreaker6 小时前
重新思考 weapp-tailwindcss 的未来
前端·javascript·css
涤生啊6 小时前
一键搭建 Coze 智能体对话页面:支持流式输出 + 图片直显,开发效率拉满!
javascript·html5