1. 问题背景与业务痛点
在电商、票务、外卖等高频交易系统中,重复下单是个令人头疼的典型问题。想象一下这样的场景:用户点击"提交订单"后页面卡顿,心急之下多次点击按钮;或者支付网关响应延迟导致用户重复发起支付请求。这些情况轻则导致库存误扣、用户投诉,重则引发资金损失和审计风险。
我经历过一个真实案例:某生鲜平台促销期间,由于未做防重处理,同一用户毫秒级并发提交了5个相同订单,系统全部创建成功。最终不得不人工介入退款,额外产生了2000多元的运费损失。这个教训让我深刻意识到,防重设计不是"可有可无"的功能优化,而是交易系统的核心防线。
2. 技术方案选型与对比
2.1 前端防重方案
按钮防抖(Debounce)是最基础的解决方案:
javascript复制// Vue实现示例
<button @click="submitOrder">提交订单</button>
methods: {
submitOrder: _.debounce(function() {
// 实际提交逻辑
}, 1000) // 1秒内仅允许触发一次
}
但纯前端方案存在明显缺陷:
- 用户可以绕过前端直接调用API
- 不同浏览器标签页无法共享防重状态
- 网络延迟可能导致用户误判操作结果
2.2 服务端幂等设计
更可靠的方案需要在服务端实现幂等控制,常见模式包括:
2.2.1 唯一订单号方案
要求客户端在首次请求时生成唯一ID(建议格式:业务前缀+用户ID+时间戳+随机数),服务端通过数据库唯一索引拦截重复请求:
sql复制CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) UNIQUE, -- 唯一约束
...
);
2.2.2 令牌桶方案
流程设计:
- 用户进入下单页时,服务端生成token并存入Redis(设置5分钟过期)
- 提交订单时必须携带有效token
- 使用后立即删除token,确保单次有效性
java复制// 伪代码示例
public String createToken(Long userId) {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"order:token:" + userId,
token,
5, TimeUnit.MINUTES);
return token;
}
public boolean submitOrder(OrderRequest request) {
String key = "order:token:" + request.getUserId();
String savedToken = redisTemplate.opsForValue().get(key);
if (!request.getToken().equals(savedToken)) {
throw new BusinessException("请勿重复提交");
}
redisTemplate.delete(key);
// 继续订单处理...
}
2.2.3 支付流水号映射
针对支付环节,建议采用第三方支付平台(如支付宝、微信支付)的out_trade_no作为防重依据。在支付回调时校验该编号的唯一性。
3. 分布式环境下的进阶方案
3.1 分布式锁实现
在高并发场景下,需要引入Redis或Zookeeper实现分布式锁:
python复制# Redis锁示例
def submit_order(user_id, order_info):
lock_key = f"order_lock:{user_id}"
# 尝试获取锁(设置10秒自动释放)
if not redis.set(lock_key, 1, nx=True, ex=10):
raise Exception("操作过于频繁")
try:
# 核心下单逻辑
create_order(order_info)
finally:
redis.delete(lock_key)
3.2 数据库乐观锁
适用于库存扣减等场景:
sql复制UPDATE product_stock
SET quantity = quantity - 1,
version = version + 1
WHERE product_id = 1001
AND version = 5 -- 携带查询时获得的版本号
3.3 消息队列消峰
通过RabbitMQ或Kafka将并发请求转为串行处理:
java复制// Spring Boot集成RabbitMQ示例
@RabbitListener(queues = "order.queue")
public void processOrder(OrderMessage message) {
// 幂等检查
if (orderService.checkDuplicate(message.getOrderNo())) {
return;
}
// 处理订单
orderService.createOrder(message);
}
4. 实战经验与避坑指南
4.1 时间窗口选择
- 前端防抖:300-500ms(适合按钮点击)
- 订单令牌:3-5分钟(覆盖典型支付超时)
- 支付防重:24小时(适配支付平台对账周期)
4.2 异常处理要点
- 网络超时必须明确返回结果状态,避免用户重试
- 防重错误码应与业务错误区分(如HTTP 409 Conflict)
- 前端收到防重错误时应展示原始订单信息
4.3 监控指标建议
- 重复请求拦截率
- 防重锁等待时间
- 令牌使用异常率
5. 架构设计演进路线
根据业务规模可选择不同方案组合:
-
初创期(QPS < 100):
- 前端防抖 + 数据库唯一索引
-
成长期(QPS 100-1000):
- 令牌机制 + Redis防重
-
成熟期(QPS > 1000):
- 分布式锁 + 消息队列 + 热点数据缓存
在金融级场景中,我们还需要考虑:
- 异地多活架构下的防重设计
- 分布式事务与防重机制的协调
- 对账系统兜底检查
实际项目中,我会在订单表添加create_time和update_time索引,配合定时任务扫描异常订单(如10分钟内未支付的重复订单),这种"事前防御+事后核查"的组合拳在实践中效果显著。