如何避免订单重复提交

如何避免订单重复提交

🔍 先一句话说透问题本质

订单重复提交的核心是接口幂等性问题,即同一个请求执行多次和执行一次的结果完全相同。常见触发场景包括:用户重复点击、网络延迟重试、浏览器刷新 / 后退、RPC 框架自动重试、MQ 消息重投等。

🛡️ 第一道防线:前端防重(成本最低,拦截 80% 误操作)

这是用户最先接触的一层,必须做好,能大幅减轻后端压力。

方案 实现方式 拦截效果 适用场景
按钮禁用 + Loading 点击后立即置灰,显示加载动画,请求返回后恢复 ★★★★☆ 所有表单提交
提交防抖 300ms 内只执行最后一次提交 ★★★☆☆ 快速连续点击
页面跳转 提交成功后立即跳转到结果页,禁止浏览器后退 ★★★☆☆ 防止刷新 / 后退重发
历史记录清除 history.replaceState()替换当前历史记录 ★★☆☆☆ 辅助防止后退

⚠️ 注意:前端防重只能防君子,不能防小人!恶意用户可以直接调用接口绕过,所以后端防重才是核心。

⚔️ 核心防线:后端防重(必须做,100% 拦截重复请求)

我在项目中主要使用以下 4 种后端方案,按推荐优先级排序:

1. 分布式 Token 机制(⭐⭐⭐⭐⭐ 首选方案)

这是大厂最通用、最健壮的方案,完美适配分布式系统。
sequenceDiagram participant 用户 participant 前端 participant 后端 participant Redis 用户->>前端: 进入订单结算页 前端->>后端: 请求获取防重Token 后端->>Redis: SET order:token:{userId}:{uuid} 1 EX 60 Redis-->>后端: OK 后端-->>前端: 返回Token 用户->>前端: 点击提交订单 前端->>后端: 携带Token提交订单 后端->>Redis: DEL order:token:{userId}:{uuid} Redis-->>后端: 返回1(删除成功)/0(已删除) alt 删除成功=首次提交 后端->>后端: 执行业务逻辑(扣库存、创建订单) 后端-->>前端: 订单创建成功 else 删除失败=重复提交 后端-->>前端: 返回"请勿重复提交" end

关键要点:

  • ✅ 必须用DEL命令的返回值判断,而不是先 GET 再判断
  • ✅ Token 必须和用户 ID 绑定,防止 Token 泄露被滥用
  • ✅ 设置合理的过期时间(一般 60 秒),避免 Redis 内存泄漏

2. 数据库唯一约束(⭐⭐⭐⭐ 兜底方案)

利用数据库的唯一索引特性,从根本上防止重复数据插入。

sql 复制代码
-- 业务唯一索引:同一用户1分钟内不能购买同一商品超过1次
CREATE UNIQUE INDEX uk_order_user_goods_time ON t_order (user_id, goods_id, FLOOR(UNIX_TIMESTAMP(create_time)/60));

-- 或全局唯一订单号索引
CREATE UNIQUE INDEX uk_order_no ON t_order (order_no);

关键要点:

  • ✅ 捕获DuplicateKeyException异常,返回友好提示
  • ✅ 索引字段选择要合理,避免过度唯一或不够唯一
  • ✅ 这是最后一道防线,永远不能省!

3. 分布式锁(⭐⭐⭐ 高并发场景)

适合对同一资源的并发操作控制,比如秒杀、限购场景。

