下订单重复提交问题递进式解决方案案例

0 阅读22分钟

分布式场景下订单重复提交问题递进式解决方案案例

一、案例背景(优化细节落地,适配通用订单场景)

1.1 业务场景

用户在移动端填写订单信息、上传附件后,点击“提交订单”按钮时,因网络延迟、误触或快速重复点击,易出现以下异常场景,严重影响业务正常运转及数据一致性:

  • 重复生成订单号,导致编码资源浪费、订单编码断层(如当日序列号跳过某一数字);
  • 同一批附件被多个订单争抢绑定,部分订单因“附件已被绑定”报错,事务回滚后仍残留无效订单号,增加数据清理成本;
  • 平台采用分布式部署(多服务器节点),单机锁(synchronized)无法跨节点生效,重复提交问题加剧,出现订单主表与明细数据不一致、附件绑定错乱等严重问题。

1.2 核心痛点(聚焦问题本质,适配通用订单场景)

  • 前端防重复点击(如按钮禁用)可被绕过(如刷新页面、模拟请求、抓包重放),仅能作为辅助防护,必须依赖后端核心防护;
  • 仅靠事务回滚无法彻底消除重复提交的残留影响(如订单号生成、序列号占用、附件预占标识),易造成数据冗余;
  • 分布式环境下,单机锁(synchronized)失效,无法实现跨节点全局并发控制,无法应对多节点并发提交场景;
  • 订单涉及后续履约、结算等流程,需兼顾“拦截重复请求”“保护核心资源”“幂等性兜底”,方案需递进式落地,降低改造成本,同时不影响业务正常流转。

1.3 前置说明

本案例不涉及真实公司代码,所有类名、方法名、业务参数均为通用设计,适配大多数分布式订单场景(尤其适配对数据一致性要求高的核心业务);方案按“基础防护→进阶拦截→终极兜底”递进,每一步均明确“解决什么问题、未解决什么问题”,确保逻辑闭环。

补充说明:本案例中分布式锁基于Redisson实现,核心依赖其tryLock方法(内部封装Lua脚本保证原子性),但在幂等Token校验场景,需手动编写Lua脚本——两者解决的业务问题不同,后续将详细说明区别与适用场景,避免混淆使用。

二、递进式解决方案(优化递进逻辑,补充实操细节与业务适配)

核心思路:从“保护核心资源”到“拦截重复请求”,再到“幂等性兜底”,逐步解决重复提交的全场景问题,每一步方案均基于上一步的不足进行优化,降低改造难度,同时保证可落地性;补充订单预生成、附件预占等适配逻辑,贴合通用订单业务流程。

方案1:基础防护——核心资源(附件)分布式锁(解决“附件争抢”问题)

2.1 方案目标

优先解决“同一批附件被多个订单争抢绑定”的核心问题,避免因附件绑定冲突导致的订单提交失败,同时验证分布式锁的基础可用性;兼顾附件的唯一性要求,避免附件被重复绑定至不同订单。

2.2 实现思路

针对“附件绑定”这一核心操作,通过自定义Redisson分布式锁注解,按“用户ID + 附件URL集合”粒度加锁(MD5压缩锁名,避免Key过长)——补充用户ID可避免不同用户提交相同附件时被误拦截,贴合通用订单“一人一附件”的核心场景;保证同一用户的同一批附件仅能被一个订单绑定,从资源层面阻止重复提交的后续异常。

此处需注意:Redisson的tryLock方法已封装Lua脚本,可确保“加锁+设置过期时间”的原子性,无需手动编写Lua脚本,仅需通过注解+切面简化锁的调用逻辑;采用非阻塞式获取锁(waitTime=0),直接拒绝重复请求,提升用户体验。

2.3 核心代码(完整可落地,补充异常处理与业务适配,删除冗余实体)

(1)自定义RedLock注解(适配切面解析,补充锁策略配置)
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * 自定义分布式锁注解,用于核心资源防护(如附件绑定)
 */
