当用户在网络延迟下焦急地多次点击提交按钮,你的后端系统准备好应对这种“连击风暴”了吗?在电商系统开发中,重复提交引发的资损问题屡见不鲜,一次用户操作却生成多个订单,直接导致库存错乱、重复扣款和客服压力。今天,我们就来系统性地探讨如何构建一套坚固的防线,彻底解决高并发下的重复下单问题。
一、 重复提交的根源分析
在部署解决方案之前,我们必须先理解问题是如何发生的。以下几个是常见的“案发现场”:
- 用户无意识重复点击:页面响应慢时,用户心急连续点击。
- 前端防抖失效:前端防护被绕过或配置不当。
- 网络超时重试:客户端或网关在请求超时后自动重试。
- 恶意攻击:竞争对手或恶意用户通过脚本等手段故意重复提交。
- 后端处理超时:第一个请求处理缓慢,客户端误以为失败而发起新请求。
整个下单流程中,从用户点击到数据落库,几乎每个环节都可能成为突破口。下图清晰地展示了这个过程中可能发生重复的关键节点:

二、 第一道防线:前端防抖与按钮控制
这是最直接、成本最低的防护措施,核心目标是在用户交互层面尽可能减少无效请求。
2.1 按钮状态控制
通过禁用按钮和加载状态,防止用户连续点击。
<template>
<el-button
:loading="submitting"
:disabled="submitting"
@click="handleSubmitOrder"
>
{{ submitting ? ‘提交中...’ : ‘提交订单’ }}
</el-button>
</template>
<script>
export default {
data() {
return {
submitting: false,
submitToken: null // 用于标识当前提交的token
}
},
methods: {
async handleSubmitOrder() {
if (this.submitting) {
this.$message.warning(‘正在提交,请勿重复点击’)
return
}
this.submitting = true
try {
// 生成唯一token,用于后端幂等性校验
this.submitToken = this.generateSubmitToken()
const result = await this.$api.order.submit({
orderData: this.orderData,
submitToken: this.submitToken
})
this.$message.success(‘订单提交成功’)
this.$router.push(`/order/detail/${result.orderId}`)
} catch (error) {
this.$message.error(`提交失败: ${error.message}`)
this.submitting = false // 失败后重置状态
}
},
generateSubmitToken() {
// 生成唯一标识,可以用UUID或时间戳+随机数
return `order_submit_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
}
</script>
2.2 请求防抖与拦截
在HTTP客户端层面拦截短时间内完全相同的请求。
// 使用axios拦截器实现请求防抖
import axios from ‘axios’
// 存储正在进行的请求
const pendingRequests = new Map()
// 生成请求key
const generateReqKey = (config) => {
const { method, url, params, data } = config
return [method, url, JSON.stringify(params), JSON.stringify(data)].join(‘&’)
}
// 请求拦截器
axios.interceptors.request.use(config => {
const key = generateReqKey(config)
if (pendingRequests.has(key)) {
// 请求已存在,取消当前请求
config.cancelToken = new axios.CancelToken(cancel => {
cancel(`重复请求已被拦截: ${key}`)
})
} else {
// 新请求,添加到pending中
pendingRequests.set(key, config)
}
return config
})
// 响应拦截器
axios.interceptors.response.use(
response => {
const key = generateReqKey(response.config)
pendingRequests.delete(key)
return response
},
error => {
if (axios.isCancel(error)) {
console.log(‘请求被取消:’, error.message)
return Promise.reject(error)
}
// 错误处理完成后,也要从pending中移除
if (error.config) {
const key = generateReqKey(error.config)
pendingRequests.delete(key)
}
return Promise.reject(error)
}
)
前端防护小结:
- 优点:实现简单,能拦截大部分无意识的重复点击。
- 缺点:可被轻易绕过(如直接调用API、禁用JS、使用Postman等工具)。
- 结论:前端防护是必要但不充分的措施,绝不能作为唯一防线。
三、 第二道防线:后端接口幂等性设计
解决重复提交问题的核心理念是幂等性:即同一个操作执行一次或多次,对系统产生的影响是一致的。对于下单接口来说,无论调用多少次,都只应成功创建一个订单。
3.1 基于Token的幂等实现
这是最通用的方案,流程为:客户端先获取Token -> 提交时携带Token -> 服务端校验Token状态。
// 幂等性Token服务
@Service
public class IdempotentTokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String IDEMPOTENT_PREFIX = “idempotent:token:”;
private static final long TOKEN_EXPIRE_SECONDS = 300; // Token有效期5分钟
/**
* 生成幂等性Token
*/
public String generateToken(String userId) {
String token = UUID.randomUUID().toString();
String redisKey = IDEMPOTENT_PREFIX + userId + “:” + token;
// 存储Token,设置过期时间
redisTemplate.opsForValue().set(
redisKey,
“1”,
TOKEN_EXPIRE_SECONDS,
TimeUnit.SECONDS
);
return token;
}
/**
* 检查并消费Token
* @return true: Token有效且消费成功; false: Token无效或已消费
*/
public boolean checkAndConsumeToken(String userId, String token) {
String redisKey = IDEMPOTENT_PREFIX + userId + “:” + token;
// 使用Lua脚本保证原子性
String luaScript = “””
if redis.call(‘get’, KEYS[1]) == ‘1’ then
redis.call(‘del’, KEYS[1])
return 1
else
return 0
end
“”“;
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(redisKey)
);
return result != null && result == 1L;
}
}
// 使用AOP实现幂等性校验
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key() default “”; // 幂等键,支持SpEL表达式
long expireTime() default 300; // 过期时间,秒
}
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around(“@annotation(idempotent)”)
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 获取方法参数
Object[] args = joinPoint.getArgs();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 2. 解析幂等键(支持SpEL)
String keyExpression = idempotent.key();
String redisKey = parseKey(keyExpression, method, args);
// 3. 尝试获取分布式锁(防止并发请求同时通过检查)
String lockKey = redisKey + “:lock”;
boolean lockAcquired = false;
try {
// 尝试加锁
lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, “1”, 10, TimeUnit.SECONDS);
if (!lockAcquired) {
throw new BusinessException(“系统繁忙,请稍后重试”);
}
// 4. 检查Token是否已使用
Boolean exists = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(exists)) {
// Token已使用,直接返回之前的处理结果(这里需要根据实际业务调整)
throw new BusinessException(“请勿重复提交订单”);
}
// 5. 执行业务逻辑
Object result = joinPoint.proceed();
// 6. 标记Token已使用
redisTemplate.opsForValue().set(
redisKey,
“processed”,
idempotent.expireTime(),
TimeUnit.SECONDS
);
return result;
} finally {
// 释放锁
if (lockAcquired) {
redisTemplate.delete(lockKey);
}
}
}
private String parseKey(String expression, Method method, Object[] args) {
// 这里实现SpEL表达式解析,获取实际的幂等键
// 例如可以从参数中提取userId+orderToken
return “parsed:key:from:expression”;
}
}
// 在订单提交接口上使用
@RestController
@RequestMapping(“/order”)
public class OrderController {
@PostMapping(“/submit”)
@Idempotent(key = “#request.userId + ‘:’ + #request.submitToken”, expireTime = 300)
public ApiResponse<OrderSubmitResult> submitOrder(@RequestBody OrderSubmitRequest request) {
// 这里是真正的订单创建逻辑
OrderSubmitResult result = orderService.createOrder(request);
return ApiResponse.success(result);
}
}
3.2 基于唯一业务标识的幂等
利用业务本身的自然唯一性(如用户+商品+时间)来实现幂等,更为直观。
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional
public OrderSubmitResult createOrder(OrderSubmitRequest request) {
// 方法1:先查询是否存在
Order existingOrder = orderMapper.selectByUniqueKey(
request.getUserId(),
request.getProductId(),
request.getSubmitTime()
);
if (existingOrder != null) {
// 订单已存在,直接返回
return convertToResult(existingOrder);
}
// 方法2:利用数据库唯一约束
try {
Order newOrder = buildOrder(request);
orderMapper.insert(newOrder);
return convertToResult(newOrder);
} catch (DuplicateKeyException e) {
// 捕获唯一键冲突异常
log.warn(“订单重复提交,uniqueKey={}”, request.getUniqueKey());
// 查询已创建的订单并返回
Order createdOrder = orderMapper.selectByUniqueKey(
request.getUserId(),
request.getProductId(),
request.getSubmitTime()
);
if (createdOrder == null) {
throw new BusinessException(“订单处理异常,请稍后重试”);
}
return convertToResult(createdOrder);
}
}
// 订单表可添加唯一索引
// ALTER TABLE t_order ADD UNIQUE KEY uk_user_product_time (user_id, product_id, submit_time);
}
幂等性设计小结:
- Token方案:通用性强,适合大多数业务场景。
- 业务标识方案:更自然,但依赖于业务本身存在天然唯一键。
- 关键点:所有幂等性检查必须在事务开始前完成,否则在高并发下可能失效。
四、 第三道防线:数据库层防护
数据库是数据持久化的最后一道关卡,在这里设置防护是最终保障。
4.1 唯一约束与乐观锁
通过在表结构设计上增加唯一约束,从根源上杜绝重复数据。
-- 订单表设计示例
CREATE TABLE `t_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT ‘主键’,
`order_no` varchar(32) NOT NULL COMMENT ‘订单号,业务唯一’,
`user_id` bigint(20) NOT NULL COMMENT ‘用户ID’,
`product_id` bigint(20) NOT NULL COMMENT ‘商品ID’,
`quantity` int(11) NOT NULL COMMENT ‘购买数量’,
`amount` decimal(10,2) NOT NULL COMMENT ‘订单金额’,
`status` tinyint(4) NOT NULL DEFAULT ‘1’ COMMENT ‘订单状态:1-待支付,2-已支付’,
`submit_token` varchar(64) DEFAULT NULL COMMENT ‘提交Token,用于幂等’,
`version` int(11) NOT NULL DEFAULT ‘1’ COMMENT ‘版本号,用于乐观锁’,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`), -- 订单号唯一
UNIQUE KEY `uk_user_submit_token` (`user_id`, `submit_token`), -- 提交Token唯一
UNIQUE KEY `uk_user_product_time` (`user_id`, `product_id`, `create_time`), -- 业务维度唯一
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=’订单表’;
五、 第四道防线:分布式锁
在分布式环境下,多个服务实例可能同时处理同一个请求,此时需要分布式锁来保证全局唯一性。
@Component
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
/**
* 订单提交分布式锁
*/
public RLock lockForOrderSubmit(String userId, String submitToken) {
String lockKey = String.format(“order:submit:lock:%s:%s”, userId, submitToken);
return tryLock(lockKey, 100, 5000); // 等待100ms,锁持有5秒
}
private RLock tryLock(String lockKey, long waitTime, long leaseTime) {
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
return acquired ? lock : null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
}
六、 第五道防线:异步处理与消息队列
对于超高并发场景(如秒杀),可以将同步请求转为异步处理,快速响应用户,后台通过消息队列保证最终一致性。这种架构能极大提升系统吞吐量。

七、 综合方案:多层次联合防护
在实际生产环境中,我们通常不会只依赖单一方案,而是采用多层次、立体化的纵深防御体系。下图展示了一个从用户端到数据层的完整防护流程:

这个方案每一层都扮演着关键角色:
- 前端层:优化用户体验,拦截大部分无意识的重复操作。
- 网关层:进行安全防护,如频率限制、黑名单过滤和参数校验。
- 业务层:实现核心幂等逻辑,并通过分布式锁保证高并发安全。
- 数据层:作为最终保障,利用数据库的唯一约束防止数据不一致。
- 异步层:可选路径,用于削峰填谷,提升系统整体吞吐能力。
八、 实战:不同场景下的方案选择
不同的业务场景需要不同的防护策略。这里给出一些实践建议:
8.1 普通电商订单
推荐采用 前端防抖 + Token幂等 + 数据库唯一约束 的组合方案,必要时引入分布式锁。
8.2 秒杀订单
秒杀场景对性能和一致性要求极高,需要更极致的方案:异步处理 + Redis原子操作预扣库存 + 消息队列保证最终一致性。核心思想是“快速验证,异步落库”。
九、 总结与最佳实践
防止重复提交订单是一个系统工程,没有单一的银弹。我们需要从前到后建立多层次的防护。回顾一下核心要点:
- 前端防护是体验,不是保障:它改善用户体验,但绝不能作为唯一防线。
- 幂等性是核心理念:确保接口无论被调用多少次,结果都一致。
- 分布式锁解决并发问题:在分布式系统中,防止多个节点同时处理同一请求。
- 数据库是最后防线:唯一约束、乐观锁等机制是保证数据一致性的终极手段。
- 异步处理提升吞吐:对于高并发场景,异步化是提高系统容量的有效手段。
- 监控与告警必不可少:没有监控的系统无法及时发现问题并优化。
在实际的架构设计中,我通常推荐采用 “前端防抖 + 网关限流 + Token幂等 + 分布式锁 + 数据库唯一约束” 的综合方案。对于像秒杀这样的极致场景,再叠加异步处理和缓存预扣。
可能有些开发者会觉得这些措施增加了复杂性,但请记住:预防的成本远低于修复的成本。一次由重复提交导致的资损事故,其带来的数据修复、用户沟通和品牌信誉损失,远超前期进行稳健设计的投入。技术方案的选择,永远是业务场景、性能要求和开发成本之间的最佳平衡。如果你想就具体的高并发架构问题做更深入的探讨,可以到云栈社区和更多开发者一起交流。