接口幂等性保证方案详解

一、幂等性概念与重要性

1.1 什么是幂等性?

复制代码
/**
 * 幂等性:无论调用多少次,结果都相同的操作
 * f(f(x)) = f(x)
 * 
 * 举例:
 * 1. GET请求:天然幂等(查询不改变状态)
 * 2. DELETE请求:删除同一个资源多次,结果相同(资源已不存在)
 * 3. PUT请求:更新同一个资源为相同值,结果相同
 * 4. POST请求:通常不幂等(创建多个资源)
 */

1.2 为什么需要幂等性?

java 复制代码
// 场景分析
public class IdempotentScenario {
    // 1. 网络重试
    // 客户端未收到响应,自动重试 → 可能重复提交
    
    // 2. 前端重复点击
    // 用户连续点击提交按钮 → 多次请求
    
    // 3. 消息队列重复消费
    // MQ保证至少一次投递 → 可能重复处理
    
    // 4. 分布式系统调用失败重试
    // 服务间调用失败 → 上游服务重试
}

二、幂等性实现方案对比

方案 原理 优点 缺点 适用场景
唯一索引 数据库唯一约束 简单可靠 仅限数据库操作 数据创建类接口
Token机制 一次性令牌 完全防止重复提交 需要额外交互 前端表单提交
乐观锁 版本号控制 高性能,无锁竞争 需要设计版本字段 数据更新类接口
状态机 状态流转约束 业务语义清晰 需要设计状态字段 有状态流转的业务
分布式锁 互斥锁 保证强一致性 性能开销大 并发控制场景
防重表 记录请求ID 通用性强 需要额外表维护 通用接口幂等

三、具体实现方案详解

3.1 唯一索引方案

java 复制代码
/**
 * 方案一:数据库唯一索引
 * 适用场景:创建类操作(订单、支付记录等)
 */
@Service
@Transactional
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    public Order createOrder(OrderRequest request) {
        // 业务唯一标识(如:订单号+业务类型)
        String businessKey = generateBusinessKey(request);
        
        try {
            // 尝试插入,依赖数据库唯一索引保证幂等
            Order order = new Order();
            order.setOrderNo(request.getOrderNo());
            order.setBusinessType(request.getType());
            order.setUniqueKey(businessKey); // 唯一键
            order.setStatus(OrderStatus.CREATED);
            
            return orderRepository.save(order);
            
        } catch (DataIntegrityViolationException e) {
            // 唯一索引冲突,说明已创建过
            log.info("订单已存在,businessKey: {}", businessKey);
            
            // 返回已存在的订单
            return orderRepository.findByUniqueKey(businessKey)
                    .orElseThrow(() -> new BusinessException("订单查询失败"));
        }
    }
    
    private String generateBusinessKey(OrderRequest request) {
        // 生成唯一业务键
        return String.format("%s_%s_%s", 
            request.getOrderNo(), 
            request.getType(), 
            request.getUserId()
        );
    }
}

// 数据库表设计
@Entity
@Table(name = "t_order", 
       uniqueConstraints = {
           @UniqueConstraint(columnNames = {"unique_key"})
       })
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "unique_key", nullable = false)
    private String uniqueKey; // 业务唯一键
    
    private String orderNo;
    private String businessType;
    private String status;
    // ...
}

3.2 Token令牌方案

javascript 复制代码
/**
 * 方案二:Token令牌机制
 * 适用场景:前端表单提交,防止重复点击
 */
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    // 1. 获取Token
    @GetMapping("/token")
    public ApiResponse<String> getToken() {
        String token = UUID.randomUUID().toString();
        
        // Token存入Redis,设置5分钟过期
        String key = "submit_token:" + token;
        redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
        
        return ApiResponse.success(token);
    }
    
    // 2. 提交订单(验证Token)
    @PostMapping("/submit")
    public ApiResponse<OrderDTO> submitOrder(
            @RequestHeader("X-Submit-Token") String token,
            @RequestBody OrderRequest request) {
        
        // 验证Token
        if (!validateToken(token)) {
            return ApiResponse.error("重复提交或Token已过期");
        }
        
        // 删除Token,保证一次性使用
        deleteToken(token);
        
        // 执行业务逻辑
        OrderDTO order = orderService.createOrder(request);
        
        return ApiResponse.success(order);
    }
    
    private boolean validateToken(String token) {
        String key = "submit_token:" + token;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }
    
    private void deleteToken(String token) {
        String key = "submit_token:" + token;
        redisTemplate.delete(key);
    }
}

