SpringBoot中4种接口幂等性实现策略

幂等性是指对同一操作执行多次与执行一次的效果相同,不会因为重复执行而产生副作用。在实际应用中,由于网络延迟、用户重复点击提交、系统自动重试等原因,可能导致同一请求被多次发送到服务端处理,如果没有实现幂等性,就可能导致数据重复、业务异常等问题。

1. 基于Token令牌的幂等性实现

Token令牌策略是最常见的幂等性实现方式之一,其核心思想是在执行业务操作前先获取一个唯一token,然后在调用接口时将其随请求一起提交,服务端校验并销毁token,确保其只被使用一次。

实现步骤

  1. 客户端先调用获取token接口
  2. 服务端生成唯一token并存入Redis,设置过期时间
  3. 客户端调用业务接口时附带token参数
  4. 服务端验证token存在性并删除,防止重复使用

代码实现

less 复制代码
@RestController
@RequestMapping("/api")
public class OrderController {

    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private OrderService orderService;
    
    // 获取token接口
    @GetMapping("/token")
    public Result<String> getToken() {
        // 生成唯一token
        String token = UUID.randomUUID().toString();
        // 存入Redis并设置过期时间
        redisTemplate.opsForValue().set("idempotent:token:" + token, "1", 10, TimeUnit.MINUTES);
        return Result.success(token);
    }
    
    // 创建订单接口
    @PostMapping("/order")
    public Result<Order> createOrder(@RequestHeader("Idempotent-Token") String token, @RequestBody OrderRequest request) {
        // 检查token是否存在
        String key = "idempotent:token:" + token;
        Boolean exist = redisTemplate.hasKey(key);
        if (exist == null || !exist) {
            return Result.fail("令牌不存在或已过期");
        }
        
        // 删除token,保证幂等性
        if (Boolean.FALSE.equals(redisTemplate.delete(key))) {
            return Result.fail("令牌已被使用");
        }
        
        // 执行业务逻辑
        Order order = orderService.createOrder(request);
        return Result.success(order);
    }
}

通过AOP简化实现

可以通过自定义注解和AOP进一步简化幂等性实现:

less 复制代码
// 自定义幂等性注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    long timeout() default 10; // 过期时间,单位分钟
}

// AOP实现
@Aspect
@Component
public class IdempotentAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 获取请求头中的token
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String token = request.getHeader("Idempotent-Token");
        
        if (StringUtils.isEmpty(token)) {
            throw new BusinessException("幂等性Token不能为空");
        }
        
        String key = "idempotent:token:" + token;
        Boolean exist = redisTemplate.hasKey(key);
        
        if (exist == null || !exist) {
            throw new BusinessException("令牌不存在或已过期");
        }
        
        // 删除token,保证幂等性
        if (Boolean.FALSE.equals(redisTemplate.delete(key))) {
            throw new BusinessException("令牌已被使用");
        }
        
        // 执行目标方法
        return joinPoint.proceed();
    }
}

// 控制器使用注解
@RestController
@RequestMapping("/api")
public class OrderController {

    @Autowired
    private OrderService orderService;
    
    @PostMapping("/order")
    @Idempotent(timeout = 30)
    public Result<Order> createOrder(@RequestBody OrderRequest request) {
        Order order = orderService.createOrder(request);
        return Result.success(order);
    }
}

优缺点分析

优点

  • 实现简单,易于理解
  • 对业务代码侵入小,可通过AOP实现
  • 可以预先生成token,减少请求处理时的延迟

缺点

  • 需要两次请求才能完成一次业务操作
  • 增加了客户端的复杂度
  • 依赖Redis等外部存储

2. 基于数据库唯一约束的幂等性实现

利用数据库的唯一约束特性可以简单有效地实现幂等性。当尝试插入重复数据时,数据库会抛出唯一约束异常,我们可以捕获这个异常并进行合适的处理。

实现方式

  1. 在关键业务表上添加唯一索引
  2. 在插入数据时捕获唯一约束异常
  3. 根据业务需求决定是返回错误还是返回已存在的数据

代码实现

typescript 复制代码
@Service
public class PaymentServiceImpl implements PaymentService {

    @Autowired
    private PaymentRepository paymentRepository;
    
