接口幂等性
幂等性是指对同一操作执行多次,结果与执行一次完全相同。HTTP 语义上 GET / PUT / DELETE 天然幂等,POST 不幂等——同一请求重复发送会创建多条数据。
为什么需要幂等
客户端 网络 服务器
│ │
│─── POST /orders ──────────────────►│ 处理成功,订单已创建
│ │
│◄── (响应丢失,超时) │
│
│─── POST /orders(重试)────────────►│ ← 重复创建!
常见触发场景:
- 网络超时重试:客户端未收到响应,重发请求
- 前端按钮双击:用户快速点击两次提交
- 消息队列重投:MQ 至少一次语义,消费者重复消费
- 支付回调重推:第三方支付系统因超时多次回调
方案一:幂等 Token(通用,推荐)
核心思路:客户端提交前先获取一次性 Token,服务端用 Redis 原子检查并消费该 Token。
① GET /idempotency-token → 服务端生成 UUID,存入 Redis(TTL=10min)
② POST /orders Header: Idempotency-Token: <uuid>
③ 服务端:Redis DEL token(原子),成功则处理,失败则返回"重复请求"
实现
Token 颁发接口:
@RestController
@RequiredArgsConstructor
public class IdempotencyController {
private final StringRedisTemplate redisTemplate;
@GetMapping("/idempotency-token")
public String generateToken() {
String token = UUID.randomUUID().toString();
// 存入 Redis,10 分钟有效
redisTemplate.opsForValue().set("idempotency:" + token, "1",
Duration.ofMinutes(10));
return token;
}
}AOP 切面(注解驱动):
// 1. 注解定义
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String headerName() default "Idempotency-Token";
String message() default "请勿重复提交";
}
// 2. 切面实现
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private final StringRedisTemplate redisTemplate;
private static final String PREFIX = "idempotency:";
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp,
Idempotent idempotent) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader(idempotent.headerName());
if (!StringUtils.hasText(token)) {
throw new BusinessException("缺少幂等 Token");
}
// DEL 操作原子性:成功=1 首次请求,失败=0 重复请求
Boolean deleted = redisTemplate.delete(PREFIX + token);
if (!Boolean.TRUE.equals(deleted)) {
throw new BusinessException(idempotent.message());
}
return pjp.proceed();
}
}
// 3. 业务接口使用
@PostMapping("/orders")
@Idempotent
public OrderResponse createOrder(@RequestBody CreateOrderRequest req) {
return orderService.create(req);
}方案二:数据库唯一约束
适合有天然业务唯一键的场景(订单号、支付流水号等),利用数据库唯一索引兜底:
-- 订单表设置业务唯一键
ALTER TABLE orders ADD UNIQUE KEY uk_request_id (request_id);@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepo;
@Transactional
public OrderResponse createOrder(CreateOrderRequest req) {
// 先查再写(适合低并发)
return orderRepo.findByRequestId(req.getRequestId())
.map(this::toResponse)
.orElseGet(() -> {
Order order = new Order();
order.setRequestId(req.getRequestId());
// ... 设置其他字段
return toResponse(orderRepo.save(order));
});
}
}高并发场景:直接插入,捕获唯一键冲突并返回已有记录:
@Transactional
public OrderResponse createOrder(CreateOrderRequest req) {
try {
Order order = buildOrder(req);
return toResponse(orderRepo.save(order));
} catch (DataIntegrityViolationException e) {
// 唯一键冲突,说明已处理过
return toResponse(orderRepo.findByRequestId(req.getRequestId())
.orElseThrow());
}
}事务管理详见 事务管理。
方案三:状态机防重
适合有明确状态流转的业务(支付、审批流),利用状态转换的原子性:
@Transactional
public void payOrder(Long orderId) {
// 用带条件的 UPDATE 原子操作,只有 PENDING 状态才能转 PAID
int updated = orderRepo.compareAndSetStatus(
orderId, OrderStatus.PENDING, OrderStatus.PAID);
if (updated == 0) {
// 0 行受影响 = 订单不存在 或 已是 PAID/CANCELLED
Order order = orderRepo.findById(orderId).orElseThrow();
if (order.getStatus() == OrderStatus.PAID) {
return; // 幂等:已支付,直接返回
}
throw new BusinessException("订单状态异常: " + order.getStatus());
}
// 执行支付后续逻辑
paymentService.charge(orderId);
}// Repository
@Modifying
@Query("UPDATE Order o SET o.status = :newStatus " +
"WHERE o.id = :id AND o.status = :oldStatus")
int compareAndSetStatus(@Param("id") Long id,
@Param("oldStatus") OrderStatus oldStatus,
@Param("newStatus") OrderStatus newStatus);方案四:分布式锁 + 查重
适合无法改造客户端的场景,分布式锁保证同一 key 的并发请求串行化:
@DistributedLock(key = "'order:create:' + #req.userId", leaseTime = 10)
@Transactional
public OrderResponse createOrder(CreateOrderRequest req) {
// 锁内再查一次,防止并发穿透
Order existing = orderRepo.findByRequestId(req.getRequestId());
if (existing != null) {
return toResponse(existing);
}
return toResponse(orderRepo.save(buildOrder(req)));
}分布式锁实现详见 分布式锁。
消息队列消费幂等
MQ 的”至少一次”语义要求消费者自行实现幂等:
@Component
@RequiredArgsConstructor
public class OrderMessageConsumer {
private final StringRedisTemplate redisTemplate;
private final OrderService orderService;
@RabbitListener(queues = "order.create")
public void consume(OrderMessage message) {
String dedupKey = "mq:consumed:" + message.getMessageId();
// SETNX:首次消费成功才处理,已处理过直接跳过
Boolean isFirst = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", Duration.ofHours(24));
if (!Boolean.TRUE.equals(isFirst)) {
log.info("消息已处理,跳过: {}", message.getMessageId());
return;
}
try {
orderService.createOrder(message);
} catch (Exception e) {
// 处理失败时删除去重 key,允许重试
redisTemplate.delete(dedupKey);
throw e;
}
}
}消息队列详见 消息队列。
方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 幂等 Token | 通用,前后端分离 | 语义清晰,无业务侵入 | 需客户端先获取 Token |
| 数据库唯一约束 | 有天然业务唯一键 | 兜底可靠,无额外依赖 | 唯一键设计需提前规划 |
| 状态机 | 有状态流转的业务 | 业务语义完整 | 需状态机设计 |
| 分布式锁 | 无法改造客户端 | 对客户端透明 | 锁粒度需仔细设计 |