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

 更新时间:2025年04月17日 08:14:26   作者:风象南  
幂等性是指对同一操作执行多次与执行一次的效果相同,不会因为重复执行而产生副作用,本文整理了4个SpringBoot实现接口幂等性的方法,大家可以根据需要进行选择

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

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

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

实现步骤

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

代码实现

@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进一步简化幂等性实现:

// 自定义幂等性注解
@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. 基于数据库唯一约束的幂等性实现

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

实现方式

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

代码实现

@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. 基于分布式锁的幂等性实现

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

实现方式

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

基于Redis的分布式锁实现

@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简化实现

@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. 基于请求内容摘要的幂等性实现

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

实现方式

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

代码实现

@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...
}

使用自定义注解简化实现

// 自定义幂等性注解
@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请求

缺点

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

总结

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

以上就是SpringBoot中4种接口幂等性的实现策略的详细内容,更多关于SpringBoot接口幂等性的资料请关注脚本之家其它相关文章!

相关文章

  • springboot整合SSE技术开发小结

    springboot整合SSE技术开发小结

    本文主要介绍了springboot整合SSE技术开发小结,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-11-11
  • SpringBoot使用classfinal-maven-plugin插件加密Jar包的示例代码

    SpringBoot使用classfinal-maven-plugin插件加密Jar包的示例代码

    这篇文章给大家介绍了SpringBoot使用classfinal-maven-plugin插件加密Jar包的实例,文中通过代码示例和图文讲解的非常详细,对大家的学习或工作有一定的帮助,需要的朋友可以参考下
    2024-02-02
  • SpringMVC中controller接收json数据的方法

    SpringMVC中controller接收json数据的方法

    这篇文章主要为大家详细介绍了SpringMVC中controller接收json数据的方法,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-09-09
  • 基于Spring-cloud-gateway实现全局日志记录的方法

    基于Spring-cloud-gateway实现全局日志记录的方法

    最近项目在线上运行出现了一些难以复现的bug需要定位相应api的日志,通过nginx提供的api请求日志难以实现,于是在gateway通过全局过滤器记录api请求日志,本文给大家介绍基于Spring-cloud-gateway实现全局日志记录,感兴趣的朋友一起看看吧
    2023-11-11
  • spring boot+mybatis搭建一个后端restfull服务的实例详解

    spring boot+mybatis搭建一个后端restfull服务的实例详解

    这篇文章主要介绍了spring boot+mybatis搭建一个后端restfull服务,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2020-11-11
  • 基于Java实现动态切换ubuntu壁纸功能

    基于Java实现动态切换ubuntu壁纸功能

    这篇文章主要为大家详细介绍了如何使用 Java 在 Ubuntu Linux 系统中实现自动切换壁纸的示例程序,感兴趣的小伙伴可以跟随小编一起学习一下
    2024-11-11
  • jvm信息jmap使用的基本方法教程

    jvm信息jmap使用的基本方法教程

    JDK本身提供了很多方便的JVM性能调优监控工具,除了集成式的VisualVM和jConsole外,还有jps、jstack、jmap、jhat、jstat等小巧的工具,下面这篇文章主要给大家介绍了关于jvm信息jmap使用的基本方法教程,需要的朋友可以参考下
    2018-08-08
  • Ajax实现省市区三级联动

    Ajax实现省市区三级联动

    这篇文章主要为大家详细介绍了jQuery ajax实现省市县三级联动的相关资料,具有一定的参考价值,感兴趣的小伙伴们可以参考一下,希望能帮助到你
    2021-07-07
  • 细数java中Long与Integer比较容易犯的错误总结

    细数java中Long与Integer比较容易犯的错误总结

    下面小编就为大家带来一篇细数java中Long与Integer比较容易犯的错误总结。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-01-01
  • Java/Spring项目的包开头为什么是com详解

    Java/Spring项目的包开头为什么是com详解

    这篇文章主要介绍了Java/Spring项目的包开头为什么是com的相关资料,在Java中包命名遵循域名反转规则,即使用公司的域名反转作为包的前缀,以确保其全球唯一性和避免命名冲突,这种规则有助于逻辑分层、代码可读性提升和标识代码来源,需要的朋友可以参考下
    2024-10-10

最新评论