用户狂点提交按钮,订单却只有一笔?8种方案彻底搞定接口幂等性
2026/5/28 17:10:04 网站建设 项目流程

一、什么是幂等性?

定义:在分布式系统中,同一个操作(如创建订单、支付扣款)被执行一次或多次,对系统状态的影响是完全相同的。

典型场景

  • 用户重复点击提交按钮

  • 网络超时后客户端重试

  • 消息队列重复消费

  • 第三方回调重复通知


二、8 种幂等性设计方案

系统组件交互图

方案1:前端防抖(第一道防线)

适用场景:所有Web/移动端应用

// 前端按钮防抖(如使用lodash) submitOrder = _.debounce(async () => { await api.createOrder(data); }, 1000, { leading: true, trailing: false }); // 或提交后禁用按钮 button.disabled = true;

优点:简单有效,减少无效请求
缺点:无法防止绕过前端的请求(如API直接调用)

方案2:Token 令牌机制(最常用)

流程

  1. 进入提交页时,后端生成唯一Token存入Redis(设置合理过期时间)

  2. Token返回前端,提交时随请求带上

  3. 后端验证Token是否存在:

    • 存在:删除Token,继续业务

    • 不存在:返回“重复提交”错误

// 伪代码示例 public boolean checkToken(String token) { String key = "order:token:" + token; // 使用Redis的del命令,删除成功表示第一次请求 Long result = redisTemplate.delete(key); return result != null && result == 1; }

优点:实现简单,可靠性高
缺点:需要额外存储,增加一次Redis操作

方案3:数据库唯一约束

适用场景:创建具有唯一标识的资源

