在分布式系统中,由于网络不可靠,超时重试、消息重投、网关重试是常态。接口幂等性(Idempotency)是指同一个操作无论执行一次还是多次,最终产生的业务结果与执行一次完全一致,不会产生副作用(如重复扣款、重复创建数据等)。
以下是工业界主流的幂等性设计方案,按适用场景分类:
一、 新增/创建类场景
推荐方案:数据库唯一索引(去重表)
实现原理:利用数据库唯一约束的原子性,为业务关键字段(如订单号、支付流水号、请求ID)建立唯一索引。当重复请求插入时,数据库会直接拦截并抛出唯一键冲突异常(DuplicateKeyException),服务端捕获后直接返回成功结果。
优点:实现简单、强可靠、无需额外中间件、无并发安全问题。
缺点:仅适用于"新增"场景,无法解决更新类操作的幂等性。
二、 更新/状态流转类场景
推荐方案 1:有限状态机控制
实现原理:基于业务固有状态流转规则,限制状态单向推进。例如订单状态只能从"待支付"变为"已支付",更新操作时携带前置状态作为条件(如 UPDATE ... SET status='已支付' WHERE id=? AND status='待支付')。若重复请求到来时状态已变更,SQL 影响行数为 0,直接判定为重复请求。
优点:业务语义清晰,天然防重复操作,贴合真实业务逻辑。
缺点:需要精心设计状态流转规则,通常需结合数据库行锁防止并发状态变更。
推荐方案 2:乐观锁(版本号机制)
实现原理:在业务数据表新增 version 字段,每次更新时携带当前版本号作为条件(如 WHERE id=? AND version=old_version),更新成功后版本号加 1。重复请求携带旧版本号匹配失败,更新无效。
优点:无额外建表,并发安全,性能好。
缺点:仅支持更新类操作,不支持新增创建场景。
三、 高并发与通用接口防重
推荐方案 1:Token 令牌机制
实现原理:客户端在提交表单前先请求服务端获取唯一 Token(存入 Redis,设置过期时间)。提交业务请求时携带该 Token,服务端使用 Redis 的原子操作(如 delete 或 SETNX)校验并销毁 Token。删除成功则执行业务,删除失败则判定为重复提交。
优点:通用性强,任何接口都能用,特别适合传统表单提交防重复点击。
缺点:客户端需多一次请求获取 Token,增加了交互次数;无法解决 MQ 消息重投、服务间重试等场景。
推荐方案 2:Redis 幂等锁(高并发通用临时幂等)
实现原理:以全局唯一业务流水号作为幂等 Key,请求执行前使用 SETNX 原子命令抢占标识。抢占成功则执行业务,完成后更新 Key 标记成功并设置过期时间;抢占失败则直接返回。
优点:性能高,无数据库写入压力,适合高并发短流程接口。
缺点:Redis 宕机可能丢失未完成的幂等状态,需配合数据库唯一约束作为兜底。
四、 核心设计原则与组合使用
在实际生产项目中,通常不会单一使用某种方案,而是组合使用以构建多重防线:
唯一性标识:为每次操作生成全局唯一的幂等号(如 UUID、雪花算法、业务单号+操作类型)。
多层兜底:例如前端禁用按钮(减少无效请求) + Redis 临时防重(高并发拦截) + 数据库唯一索引兜底(最终一致性保障)。
事务一致性:幂等结果的存储与业务操作需保证原子性(处于同一事务或保证最终一致)。
存储过期:幂等结果(如 Redis 中的 Key)应设置合理的过期时间(如 24 小时),避免存储无限增长。
为了让你更直观地理解,我将结合具体的业务场景(创建订单、支付扣款、消息队列消费),为你提供生产级的代码级解决方案。
在工业界,单一方案往往存在短板(如 Redis 会宕机、前端可绕过),因此生产环境几乎永远是多层叠加的。以下是三大核心场景的生产级实战方案:
场景一:创建订单(新增类操作)
痛点:网络超时或用户手抖,导致同一笔订单被创建两次。
生产级方案:全局唯一标识 + 数据库唯一索引
这是最基础也是最坚固的防线。核心思想是:在插入数据前,确保有一个全局唯一的幂等键(如订单号),利用数据库的原子性拦截重复数据。
java
编辑
@Transactional(rollbackFor = Exception.class)
public String createOrder(OrderDTO orderDTO) {
String orderNo = orderDTO.getOrderNo(); // 全局唯一订单号(如雪花算法生成)
try {
OrderDO orderDO = new OrderDO();
orderDO.setOrderNo(orderNo);
orderDO.setAmount(orderDTO.getAmount());
orderMapper.insert(orderDO); // 插入数据
return "订单创建成功";
} catch (DuplicateKeyException e) {
// 捕获唯一索引冲突,判定为重复创建,直接返回成功
log.warn("订单{}已存在,无需重复创建", orderNo);
return "订单创建成功";
}
}
注:数据库需提前对 order_no 建立唯一索引:CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);
场景二:支付扣款(状态流转类操作)
痛点:支付回调超时,网关自动重试,导致用户被重复扣款(资损级事故)。
生产级方案:状态机 + 乐观锁(企业最爱)
资金类场景的黄金法则是:宁可多一次状态查询,不可多一次资金操作。通过限制状态单向推进,确保只有处于"待支付"状态的订单才能被更新。
java
编辑
@Transactional(rollbackFor = Exception.class)
public String payOrder(String orderNo) {
// 核心SQL:仅当状态为"待支付(1)"时,才允许更新为"已支付(2)"
int rows = orderMapper.updateStatus(
orderNo,
OrderStatus.PAID, // 目标状态
OrderStatus.WAIT_PAY // 前置状态
);
if (rows == 1) {
// 影响行数为1,说明是首次处理,执行核心扣款逻辑
doActualDeduction(orderNo);
return "支付成功";
} else {
// 影响行数为0,说明状态已变更(重复请求或非法请求)
log.info("订单{}支付操作已执行或状态异常", orderNo);
return "支付成功"; // 幂等返回,结果与首次一致
}
}
注:对应的 SQL 为 UPDATE order_info SET status=2 WHERE order_no=? AND status=1;
场景三:MQ 消息消费(分布式异步场景)
痛点:RocketMQ/Kafka 只保证 At Least Once(至少投递一次),网络抖动或消费者重启会导致消息被重复投递。
生产级方案:去重表 + 业务事务绑定
对于消息队列,最稳妥的方式是建立一张独立的去重表,将"记录已处理"与"执行业务逻辑"放在同一个本地事务中。
java
编辑
@Transactional(rollbackFor = Exception.class)
public void handleOrderMessage(String messageKey) {
// 1. 插入去重表(利用唯一索引)
int rows = idempotentMapper.insertIgnore(messageKey);
if (rows == 0) {
// 已处理过,直接返回成功
log.info("消息{}已消费,跳过", messageKey);
return;
}
// 2. 执行业务逻辑(如创建订单、扣减库存)
businessService.createOrder(messageKey);
}
注:insertIgnore 在遇到唯一键冲突时不会报错,而是静默返回 0 行受影响。
场景四:前端表单提交(通用防重)
痛点:用户快速双击提交按钮。
生产级方案:Redis Token 机制
前端在打开表单时向后端请求一个一次性 Token(存入 Redis)。提交时携带该 Token,后端使用 Lua 脚本保证"校验+删除"的原子性。
java
编辑
// 校验并删除 Token(需使用 Lua 脚本保证原子性)
String luaScript = "if redis.call('get', KEYS1) then return redis.call('del', KEYS1) else return 0 end";
Long result = redisTemplate.execute(script, Collections.singletonList("order:token:" + token));
if (result == 1) {
// 校验通过,执行业务逻辑
createOrder(orderDTO);
} else {
// Token 不存在或已被使用,拒绝请求
throw new BusinessException("请勿重复提交");
}
💡 生产级架构的三条通用红线
幂等键必须全局唯一且稳定:绝不能用数据库自增 ID 作为幂等键(每次请求都不一样),必须使用业务外部流水号、消息 ID 或订单号。
防重放要看语义:查询/状态校验可以随便做;但写操作(如扣款)必须先判重再执行,且判重与执行之间必须是原子的。
组合拳防御:单一方案都有短板。生产上通常是"前端按钮禁用(第一道) + Redis Token 拦截(第二道) + 数据库唯一索引/状态机兜底(最后防线)"。