// 前端实现示例(Vue)
<script>
export default {
  methods: {
    async submitOrder() {
      // 1. 获取Token
      const { data: token } = await this.$http.get('/api/orders/token');
      
      // 2. 提交请求(携带Token)
      try {
        const response = await this.$http.post('/api/orders/submit', 
          this.formData,
          { headers: { 'X-Submit-Token': token } }
        );
        
        // 3. 处理响应
        if (response.code === 200) {
          this.$message.success('提交成功');
        }
      } catch (error) {
        if (error.response?.data?.message?.includes('重复提交')) {
          this.$message.warning('请勿重复提交');
        }
      }
    }
  }
}
</script>

3.3 乐观锁方案

java 复制代码
/**
 * 方案三:乐观锁(版本号控制)
 * 适用场景:更新类操作,并发更新
 */
@Entity
@Table(name = "t_account")
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String accountNo;
    private BigDecimal balance;
    
    @Version  // JPA乐观锁注解
    private Integer version;
    
    // 其他字段...
}

@Service
public class AccountService {
    
    @Autowired
    private AccountRepository accountRepository;
    
    /**
     * 扣减余额(幂等更新)
     * @param requestId 请求唯一ID,用于幂等
     */
    @Transactional
    public void deductBalance(String accountNo, BigDecimal amount, String requestId) {
        // 方法1:使用版本号
        Account account = accountRepository.findByAccountNoForUpdate(accountNo);
        
        // 检查余额
        if (account.getBalance().compareTo(amount) < 0) {
            throw new BusinessException("余额不足");
        }
        
        // 更新余额
        account.setBalance(account.getBalance().subtract(amount));
        
        try {
            // 保存时会检查版本号
            accountRepository.save(account);
            
            // 记录操作流水(幂等检查)
            saveDeductRecord(requestId, accountNo, amount);
            
        } catch (OptimisticLockingFailureException e) {
            // 版本冲突,说明数据已被修改
            log.warn("账户余额并发更新冲突,accountNo: {}", accountNo);
            throw new ConcurrentUpdateException("请重试");
        }
    }
    
    /**
     * 方法2:使用条件更新(推荐)
     */
    @Transactional
    public boolean deductBalanceWithCondition(String accountNo, BigDecimal amount, 
                                              String requestId, BigDecimal expectBalance) {
        // 先检查是否已处理过
        if (isRequestProcessed(requestId)) {
            return true; // 幂等返回
        }
        
        // 使用条件更新(CAS操作)
        int rows = accountRepository.updateBalance(
            accountNo, 
            amount, 
            expectBalance  // 期望的余额(用于比较)
        );
        
        if (rows > 0) {
            // 更新成功,记录请求ID
            markRequestProcessed(requestId, accountNo, amount);
            return true;
        }
        
        return false; // 更新失败(可能余额不足或数据已变更)
    }
    
    // SQL示例
    @Modifying
    @Query("UPDATE Account a SET a.balance = a.balance - :amount, a.version = a.version + 1 " +
           "WHERE a.accountNo = :accountNo AND a.balance >= :amount")
    int deductBalance(@Param("accountNo") String accountNo, 
                      @Param("amount") BigDecimal amount);
}

3.4 状态机方案

java 复制代码
/**
 * 方案四:状态机幂等
 * 适用场景:有明确状态流转的业务(订单、工单等)
 */
@Entity
@Table(name = "t_order")
public class Order {
    @Id
    private String orderId;
    
    @Enumerated(EnumType.STRING)
    private OrderStatus status;
    
    // 订单其他字段...
    
    /**
     * 状态流转方法
     * 保证幂等:只有特定状态才能转移到目标状态
     */
    public void transitionTo(OrderStatus targetStatus) {
        // 定义状态流转规则
        Map<OrderStatus, List<OrderStatus>> transitionRules = new HashMap<>();
        transitionRules.put(OrderStatus.CREATED, 
            Arrays.asList(OrderStatus.PAID, OrderStatus.CANCELLED));
        transitionRules.put(OrderStatus.PAID, 
            Arrays.asList(OrderStatus.SHIPPED, OrderStatus.REFUNDING));
        // ... 其他规则
        
        // 检查当前状态是否可以转移到目标状态
        List<OrderStatus> allowedTargets = transitionRules.get(this.status);
        if (allowedTargets == null || !allowedTargets.contains(targetStatus)) {
            throw new IllegalStateException(
                String.format("状态流转不允许:%s -> %s", this.status, targetStatus)
            );
        }
        
        // 执行状态转移
        this.status = targetStatus;
    }
}

