分布式场景下订单重复提交问题递进式解决方案案例
一、案例背景(优化细节落地,适配通用订单场景)
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”的幂等性方案,结合通用订单业务流程,核心逻辑:
- 前端页面初始化时,向后端申请唯一幂等Token(与用户ID、会话绑定,有效期1-2分钟,避免资源浪费);
- 前端点击“提交订单”前,先预生成订单号,再携带Token、预生成订单号、订单信息一并提交;
- 后端接收请求后,先校验Token有效性(原子操作:校验+删除,避免并发重复使用),Token有效则执行订单提交,无效则直接拒绝;
- 保留方案1、方案2的分布式锁作为兜底,即使Token校验出现异常,仍能拦截大部分重复请求;
- Redis宕机时,自动降级为数据库存储Token,数据库校验时加行锁,避免并发问题,保证幂等性校验不失效;
- 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校验+入口分布式锁+附件资源锁”四重防护,兼顾开发效率与数据一致性;
- ✅ 适配通用订单场景:删除所有冗余实体和行业专属称呼,代码简洁可落地,可直接复用至各类分布式订单业务。
方案总结
本方案按“基础防护→进阶拦截→终极兜底”的递进思路,逐步解决分布式订单重复提交的全场景问题,核心逻辑可总结为:
-
基础层:用Redisson分布式锁保护附件等核心资源,解决资源争抢问题,快速实现分布式并发控制;
-
进阶层:在订单提交入口加锁,拦截重复请求,解决订单号浪费、重复订单生成问题,形成双重防护;
-
兜底层:引入Token+手写Lua脚本实现幂等性校验,解决锁时效性、Redis可用性问题,实现全场景重复提交拦截。
三者结合,既保证了开发效率(复用Redisson封装能力,减少手写Lua成本),又确保了数据一致性(幂等性兜底,覆盖所有异常场景),适配各类分布式订单业务,可直接落地使用。