@Target(ElementType.METHOD) // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时生效
@Documented
public @interface RedLock {
    String value(); // 锁名(SpEL表达式,用于动态生成唯一锁Key)
    long waitTime() default 0; // 获取锁的等待时间(默认0秒,非阻塞式,直接拒绝重复请求)
    long leaseTime() default 3; // 锁持有时间(默认3秒,需大于方法执行时间,预留1秒冗余)
    TimeUnit unit() default TimeUnit.SECONDS; // 时间单位(默认秒)
}
    
(2)分布式锁切面(优化SpEL解析,补充参数校验与锁释放说明)
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;

/**
 * RedLock注解切面,负责解析锁名、获取锁、释放锁
 * 说明:Redisson的unlock()方法本身会校验当前线程是否持有锁,此处保留isHeldByCurrentThread()做双重保障,避免锁泄露
 */
@Aspect
@Component
@RequiredArgsConstructor
public class RedLockAspect {
    private final RedissonClient redissonClient;
    private final ExpressionParser parser = new SpelExpressionParser();

    @Around("@annotation(redLock)")
    public Object around(ProceedingJoinPoint point, RedLock redLock) throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Object[] args = point.getArgs();
        // 1. 校验方法参数(避免空指针导致SpEL解析失败)
        if (args == null || args.length == 0) {
            throw new BusinessException("方法参数不能为空,无法生成锁名");
        }
        // 2. 构建SpEL上下文,注册方法参数(支持#参数名引用)
        StandardEvaluationContext context = new StandardEvaluationContext();
        String[] parameterNames = signature.getParameterNames();
        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }
        // 3. 解析锁名(SpEL表达式转字符串),确保编码一致(UTF-8)
        String lockName = parser.parseExpression(redLock.value()).getValue(context, String.class);
        if (lockName == null || lockName.trim().isEmpty()) {
            throw new BusinessException("锁名解析失败,请检查@RedLock注解配置");
        }
        // 4. 获取分布式锁并执行方法
        RLock lock = redissonClient.getLock(lockName);
        try {
            // 尝试获取锁:非阻塞式(waitTime=0),持有leaseTime秒,超时则拒绝
            // Redisson tryLock内部封装Lua脚本,确保加锁+过期时间原子化,无需手动编写
            boolean isLocked = lock.tryLock(redLock.waitTime(), redLock.leaseTime(), redLock.unit());
            if (!isLocked) {
                throw new BusinessException("操作频繁,请稍后重试(附件绑定中)");
            }
            // 执行目标方法(订单提交/附件绑定)
            return point.proceed();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("请求被中断,请重新提交");
        } finally {
            // 释放锁:仅当前线程持有锁时释放,避免误释放其他线程的锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}
    
(3)附件绑定方法加锁(优化锁名粒度,补充空值防护与附件预占)
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 附件服务:负责附件绑定、校验、预占逻辑(适配订单附件唯一性要求)
 */
@Component
@RequiredArgsConstructor
public class FileService {
    private final FileMapper fileMapper;

    /**
     * 绑定附件到订单(加分布式锁,避免附件争抢)
     * @param fileList 附件列表(包含URL)
     * @param orderNo 订单号(预生成订单号,避免绑定失败后订单号浪费)
     */
    @RedLock(
            value = "'bind_file:' + T(com.example.util.SecurityUtils).getUserId() + '_' + T(org.springframework.util.DigestUtils).md5DigestAsHex(T(com.example.util.SpelHelper).joinFileUrls(#fileList).getBytes())",
            waitTime = 0, // 非阻塞式,直接拒绝重复请求
            leaseTime = 3
    )
    @Transactional(rollbackFor = Exception.class)
    public void bindFileToOrder(List<String> fileList, String orderNo) {
        // 空值防护:避免fileList为null导致Stream报错
        List<String> safeFileList = fileList == null ? Collections.emptyList() : fileList;
        if (safeFileList.isEmpty()) {
            throw new BusinessException("附件列表不能为空,无法提交订单");
        }
        // 1. 校验附件是否存在(数据库查询)
        List<String> fileUrls = safeFileList.stream().collect(Collectors.toList());
        List<String> dbFiles = fileMapper.selectByUrls(fileUrls);
        if (dbFiles.isEmpty()) {
            throw new BusinessException("附件不存在,请重新上传");
        }
        // 2. 校验附件是否已绑定其他订单(核心校验)
        List<String> boundFiles = dbFiles.stream()
                .filter(file -> file != null && !file.equals(orderNo))
                .toList();
        if (!boundFiles.isEmpty()) {
            String boundUrls = boundFiles.stream().collect(Collectors.joining(","));
            throw new BusinessException("以下附件已被其他订单绑定:" + boundUrls);
        }
        // 3. 绑定附件到当前订单(仅更新未绑定的附件,补充预占标识)
        List<String> unboundFiles = dbFiles.stream()
                .filter(file -> file == null)
                .toList();
        if (!unboundFiles.isEmpty()) {
            fileMapper.updateBatchByUrls(unboundFiles, orderNo);
        }
    }
}

// SpEL辅助工具类:简化SpEL表达式,避免嵌套过深导致解析失败,做空值防护
class SpelHelper {
    /**
     * 拼接附件URL,做空值防护,返回字节数组(供MD5加密)
     */
    public static byte[] joinFileUrls(List<String> fileList) {
        List<String> safeList = fileList == null ? Collections.emptyList() : fileList;
        String urls = safeList.stream().collect(Collectors.joining(","));
        return urls.getBytes();
    }
}

// 自定义业务异常(统一异常处理)
class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}
    

