上周压测时,我们的订单系统突然出现诡异的"幽灵订单"------同一个请求生成了两条完全相同的订单,而数据库的唯一索引竟然没拦住。你可能会想:"这不就是简单的并发重复提交吗?"但真相远比你想的复杂,且听我慢慢拆解。
现象:唯一索引失效的"幽灵订单"
场景:分布式环境下,订单服务接收支付回调后生成订单。压测时模拟 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); // 这里仍可能重复插入!
}
根因:你以为的原子性并不是真的原子
问题出在 事务隔离级别与并发控制的认知误区:
- 默认的 MySQL REPEATABLE READ 不防幻读 :在同一个事务内,两次
SELECT可能看到相同的结果集,但其他事务的INSERT已经提交。 - Spring 事务的传播机制 :
@Transactional默认的PROPAGATION_REQUIRED可能导致多个回调共用一个事务,加剧幻读风险。 - 唯一索引的生效时机 :索引约束只在
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 |
避坑清单:并发控制的常见陷阱
- 迷信唯一索引 :索引是最后防线,但不能替代代码层的并发控制。高并发下仍可能先通过
SELECT检查再违反约束。 - 过度依赖事务 :事务保证 ACID,但不自动解决并发问题。
REPEATABLE READ下仍可能幻读。 - 忽略锁粒度 :
SELECT FOR UPDATE必须锁定正确的行(甚至不存在的行),否则锁表可能成为性能瓶颈。 - 混淆幂等与并发安全:幂等设计(如重试)需要与并发控制配合使用,单纯的重试可能放大问题。
结论:并发问题从无银弹
这次踩坑让我彻底明白:在分布式系统中,没有单一方案能100%解决并发问题。必须组合:
- 代码层:悲观锁/乐观锁
- 数据库:唯一索引 + 合理事务隔离级别
- 架构层:分布式锁(如 Redis)或消息队列去重
你在项目中是怎么处理这类问题的?有没有遇到过更诡异的并发坑?欢迎评论区聊聊你的实战经验。