前言
當你的用户瘋狂點擊提交按鈕時,你的系統準備好迎接這場“連擊風暴”了嗎?
在電商系統的實戰中,我見過太多因重複提交導致的資損事故——用户一次點擊,系統卻創建了多個訂單,導致庫存錯亂、用户重複支付、客服投訴爆棚。
有些小夥伴在工作中可能遇到過這樣的場景:大促期間,用户反饋“明明只點了一次,為什麼扣了兩次款?”
開發同學查了半天日誌,發現同一個用户請求在毫秒級內真的到達了服務器兩次。
今天這篇文章就跟大家聊聊高併發下防止重複提交訂單,希望對你會有所幫助。
01 為什麼會重複提交?
在深入解決方案前,我們必須搞清楚重複提交是如何發生的。
常見的場景有:
- 用户無意識重複點擊:網絡延遲時,用户心急多次點擊提交按鈕
- 前端防抖失效:前端做了防抖處理,但被繞過或配置不當
- 網絡超時重試:請求超時後,客户端或網關自動重試
- 惡意攻擊:競爭對手或黑客故意重複提交
- 後端處理超時:第一個請求處理慢,客户端以為失敗又發一次
來看一個典型的用户操作流程,以及其中可能發生重複的各個環節:
從圖中可以看到,從用户點擊到訂單落庫,幾乎每個環節都可能成為重複提交的“案發現場”。
下面,我們就針對這些環節,層層佈防。
02 第一道防線:前端防抖與按鈕控制
這是最直觀、成本最低的防護措施。
原則是:在用户交互層面儘量減少無效請求。
2.1 按鈕狀態控制
// 前端防抖實現示例(Vue + Element UI)
<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 請求防抖與攔截
// 使用axios攔截器實現請求防抖
import axios from'axios'
// 存儲正在進行的請求
const pendingRequests = newMap()
// 生成請求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)
returnPromise.reject(error)
}
// 錯誤處理完成後,也要從pending中移除
if (error.config) {
const key = generateReqKey(error.config)
pendingRequests.delete(key)
}
returnPromise.reject(error)
}
)
前端防護小結:
- 優點:實現簡單,能攔截大部分用户無意識的重複點擊
- 缺點:可被繞過(如直接調用API、禁用JS、使用Postman等工具)
- 結論:前端防護是必要但不充分的措施,絕不能作為唯一防線
03 第二道防線:後端接口冪等性設計
冪等性是解決重複提交的核心理念。
所謂冪等,就是同一個操作執行多次的結果與執行一次的結果相同。
3.1 什麼是冪等性?
對於訂單提交接口:
- 冪等:無論調用1次還是N次,都只創建一個訂單
- 非冪等:調用N次可能創建N個訂單
3.2 基於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) {
thrownew 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.3 基於唯一業務標識的冪等
除了Token方案,還可以利用業務的自然唯一性實現冪等:
@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方案:通用性強,適合大多數場景
- 業務標識方案:更自然,但依賴業務的天然唯一性
- 關鍵點:所有冪等性檢查必須在事務開始前完成,否則可能失效
04 第三道防線:數據庫層防護
數據庫是數據持久化的最後一道關卡,在這裏設置防護至關重要。
4.1 唯一約束與樂觀鎖
-- 訂單表設計示例
CREATE TABLE`t_order` (
`id`bigint(20) NOTNULL AUTO_INCREMENT COMMENT'主鍵',
`order_no`varchar(32) NOTNULLCOMMENT'訂單號,業務唯一',
`user_id`bigint(20) NOTNULLCOMMENT'用户ID',
`product_id`bigint(20) NOTNULLCOMMENT'商品ID',
`quantity`int(11) NOTNULLCOMMENT'購買數量',
`amount`decimal(10,2) NOTNULLCOMMENT'訂單金額',
`status`tinyint(4) NOTNULLDEFAULT'1'COMMENT'訂單狀態:1-待支付,2-已支付',
`submit_token`varchar(64) DEFAULTNULLCOMMENT'提交Token,用於冪等',
`version`int(11) NOTNULLDEFAULT'1'COMMENT'版本號,用於樂觀鎖',
`create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMP,
`update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_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='訂單表';
4.2 數據庫層面的冪等實現
// 使用數據庫事務+唯一約束保證最終一致性
@Service
public class OrderServiceV2 {
@Autowired
private OrderMapper orderMapper;
@Autowired
private IdempotentTokenService tokenService;
@Transactional(rollbackFor = Exception.class)
public OrderSubmitResult submitOrderWithDBProtection(OrderSubmitRequest request) {
String userId = request.getUserId();
String submitToken = request.getSubmitToken();
// 1. 檢查冪等Token(在事務外先檢查一次)
if (!tokenService.checkAndConsumeToken(userId, submitToken)) {
throw new BusinessException("請勿重複提交訂單");
}
try {
// 2. 生成訂單號(雪花算法等分佈式ID生成器)
String orderNo = generateOrderNo();
// 3. 創建訂單對象
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setProductId(request.getProductId());
order.setQuantity(request.getQuantity());
order.setAmount(calculateAmount(request));
order.setSubmitToken(submitToken);
// 4. 插入訂單(這裏依賴數據庫唯一約束)
orderMapper.insert(order);
// 5. 更新庫存等後續操作...
updateProductStock(request.getProductId(), request.getQuantity());
returnnew OrderSubmitResult(orderNo, "訂單創建成功");
} catch (DuplicateKeyException e) {
// 6. 處理唯一約束衝突
log.warn("訂單重複提交,userId={}, token={}", userId, submitToken);
// 查詢已創建的訂單
Order existingOrder = orderMapper.selectBySubmitToken(userId, submitToken);
if (existingOrder != null) {
return new OrderSubmitResult(
existingOrder.getOrderNo(),
"訂單已創建成功,請勿重複提交"
);
}
// 理論上不會走到這裏,除非有極端情況
throw new BusinessException("訂單處理異常,請稍後重試");
}
}
}
05 第四道防線:分佈式鎖
在分佈式環境下,多個實例可能同時處理同一個請求,需要分佈式鎖來保證只有一個實例執行核心邏輯。
5.1 基於Redis的分佈式鎖
@Component
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
/**
* 嘗試獲取分佈式鎖
* @param lockKey 鎖的key
* @param waitTime 等待時間(毫秒)
* @param leaseTime 持有時間(毫秒)
* @return 鎖對象,獲取失敗返回null
*/
public 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();
returnnull;
}
}
/**
* 訂單提交分佈式鎖
*/
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秒
}
}
// 在訂單服務中使用分佈式鎖
@Service
public class OrderServiceV3 {
@Autowired
private DistributedLockService lockService;
@Autowired
private OrderMapper orderMapper;
public OrderSubmitResult submitOrderWithDistributedLock(OrderSubmitRequest request) {
String userId = request.getUserId();
String submitToken = request.getSubmitToken();
// 1. 獲取分佈式鎖
RLock lock = lockService.lockForOrderSubmit(userId, submitToken);
if (lock == null) {
throw new BusinessException("系統繁忙,請稍後重試");
}
try {
// 2. 檢查是否已處理
Order existingOrder = orderMapper.selectBySubmitToken(userId, submitToken);
if (existingOrder != null) {
return new OrderSubmitResult(
existingOrder.getOrderNo(),
"訂單已創建成功,請勿重複提交"
);
}
// 3. 執行業務邏輯
return doCreateOrder(request);
} finally {
// 4. 釋放鎖
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private OrderSubmitResult doCreateOrder(OrderSubmitRequest request) {
// 實際的訂單創建邏輯
// 這裏已經保證了同一時刻只有一個線程在處理同一個提交請求
// ...
}
}
5.2 分佈式鎖的注意事項
使用分佈式鎖時要注意:
- 鎖粒度:不要太粗(影響性能)也不要太細(增加複雜度)
- 鎖超時:必須設置合理的超時時間,防止死鎖
- 鎖續期:對於長時間操作,需要實現鎖續期機制
- 可重入性:同一個線程可以重複獲取鎖
- 容錯性:Redis集羣故障時要有降級方案
06 第五道防線:異步處理與消息隊列
對於高併發場景,可以採用異步處理模式,將同步請求轉為異步任務。
實現代碼示例:
// 異步訂單處理實現
@Component
public class AsyncOrderService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 異步提交訂單
*/
public AsyncSubmitResult asyncSubmitOrder(OrderSubmitRequest request) {
// 1. 生成唯一請求ID
String requestId = generateRequestId(request.getUserId());
// 2. 快速驗證(庫存、用户狀態等)
quickValidate(request);
// 3. 將請求ID與用户關聯(用於查詢結果)
String pendingKey = "order:pending:" + request.getUserId() + ":" + requestId;
redisTemplate.opsForValue().set(pendingKey, "processing", 10, TimeUnit.MINUTES);
// 4. 發送到消息隊列
OrderMessage message = new OrderMessage();
message.setRequestId(requestId);
message.setRequest(request);
message.setTimestamp(System.currentTimeMillis());
rocketMQTemplate.asyncSend(
"ORDER_SUBMIT_TOPIC",
message,
new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("訂單消息發送成功: {}", requestId);
}
@Override
public void onException(Throwable throwable) {
log.error("訂單消息發送失敗: {}", requestId, throwable);
// 發送失敗,更新狀態
redisTemplate.opsForValue().set(
pendingKey,
"failed",
5,
TimeUnit.MINUTES
);
}
}
);
// 5. 立即返回,告知用户處理中
return new AsyncSubmitResult(requestId, "訂單提交成功,正在處理中");
}
}
// 消息消費者
@Component
@RocketMQMessageListener(
topic = "ORDER_SUBMIT_TOPIC",
consumerGroup = "order-submit-consumer-group"
)
public class OrderSubmitConsumer implements RocketMQListener<OrderMessage> {
@Autowired
private OrderMapper orderMapper;
@Override
public void onMessage(OrderMessage message) {
String requestId = message.getRequestId();
OrderSubmitRequest request = message.getRequest();
// 1. 冪等檢查(基於requestId)
Order existing = orderMapper.selectByRequestId(requestId);
if (existing != null) {
log.info("訂單已處理,跳過: {}", requestId);
return;
}
// 2. 創建訂單
Order order = createOrder(request, requestId);
try {
orderMapper.insert(order);
log.info("訂單創建成功: {}", order.getOrderNo());
// 3. 更新處理狀態
updateProcessingStatus(request.getUserId(), requestId, "success", order.getOrderNo());
} catch (DuplicateKeyException e) {
log.warn("訂單重複,requestId={}", requestId);
// 查詢已創建的訂單
Order created = orderMapper.selectByRequestId(requestId);
if (created != null) {
updateProcessingStatus(request.getUserId(), requestId, "success", created.getOrderNo());
}
}
}
}
07 綜合方案:多層次聯合防護
在實際生產環境中,我們通常會採用多層次、立體化的防護策略。
以下是一個完整的綜合方案流程圖:
這個多層次方案中,每一層都有其特定作用:
- 前端層:用户體驗優化,攔截大部分無意識重複
- 網關層:安全防護,防刷、限流
- 業務層:核心冪等邏輯,分佈式鎖保證併發安全
- 數據層:最終保障,唯一約束防止數據不一致
- 異步層:削峯填谷,提升系統吞吐量
08 實戰:不同場景下的方案選擇
不同的業務場景需要不同的防護策略,這裏給出一些實踐建議:
8.1 普通電商訂單
// 普通電商訂單推薦方案
@Service
public class StandardOrderService {
// 綜合使用:前端防抖 + Token冪等 + 數據庫唯一約束
public OrderSubmitResult submitStandardOrder(OrderSubmitRequest request) {
// 1. 參數校驗
validateRequest(request);
// 2. 冪等Token檢查(Redis)
if (!idempotentCheck(request.getUserId(), request.getSubmitToken())) {
return getExistingOrderResult(request.getUserId(), request.getSubmitToken());
}
// 3. 分佈式鎖(防併發)
RLock lock = acquireOrderLock(request.getUserId(), request.getProductId());
try {
// 4. 庫存檢查等業務校驗
checkInventory(request.getProductId(), request.getQuantity());
// 5. 創建訂單(依賴數據庫唯一約束)
return createOrderInTransaction(request);
} finally {
lock.unlock();
}
}
}
8.2 秒殺訂單
// 秒殺訂單需要更極致的優化
@Service
public class FlashSaleOrderService {
// 秒殺方案:異步處理 + 庫存預扣 + 最終一致性
public FlashSaleSubmitResult submitFlashSaleOrder(FlashSaleRequest request) {
// 1. 驗證用户資格和活動狀態(緩存中檢查)
if (!checkUserQualification(request.getUserId(), request.getActivityId())) {
throw new BusinessException("您不具備參與資格");
}
// 2. 預扣庫存(Redis原子操作)
boolean stockDeducted = preDeductStock(
request.getActivityId(),
request.getProductId(),
request.getUserId()
);
if (!stockDeducted) {
throw new BusinessException("庫存不足");
}
// 3. 生成唯一請求ID
String requestId = generateRequestId(request.getUserId(), request.getActivityId());
// 4. 發送到消息隊列(快速返回)
sendToMQ(request, requestId);
// 5. 立即返回
return new FlashSaleSubmitResult(requestId, "秒殺請求已接受,處理中");
}
// 消費者異步創建訂單
@Transactional
public void processFlashSaleOrder(FlashSaleRequest request, String requestId) {
// 這裏只需要處理真正的訂單創建
// 因為庫存已在Redis中預扣,只需保證最終一致性
try {
createOrder(request, requestId);
// 同步庫存到數據庫
syncStockToDB(request.getProductId(), request.getActivityId());
} catch (Exception e) {
// 失敗時回滾Redis庫存
rollbackStockInRedis(request.getActivityId(), request.getProductId(), request.getUserId());
throw e;
}
}
}
10 總結
防止重複提交訂單是一個系統工程,需要從前到後、多層次的防護。
讓我們回顧一下關鍵點:
- 前端防護是體驗,不是保障:按鈕防抖、請求攔截能改善用户體驗,但不能作為唯一防線。
- 冪等性是核心理念:無論是Token方案還是業務唯一標識,都要保證同一操作執行多次的結果一致。
- 分佈式鎖解決併發問題:在分佈式環境下,防止多個實例同時處理同一請求。
- 數據庫是最後防線:唯一約束、樂觀鎖等機制能在應用層防護失效時保證數據一致性。
- 異步處理提升吞吐:對於高併發場景,將同步請求轉為異步處理,提高系統整體吞吐量。
- 監控告警必不可少:沒有監控的系統就像沒有儀表的飛機,無法發現問題和優化性能。