2.4 方案效果与不足

已解决问题
  • ✅ 解决“同一批附件被多个订单争抢绑定”的问题,避免附件绑定冲突,适配订单附件唯一性要求;
  • ✅ 实现分布式环境下的附件资源保护,单机锁失效问题得到解决,支持多节点并发;
  • ✅ 补充空值防护和异常处理,避免因参数为空、锁名解析失败导致的系统异常;
  • ✅ 借助Redisson tryLock的封装能力,无需手动编写Lua脚本,快速实现分布式锁的原子性加锁;
  • ✅ 优化锁名粒度(用户ID+附件URL),避免不同用户提交相同附件时被误拦截,提升业务适配性。
未解决问题
  • ❌ 无法拦截重复订单生成:仅保护了附件绑定环节,重复请求仍会执行“生成订单号、保存订单主表/明细”逻辑,导致订单号浪费、编码断层;
  • ❌ 依赖Redis可用性,Redis宕机时,分布式锁失效,附件绑定冲突问题会复现,无降级方案;
  • ❌ Redisson tryLock仅能实现“互斥访问”,无法实现“一次性请求”,无法应对锁过期后的重复提交;
  • ❌ 未适配订单预生成需求,绑定失败后订单号已生成,仍会造成编码浪费。

方案2:进阶拦截——订单提交入口分布式锁(解决“重复订单生成”问题)

2.1 方案目标

在上一步“附件保护”的基础上,从订单提交入口拦截重复请求,彻底避免重复订单生成,解决订单号浪费、编码断层问题;补充订单预生成流程,强化分布式并发控制,形成双重防护。

2.2 实现思路

在订单提交方法入口,按“用户ID + 核心业务参数(附件URL集合)”生成唯一锁名,实现“同一用户对同一批附件的重复提交”拦截;新增订单预生成接口,先生成“待提交”状态的订单号,提交成功后更新为“已提交”,失败则释放订单号,避免编码浪费;保留方案1的附件绑定锁作为兜底,形成“入口拦截+资源保护”双重防护;优化锁的过期时间配置(延长至5秒),避免锁提前释放或死锁,同时采用非阻塞式获取锁,提升用户体验。

此处继续复用Redisson tryLock方法,无需手动编写Lua脚本——其核心价值在于“互斥拦截”,确保同一时间仅有一个请求执行订单提交流程,适配入口拦截的业务需求;订单号生成逻辑补充分布式锁,确保分布式环境下序列号唯一。