java 复制代码
// Redisson实现分布式锁(推荐)
RLock lock = redissonClient.getLock("order:lock:" + userId);
try {
    // 尝试获取锁,最多等待3秒,10秒后自动释放
    if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
        // 执行业务逻辑
        createOrder();
    } else {
        return Result.fail("操作太频繁,请稍后再试");
    }
} finally {
    // 确保锁释放
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

4. 状态机控制(⭐⭐⭐ 复杂业务流程)

通过订单状态流转来控制操作的合法性,防止状态回退。
stateDiagram-v2 \* --> 待提交: 用户下单 待提交 --> 已提交: 创建订单成功 已提交 --> 待支付: 订单初始化完成 待支付 --> 已支付: 支付成功 已支付 --> 已发货: 商家发货 已发货 --> 已完成: 用户确认收货 note right of 待提交: 只有此状态才能执行创建操作 note right of 已提交: 重复提交直接返回成功

🛟 终极兜底:对账与补偿

即使前面所有防线都失效,也要有兜底机制:

  1. 定时任务对账:每天凌晨核对订单表与支付流水表,发现重复订单自动退款
  2. 支付回调幂等:支付回调接口必须做幂等处理,防止重复回调导致多次发货
  3. 人工审核:对于异常订单(同一用户短时间内多个相同订单),进入人工审核队列

✅ 方案选型总结

text 复制代码
拦截效果好
                       ↑
                       |
    实现复杂度低 ←───────┼─────→ 实现复杂度高
                       |
                       ↓
                    拦截效果差

┌─────────────────────┬─────────────────────┐
│      🌟 优先使用      │   🎯 特定场景使用    │
│                     │                     │
│  ✅ 数据库唯一约束    │  ⚡ 分布式锁         │
│  ✅ 分布式Token      │  📦 状态机控制       │
│  ✅ 定时对账         │                     │
│                     │                     │
├─────────────────────┼─────────────────────┤
│      🛠️ 辅助使用      │      ❌ 不推荐       │
│                     │                     │
│  🖱️ 前端按钮禁用      │  无合适方案         │
│                     │                     │
└─────────────────────┴─────────────────────┘
推荐等级 方案名称 实现复杂度 拦截效果 适用场景
🌟 优先使用 数据库唯一约束 极低 极高 所有场景(兜底必备)
🌟 优先使用 分布式 Token 极高 通用订单提交(首选)
🌟 优先使用 定时对账 极高 终极兜底(数据校验)
🎯 特定场景 分布式锁 秒杀、限购等高并发场景
🎯 特定场景 状态机控制 复杂订单流程管控
🛠️ 辅助使用 前端按钮禁用 极低 拦截用户误操作
❌ 不推荐 其他方案 - -

我的最佳实践:前端防重 + 分布式 Token + 数据库唯一约束 + 定时对账,四层防护,万无一失!

(面试加分项补充):在实际项目中,我还会做一个统一的防重注解,通过 AOP 切面实现,这样所有需要防重的接口只需要加一个@PreventDuplicateSubmit注解即可,非常优雅,也便于维护。

🧑‍💻 核心代码实现(技术亮点版)

1. 通用防重注解(低侵入性设计 ✨)

java 复制代码
/**
 * 防止重复提交注解
 * 技术亮点:通过注解配置化实现,零业务代码侵入
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventDuplicateSubmit {
    
    /**
     * 防重令牌过期时间,单位:秒
     */
    int expireTime() default 60;
    
    /**
     * 防重key前缀,用于区分不同业务
     */
    String keyPrefix() default "order:submit:";
    
    /**
     * 是否需要用户绑定,防止token跨用户盗用
     */
    boolean bindUser() default true;
}

2. AOP 切面实现(统一拦截逻辑 🎯)

java 复制代码
/**
 * 防重复提交切面
 * 技术亮点:
 * 1. 使用Redis原子命令DEL保证判断和删除的原子性
 * 2. 支持SpEL表达式解析动态key
 * 3. 全局统一异常处理
 */
@Aspect
@Component
@RequiredArgsConstructor
public class PreventDuplicateSubmitAspect {

    private final StringRedisTemplate stringRedisTemplate;
    private final HttpServletRequest request;

    @Around("@annotation(preventDuplicateSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
        // 1. 生成防重key
        String key = generateKey(joinPoint, preventDuplicateSubmit);
        
        // 2. 原子性删除key,返回1表示成功(首次提交),返回0表示失败(重复提交)
        // ⚠️ 核心:必须用DEL的返回值判断,绝对不能用GET+SET!
        Long deleteCount = stringRedisTemplate.delete(key);
        
        if (deleteCount == null || deleteCount == 0) {
            throw new BusinessException("请勿重复提交订单");
        }
        
        try {
            // 3. 执行业务逻辑
            return joinPoint.proceed();
        } finally {
            // 可选:业务执行失败时恢复token,允许用户重新提交
            // stringRedisTemplate.opsForValue().set(key, "1", preventDuplicateSubmit.expireTime(), TimeUnit.SECONDS);
        }
    }