-- 订单表添加唯一索引 ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no); -- 或业务唯一组合约束 ALTER TABLE orders ADD UNIQUE INDEX uk_user_product (user_id, product_id, date);
try { // 插入订单 orderDao.insert(order); } catch (DuplicateKeyException e) { // 已存在,直接返回原订单 return getExistingOrder(orderNo); }

优点:绝对可靠,数据库层保证
缺点

  • 只能用于插入操作

  • 索引影响写入性能

  • 难以处理“部分字段相同”的复杂场景

方案4:乐观锁(版本号控制)

适用场景:更新操作,如扣减库存、更新状态

-- 添加version字段 UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 1001 AND version = 1;
// MyBatis Plus示例 boolean success = update() .setSql("stock = stock - 1") .eq("product_id", productId) .eq("stock", currentStock) // 或使用版本号 .update();

优点:避免数据覆盖,保证更新安全
缺点:需要设计版本字段,高并发时可能更新失败

方案5:状态机幂等

适用场景:有明确状态流转的业务(订单、支付等)

// 检查订单状态是否允许变更 public boolean processOrder(String orderId, String targetStatus) { Order order = orderDao.selectById(orderId); // 状态机验证:比如已支付订单不允许再次支付 if (OrderStatus.PAID.equals(order.getStatus())) { log.warn("订单已支付,重复请求"); return true; // 幂等返回成功 } // 只有待支付才能变更为已支付 if (!OrderStatus.UNPAID.equals(order.getStatus())) { throw new IllegalStateException("订单状态异常"); } // 正常处理... orderDao.updateStatus(orderId, targetStatus); }

优点:符合业务逻辑,自然幂等
缺点:需要清晰的状态设计

方案6:分布式锁

适用场景:分布式系统,防止多台机器同时处理

public String createOrder(OrderRequest request) { String lockKey = "order:lock:" + request.getUserId(); // 使用Redis分布式锁 String lockValue = UUID.randomUUID().toString(); try { // 尝试加锁(设置过期时间防止死锁) Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("请勿重复提交"); } // 执行业务逻辑 return doCreateOrder(request); } finally { // 使用Lua脚本保证原子性解锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(script, Collections.singletonList(lockKey), lockValue); } }

优点:分布式环境下强一致性
缺点:性能损耗,实现复杂

方案7:去重表(防重表)

设计思路:单独建立一张防重表,记录已处理的请求

CREATE TABLE request_unique ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) COMMENT '业务类型', unique_key VARCHAR(128) COMMENT '唯一标识', created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_type_key (biz_type, unique_key) );
public boolean checkDuplicate(String bizType, String uniqueKey) { try { // 插入成功说明是第一次请求 duplicateDao.insert(bizType, uniqueKey); return false; } catch (DuplicateKeyException e) { // 插入失败说明已存在 return true; } }

优点:通用性强,可追溯
缺点:表数据量会持续增长,需定期清理

方案8:消息队列幂等消费

适用场景:异步消息处理

// RocketMQ示例:使用MessageId或业务Key去重 public void handleOrderMessage(Message message) { String msgId = message.getMsgId(); String orderNo = message.getUserProperty("orderNo"); String dedupKey = "mq:dedup:" + orderNo; if (redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS)) { // 第一次处理 processOrder(orderNo); } else { log.info("消息已处理,直接确认: {}", msgId); } }

优点:适合异步场景,保证消息只消费一次
缺点:需要消息中间件支持


三、方案选型建议

方案适用场景性能影响实现复杂度可靠性
前端防抖所有Web场景简单
Token令牌表单提交类简单
唯一约束数据创建类简单非常高
乐观锁数据更新类中等
状态机有状态流转业务中等
分布式锁分布式并发控制复杂非常高
去重表通用防重中等
消息幂等异步消息处理中等

四、最佳实践组合

对于订单提交这种核心场景,建议采用多层次防护

  1. 第一层:前端防抖(立即反馈用户)

  2. 第二层:Token机制(拦截重复请求)

  3. 第三层:数据库唯一索引(最终保障)

    -- 订单号全局唯一 ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no); -- 或用户+商品+时间组合唯一(防同一商品重复下单)
  4. 第四层:状态机校验(业务逻辑幂等)

    // 伪代码:完整的订单创建 public OrderResponse createOrder(OrderRequest request) { // 1. Token校验 if (!tokenService.checkToken(request.getToken())) { throw new DuplicateRequestException(); } // 2. 分布式锁(防并发) Lock lock = lockFactory.getLock("order:" + request.getUserId()); try { if (!lock.tryLock(3, TimeUnit.SECONDS)) { throw new ConcurrentRequestException(); } // 3. 业务唯一性校验(如:同一活动只能参与一次) if (activityService.hasParticipated(request.getUserId(), request.getActivityId())) { return getPreviousOrder(request); } // 4. 创建订单(数据库唯一约束兜底) return orderDao.insert(request); } finally { lock.unlock(); } }

五、重要注意事项

  1. 幂等与防重的区别

    • 防重:防止重复提交,但可能返回错误

    • 幂等:重复提交返回相同结果,用户无感知

  2. HTTP方法幂等性

    • GET、PUT、DELETE:天然幂等

    • POST:非幂等,需要特殊处理

  3. 全局ID生成:确保订单号等业务ID全局唯一

    • 雪花算法(Snowflake)

    • Redis原子递增

    • 数据库序列

  4. 响应设计:重复请求应返回什么?

    // 首次成功 { "code": 200, "data": { "orderNo": "202311210001" } } // 重复请求(幂等返回) { "code": 200, "data": { "orderNo": "202311210001" }, "message": "订单已创建" } // 或使用特定code { "code": 409, "message": "订单已存在,请勿重复提交" }
  5. 清理策略:Token、分布式锁、去重记录需要设置合理的过期时间,避免存储膨胀。

总结

解决接口幂等性问题没有银弹,需要根据业务场景选择合适方案。对于电商订单这种高价值操作,建议采用Token+分布式锁+数据库唯一约束的组合方案,既保证用户体验(快速响应),又确保数据一致性(绝对防重)。

黄金法则:越是核心的业务,越要在靠近数据层的地方做幂等保障,因为前端和网关的拦截都可能被绕过,但数据库的唯一约束是最后的坚实防线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询