@Service
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    /**
     * 支付订单(幂等)
     */
    @Transactional
    public void payOrder(String orderId, String paymentNo) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        // 检查是否已支付
        if (order.getStatus() == OrderStatus.PAID) {
            log.info("订单已支付,幂等返回,orderId: {}, paymentNo: {}", orderId, paymentNo);
            return;
        }
        
        // 状态流转(如果状态不是CREATED会抛异常)
        order.transitionTo(OrderStatus.PAID);
        
        // 更新其他信息
        order.setPaymentNo(paymentNo);
        order.setPayTime(LocalDateTime.now());
        
        orderRepository.save(order);
        
        // 后续业务处理...
    }
    
    /**
     * 取消订单(幂等)
     */
    @Transactional
    public void cancelOrder(String orderId, String reason) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        // 幂等检查:已取消的订单直接返回
        if (order.getStatus() == OrderStatus.CANCELLED) {
            log.info("订单已取消,幂等返回,orderId: {}", orderId);
            return;
        }
        
        // 检查是否允许取消(只有特定状态可以取消)
        if (!canCancel(order.getStatus())) {
            throw new BusinessException("当前状态不允许取消");
        }
        
        // 状态流转
        order.transitionTo(OrderStatus.CANCELLED);
        order.setCancelReason(reason);
        order.setCancelTime(LocalDateTime.now());
        
        orderRepository.save(order);
        
        // 执行取消相关业务(退款、库存释放等)
    }
    
    private boolean canCancel(OrderStatus status) {
        // 定义可以取消的状态
        return Arrays.asList(
            OrderStatus.CREATED, 
            OrderStatus.PAID,
            OrderStatus.SHIPPING
        ).contains(status);
    }
}

3.5 防重表方案

java 复制代码
/**
 * 方案五:防重表(通用方案)
 * 适用场景:通用接口幂等,不依赖业务状态
 */
@Entity
@Table(name = "t_request_record",
       uniqueConstraints = {
           @UniqueConstraint(columnNames = {"request_id", "biz_type"})
       })
public class RequestRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "request_id", nullable = false)
    private String requestId;           // 请求唯一标识
    
    @Column(name = "biz_type", nullable = false)
    private String bizType;             // 业务类型
    
    @Column(name = "biz_id")
    private String bizId;               // 业务ID(如订单号)
    
    @Enumerated(EnumType.STRING)
    private RequestStatus status;       // 处理状态
    
    @Column(columnDefinition = "text")
    private String requestParams;       // 请求参数(用于校验)
    
    private String result;              // 处理结果
    
    @Column(name = "create_time")
    private LocalDateTime createTime;
    
    @Column(name = "update_time")
    private LocalDateTime updateTime;
}

@Aspect
@Component
@Slf4j
public class IdempotentAspect {
    
    @Autowired
    private RequestRecordRepository requestRecordRepository;
    
    /**
     * 幂等注解
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface Idempotent {
        String key() default "";           // 幂等键(支持SpEL)
        long expire() default 3600;        // 过期时间(秒)
        String message() default "重复请求";
    }
    
    /**
     * 幂等切面
     */
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 1. 获取方法参数
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Object[] args = joinPoint.getArgs();
        
        // 2. 生成幂等键(支持SpEL表达式)
        String requestId = generateRequestId(idempotent, method, args);
        
        // 3. 检查是否已处理
        Optional<RequestRecord> existingRecord = 
            requestRecordRepository.findByRequestIdAndBizType(
                requestId, 
                method.getName()
            );
        
        if (existingRecord.isPresent()) {
            // 已处理,直接返回之前的结果
            RequestRecord record = existingRecord.get();
            if (record.getStatus() == RequestStatus.SUCCESS) {
                log.info("幂等返回,requestId: {}", requestId);
                return JSON.parseObject(record.getResult(), method.getReturnType());
            } else if (record.getStatus() == RequestStatus.PROCESSING) {
                // 正在处理中,防止并发
                throw new BusinessException("请求正在处理中,请勿重复提交");
            }
            // 失败状态可以重试
        }
        
        // 4. 创建处理记录
        RequestRecord record = new RequestRecord();
        record.setRequestId(requestId);
        record.setBizType(method.getName());
        record.setRequestParams(JSON.toJSONString(args));
        record.setStatus(RequestStatus.PROCESSING);
        record.setCreateTime(LocalDateTime.now());
        
        try {
            requestRecordRepository.save(record);
        } catch (DataIntegrityViolationException e) {
            // 并发插入,说明已有其他请求在处理
            throw new BusinessException(idempotent.message());
        }
        
