一、什么是幂等性?
定义:在分布式系统中,同一个操作(如创建订单、支付扣款)被执行一次或多次,对系统状态的影响是完全相同的。
典型场景:
用户重复点击提交按钮
网络超时后客户端重试
消息队列重复消费
第三方回调重复通知
二、8 种幂等性设计方案
系统组件交互图
方案1:前端防抖(第一道防线)
适用场景:所有Web/移动端应用
// 前端按钮防抖(如使用lodash) submitOrder = _.debounce(async () => { await api.createOrder(data); }, 1000, { leading: true, trailing: false }); // 或提交后禁用按钮 button.disabled = true;优点:简单有效,减少无效请求
缺点:无法防止绕过前端的请求(如API直接调用)
方案2:Token 令牌机制(最常用)
流程:
进入提交页时,后端生成唯一Token存入Redis(设置合理过期时间)
Token返回前端,提交时随请求带上
后端验证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令牌 | 表单提交类 | 低 | 简单 | 高 |
| 唯一约束 | 数据创建类 | 中 | 简单 | 非常高 |
| 乐观锁 | 数据更新类 | 低 | 中等 | 高 |
| 状态机 | 有状态流转业务 | 低 | 中等 | 高 |
| 分布式锁 | 分布式并发控制 | 高 | 复杂 | 非常高 |
| 去重表 | 通用防重 | 中 | 中等 | 高 |
| 消息幂等 | 异步消息处理 | 中 | 中等 | 高 |
四、最佳实践组合
对于订单提交这种核心场景,建议采用多层次防护:
第一层:前端防抖(立即反馈用户)
第二层:Token机制(拦截重复请求)
第三层:数据库唯一索引(最终保障)
-- 订单号全局唯一 ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no); -- 或用户+商品+时间组合唯一(防同一商品重复下单)第四层:状态机校验(业务逻辑幂等)
// 伪代码:完整的订单创建 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(); } }
五、重要注意事项
幂等与防重的区别:
防重:防止重复提交,但可能返回错误
幂等:重复提交返回相同结果,用户无感知
HTTP方法幂等性:
GET、PUT、DELETE:天然幂等
POST:非幂等,需要特殊处理
全局ID生成:确保订单号等业务ID全局唯一
雪花算法(Snowflake)
Redis原子递增
数据库序列
响应设计:重复请求应返回什么?
// 首次成功 { "code": 200, "data": { "orderNo": "202311210001" } } // 重复请求(幂等返回) { "code": 200, "data": { "orderNo": "202311210001" }, "message": "订单已创建" } // 或使用特定code { "code": 409, "message": "订单已存在,请勿重复提交" }清理策略:Token、分布式锁、去重记录需要设置合理的过期时间,避免存储膨胀。
总结
解决接口幂等性问题没有银弹,需要根据业务场景选择合适方案。对于电商订单这种高价值操作,建议采用Token+分布式锁+数据库唯一约束的组合方案,既保证用户体验(快速响应),又确保数据一致性(绝对防重)。
黄金法则:越是核心的业务,越要在靠近数据层的地方做幂等保障,因为前端和网关的拦截都可能被绕过,但数据库的唯一约束是最后的坚实防线。