接口幂等性

幂等性是指对同一操作执行多次,结果与执行一次完全相同。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
数据库唯一约束有天然业务唯一键兜底可靠,无额外依赖唯一键设计需提前规划
状态机有状态流转的业务业务语义完整需状态机设计
分布式锁无法改造客户端对客户端透明锁粒度需仔细设计

相关链接

  • Redis集成StringRedisTemplate 操作与 SETNX 原子性
  • 分布式锁 — Redisson 注解驱动锁,锁 + 查重组合方案
  • AOP — 幂等切面实现,SpEL key 解析
  • 事务管理 — 幂等与事务边界(锁应在事务外层)
  • 消息队列 — MQ 消费幂等与去重 key 设计
  • 全局异常处理 — 重复提交的统一错误响应