刚解决一个 Java 并发问题,这坑踩得我怀疑人生

11 阅读1分钟

上周压测时,我们的订单系统突然出现诡异的“幽灵订单”——同一个请求生成了两条完全相同的订单,而数据库的唯一索引竟然没拦住。你可能会想:“这不就是简单的并发重复提交吗?”但真相远比你想的复杂,且听我慢慢拆解。

现象:唯一索引失效的“幽灵订单”

场景:分布式环境下,订单服务接收支付回调后生成订单。压测时模拟 500 QPS 的并发回调,日志显示有约 0.3% 的请求生成了重复订单(肉眼可见两条 order_id 相同、create_time 相差 1ms 的记录)。
关键点:

  • 数据库有 UNIQUE KEY (order_id)
  • 代码中先 SELECT 检查是否存在,再 INSERT(经典的“检查-执行”模式)
  • 使用 Spring 的 @Transactional 注解
// 错误写法:看似安全的双重检查
@Transactional
public void createOrder(String orderId, OrderDTO dto) {
    // 第一次检查
    if (orderRepository.existsByOrderId(orderId)) {
        return;
    }
    
    Order order = convertToEntity(dto);
    // 第二次检查(你以为这样更安全?)
    if (orderRepository.existsByOrderId(orderId)) {
        return;
    }
    
    orderRepository.save(order); // 这里仍可能重复插入!
}

根因:你以为的原子性并不是真的原子

问题出在 事务隔离级别与并发控制的认知误区:

  1. 默认的 MySQL REPEATABLE READ 不防幻读:在同一个事务内,两次 SELECT 可能看到相同的结果集,但其他事务的 INSERT 已经提交。
  2. Spring 事务的传播机制:@Transactional 默认的 PROPAGATION_REQUIRED 可能导致多个回调共用一个事务,加剧幻读风险。
  3. 唯一索引的生效时机:索引约束只在 INSERT 执行时触发,而 SELECT 和 INSERT 之间仍有时间窗口。

验证实验:在压测中监控数据库事务 ID,发现重复订单的两个 INSERT 竟然分属不同事务(证明不是简单的事务未提交问题),但这两个事务的 SELECT 阶段都未看到对方的数据。

解法:从数据库到代码的立体防御

正确代码:悲观锁 + 唯一索引 + 幂等设计

// 正确写法:SELECT FOR UPDATE 锁表 + 数据库唯一索引兜底
@Transactional
public void createOrder(String orderId, OrderDTO dto) {
    // 关键:先锁定可能冲突的行(甚至不存在的行)
    orderRepository.lockByOrderId(orderId); // 实现:SELECT 1 FROM dual WHERE order_id=? FOR UPDATE
    
    if (orderRepository.existsByOrderId(orderId)) {
        return;
    }
    
    Order order = convertToEntity(dto);
    orderRepository.save(order); // 唯一索引是最后的防线
}

性能对比

方案1000 QPS 重复订单率平均耗时增加
原始方案0.3%基准
悲观锁方案0%+15ms
改用 Redis 分布式锁0%+25ms

避坑清单:并发控制的常见陷阱

  1. 迷信唯一索引:索引是最后防线,但不能替代代码层的并发控制。高并发下仍可能先通过 SELECT 检查再违反约束。
  2. 过度依赖事务:事务保证 ACID,但不自动解决并发问题。REPEATABLE READ 下仍可能幻读。
  3. 忽略锁粒度:SELECT FOR UPDATE 必须锁定正确的行(甚至不存在的行),否则锁表可能成为性能瓶颈。
  4. 混淆幂等与并发安全:幂等设计(如重试)需要与并发控制配合使用,单纯的重试可能放大问题。

结论:并发问题从无银弹

这次踩坑让我彻底明白:在分布式系统中,没有单一方案能100%解决并发问题。必须组合:

  • 代码层:悲观锁/乐观锁
  • 数据库:唯一索引 + 合理事务隔离级别
  • 架构层:分布式锁(如 Redis)或消息队列去重

你在项目中是怎么处理这类问题的?有没有遇到过更诡异的并发坑?欢迎评论区聊聊你的实战经验。