2.3 核心代码(基于方案1优化,衔接流畅,补全缺失逻辑,删除冗余实体)

(1)订单号生成工具类(优化,避免编码断层,补充分布式锁)
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.transaction.annotation.Transactional;
import java.text.SimpleDateFormat;
import java.util.Date;
import lombok.RequiredArgsConstructor;

/**
 * 订单号生成工具类(基于每日序列号,保证分布式唯一)
 */
@Component
@RequiredArgsConstructor
public class CodeGenerator {
    private final CodeRecordMapper codeRecordMapper;
    private final RedissonClient redissonClient; // 补充分布式锁依赖
    private static final String ORDER_PREFIX = "ORDER"; // 订单前缀
    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyMMdd");
    // 单机锁:保证单节点内序列号不重复(配合分布式锁,双重保障)
    private final Object lock = new Object();

    @Transactional(rollbackFor = Exception.class)
    public String generateOrderCode() {
        String datePart = DATE_FORMAT.format(new Date());
        // 分布式锁:保证多节点并发下序列号唯一(锁名粒度:日期)
        String seqLockKey = "order:seq:" + datePart;
        RLock seqLock = redissonClient.getLock(seqLockKey);
        try {
            // 非阻塞式获取锁,避免阻塞正常请求
            boolean isSeqLocked = seqLock.tryLock(0, 3, TimeUnit.SECONDS);
            if (!isSeqLocked) {
                throw new BusinessException("订单号生成繁忙,请稍后重试");
            }
            // 单机锁:单节点内并发保障
            synchronized (lock) {
                // 获取当日最大序列号
                Integer maxSeq = codeRecordMapper.selectMaxSequence(ORDER_PREFIX, datePart);
                // 生成新序列号(若当日无记录,从1开始)
                int newSeq = maxSeq == null ? 1 : maxSeq + 1;
                // 生成订单号(格式:前缀+日期+6位序列号)
                String orderNo = String.format("%s_%s_%06d", ORDER_PREFIX, datePart, newSeq);
                // 记录序列号(避免编码断层,便于后续排查)
                codeRecordMapper.insert(ORDER_PREFIX, datePart, newSeq);
                return orderNo;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("订单号生成被中断,请重新提交");
        } finally {
            // 释放分布式锁
            if (seqLock.isHeldByCurrentThread()) {
                seqLock.unlock();
            }
        }
    }
}
    
(2)订单服务(预生成订单+入口加锁,衔接附件绑定)
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
import lombok.RequiredArgsConstructor;

/**
 * 订单服务:负责订单预生成、提交、订单号生成、明细保存
 */
@Component
@RequiredArgsConstructor
public class OrderService {
    private final OrderMapper orderMapper;
    private final OrderDetailMapper detailMapper;
    private final FileService fileService;
    private final CodeGenerator codeGenerator;

    /**
     * 预生成订单号(无事务,仅占号,避免提交失败后订单号浪费)
     * @return 预生成的订单号(状态:待提交)
     */
    public String preGenerateOrderNo() {
        // 生成订单号
        String orderNo = codeGenerator.generateOrderCode();
        // 保存预生成订单(状态:待提交)
        orderMapper.insertPreOrder(orderNo, SecurityUtils.getUserId());
        return orderNo;
    }