    @Transactional
    @Override
    public PaymentResponse processPayment(PaymentRequest request) {
        try {
            // 创建支付记录,包含唯一业务标识
            Payment payment = new Payment();
            payment.setOrderNo(request.getOrderNo());
            payment.setTransactionId(request.getTransactionId()); // 唯一交易ID
            payment.setAmount(request.getAmount());
            payment.setStatus(PaymentStatus.PROCESSING);
            payment.setCreateTime(new Date());
            
            // 保存支付记录
            paymentRepository.save(payment);
            
            // 调用支付网关API
            // ...支付处理逻辑...
            
            // 更新支付状态
            payment.setStatus(PaymentStatus.SUCCESS);
            paymentRepository.save(payment);
            
            return new PaymentResponse(true, "支付成功", payment.getId());
        } catch (DataIntegrityViolationException e) {
            // 捕获唯一约束异常
            if (e.getCause() instanceof ConstraintViolationException) {
                // 幂等性处理 - 查询已存在的支付记录
                Payment existingPayment = paymentRepository
                        .findByTransactionId(request.getTransactionId())
                        .orElse(null);
                
                if (existingPayment != null) {
                    if (PaymentStatus.SUCCESS.equals(existingPayment.getStatus())) {
                        // 支付已成功处理,返回成功结果
                        return new PaymentResponse(true, "支付已处理", existingPayment.getId());
                    } else {
                        // 支付正在处理中,返回适当提示
                        return new PaymentResponse(false, "支付处理中", existingPayment.getId());
                    }
                }
            }
            
            // 其他数据完整性问题
            log.error("支付失败", e);
            return new PaymentResponse(false, "支付失败", null);
        }
    }
}

// 支付实体类
@Entity
@Table(name = "payments")
public class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String orderNo;
    
    @Column(unique = true) // 唯一约束
    private String transactionId;
    
    private BigDecimal amount;
    
    @Enumerated(EnumType.STRING)
    private PaymentStatus status;
    
    private Date createTime;
    
    // Getters and setters...
}

优缺点分析

优点

  • 实现简单,利用数据库已有特性
  • 无需额外的存储组件
  • 强一致性保证

缺点

  • 依赖数据库的唯一约束特性
  • 可能导致频繁的异常处理
  • 在高并发情况下可能成为性能瓶颈

3. 基于分布式锁的幂等性实现

分布式锁是实现幂等性的另一种有效方式,特别适合于高并发场景。通过对业务唯一标识加锁,可以确保同一时间只有一个请求能够执行业务逻辑。

实现方式

  1. 使用Redis、Zookeeper等实现分布式锁
  2. 以请求的唯一标识作为锁的key
  3. 在业务处理前获取锁,处理完成后释放锁

基于Redis的分布式锁实现

java 复制代码
@Service
public class InventoryServiceImpl implements InventoryService {

    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private InventoryRepository inventoryRepository;
    
    private static final String LOCK_PREFIX = "inventory:lock:";
    private static final long LOCK_EXPIRE = 10000; // 10秒
    
    @Override
    public DeductResponse deductInventory(DeductRequest request) {
        String lockKey = LOCK_PREFIX + request.getRequestId();
        String requestId = UUID.randomUUID().toString();
        
        try {
            // 尝试获取分布式锁
            Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, LOCK_EXPIRE, TimeUnit.MILLISECONDS);
            
            if (Boolean.FALSE.equals(acquired)) {
                // 获取锁失败,说明可能是重复请求
                return new DeductResponse(false, "请求正在处理中,请勿重复提交");
            }
            
            // 查询是否已处理过该请求
            Optional<InventoryRecord> existingRecord = inventoryRepository.findByRequestId(request.getRequestId());
            if (existingRecord.isPresent()) {
                // 幂等性控制 - 请求已处理过
                return new DeductResponse(true, "库存已扣减", existingRecord.get().getId());
            }
            
            // 执行库存扣减逻辑
            Inventory inventory = inventoryRepository.findByProductId(request.getProductId())
                    .orElseThrow(() -> new BusinessException("商品不存在"));
                    
            if (inventory.getStock() < request.getQuantity()) {
                throw new BusinessException("库存不足");
            }
            
            // 扣减库存
            inventory.setStock(inventory.getStock() - request.getQuantity());
            inventoryRepository.save(inventory);
            
            // 记录库存操作
            InventoryRecord record = new InventoryRecord();
            record.setRequestId(request.getRequestId());
            record.setProductId(request.getProductId());
            record.setQuantity(request.getQuantity());
            record.setCreateTime(new Date());
            inventoryRepository.save(record);
            
            return new DeductResponse(true, "库存扣减成功", record.getId());
        } catch (BusinessException e) {
            return new DeductResponse(false, e.getMessage(), null);
        } catch (Exception e) {
            log.error("库存扣减失败", e);
            return new DeductResponse(false, "库存扣减失败", null);
        } finally {
            // 释放锁,注意只释放自己的锁
            if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
                redisTemplate.delete(lockKey);
            }
        }
    }
}

使用Redisson简化实现

java 复制代码
@Service
public class InventoryServiceImpl implements InventoryService {

    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private InventoryRepository inventoryRepository;
    
    private static final String LOCK_PREFIX = "inventory:lock:";
    