        // 5. 执行业务逻辑
        try {
            Object result = joinPoint.proceed();
            
            // 6. 更新记录为成功
            record.setStatus(RequestStatus.SUCCESS);
            record.setResult(JSON.toJSONString(result));
            record.setUpdateTime(LocalDateTime.now());
            requestRecordRepository.save(record);
            
            return result;
            
        } catch (Exception e) {
            // 7. 更新记录为失败
            record.setStatus(RequestStatus.FAILED);
            record.setResult(e.getMessage());
            record.setUpdateTime(LocalDateTime.now());
            requestRecordRepository.save(record);
            
            throw e;
        }
    }
    
    private String generateRequestId(Idempotent idempotent, Method method, Object[] args) {
        if (!StringUtils.isEmpty(idempotent.key())) {
            // 使用SpEL解析表达式
            ExpressionParser parser = new SpelExpressionParser();
            Expression expression = parser.parseExpression(idempotent.key());
            EvaluationContext context = new StandardEvaluationContext();
            
            // 设置参数
            Parameter[] parameters = method.getParameters();
            for (int i = 0; i < parameters.length; i++) {
                context.setVariable(parameters[i].getName(), args[i]);
            }
            
            Object value = expression.getValue(context);
            return String.valueOf(value);
        }
        
        // 默认生成方式:方法名 + 参数MD5
        StringBuilder builder = new StringBuilder(method.getName());
        for (Object arg : args) {
            builder.append(JSON.toJSONString(arg));
        }
        return DigestUtils.md5DigestAsHex(builder.toString().getBytes());
    }
}

// 使用示例
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
    
    @PostMapping("/pay")
    @Idempotent(key = "#request.orderNo + '_' + #request.userId")
    public ApiResponse<PaymentResult> pay(@RequestBody PaymentRequest request) {
        // 业务逻辑...
        return paymentService.process(request);
    }
    
    @PostMapping("/refund")
    @Idempotent(key = "#refundNo", message = "退款申请已提交,请勿重复操作")
    public ApiResponse<Void> refund(@RequestParam String refundNo) {
        // 业务逻辑...
        return ApiResponse.success();
    }
}

3.6 分布式锁方案

java 复制代码
/**
 * 方案六:分布式锁(强一致性)
 * 适用场景:并发控制,保证同一时间只有一个请求处理
 */
@Component
@Slf4j
public class DistributedLockIdempotent {
    
    @Autowired
    private RedissonClient redissonClient;
    
