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

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

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

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

关键点:

  • 数据库有 UNIQUE KEY (order_id)
  • 代码中先 SELECT 检查是否存在,再 INSERT(经典的"检查-执行"模式)
  • 使用 Spring 的 @Transactional 注解
java 复制代码
// 错误写法:看似安全的双重检查
@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 阶段都未看到对方的数据。

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

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

java 复制代码
// 正确写法: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)或消息队列去重

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

相关推荐
古法老登瞎折腾2 小时前
大数据智能运维实战(九):让 AI 查日志——找到异常,也保留上下文
java·大数据·运维·人工智能·spring ai·mcp
前端一课2 小时前
一个晚上,四个“小问题”:我重写了 12 章 ArkUI 笔记
前端
Smoothcloud润云2 小时前
GPU远程纳管安全吗?最小权限、网络边界与日志审计实战
服务器·网络·人工智能·安全·gpu算力·gpu
AI源码2 小时前
AI在线问诊平台
人工智能
小码哥_常2 小时前
前端图片压缩实战——我把网站图片体积砍了 70%
前端
小兔子2 小时前
Unix domain socket 的四个边界:抽象地址没有权限,SO_PEERCRED 是快照
后端
小兔子2 小时前
no-cache 不是「不缓存」:RFC 9111 里几句常被读反的话
后端
Vecter2 小时前
于 Spring Boot 和 LangChain4j 的 Web 智能助手
后端
张一凡932 小时前
把“不要乱改”从提示词里拿出来,放进可校验的提案里
前端·后端·架构