    /**
     * 生成防重key
     * 格式:prefix:userId:token
     */
    private String generateKey(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) {
        StringBuilder key = new StringBuilder(preventDuplicateSubmit.keyPrefix());
        
        // 绑定当前用户ID
        if (preventDuplicateSubmit.bindUser()) {
            Long userId = SecurityUtils.getCurrentUserId();
            key.append(userId).append(":");
        }
        
        // 从请求头获取防重token
        String token = request.getHeader("X-Duplicate-Token");
        if (StringUtils.isBlank(token)) {
            throw new BusinessException("防重令牌不能为空");
        }
        
        key.append(token);
        return key.toString();
    }
}

3. Token 生成接口

java 复制代码
@RestController
@RequestMapping("/api/token")
@RequiredArgsConstructor
public class TokenController {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 获取防重token
     */
    @GetMapping("/generate")
    public Result<String> generateToken() {
        Long userId = SecurityUtils.getCurrentUserId();
        String token = UUID.randomUUID().toString().replace("-", "");
        String key = "order:submit:" + userId + ":" + token;
        
        // 设置60秒过期
        stringRedisTemplate.opsForValue().set(key, "1", 60, TimeUnit.SECONDS);
        
        return Result.success(token);
    }
}

4. 订单控制器使用示例

java 复制代码
@RestController
@RequestMapping("/api/order")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    /**
     * 创建订单接口
     * 只需添加一个注解即可实现防重
     */
    @PostMapping("/create")
    @PreventDuplicateSubmit(expireTime = 120, keyPrefix = "order:create:")
    public Result<OrderVO> createOrder(@RequestBody CreateOrderDTO dto) {
        OrderVO orderVO = orderService.createOrder(dto);
        return Result.success(orderVO);
    }
}

5. 全局异常处理器(统一返回格式)

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(DuplicateKeyException.class)
    public Result<Void> handleDuplicateKeyException(DuplicateKeyException e) {
        log.error("数据库唯一约束冲突:", e);
        return Result.fail("订单提交失败,请稍后重试");
    }

    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        return Result.fail(e.getMessage());
    }
}

🚩 技术难点与解决方案汇总

技术难点 问题描述 解决方案 实战建议
高并发下的原子性问题 传统的 "先查后改" 模式在高并发下会出现竞态条件,导致多个请求同时通过校验 使用 Redis 原子命令DEL或SETNX,保证判断和操作的原子性 ❌ 禁止使用:if (redis.hasKey(key)) { throw; } else { redis.delete(key); } ✅ 推荐使用:if (redis.delete(key) == 0) { throw; }
分布式环境一致性 单体应用的本地锁无法跨服务、跨机器生效 采用 Redis 分布式锁或 Redisson 框架,实现全局唯一的锁机制 优先使用 Redisson 的tryLock方法,自带自动续期和死锁防护
Token 泄露与盗用 防重 token 被恶意用户获取后,可能被用于伪造请求 1. Token 必须与用户 ID、IP 地址绑定2. 设置短过期时间(60-120 秒)3. 一次使用立即失效 不要将 token 作为 URL 参数传递,必须放在请求头中
过期时间设置不合理 过期时间太短导致正常请求被拦截,太长导致 Redis 内存浪费 根据业务场景动态调整:- 普通订单:60 秒- 复杂订单:120 秒- 秒杀订单:10 秒 可以在注解中配置过期时间,灵活适配不同业务
数据库唯一索引性能 唯一索引会降低数据库写入性能,大表上影响更明显 1. 选择短小精悍的字段作为唯一索引2. 避免使用大字段(如 varchar (255))3. 合理设置索引的时间粒度 推荐使用user_id + goods_id + floor(create_time/60)组合索引
MQ 消息重投导致重复 支付回调、库存扣减等 MQ 消息可能被重复投递 1. 消费端基于消息 ID 做幂等校验2. 数据库增加message_id唯一索引 消费成功后立即删除消息,避免重投
跨服务调用幂等性 订单服务调用支付服务、库存服务时,可能因网络问题重复调用 1. 每个服务接口都必须实现幂等性2. 传递全局唯一的请求 ID3. 下游服务基于请求 ID 做防重 建议使用 Sleuth 或 SkyWalking 生成全局链路 ID
异常情况下的资源泄漏 业务逻辑抛出异常时,锁或 token 未被正确释放 1. 在 finally 块中释放锁2. 为 Redis 键设置过期时间3. 使用 Redisson 的自动续期机制 不要手动管理锁的过期时间,交给框架处理