    @Override
    public DeductResponse deductInventory(DeductRequest request) {
        String lockKey = LOCK_PREFIX + request.getRequestId();
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试获取锁,等待5秒,锁过期时间10秒
            boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
            
            if (!acquired) {
                return new DeductResponse(false, "请求正在处理中,请勿重复提交");
            }
            
            // 查询是否已处理过该请求
            // ...后续业务逻辑与前面例子相同...
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new DeductResponse(false, "请求被中断", null);
        } catch (Exception e) {
            log.error("库存扣减失败", e);
            return new DeductResponse(false, "库存扣减失败", null);
        } finally {
            // 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

优缺点分析

优点

  • 适用于高并发场景
  • 可以与其他幂等性策略结合使用
  • 提供较好的实时性控制

缺点

  • 实现复杂度较高
  • 依赖外部存储服务

4. 基于请求内容摘要的幂等性实现

这种方案通过计算请求内容的哈希值或摘要,生成唯一标识作为幂等键,确保相同内容的请求只处理一次。

实现方式

  1. 计算请求参数的摘要值(如MD5, SHA-256等)
  2. 将摘要值作为幂等键存储在Redis或数据库中
  3. 请求处理前先检查该摘要值是否已存在
  4. 存在则表示重复请求,不执行业务逻辑

代码实现

typescript 复制代码
@RestController
@RequestMapping("/api")
public class TransferController {

    @Autowired
    private TransferService transferService;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @PostMapping("/transfer")
    public Result<TransferResult> transfer(@RequestBody TransferRequest request) {
        // 生成请求摘要作为幂等键
        String idempotentKey = generateIdempotentKey(request);
        String redisKey = "idempotent:digest:" + idempotentKey;
        
        // 尝试在Redis中设置幂等键,使用SetNX操作确保原子性
        Boolean isFirstRequest = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, "processed", 24, TimeUnit.HOURS);
        
        // 如果键已存在,说明是重复请求
        if (Boolean.FALSE.equals(isFirstRequest)) {
            // 查询处理结果(也可以直接存储处理结果)
            TransferRecord record = transferService.findByIdempotentKey(idempotentKey);
            
            if (record != null) {
                // 返回之前的处理结果
                return Result.success(new TransferResult(
                        record.getTransactionId(), 
                        "交易已处理", 
                        record.getAmount(),
                        record.getStatus()));
            } else {
                // 幂等键存在但找不到记录,可能正在处理
                return Result.fail("请求正在处理中,请勿重复提交");
            }
        }
        
        try {
            // 执行转账业务逻辑
            TransferResult result = transferService.executeTransfer(request, idempotentKey);
            return Result.success(result);
        } catch (Exception e) {
            // 处理失败时,删除幂等键,允许客户端重试
            // 或者可以保留键但记录失败状态,取决于业务需求
            redisTemplate.delete(redisKey);
            return Result.fail("转账处理失败: " + e.getMessage());
        }
    }
    
    /**
     * 生成请求内容摘要作为幂等键
     */
    private String generateIdempotentKey(TransferRequest request) {
        // 组合关键字段,确保能唯一标识业务操作
        String content = request.getFromAccount() 
                + "|" + request.getToAccount() 
                + "|" + request.getAmount().toString()
                + "|" + request.getRequestTime();
        
        // 计算MD5摘要
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("生成幂等键失败", e);
        }
    }
}

@Service
public class TransferServiceImpl implements TransferService {

    @Autowired
    private TransferRecordRepository transferRecordRepository;
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Override
    @Transactional
    public TransferResult executeTransfer(TransferRequest request, String idempotentKey) {
        // 执行转账业务逻辑
        // 1. 检查账户余额
        // 2. 扣减来源账户
        // 3. 增加目标账户
        
        // 生成交易ID
        String transactionId = UUID.randomUUID().toString();
        
        // 保存交易记录,包含幂等键
        TransferRecord record = new TransferRecord();
        record.setTransactionId(transactionId);
        record.setFromAccount(request.getFromAccount());
        record.setToAccount(request.getToAccount());
        record.setAmount(request.getAmount());
        record.setIdempotentKey(idempotentKey);
        record.setStatus(TransferStatus.SUCCESS);
        record.setCreateTime(new Date());
        
        transferRecordRepository.save(record);
        
        return new TransferResult(
                transactionId,
                "转账成功",
                request.getAmount(),
                TransferStatus.SUCCESS);
    }
    
    @Override
    public TransferRecord findByIdempotentKey(String idempotentKey) {
        return transferRecordRepository.findByIdempotentKey(idempotentKey).orElse(null);
    }
}