    /**
     * 分布式锁实现幂等
     */
    public <T> T executeWithLock(String lockKey, String requestId, 
                                 Supplier<T> businessLogic, long expireSeconds) {
        // 1. 尝试获取锁
        RLock lock = redissonClient.getLock(lockKey);
        boolean locked = false;
        
        try {
            // 尝试加锁,最多等待3秒,锁超时时间expireSeconds
            locked = lock.tryLock(3, expireSeconds, TimeUnit.SECONDS);
            
            if (!locked) {
                throw new BusinessException("系统繁忙,请稍后重试");
            }
            
            // 2. 检查是否已处理(Redis记录)
            String processedKey = "idempotent:processed:" + requestId;
            String processed = (String) redissonClient.getBucket(processedKey).get();
            
            if ("1".equals(processed)) {
                log.info("请求已处理,幂等返回,requestId: {}", requestId);
                throw new IdempotentException("重复请求");
            }
            
            // 3. 执行业务逻辑
            T result = businessLogic.get();
            
            // 4. 标记已处理
            redissonClient.getBucket(processedKey).set("1", expireSeconds, TimeUnit.SECONDS);
            
            return result;
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("获取锁失败");
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    /**
     * 使用示例
     */
    public void processOrder(String orderNo, BigDecimal amount) {
        String lockKey = "order:pay:" + orderNo;
        String requestId = generateRequestId(orderNo, amount);
        
        executeWithLock(lockKey, requestId, () -> {
            // 业务逻辑
            orderService.pay(orderNo, amount);
            return null;
        }, 30); // 锁30秒
    }
    
    /**
     * 基于Redis的轻量级幂等方案
     */
    public boolean tryIdempotent(String key, long expireSeconds, Runnable task) {
        // 使用SETNX实现(Redis单命令原子性)
        String redisKey = "idempotent:" + key;
        
        // SETNX + EXPIRE 原子操作
        Boolean success = redissonClient.getBucket(redisKey).trySet("1", expireSeconds, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(success)) {
            try {
                task.run();
                return true;
            } catch (Exception e) {
                // 失败时删除key,允许重试
                redissonClient.getBucket(redisKey).delete();
                throw e;
            }
        }
        
        return false; // 已存在,幂等返回
    }
}

四、复杂场景综合方案

4.1 消息队列幂等消费

java 复制代码
/**
 * 消息队列幂等消费方案
 */
@Component
@Slf4j
public class MQIdempotentConsumer {
    
    @Autowired
    private MessageRecordRepository messageRecordRepository;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 方案1:消息ID去重
     */
    @RabbitListener(queues = "order.create.queue")
    @Transactional
    public void handleOrderCreate(Message message, Channel channel) throws IOException {
        String messageId = message.getMessageProperties().getMessageId();
        
        // 1. 检查是否已消费
        if (messageRecordRepository.existsByMessageId(messageId)) {
            log.info("消息已消费,幂等丢弃,messageId: {}", messageId);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            return;
        }
        
        // 2. 解析消息内容
        OrderMessage orderMessage = JSON.parseObject(
            new String(message.getBody()), 
            OrderMessage.class
        );
        
        // 3. 业务处理
        try {
            orderService.createOrderFromMQ(orderMessage);
            
            // 4. 记录已消费
            MessageRecord record = new MessageRecord();
            record.setMessageId(messageId);
            record.setBizId(orderMessage.getOrderNo());
            record.setStatus(ConsumeStatus.SUCCESS);
            record.setConsumeTime(LocalDateTime.now());
            messageRecordRepository.save(record);
            
            // 5. 确认消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            
        } catch (Exception e) {
            log.error("消息消费失败", e);
            
            // 记录失败
            MessageRecord record = new MessageRecord();
            record.setMessageId(messageId);
            record.setStatus(ConsumeStatus.FAILED);
            record.setErrorMsg(e.getMessage());
            messageRecordRepository.save(record);
            
            // 拒绝消息,重新入队
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
        }
    }
    
    /**
     * 方案2:Redis布隆过滤器(大数据量去重)
     */
    public boolean isDuplicateWithBloomFilter(String messageId) {
        // 使用Redisson的布隆过滤器
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("mq:bloomfilter");
        
        // 初始化布隆过滤器
        if (!bloomFilter.isExists()) {
            bloomFilter.tryInit(1000000L, 0.01); // 100万数据,1%误判率
        }
        
        // 检查是否存在
        if (bloomFilter.contains(messageId)) {
            return true; // 可能存在(有1%误判)
        }
        
        // 添加并返回false
        bloomFilter.add(messageId);
        return false;
    }
}

四、复杂场景综合方案

4.1 消息队列幂等消费

java 复制代码
/**
 * 消息队列幂等消费方案
 */
@Component
@Slf4j
public class MQIdempotentConsumer {
    
    @Autowired
    private MessageRecordRepository messageRecordRepository;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 方案1:消息ID去重
     */
    @RabbitListener(queues = "order.create.queue")
    @Transactional
    public void handleOrderCreate(Message message, Channel channel) throws IOException {
        String messageId = message.getMessageProperties().getMessageId();
        
        // 1. 检查是否已消费
        if (messageRecordRepository.existsByMessageId(messageId)) {
            log.info("消息已消费,幂等丢弃,messageId: {}", messageId);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            return;
        }
        
        // 2. 解析消息内容
        OrderMessage orderMessage = JSON.parseObject(
            new String(message.getBody()), 
            OrderMessage.class
        );
        
        // 3. 业务处理
        try {
            orderService.createOrderFromMQ(orderMessage);
            
            // 4. 记录已消费
            MessageRecord record = new MessageRecord();
            record.setMessageId(messageId);
            record.setBizId(orderMessage.getOrderNo());
            record.setStatus(ConsumeStatus.SUCCESS);
            record.setConsumeTime(LocalDateTime.now());
            messageRecordRepository.save(record);
            
            // 5. 确认消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            
        } catch (Exception e) {
            log.error("消息消费失败", e);
            
            // 记录失败
            MessageRecord record = new MessageRecord();
            record.setMessageId(messageId);
            record.setStatus(ConsumeStatus.FAILED);
            record.setErrorMsg(e.getMessage());
            messageRecordRepository.save(record);
            
            // 拒绝消息,重新入队
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
        }
    }
    
    /**
     * 方案2:Redis布隆过滤器(大数据量去重)
     */
    public boolean isDuplicateWithBloomFilter(String messageId) {
        // 使用Redisson的布隆过滤器
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("mq:bloomfilter");
        
        // 初始化布隆过滤器
        if (!bloomFilter.isExists()) {
            bloomFilter.tryInit(1000000L, 0.01); // 100万数据,1%误判率
        }
        
        // 检查是否存在
        if (bloomFilter.contains(messageId)) {
            return true; // 可能存在(有1%误判)
        }
        
        // 添加并返回false
        bloomFilter.add(messageId);
        return false;
    }
}

4.2 分布式系统调用幂等

java 复制代码
/**
 * 微服务间调用幂等方案
 */
@FeignClient(name = "inventory-service")
public interface InventoryFeignClient {
    
    /**
     * 扣减库存(幂等接口)
     * @param requestId 请求唯一ID
     */
    @PostMapping("/inventory/deduct")
    ApiResponse<Void> deductStock(@RequestBody StockDeductRequest request);
}

@Service
public class InventoryService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 扣减库存实现
     */
    @PostMapping("/deduct")
    @Transactional
    public ApiResponse<Void> deductStock(@RequestBody StockDeductRequest request) {
        // 1. 幂等检查
        String requestKey = String.format("deduct:%s:%s", 
            request.getRequestId(), request.getSkuCode());
        
        // 使用Redis原子操作
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(requestKey, "processing", 5, TimeUnit.MINUTES);
        
        if (Boolean.FALSE.equals(success)) {
            // 已处理或正在处理
            String status = redisTemplate.opsForValue().get(requestKey);
            if ("success".equals(status)) {
                return ApiResponse.success("已处理");
            }
            return ApiResponse.error("处理中,请勿重复请求");
        }
        
        try {
            // 2. 业务逻辑
            int rows = inventoryMapper.deductStock(
                request.getSkuCode(), 
                request.getQuantity()
            );
            
            if (rows == 0) {
                throw new BusinessException("库存不足");
            }
            
            // 3. 标记成功
            redisTemplate.opsForValue().set(requestKey, "success", 1, TimeUnit.HOURS);
            
            return ApiResponse.success();
            
        } catch (Exception e) {
            // 4. 失败时删除key,允许重试
            redisTemplate.delete(requestKey);
            throw e;
        }
    }
    
    /**
     * 基于数据库的分布式锁方案
     */
    @Transactional
    public ApiResponse<Void> deductStockWithDBLock(@RequestBody StockDeductRequest request) {
        // 1. 获取分布式锁(数据库行锁)
        LockRecord lock = lockRepository.findByLockKeyForUpdate(request.getRequestId());
        
        if (lock != null && lock.getStatus() == LockStatus.PROCESSED) {
            return ApiResponse.success("已处理");
        }
        
        // 2. 创建或更新锁记录
        if (lock == null) {
            lock = new LockRecord();
            lock.setLockKey(request.getRequestId());
            lock.setStatus(LockStatus.PROCESSING);
            lock.setCreateTime(LocalDateTime.now());
            lockRepository.save(lock);
        }
        
        try {
            // 3. 执行业务逻辑
            inventoryMapper.deductStock(request.getSkuCode(), request.getQuantity());
            
            // 4. 更新锁状态
            lock.setStatus(LockStatus.PROCESSED);
            lock.setUpdateTime(LocalDateTime.now());
            lockRepository.save(lock);
            
            return ApiResponse.success();
            
        } catch (Exception e) {
            // 5. 失败时删除锁记录
            lockRepository.delete(lock);
            throw e;
        }
    }
}

4.3 前端重复提交防护

javascript 复制代码
/**
 * 前端幂等性防护方案
 */

// 方案1:按钮防重复点击
class SubmitButton {
  constructor(button, options = {}) {
    this.button = button;
    this.delay = options.delay || 3000; // 禁用3秒
    this.originalText = button.innerHTML;
    this.isSubmitting = false;
    
    this.init();
  }
  