💡 技术亮点总结

  1. 低侵入性设计:通过 AOP + 注解实现,业务代码零侵入,便于维护和扩展
  2. 原子性保证:使用 Redis 原子命令,从根本上解决高并发竞态问题
  3. 通用化能力:支持不同业务场景的配置化防重,可复用性强
  4. 多层防护体系:前端 + 后端 + 数据库 + 对账,全方位保障数据一致性
  5. 异常友好处理:全局统一异常处理,返回清晰的错误提示

现场高压面试

现场高压面试

面试官 🤓:

先来个经典场景,用户网卡,订单按钮点了好几下,后台生成了重复订单,还扣了多次钱。从你角度,怎么全链路避免? 不用大而全,讲关键思路。

候选人 🧑‍💻:

好的面试官,我觉得本质是保证下单的幂等性 。我习惯从前端 → 网关/服务层 → 数据库,层层设防,用一张图快速展示:
sequenceDiagram participant U as 👤用户 participant F as 🌐前端 participant G as 🚪网关 participant S as ⚙️订单服务 participant R as 🗄️Redis participant D as 🗄️DB U->>F: 点击"提交订单" F-->>F: 防抖+置灰 F->>G: 先请求幂等Token G->>S: 生成Token S->>R: SET token:uid:xxx (过期5分钟) S-->>G: 返回Token G-->>F: Token U->>F: 再次点击(已被前端拦截) F->>G: 真实提交(携带Token) G->>S: 处理 S->>R: Lua原子校验并删除Token alt Token存在且删除成功 S->>D: 插入订单(唯一约束兜底) S-->>G: 下单成功 else Token已被消费 S-->>G: 重复提交,直接拒绝 end

核心是"先领号,后办事",我拆三层细讲。

面试官 🤔:

行,那先说第一层,前端能做什么?能做到什么程度?

候选人 🧑‍💻:

前端是体验防线,防君子不防小人,三点:

  1. 按钮立刻置灰 disabled = true,文案变"提交中...";
  2. 防抖 300ms~2s,短时间内只允许触发一次;
  3. 成功后跳转到结果页,并替换当前历史记录,防止用户后退重复提交。

但再严的前端,扛不住脚本直接调接口,所以后端的防线才是根本。

面试官 🧐:

同意,那你说的"幂等Token "机制,详细展开讲,怎么生成,怎么校验,高并发下怎么保证原子性?

候选人 🧑‍💻:

好的,这属于一次性令牌模式:

  • 获取Token :下单前调接口,服务端用雪花ID或其他UUID生成全局唯一Token,以 idempotent:order:uid:123 为 key 存入 Redis,过期5分钟,返回给前端。
  • 提交携带Token:正式下单必须带这个 Token。
  • 原子校验 :核心在于不能先查再删 ,会有并发窗口。我用 Lua 脚本 把"查询+删除"合成一个原子操作:
lua 复制代码
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

脚本返回删除成功才放行执行业务,否则直接返回"订单处理中,勿重复提交"。

这能保证即使 10 个线程同时过来,也只有一个能拿到执行权。

面试官 💡:

不错,那如果Redis突然挂了,或者极端并发穿透了你的 Token 机制,还有办法吗?

候选人 🧑‍💻:

必须有,数据库兜底

  • 订单表对订单号 或"用户ID+商品SKU+时间窗口 "建唯一索引
  • 插入时用 insert ignoreon duplicate key update,碰上重复会抛 DuplicateKeyException,业务捕获后返回"订单已存在",并做好日志记录。

这就是物理上的最后一道锁,就算前面所有防护都挂了,数据库也绝不允许落两条相同的订单。

面试官 🤖:

再考你个延伸,如果用了消息队列处理下单,消费者重复消费怎么办?

候选人 🧑‍💻:

本质还是幂等,消费者用业务唯一ID (比如订单号)当 Redis Key,消费前 SET NX 抢占,成功才执行业务,执行完不删,等过期;或者直接依赖数据库唯一索引插入防重,配合事务回滚。

面试官 🎯:

可以,总结一下你的方案选型。

候选人 🧑‍💻:

我用一个表对比:

策略 防护层 用户体验 可靠性 备注
🖱️ 按钮置灰+防抖 前端 ⭐⭐⭐⭐⭐ ⭐⭐ 防正常用户
🎟️ 幂等Token 服务层 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 主流方案,Lua保证原子
🧱 DB唯一索引 数据层 ⭐⭐⭐ ⭐⭐⭐⭐⭐ 最后兜底必须有

多层防御,哪怕前一层漏了,后一层也能兜住,保证最终幂等。

面试官 😄:

条理很清楚,尤其原子 Lua 和兜底唯一索引的意识很强,这个场景题过了。✅

面试官 😄:

前面方案聊得挺透,那趁热打铁,把刚才说的核心代码写几段出来,顺带总结下这个场景的技术难点和你的解决思路。就当白板写码,不用完整项目,关键逻辑出来就行。

候选人 🧑‍💻:

没问题,我就用 Java + Spring Boot + Redis 的经典组合,把生成令牌、原子校验、下单流程数据库兜底四块写出来,技术亮点我顺带标上。

🎟️ 1. 生成幂等 Token 接口

java 复制代码
@RestController
public class IdempotentTokenController {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 前端下单前调用,领取一次性令牌
    @GetMapping("/order/token")
    public Result<String> getOrderToken(@RequestParam Long userId) {
        // 亮点1:使用雪花算法全局唯一ID,性能高且有序
        String token = SnowflakeIdWorker.nextIdStr();
        String key = "idempotent:order:" + userId;
        // 亮点2:设置合理过期,防止Token堆积占用内存
        redisTemplate.opsForValue().set(key, token, 5, TimeUnit.MINUTES);
        return Result.success(token);
    }
}

💡 技术点:雪花 ID 保证令牌唯一且生成极快;过期时间 5 分钟是产品体验和安全的平衡。

⚛️ 2. Lua 脚本原子校验并删除

java 复制代码
// 加载为静态资源,避免每次都编译
private static final DefaultRedisScript<Long> TOKEN_CHECK_SCRIPT;
static {
    TOKEN_CHECK_SCRIPT = new DefaultRedisScript<>();
    TOKEN_CHECK_SCRIPT.setLocation(new ClassPathResource("lua/token_check.lua"));
    TOKEN_CHECK_SCRIPT.setResultType(Long.class);
}

// 下单时调用,返回true表示校验通过(已删除Token)
public boolean checkAndConsumeToken(String key, String token) {
    // 亮点3:Lua脚本保证"查+删"原子,无并发缝隙
    Long result = redisTemplate.execute(
        TOKEN_CHECK_SCRIPT, 
        Collections.singletonList(key), 
        token
    );
    return result != null && result == 1L;
}

Lua 脚本 token_check.lua

lua 复制代码
-- KEYS[1] = 幂等key,ARGV[1] = 前端传来的token
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

⚡ 为什么是 Lua?

如果是 Java 代码 if (get(key) == token) del(key),高并发下会有时间窗口。Lua 在 Redis 内单线程执行,直接封死并发穿透可能。

🛒 3. 下单核心逻辑(拼接令牌校验 + DB 兜底)