// 转账记录实体
@Entity
@Table(name = "transfer_records", indexes = {
    @Index(name = "idx_idempotent_key", columnList = "idempotent_key", unique = true)
})
public class TransferRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String transactionId;
    
    private String fromAccount;
    
    private String toAccount;
    
    private BigDecimal amount;
    
    @Column(name = "idempotent_key")
    private String idempotentKey;
    
    @Enumerated(EnumType.STRING)
    private TransferStatus status;
    
    private Date createTime;
    
    // Getters and setters...
}

使用自定义注解简化实现

less 复制代码
// 自定义幂等性注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 过期时间(秒)
     */
    int expireSeconds() default 86400; // 默认24小时
    
    /**
     * 幂等键来源,可从请求体、请求参数等提取
     */
    KeySource source() default KeySource.REQUEST_BODY;
    
    /**
     * 提取参数的表达式(如SpEL表达式)
     */
    String[] expression() default {};
    
    enum KeySource {
        REQUEST_BODY,  // 请求体
        PATH_VARIABLE, // 路径变量
        REQUEST_PARAM, // 请求参数
        CUSTOM        // 自定义
    }
}

// AOP实现
@Aspect
@Component
public class IdempotentAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 获取请求参数
        Object[] args = joinPoint.getArgs();
        
        // 根据注解配置生成幂等键
        String idempotentKey = generateKey(joinPoint, idempotent);
        String redisKey = "idempotent:digest:" + idempotentKey;
        
        // 检查是否重复请求
        Boolean setSuccess = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, "processing", idempotent.expireSeconds(), TimeUnit.SECONDS);
        
        if (Boolean.FALSE.equals(setSuccess)) {
            // 获取存储的处理结果
            String value = redisTemplate.opsForValue().get(redisKey);
            
            if ("processing".equals(value)) {
                throw new BusinessException("请求正在处理中,请勿重复提交");
            } else if (value != null) {
                // 已处理,返回缓存的结果
                return JSON.parseObject(value, Object.class);
            }
        }
        
        try {
            // 执行实际方法
            Object result = joinPoint.proceed();
            
            // 存储处理结果
            redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(result), 
                    idempotent.expireSeconds(), TimeUnit.SECONDS);
            
            return result;
        } catch (Exception e) {
            // 处理失败,删除键允许重试
            redisTemplate.delete(redisKey);
            throw e;
        }
    }
    
    /**
     * 根据注解配置生成幂等键
     */
    private String generateKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
        // 提取请求参数,根据KeySource和expression生成摘要
        // 实际实现会更复杂,这里简化
        String content = "";
        
        // 计算MD5摘要
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("生成幂等键失败", e);
        }
    }
}

// 控制器使用注解
@RestController
@RequestMapping("/api")
public class TransferController {

    @Autowired
    private TransferService transferService;
    
    @PostMapping("/transfer")
    @Idempotent(expireSeconds = 3600, source = KeySource.REQUEST_BODY, 
                expression = {"fromAccount", "toAccount", "amount", "requestTime"})
    public Result<TransferResult> transfer(@RequestBody TransferRequest request) {
        // 执行转账业务逻辑
        TransferResult result = transferService.executeTransfer(request);
        return Result.success(result);
    }
}

优缺点分析

优点

  • 方案更通用
  • 实现相对简单,易于集成
  • 对客户端友好,不需要额外的token请求

缺点

  • 哈希计算有一定性能开销
  • 表单数据顺序变化可能导致不同的摘要值

总结

幂等性设计是系统稳定性和可靠性的重要保障,通过合理选择和实现幂等性策略,可以有效防止因重复请求导致的数据不一致问题。在实际项目中,应根据具体的业务需求和系统架构,选择最适合的幂等性实现方案。

相关推荐
柒七爱吃麻辣烫38 分钟前
在Linux中安装JDK并且搭建Java环境
java·linux·开发语言
极小狐1 小时前
极狐GitLab 容器镜像仓库功能介绍
java·前端·数据库·npm·gitlab
杨不易呀1 小时前
Java面试高阶篇:Spring Boot+Quarkus+Redis高并发架构设计与性能优化实战
spring boot·redis·高并发·分布式锁·java面试·quarkus
努力的搬砖人.1 小时前
如何让rabbitmq保存服务断开重连?保证高可用?
java·分布式·rabbitmq
_星辰大海乀1 小时前
数据库约束
java·数据结构·数据库·sql·链表
多多*1 小时前
Java反射 八股版
java·开发语言·hive·python·sql·log4j·mybatis
Moshow郑锴2 小时前
Spring Boot 3 + Undertow 服务器优化配置
服务器·spring boot·后端
码农飞哥2 小时前
互联网大厂Java面试实战:Spring Boot到微服务的技术问答解析
java·数据库·spring boot·缓存·微服务·消息队列·面试技巧
liudongyang1232 小时前
jenkins 启动报错
java·运维·jenkins
曹牧2 小时前
JSON 实体属性映射的最佳实践
java