  init() {
    this.button.addEventListener('click', (e) => {
      if (this.isSubmitting) {
        e.preventDefault();
        e.stopPropagation();
        return;
      }
      
      this.disable();
      this.isSubmitting = true;
      
      // 自动恢复
      setTimeout(() => {
        this.enable();
      }, this.delay);
    });
  }
  
  disable() {
    this.button.disabled = true;
    this.button.innerHTML = '提交中...';
    this.button.classList.add('disabled');
  }
  
  enable() {
    this.button.disabled = false;
    this.button.innerHTML = this.originalText;
    this.button.classList.remove('disabled');
    this.isSubmitting = false;
  }
}

// 方案2:请求拦截器
const pendingRequests = new Map();

axios.interceptors.request.use(config => {
  // 生成请求唯一标识
  const requestKey = `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`;
  
  // 检查是否已有相同请求在pending
  if (pendingRequests.has(requestKey)) {
    // 取消当前请求
    config.cancelToken = new axios.CancelToken(cancel => {
      cancel('重复请求,自动取消');
    });
  } else {
    // 添加到pending列表
    pendingRequests.set(requestKey, config);
    
    // 请求完成后移除
    config.complete = () => {
      pendingRequests.delete(requestKey);
    };
  }
  
  // 添加时间戳防止缓存
  if (config.method === 'get') {
    config.params = {
      ...config.params,
      _t: Date.now()
    };
  }
  
  return config;
});

// 方案3:Token机制(配合后端)
class TokenManager {
  constructor() {
    this.token = null;
    this.tokenExpire = 0;
  }
  