    /**
     * 订单提交(入口加锁,拦截重复请求,复用附件绑定锁)
     * @param orderNo 预生成的订单号
     * @param fileList 附件列表
     * @param totalAmount 订单总金额
     * @return 提交结果
     */
    @RedLock(
            value = "'submit_order:' + T(com.example.util.SecurityUtils).getUserId() + '_' + T(org.springframework.util.DigestUtils).md5DigestAsHex(T(com.example.util.SpelHelper).joinSubmitFiles(#fileList).getBytes())",
            waitTime = 0, // 非阻塞式,直接拒绝重复请求
            leaseTime = 5, // 延长锁持有时间(大于订单提交全流程执行时间,预留冗余)
            unit = TimeUnit.SECONDS
    )
    @Transactional(rollbackFor = Exception.class)
    public Boolean submitOrder(String orderNo, List<String> fileList, BigDecimal totalAmount) {
        // 1. 基础校验
        if (orderNo == null || orderNo.trim().isEmpty() || totalAmount == null) {
            throw new BusinessException("订单号或订单金额不能为空");
        }
        // 2. 获取当前用户ID(静态方法,避免SpEL解析Bean失败)
        String userId = SecurityUtils.getUserId();
        if (userId == null || userId.trim().isEmpty()) {
            throw new BusinessException("请先登录后提交订单");
        }
        // 3. 校验预生成订单状态(避免重复提交、订单号被冒用)
        Integer orderStatus = orderMapper.selectOrderStatusByNo(orderNo);
        if (orderStatus == null) {
            throw new BusinessException("订单号不存在,请重新预生成");
        }
        if (!userId.equals(orderMapper.selectUserIdByOrderNo(orderNo))) {
            throw new BusinessException("订单号归属错误,无法提交");
        }
        if (orderStatus != 0) {
            throw new BusinessException("订单已提交,请勿重复操作");
        }
        // 4. 更新订单状态为“已提交”
        orderMapper.updateOrderStatus(orderNo, 1, totalAmount);
        // 5. 绑定附件(调用方案1的加锁方法,双重防护)
        List<String> allFiles = fileList == null ? Collections.emptyList() : fileList;
        fileService.bindFileToOrder(allFiles, orderNo);
        return true;
    }
}

// 安全工具类:静态方法获取当前用户ID(避免SpEL解析Bean失败)
class SecurityUtils {
    public static String getUserId() {
        // 实际项目需替换为Spring Security获取当前用户ID的真实逻辑,绑定用户会话
        // 此处模拟:用户ID格式为“user_+用户唯一标识”,避免匿名提交
        return "user_" + Thread.currentThread().getId();
    }
}

// SpelHelper新增方法:拼接订单提交表单中的所有附件URL
class SpelHelper {
    // 原有joinFileUrls方法不变...
    