java 复制代码
@Transactional
public Order placeOrder(OrderDTO dto) {
    // 1. 幂等令牌校验
    String idempotentKey = "idempotent:order:" + dto.getUserId();
    String token = dto.getToken();
    if (!checkAndConsumeToken(idempotentKey, token)) {
        throw new BizException("订单处理中,请勿重复提交");
    }

    // 2. 业务逻辑(库存扣减、金额计算等)...
    Order order = buildOrder(dto);
    
    // 3. 插入数据库,利用唯一约束兜底
    try {
        // 亮点4:insert ignore 或 on duplicate key 配合唯一索引,防极端重复
        orderMapper.insertIgnore(order);
    } catch (DuplicateKeyException e) {
        throw new BizException("订单已存在,不可重复创建");
    }
    
    // 4. 异步通知等...
    return order;
}

MyBatis Mapper 示例:

java 复制代码
@Insert("INSERT IGNORE INTO orders (order_no, user_id, ...) VALUES (#{orderNo}, #{userId}, ...)")
int insertIgnore(Order order);

对应表结构(关键):

sql 复制代码
ALTER TABLE orders ADD UNIQUE INDEX uniq_order_no (order_no);
-- 或更业务化的唯一组合
ALTER TABLE orders ADD UNIQUE INDEX uniq_user_sku_time (user_id, sku_id, create_time_hour);

🧱 数据库为什么是最后一道墙?

Redis 可能挂了、脚本超时、网络分区,唯一索引是物理保证,真正实现"不落两条一样的订单"。

🔥 技术难点 & 解决方案总结

难点 具体问题 我的解决方案
并发穿透 多个请求同时通过Token校验 Lua 原子脚本"查+删"合并,Redis单线程执行
Redis故障降级 Token服务不可用,业务中断 数据库唯一索引兜底,Redis故障时仅失去提前拦截能力,不影响数据一致性
令牌过期 用户填单慢,Token 5分钟过期 前端定时续期或提交前无感刷新Token;过期后重新获取
分布式时钟问题 雪花ID依赖机器时钟,回拨会重复 雪花算法内做时钟回拨处理(等待或抛异常)
前端绕过 黑客跳过页面直接调用接口 后端强制Token校验,不信任前端任何数据
消息队列重复消费 消费者重试导致重复下单 消费端用业务唯一ID做Redis幂等(SETNX)或依赖数据库唯一约束

用一张图串联起来就是:
flowchart TD A👤用户提交 --> B{前端防抖置灰} B -->|通过| C{网关频控} C --> D🎟️ 幂等Token校验 D -->|Token有效| E⚛️ Lua原子删除 E -->|成功| F📦 业务处理 F --> G{🧱 DB唯一索引} G -->|插入成功| H✅ 订单完成 D -->|Token无效/重复| I❌ 拒绝 E -->|已被消费| I G -->|键冲突| J❌ 捕获异常返回已存在

面试官 🤙:

代码干净,Lua 脚本和 insert ignore 的细节拉出来了,难点总结也有深度,尤其 Redis 故障降级的时候还能依赖 DB 唯一约束兜底,这个点不少人会忽略。可以,这轮稳了。✅


公众号"Rain的Java大神之路"

个人博客"www.javadashen.com"

相关推荐
Rain的Java大神实战圈1 天前
MySQL误删数据如何恢复
场景设计题
Rain的Java大神实战圈2 天前
介绍一下分布式事务
场景设计题
Rain的Java大神实战圈3 天前
频繁FullGC如何优化
场景设计题
Rain的Java大神实战圈7 天前
MQ重复消费问题怎么解决
场景设计题
Rain的Java大神实战圈8 天前
线上服务器反应慢如何排查
场景设计题
Rain的Java大神实战圈9 天前
如何保证MQ消息顺序消费
场景设计题
Rain的Java大神实战圈10 天前
线上MQ消息积压了怎么处理
场景设计题
Rain的Java大神实战圈13 天前
介绍一下Redis的高可用方案
场景设计题
Rain的Java大神实战圈14 天前
如何保证Mysql和Redis双写一致性
场景设计题