  async getToken() {
    // 检查token是否有效
    if (this.token && Date.now() < this.tokenExpire) {
      return this.token;
    }
    
    // 请求新token
    const response = await axios.get('/api/token');
    this.token = response.data.token;
    this.tokenExpire = Date.now() + 5 * 60 * 1000; // 5分钟有效期
    
    return this.token;
  }
  
  async requestWithToken(url, data) {
    const token = await this.getToken();
    
    try {
      const response = await axios.post(url, data, {
        headers: {
          'X-Request-Token': token
        }
      });
      
      // 成功消费token
      this.token = null;
      return response;
      
    } catch (error) {
      if (error.response?.status === 409) { // 409 Conflict
        // Token重复,获取新token重试
        this.token = null;
        return this.requestWithToken(url, data);
      }
      throw error;
    }
  }
}

五、最佳实践总结

5.1 方案选择建议

java 复制代码
public class IdempotentStrategySelector {
    
    /**
     * 根据场景选择合适的幂等方案
     */
    public IdempotentStrategy selectStrategy(BusinessScenario scenario) {
        switch (scenario) {
            case FORM_SUBMIT:
                // 表单提交:Token机制 + 前端防重复
                return IdempotentStrategy.TOKEN;
                
            case ORDER_CREATE:
                // 订单创建:唯一索引 + 状态机
                return IdempotentStrategy.UNIQUE_INDEX;
                
            case INVENTORY_DEDUCT:
                // 库存扣减:乐观锁 + 防重表
                return IdempotentStrategy.OPTIMISTIC_LOCK;
                
            case PAYMENT_PROCESS:
                // 支付处理:分布式锁 + 防重表
                return IdempotentStrategy.DISTRIBUTED_LOCK;
                
            case MQ_CONSUME:
                // 消息消费:消息ID去重 + 布隆过滤器
                return IdempotentStrategy.MESSAGE_ID;
                
            case BATCH_PROCESS:
                // 批量处理:请求ID记录 + 幂等表
                return IdempotentStrategy.REQUEST_RECORD;
                
            default:
                // 默认:防重表(最通用)
                return IdempotentStrategy.REQUEST_RECORD;
        }
    }
}

5.2 设计原则

java 复制代码
public class IdempotentDesignPrinciples {
    
    /**
     * 幂等性设计原则
     */
    
    // 原则1:识别幂等操作
    // - GET、PUT、DELETE天然幂等
    // - POST需要特殊处理
    
    // 原则2:设计唯一标识
    // - 业务唯一键(订单号、支付流水号)
    // - 请求唯一ID(UUID、时间戳+随机数)
    
    // 原则3:实现幂等校验
    // - 前置校验:检查是否已处理
    // - 后置记录:处理成功后标记
    
    // 原则4:保证原子性
    // - 数据库操作:事务 + 唯一约束
    // - Redis操作:SETNX + EXPIRE
    // - 分布式锁:Redisson、Zookeeper
    
    // 原则5:考虑并发场景
    // - 乐观锁解决并发更新
    // - 分布式锁解决并发创建
    
    // 原则6:设计重试机制
    // - 可重试的失败(网络超时)
    // - 不可重试的失败(参数错误)
    
    // 原则7:监控与告警
    // - 监控幂等失败率
    // - 告警异常重复请求
}

5.3 监控与告警

java 复制代码
@Component
@Slf4j
public class IdempotentMonitor {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    private final Counter duplicateRequestCounter;
    private final Timer idempotentProcessingTimer;
    
    public IdempotentMonitor() {
        // 注册监控指标
        duplicateRequestCounter = Counter.builder("idempotent.duplicate.requests")
            .description("重复请求数量")
            .register(meterRegistry);
            
        idempotentProcessingTimer = Timer.builder("idempotent.processing.time")
            .description("幂等处理耗时")
            .register(meterRegistry);
    }
    
    /**
     * 记录幂等处理
     */
    public void recordIdempotentProcess(String requestId, long duration, boolean isDuplicate) {
        if (isDuplicate) {
            duplicateRequestCounter.increment();
            log.info("幂等请求拦截,requestId: {}", requestId);
            
            // 告警(高频重复请求)
            if (duplicateRequestCounter.count() > 1000) { // 阈值
                alertService.sendAlert("高频重复请求告警");
            }
        }
        
        idempotentProcessingTimer.record(duration, TimeUnit.MILLISECONDS);
    }
    
    /**
     * 生成幂等性报告
     */
    public IdempotentReport generateReport() {
        IdempotentReport report = new IdempotentReport();
        
        // 统计重复请求率
        double duplicateRate = calculateDuplicateRate();
        report.setDuplicateRate(duplicateRate);
        
        // 统计平均处理时间
        double avgTime = idempotentProcessingTimer.mean(TimeUnit.MILLISECONDS);
        report.setAvgProcessingTime(avgTime);
        
        // 异常情况统计
        report.setExceptionCount(getExceptionCount());
        
        return report;
    }
}

六、常见问题与解决方案

6.1 幂等与并发控制的区别

java 复制代码
public class IdempotentVsConcurrent {
    
