在分布式系统中,网络超时、用户重复点击、消息重试等情况都可能导致同一个请求被多次执行。若接口缺乏幂等性保护,重复的扣款、下单、发货操作将引发严重的业务错误。接口幂等性的设计与实现,是保障系统数据一致性的重要防线。
27.4.1 什么是幂等性
幂等性(Idempotence)指同一个操作执行一次和执行多次产生的副作用完全相同。对于HTTP接口,通常要求:
- GET、HEAD、OPTIONS 请求本身就是幂等的,不应产生副作用。
- PUT、DELETE 请求通常也应保持幂等:用相同数据更新资源多次,结果不变;删除已删除的资源,状态依然是“已删除”。
- POST 请求一般是非幂等的,但很多业务场景(如创建订单、提交支付)必须人为实现幂等。
从业务视角看,幂等不是技术上的“相同响应”,而是业务状态的最终一致。例如重复提交同一个订单请求,系统应识别为同一笔交易,只创建一条订单记录,并返回相同结果。
27.4.2 典型需要幂等的场景
- 用户在网络卡顿时多次点击“提交订单”按钮。
- 支付回调接口因通道重试机制被调用多次。
- 消息队列消费者处理失败后的自动重投。
- RPC调用因超时由客户端发起重试。
- 定时任务跨实例竟态执行同一批数据。
任何一次外部请求或内部重试,只要可能重复执行业务写操作,就必须实现幂等。
27.4.3 常用幂等性实现方案
根据业务特点和技术环境,可以在不同层次落地幂等。下表列出几种主流方案及其适用场景:
| 方案 | 原理 | 适用场景 | 优点 | 缺点 |
|------|------|----------|------|------|
| 唯一Token(令牌) | 客户端先获取Token,提交时携带Token;服务端校验并删除Token,避免重复提交 | 表单提交、订单创建等前端触发的写操作 | 实现简单,不依赖业务数据 | Token需要存储(Redis),多系统共享时需全局一致 |
| 数据库唯一约束 | 利用业务唯一键(如订单号、交易流水号)和数据库唯一索引,重复插入时捕获DuplicateKeyException | 创建类接口,业务天然存在唯一标识 | 无需额外组件,由数据库强保证 | 仅适用于插入场景,业务维度复杂时唯一键难定义 |
| 状态机约束 | 将业务状态建模为状态机,仅允许从特定前置状态转移至目标状态,重复请求会因状态不符而无效 | 订单流转、审批流程、支付状态更新 | 语义清晰,能表达复杂业务规则 | 需要仔细设计状态模型,实现稍复杂 |
| 乐观锁(版本号) | 更新时携带版本号或时间戳,UPDATE语句检查版本号,匹配才更新;重复请求因版本号已变而失败 | 更新类接口,如修改订单金额、收货地址 | 实现较简单,数据库并发控制机制 | 仅适用于更新场景,需业务表增加版本字段 |
| 全局请求 ID + 去重表 | 客户端为每次请求生成唯一ID,服务端存入去重表,重复请求直接查询结果返回 | 通用场景,尤其适合RPC调用和服务间重试 | 通用性强,可封装为框架 | 需要维护去重表及结果缓存,增加存储开销 |
实际开发中经常组合使用:例如订单创建结合“Token”防重复点击和“数据库唯一约束”兜底,支付回调则采用“全局请求ID + 状态机”双重保障。
27.4.4 基于Redis的Token幂等实现(实战示例)
这是防前端重复提交常用的方案,流程如下:
- 客户端访问页面时,调用服务端接口获取一个全局唯一的Token(UUID)。
- 服务端将Token存入Redis,设置有效期(如5分钟),并返回给前端。
- 前端提交业务请求时,在Header或请求体中携带该Token。
- 服务端接收到请求,先从Redis中删除该Token(使用Lua脚本保证原子性)。若删除成功,表示首次提交,继续执行业务;若删除失败,说明Token已被消费或不存在,视为重复提交,直接返回提示。
服务端核心代码:
@Service
public class TokenService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 生成幂等Token并存入Redis
*/
public String generateToken(String businessCode) {
String token = UUID.randomUUID().toString().replace("-", "");
String key = "idempotent:token:" + businessCode + ":" + token;
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
return token;
}
/**
* 校验并消费Token,返回true表示首次请求
*/
public boolean consumeToken(String businessCode, String token) {
String key = "idempotent:token:" + businessCode + ":" + token;
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
"1"
);
return result != null && result == 1;
}
}
Controller 拦截器或AOP统一处理:
为避免每个接口都写重复逻辑,可自定义注解 @Idempotent,再通过拦截器或AOP实现统一幂等校验:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String businessCode() default ""; // 业务标识
}
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private TokenService tokenService;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader("Idempotent-Token");
String businessCode = idempotent.businessCode();
if (!tokenService.consumeToken(businessCode, token)) {
throw new BusinessException("请勿重复提交");
}
return joinPoint.proceed();
}
}
这样就可以在需要幂等的接口上使用 @Idempotent(businessCode = "order_create"),干净灵活。
27.4.5 基于数据库唯一约束的幂等实现
当业务操作天然存在唯一标识(如订单号、外部交易流水号)时,可以利用数据库唯一索引防止重复插入。例如支付回调接口:
@Transactional
public void handlePaymentCallback(PaymentNotifyDTO notify) {
// 尝试插入通知记录,表(gateway_trade_no)设有唯一索引
try {
paymentNotifyLogService.insert(notify);
} catch (DuplicateKeyException e) {
// 记录已存在,说明已处理过,查询之前处理结果返回
log.warn("重复回调,流水号: {}", notify.getGatewayTradeNo());
return;
}
// 首次处理,执行后续发货等业务
processAfterPayment(notify);
}
注意事项:
- 唯一键需要谨慎设计,务必覆盖所有去重维度(如用户ID + 活动ID等)。
- 插入成功后再执行业务逻辑,保证去重逻辑在业务处理之前。
DuplicateKeyException是Spring对数据库唯一约束违反的封装,捕获它即可识别重复。
27.4.6 全局请求ID + 去重表方案
在微服务间RPC调用或无法用Token、唯一约束的场景,可以为每次业务操作生成全局唯一的 requestId,由服务提供方维护一张去重表(request_id唯一索引),并存储处理结果。流程:
- 调用方发起请求前,生成requestId(UUID或雪花ID),放在RPC上下文。
- 提供方收到请求,尝试向去重表插入
(request_id, business_type, result)记录。 - 若插入成功,执行业务,然后将处理结果更新到该记录。
- 若插入失败(唯一冲突),说明是重复请求,直接查询已有的处理结果并返回。
这种方式可以实现跨系统的通用幂等,适合复杂的异步回调或长事务重试。
27.4.7 实现幂等的关键点总结
- 尽早识别重复:幂等校验必须放在业务逻辑执行前,最好在拦截器或AOP层统一处理。
- 原子性保障:Token的“校验-删除”、去重表的“插入-判断”必须是原子操作。用Redis的Lua脚本或数据库的INSERT … ON DUPLICATE KEY UPDATE实现。
- 结果一致性:重复请求不仅不能产生副作用,还应该返回与首次请求一致的结果。建议将业务处理结果临时缓存,避免重复查询导致状态不一致。
- Token的有效期与防重放:对于Token方案,设置合理的有效期(如5-10分钟)。可使用业务语义绑定Token(如订单Token在订单创建后主动失效),防止被恶意复用。
- 失败处理:当业务执行失败且需要重试时,应该让客户端重新获取Token或生成新的requestId,不要沿用旧的标识。
幂等性不是单个技术点的实施,而是一种需要从接口设计、数据建模到中间件运用通盘考虑的系统能力。结合上述方案,在项目中形成统一、可复用的幂等框架,将极大提升系统的健壮性和用户体验。