如何避免订单重复提交
如何避免订单重复提交
🔍 先一句话说透问题本质
订单重复提交的核心是接口幂等性问题,即同一个请求执行多次和执行一次的结果完全相同。常见触发场景包括:用户重复点击、网络延迟重试、浏览器刷新 / 后退、RPC 框架自动重试、MQ 消息重投等。
🛡️ 第一道防线:前端防重(成本最低,拦截 80% 误操作)
这是用户最先接触的一层,必须做好,能大幅减轻后端压力。
| 方案 | 实现方式 | 拦截效果 | 适用场景 |
|---|---|---|---|
| 按钮禁用 + Loading | 点击后立即置灰,显示加载动画,请求返回后恢复 | ★★★★☆ | 所有表单提交 |
| 提交防抖 | 300ms 内只执行最后一次提交 | ★★★☆☆ | 快速连续点击 |
| 页面跳转 | 提交成功后立即跳转到结果页,禁止浏览器后退 | ★★★☆☆ | 防止刷新 / 后退重发 |
| 历史记录清除 | 用history.replaceState()替换当前历史记录 | ★★☆☆☆ | 辅助防止后退 |
⚠️ 注意:前端防重只能防君子,不能防小人!恶意用户可以直接调用接口绕过,所以后端防重才是核心。
⚔️ 核心防线:后端防重(必须做,100% 拦截重复请求)
我在项目中主要使用以下 4 种后端方案,按推荐优先级排序:
1. 分布式 Token 机制(⭐⭐⭐⭐⭐ 首选方案)
这是大厂最通用、最健壮的方案,完美适配分布式系统。
关键要点:
- ✅ 必须用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. 状态机控制(⭐⭐⭐ 复杂业务流程)
通过订单状态流转来控制操作的合法性,防止状态回退。
🛟 终极兜底:对账与补偿
即使前面所有防线都失效,也要有兜底机制:
- 定时任务对账:每天凌晨核对订单表与支付流水表,发现重复订单自动退款
- 支付回调幂等:支付回调接口必须做幂等处理,防止重复回调导致多次发货
- 人工审核:对于异常订单(同一用户短时间内多个相同订单),进入人工审核队列
✅ 方案选型总结
拦截效果好
↑
|
实现复杂度低 ←───────┼─────→ 实现复杂度高
|
↓
拦截效果差
┌─────────────────────┬─────────────────────┐
│ 🌟 优先使用 │ 🎯 特定场景使用 │
│ │ │
│ ✅ 数据库唯一约束 │ ⚡ 分布式锁 │
│ ✅ 分布式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<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. 订单控制器使用示例
@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. 全局异常处理器(统一返回格式)
@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. 传递全局唯一的请求 ID 3. 下游服务基于请求 ID 做防重 | 建议使用 Sleuth 或 SkyWalking 生成全局链路 ID |
| 异常情况下的资源泄漏 | 业务逻辑抛出异常时,锁或 token 未被正确释放 | 1. 在 finally 块中释放锁 2. 为 Redis 键设置过期时间 3. 使用 Redisson 的自动续期机制 | 不要手动管理锁的过期时间,交给框架处理 |
💡 技术亮点总结
- 低侵入性设计:通过 AOP + 注解实现,业务代码零侵入,便于维护和扩展
- 原子性保证:使用 Redis 原子命令,从根本上解决高并发竞态问题
- 通用化能力:支持不同业务场景的配置化防重,可复用性强
- 多层防护体系:前端 + 后端 + 数据库 + 对账,全方位保障数据一致性
- 异常友好处理:全局统一异常处理,返回清晰的错误提示
现场高压面试
现场高压面试
面试官 🤓:
先来个经典场景,用户网卡,订单按钮点了好几下,后台生成了重复订单,还扣了多次钱。从你角度,怎么全链路避免? 不用大而全,讲关键思路。
候选人 🧑💻:
好的面试官,我觉得本质是保证下单的幂等性。我习惯从前端 → 网关/服务层 → 数据库,层层设防,用一张图快速展示:
核心是“先领号,后办事”,我拆三层细讲。
面试官 🤔:
行,那先说第一层,前端能做什么?能做到什么程度?
候选人 🧑💻:
前端是体验防线,防君子不防小人,三点:
- 按钮立刻置灰
disabled = true,文案变“提交中...”; - 防抖 300ms~2s,短时间内只允许触发一次;
- 成功后跳转到结果页,并替换当前历史记录,防止用户后退重复提交。
但再严的前端,扛不住脚本直接调接口,所以后端的防线才是根本。
面试官 🧐:
同意,那你说的“幂等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 ignore或on 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<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 脚本原子校验并删除
// 加载为静态资源,避免每次都编译
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:
-- 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)或依赖数据库唯一约束 |
用一张图串联起来就是:
面试官 🤙:
代码干净,Lua 脚本和 insert ignore 的细节拉出来了,难点总结也有深度,尤其 Redis 故障降级的时候还能依赖 DB 唯一约束兜底,这个点不少人会忽略。可以,这轮稳了。✅