    /**
     * 拼接订单提交中的附件URL,供入口锁名生成
     */
    public static byte[] joinSubmitFiles(List<String> fileList) {
        if (fileList == null) {
            return "".getBytes();
        }
        // 附件URL拼接(空值防护)
        String fileUrls = fileList.stream().collect(Collectors.joining(","));
        
        // 转字节数组(供MD5加密),确保编码一致
        return fileUrls.getBytes();
    }
}
    

2.4 方案效果与不足

已解决问题
  • ✅ 从入口拦截重复请求,同一用户对同一批附件的重复提交被直接拒绝,彻底避免重复订单生成;
  • ✅ 解决订单号浪费、编码断层问题,订单号生成逻辑配合分布式锁+单机锁,保证分布式唯一;
  • ✅ 新增订单预生成流程,提交失败后订单号仍为“待提交”,可重新提交或释放,避免编码浪费;
  • ✅ 形成“入口拦截+附件保护”双重分布式锁防护,并发安全进一步提升;
  • ✅ 优化锁过期时间,避免锁提前释放导致的并发问题,补充用户登录校验、参数空值校验,提升业务安全性;
  • ✅ 继续复用Redisson tryLock的原子性封装,无需手写Lua,兼顾开发效率与并发安全。
未解决问题
  • ❌ 锁的时效性问题:若锁过期时间设置过短,订单提交流程未完成,锁提前释放,仍可能出现重复提交;若设置过长,Redis宕机时,锁无法释放,会导致正常请求阻塞;
  • ❌ 依赖Redis可用性:Redis集群宕机或网络异常时,分布式锁失效,重复提交问题复现,无降级方案;
  • ❌ 核心局限:Redisson tryLock的“互斥性”无法替代“幂等性”——仅能实现“互斥访问”(同一时间只能有一个请求执行),无法实现“一次性请求”(同一请求无论何时执行,仅生效一次),核心问题是无法应对锁过期后的重复提交。

通俗解读+类比:可以把“互斥性”比作“同一时间只能有一个人用同一台打印机”,而“幂等性”比作“同一个文件,无论打印多少次,最终只有一份有效文件”。Redisson tryLock只解决了“同一时间不让多个人操作”,但没解决“多次操作(不同时间)导致重复结果”的问题,这也是“互斥性”无法替代“幂等性”的核心原因。

结合本案例场景拆解:方案2中,订单提交入口锁持有时间设为5秒(适配正常提交流程)。若用户提交时网络卡顿,订单提交流程耗时6秒(超过锁持有时间),锁会提前释放;用户因未收到响应再次点击提交,此时锁已释放,第二次请求不会被拦截,会顺利执行订单生成、附件绑定逻辑,最终生成重复订单——这就是“无法应对锁过期后重复提交”的具体表现,也是tryLock的核心局限。

解决方案:需引入Token+手写Lua脚本实现原子性校验。Token作为“一次性凭证”,相当于“打印文件的唯一授权码”,仅能使用一次;手写Lua脚本可实现“校验Token有效+删除Token”的原子操作(避免并发下Token被重复使用),确保无论用户何时发起多少次请求,只要Token被使用过,后续请求都会被拒绝,真正实现“一次请求仅生效一次”,完美弥补tryLock的局限,这也是方案3引入该逻辑的核心原因。

方案3:终极兜底——Token+幂等性校验(彻底解决所有重复提交问题)

2.1 方案目标

在上一步“入口拦截+资源保护”的基础上,引入Token幂等性校验,彻底解决分布式锁的时效性、Redis可用性问题,实现“一次请求仅生效一次”;补充Redis降级方案,确保Redis宕机时幂等性校验不失效;强化Token与用户会话绑定,避免Token冒用;最终实现无论何种场景(网络延迟、Redis宕机、锁失效),均不会出现重复订单,保障数据一致性。

关键说明:此处Token校验需手动编写Lua脚本,而非复用Redisson tryLock——因为两者解决的核心问题不同:tryLock是“互斥访问”,手写Lua是“原子性校验+一次性消费”,具体区别将在代码后详细补充。

2.2 实现思路

采用“前端申请Token+后端校验Token”的幂等性方案,结合通用订单业务流程,核心逻辑:

  1. 前端页面初始化时,向后端申请唯一幂等Token(与用户ID、会话绑定,有效期1-2分钟,避免资源浪费);
  2. 前端点击“提交订单”前,先预生成订单号,再携带Token、预生成订单号、订单信息一并提交;
  3. 后端接收请求后,先校验Token有效性(原子操作:校验+删除,避免并发重复使用),Token有效则执行订单提交,无效则直接拒绝;
  4. 保留方案1、方案2的分布式锁作为兜底,即使Token校验出现异常,仍能拦截大部分重复请求;
  5. Redis宕机时,自动降级为数据库存储Token,数据库校验时加行锁,避免并发问题,保证幂等性校验不失效;
  6. Token与用户会话绑定,避免Token被冒用,提升业务安全性。

2.3 核心代码(完整闭环,补充降级逻辑与业务适配,删除冗余实体)

(1)幂等Token工具类(原子校验,支持降级,补充Lua脚本说明)
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * 幂等Token工具类:生成、校验Token,支持Redis降级为数据库
 * 核心说明:Token校验需手动编写Lua脚本,而非使用Redisson tryLock,原因如下:
 * 1. tryLock核心是“互斥访问”,无法实现Token的“一次性消费”(锁释放后仍可能重复提交);
 * 2. Lua脚本可实现“校验Token有效性+删除Token”原子操作,避免并发下Token被重复使用;
 * 3. Redisson tryLock封装的Lua脚本仅用于加锁/解锁,无“校验+删除”的业务逻辑,无法复用。
 */
@Component
@RequiredArgsConstructor
public class IdempotentTokenUtils {
    private final StringRedisTemplate redisTemplate;
    private final IdempotentTokenMapper tokenMapper; // 数据库Token存储Mapper(降级用)
    private static final String TOKEN_PREFIX = "order:idempotent:token:";
    private static final long TOKEN_EXPIRE = 2 * 60; // Token有效期2分钟(覆盖订单提交最大耗时,避免资源浪费)
    private static final boolean REDIS_ENABLE = true; // Redis启用开关(用于降级)