    /**
     * 幂等性:关注多次调用结果一致性
     * 并发控制:关注同时调用时的正确性
     * 
     * 两者关系:
     * 1. 幂等性解决重试问题
     * 2. 并发控制解决同时执行问题
     * 3. 有些方案可以同时解决两者(如分布式锁)
     */
    
    // 场景分析
    public void updateUserBalance(String userId, BigDecimal amount) {
        // 问题1:多次调用(幂等问题)
        // 用户重复点击提交按钮 → 需要幂等性保证
        
        // 问题2:并发调用(并发问题)
        // 两个请求同时处理同一个用户 → 需要并发控制
        
        // 解决方案:分布式锁 + 幂等token
        String lockKey = "balance:update:" + userId;
        String requestId = generateRequestId(userId, amount);
        
        distributedLock.lock(lockKey, () -> {
            // 并发控制:保证同时只有一个请求进入
            
            if (isRequestProcessed(requestId)) {
                return; // 幂等返回
            }
            
            // 执行业务逻辑
            updateBalance(userId, amount);
            
            // 标记已处理
            markRequestProcessed(requestId);
        });
    }
}

6.2 幂等key的设计

java 复制代码
public class IdempotentKeyDesign {
    
    /**
     * 幂等Key设计原则
     */
    
    // 1. 唯一性:全局唯一
    public String generateUniqueKey() {
        // UUID
        return UUID.randomUUID().toString();
        
        // 时间戳 + 随机数
        return System.currentTimeMillis() + "_" + RandomUtils.nextInt(1000);
        
        // 雪花算法
        return SnowflakeIdGenerator.nextId();
    }
    
    // 2. 业务语义:包含业务信息
    public String generateBusinessKey(String orderNo, String userId, String action) {
        return String.format("%s:%s:%s", orderNo, userId, action);
    }
    
    // 3. 可读性:便于调试
    public String generateReadableKey(String prefix, String... parts) {
        return prefix + ":" + String.join("_", parts);
    }
    
    // 4. 长度控制:避免过长
    public String generateShortKey(String input) {
        // 使用Hash缩短长度
        return DigestUtils.md5DigestAsHex(input.getBytes());
    }
    
    // 5. 分区友好:便于分库分表
    public String generateShardingKey(String userId, String bizType) {
        // 基于用户ID分片
        int shard = Math.abs(userId.hashCode()) % 1024;
        return String.format("%04d_%s_%s", shard, bizType, userId);
    }
}

总结

保证接口幂等性是分布式系统设计的重要环节。选择方案时需要综合考虑:

  1. 业务场景:创建、更新、删除等不同操作

  2. 性能要求:高并发下的性能影响

  3. 实现复杂度:开发维护成本

  4. 一致性要求:强一致还是最终一致

  5. 技术栈限制:现有技术架构支持

推荐实践

  • 简单场景:使用数据库唯一索引

  • 前端交互:使用Token机制

  • 更新操作:使用乐观锁

  • 并发控制:使用分布式锁

  • 通用方案:使用防重表+切面

  • 消息消费:使用消息ID去重

相关推荐
better_liang9 天前
每日Java面试场景题知识点之-RabbitMQ消息重复消费问题
java·分布式·消息队列·rabbitmq·幂等性
muxin-始终如一1 个月前
消息幂等性深度解析与实现方案
开发语言·消息队列·幂等性
佛祖让我来巡山1 个月前
MQ生产者确认机制捕获到消息投递失败后如何重试?
消息队列可靠性·幂等性·消息投递失败·消息重试投递·重复投递
記億揺晃着的那天2 个月前
API设计中的幂等性详解
api·后端开发·幂等性
在未来等你5 个月前
RabbitMQ面试精讲 Day 10:消息追踪与幂等性保证
消息队列·rabbitmq·幂等性·分布式系统·面试技巧·消息追踪
Pandaconda10 个月前
【后端开发面试题】每日 3 题(十五)
数据库·分布式·后端·python·面试·后端开发·幂等性
Hello Dam1 年前
分布式环境下定时任务扫描时间段模板创建可预订时间段
java·定时任务·幂等性·redis管道·mysql流式查询
运维小文1 年前
ansible剧本快速上手
linux·运维·python·自动化·ansible·幂等性·剧本
无休居士1 年前
接口幂等性和并发安全的区别?
并发安全·幂等性