在电商、出行、票务等互联网业务场景中,重复下单是最常见的业务异常之一。当用户在网络延迟或系统响应缓慢时反复点击提交按钮,或者支付回调因网络问题多次触发,都可能导致同一笔交易被重复处理。这不仅会造成数据混乱,还可能引发资金损失、库存超卖等严重问题。
从技术视角看,重复下单问题本质上是分布式系统的幂等性控制。我们需要确保:无论用户发起多少次相同请求,系统都只产生一次有效业务影响。根据业务特点,重复下单可能发生在以下环节:
| 方案 | 适用场景 | 可靠性 | 实现成本 | 性能影响 | 防御维度 |
|---|---|---|---|---|---|
| 数据库唯一索引 | 所有写操作场景 | ★★★★★ | ★★☆☆☆ | ★★★☆☆ | 数据层 |
| 前端防抖 | 用户交互场景 | ★☆☆☆☆ | ★☆☆☆☆ | ★★★★★ | 表现层 |
| Token防重复提交 | 表单提交类场景 | ★★★☆☆ | ★★★☆☆ | ★★★★☆ | 网络层 |
| 订单状态机 | 状态驱动型业务 | ★★★★☆ | ★★★☆☆ | ★★★☆☆ | 业务层 |
| 分布式锁 | 高并发秒杀场景 | ★★★★☆ | ★★★★☆ | ★★☆☆☆ | 并发控制层 |
code复制是否用户端重复触发?
├── 是 → 前端防抖 + Token防重复
└── 否 → 是否高并发场景?
├── 是 → 分布式锁 + 唯一索引
└── 否 → 状态机 + 唯一索引
实现要点:
sql复制ALTER TABLE orders
ADD UNIQUE INDEX uk_biz_unique (user_id, product_id, biz_type);
java复制try {
orderDao.insert(order);
} catch (DuplicateKeyException e) {
// 存在则转为查询
return orderDao.selectByUniqueKey(
order.getUserId(),
order.getProductId(),
order.getBizType()
);
}
避坑指南:
增强版实现流程:
java复制String token = JWT.create()
.withClaim("userId", userId)
.withExpiresAt(new Date(System.currentTimeMillis() + 300_000))
.sign(Algorithm.HMAC256(secret));
redisTemplate.opsForValue().set(
"order:token:" + token,
"1",
5,
TimeUnit.MINUTES
);
lua复制local token = redis.call('GET', KEYS[1])
if token == false then
return 0
end
redis.call('DEL', KEYS[1])
return 1
状态流转设计:
mermaid复制stateDiagram-v2
[*] --> INIT
INIT --> PAYING : 创建订单
PAYING --> PAID : 支付成功
PAYING --> CANCELLED : 取消支付
PAID --> REFUNDING : 发起退款
REFUNDING --> REFUNDED : 退款完成
幂等检查实现:
java复制public Order createOrder(CreateOrderDTO dto) {
// 检查是否存在未完成订单
List<Order> existing = orderDao.queryByUserAndProduct(
dto.getUserId(),
dto.getProductId(),
List.of("INIT", "PAYING")
);
if (!existing.isEmpty()) {
return existing.get(0);
}
// 状态机驱动
Order order = new Order();
order.transition(OrderEvent.CREATE);
return orderDao.save(order);
}
Redisson最佳实践:
java复制RLock lock = redissonClient.getLock(
"order:lock:" + userId + ":" + productId
);
try {
// 尝试获取锁,等待100ms,锁有效期30s
if (lock.tryLock(100, 30000, TimeUnit.MILLISECONDS)) {
// 双重检查
Order existing = checkExistingOrder(userId, productId);
if (existing != null) {
return existing;
}
return createOrderInternal(userId, productId);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
锁设计要点:
预扣减模式:
sql复制INSERT INTO inventory_hold
(order_no, sku_id, quantity, status)
VALUES
(?, ?, ?, 'HOLDING')
sql复制UPDATE inventory_hold
SET status = 'CONFIRMED'
WHERE order_no = ? AND status = 'HOLDING'
sql复制UPDATE inventory_hold
SET status = 'RELEASED'
WHERE order_no = ? AND status = 'HOLDING'
防御层次:
幂等处理框架:
java复制@Transactional
public void handlePaymentNotify(PaymentNotifyDTO notify) {
// 幂等记录检查
if (paymentDao.existsByNotifyId(notify.getNotifyId())) {
return;
}
// 业务处理
Order order = orderDao.lockById(notify.getOrderId());
if (order.getStatus() != OrderStatus.PAID) {
order.transition(OrderStatus.PAID);
orderDao.update(order);
}
// 记录幂等标识
paymentDao.saveNotifyRecord(notify.getNotifyId());
}
问题现象:
根因分析:
解决方案:
java复制// 添加重试机制
int retry = 0;
while (retry++ < 3) {
try {
orderDao.insert(order);
break;
} catch (DuplicateKeyException e) {
Order existing = orderDao.selectByUniqueKey(...);
if (existing != null) {
return existing;
}
Thread.sleep(100);
}
}
典型问题:
正确实践:
java复制// 使用看门狗延长锁时间
lock.lock(30, TimeUnit.SECONDS);
try {
// 业务逻辑
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
本地缓存优化:
java复制// 使用Caffeine做本地缓存
LoadingCache<OrderKey, Order> orderCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build(key -> orderDao.selectByKey(key));
// 查询时先查缓存
Order order = orderCache.get(orderKey);
旁路记录模式:
java复制@Async
public void asyncSaveOrderLog(OrderLog log) {
try {
orderLogDao.insert(log);
} catch (Exception e) {
log.error("Save order log failed", e);
}
}
在实际项目中,我们最终采用的组合方案是:前端防抖 + Token校验 + 状态机控制 + 数据库唯一索引。这套组合拳在618大促期间成功抵御了峰值QPS 12万的流量冲击,重复下单率控制在0.001%以下。关键点在于每个防御层级都要有快速失败机制,且最终一致性由数据库唯一约束保证。