    /**
     * 生成幂等Token(与用户ID、会话绑定,避免冒用)
     * @param userId 用户ID
     * @param sessionId 用户会话ID
     * @return 唯一Token
     */
    public String generateToken(String userId, String sessionId) {
        if (!StringUtils.hasText(userId) || !StringUtils.hasText(sessionId)) {
            throw new BusinessException("用户ID和会话ID不能为空,无法生成幂等Token");
        }
        String token = UUID.randomUUID().toString().replace("-", "");
        // 优先使用Redis存储Token(Key:前缀+用户ID+会话ID+Token,确保唯一性)
        if (REDIS_ENABLE) {
            String key = TOKEN_PREFIX + userId + ":" + sessionId + ":" + token;
            redisTemplate.opsForValue().set(key, "VALID", TOKEN_EXPIRE, TimeUnit.SECONDS);
        } else {
            // Redis降级:存储到数据库,加唯一索引(userId+sessionId+token)
            tokenMapper.insert(userId, sessionId, token, System.currentTimeMillis() + TOKEN_EXPIRE * 1000);
        }
        return token;
    }

    /**
     * 校验Token(原子操作,仅允许一次使用)
     * @param userId 用户ID
     * @param sessionId 用户会话ID
     * @param token 前端传入的Token
     * @return true:Token有效,false:Token无效/已使用/过期
     */
    public boolean validateToken(String userId, String sessionId, String token) {
        if (!StringUtils.hasText(userId) || !StringUtils.hasText(sessionId) || !StringUtils.hasText(token)) {
            return false;
        }
        // 优先使用Redis校验
        if (REDIS_ENABLE) {
            String key = TOKEN_PREFIX + userId + ":" + sessionId + ":" + token;
            // Lua脚本:原子校验并删除Token,避免并发下重复使用(核心)
            // 此处必须手写Lua,无法用Redisson tryLock替代:tryLock是加锁互斥,此处是校验+删除的原子操作
            String luaScript = """
                    if redis.call('get', KEYS[1]) == 'VALID' then
                        return redis.call('del', KEYS[1]) -- 校验通过,原子删除Token,避免重复使用
                    else
                        return 0 -- 校验失败(Token无效/已使用/过期)
                    end
                    """;
            Long result = redisTemplate.execute(
                    new DefaultRedisScript<>(luaScript, Long.class),
                    Collections.singletonList(key)
            );
            return result != null && result > 0;
        } else {
            // Redis降级:数据库校验(加行锁,避免并发,适配分布式场景)
            return tokenMapper.validateAndUpdate(userId, sessionId, token, System.currentTimeMillis()) > 0;
        }
    }
}
   
(2)前端接口(申请Token+预生成订单+提交订单,适配幂等逻辑)
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import java.math.BigDecimal;
import java.util.List;

/**
 * 订单控制器:提供Token申请、订单预生成、订单提交接口
 */
@RestController
@RequestMapping("/api/order")
@RequiredArgsConstructor
public class OrderController {
    private final IdempotentTokenUtils tokenUtils;
    private final OrderService orderService;

