如何避免订单重复提交

🔍 先一句话说透问题本质

订单重复提交的核心是接口幂等性问题,即同一个请求执行多次和执行一次的结果完全相同。常见触发场景包括:用户重复点击、网络延迟重试、浏览器刷新 / 后退、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. 数据库唯一约束(⭐⭐⭐⭐ 兜底方案)

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

-- 业务唯一索引:同一用户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. 分布式锁(⭐⭐⭐ 高并发场景)

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

// 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. 人工审核:对于异常订单(同一用户短时间内多个相同订单),进入人工审核队列

✅ 方案选型总结

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

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

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

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

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

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

/**
 * 防止重复提交注解
 * 技术亮点:通过注解配置化实现,零业务代码侵入
 */
@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 切面实现(统一拦截逻辑 🎯)

/**
 * 防重复提交切面
 * 技术亮点:
 * 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 生成接口

@RestController
@RequestMapping("/api/token")
@RequiredArgsConstructor
public class TokenController {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 获取防重token
     */
    @GetMapping("/generate")
    public Result 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. 订单控制器使用示例

@RestController
@RequestMapping("/api/order")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

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

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

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(BusinessException.class)
    public Result 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 脚本 把“查询+删除”合成一个原子操作:
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 接口

@RestController
public class IdempotentTokenController {

    @Autowired
    private RedisTemplate redisTemplate;

    // 前端下单前调用,领取一次性令牌
    @GetMapping("/order/token")
    public Result 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 脚本原子校验并删除

// 加载为静态资源,避免每次都编译
private static final DefaultRedisScript 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

-- 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 兜底)

@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 示例:

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

对应表结构(关键):

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”


原文地址: http://www.cveoy.top/t/topic/qHnE 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录