    /**
     * 申请订单提交幂等Token(页面初始化时调用,与会话绑定)
     * @param sessionId 用户会话ID(请求头传入,避免Token冒用)
     * @return Token字符串
     */
    @GetMapping("/get-idempotent-token")
    public Result<String> getOrderToken(@RequestHeader("Session-Id") String sessionId) {
        String userId = SecurityUtils.getUserId();
        if (userId == null) {
            return Result.fail("请先登录");
        }
        if (!StringUtils.hasText(sessionId)) {
            return Result.fail("会话ID不能为空,无法生成Token");
        }
        String token = tokenUtils.generateToken(userId, sessionId);
        return Result.success(token);
    }

    /**
     * 预生成订单号(提交订单前调用,避免订单号浪费)
     * @return 预生成订单号
     */
    @PostMapping("/pre-generate-order-no")
    public Result<String> preGenerateOrderNo() {
        try {
            String orderNo = orderService.preGenerateOrderNo();
            return Result.success(orderNo);
        } catch (BusinessException e) {
            return Result.fail(e.getMessage());
        }
    }

    /**
     * 提交订单(携带Token、会话ID、预生成订单号,幂等校验)
     * @param token 幂等Token(请求头传入)
     * @param sessionId 会话ID(请求头传入)
     * @param orderNo 预生成订单号(请求头传入)
     * @param fileList 附件列表
     * @param totalAmount 订单总金额
     * @return 提交结果
     */
    @PostMapping("/submit")
    public Result<Boolean> submitOrder(
            @RequestHeader("Idempotent-Token") String token,
            @RequestHeader("Session-Id") String sessionId,
            @RequestHeader("Pre-Order-No") String orderNo,
            @RequestParam List<String> fileList,
            @RequestParam BigDecimal totalAmount
    ) {
        String userId = SecurityUtils.getUserId();
        try {
            // 1. 先校验Token(核心幂等逻辑,手写Lua实现原子校验)
            boolean tokenValid = tokenUtils.validateToken(userId, sessionId, token);
            if (!tokenValid) {
                return Result.fail("重复提交,请稍后重试(Token无效/已使用/过期)");
            }
            // 2. 执行订单提交(复用方案2的入口加锁方法,双重兜底)
            boolean success = orderService.submitOrder(orderNo, fileList, totalAmount);
            return Result.success(success);
        } catch (BusinessException e) {
            return Result.fail(e.getMessage());
        }
    }
}
    

2.4 方案效果与总结

已解决问题(全场景覆盖)
  • ✅ 彻底解决重复订单生成问题:Token幂等性校验实现“一次请求仅生效一次”,无论锁是否过期、网络是否延迟,均不会出现重复提交;
  • ✅ 解决Redis依赖问题:补充Redis降级方案,Redis宕机时自动切换至数据库存储Token,加行锁保障并发安全,幂等性校验不失效;
  • ✅ 解决锁的时效性问题:无需依赖锁的过期时间配置,Token一次性消费特性从根本上避免锁提前释放或死锁导致的并发问题;
  • ✅ 强化安全防护:Token与用户ID、会话ID绑定,避免Token冒用,补充多重参数校验,提升订单提交的安全性;
  • ✅ 形成完整防护体系:“前端Token申请+后端Token校验+入口分布式锁+附件资源锁”四重防护,兼顾开发效率与数据一致性;
  • ✅ 适配通用订单场景:删除所有冗余实体和行业专属称呼,代码简洁可落地,可直接复用至各类分布式订单业务。
方案总结

本方案按“基础防护→进阶拦截→终极兜底”的递进思路,逐步解决分布式订单重复提交的全场景问题,核心逻辑可总结为:

  1. 基础层:用Redisson分布式锁保护附件等核心资源,解决资源争抢问题,快速实现分布式并发控制;

  2. 进阶层:在订单提交入口加锁,拦截重复请求,解决订单号浪费、重复订单生成问题,形成双重防护;

  3. 兜底层:引入Token+手写Lua脚本实现幂等性校验,解决锁时效性、Redis可用性问题,实现全场景重复提交拦截。

三者结合,既保证了开发效率(复用Redisson封装能力,减少手写Lua成本),又确保了数据一致性(幂等性兜底,覆盖所有异常场景),适配各类分布式订单业务,可直接落地使用。