Spring Boot实现接口幂等案例

wen java案例 1

本文目录导读:

Spring Boot实现接口幂等案例

  1. 完整实现代码
  2. 测试用例
  3. 使用场景说明
  4. 注意事项

我将为你提供一个完整的Spring Boot接口幂等性实现方案。

使用Token+Redis的方式实现接口幂等性,这是最常用的方案之一。

完整实现代码

添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>

自定义注解

package com.example.idempotent.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
 * 幂等性注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 幂等标识参数名称
     * 支持从请求参数、请求头、路径变量中获取
     */
    String key() default "";
    /**
     * 过期时间(秒)
     */
    long expireTime() default 60;
    /**
     * 提示信息
     */
    String message() default "重复请求,请稍后再试";
}

创建响应类

package com.example.idempotent.common;
import lombok.Data;
@Data
public class Result<T> {
    private Integer code;
    private String message;
    private T data;
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMessage("success");
        result.setData(data);
        return result;
    }
    public static <T> Result<T> error(Integer code, String message) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        return result;
    }
}

创建异常类

package com.example.idempotent.exception;
public class IdempotentException extends RuntimeException {
    public IdempotentException(String message) {
        super(message);
    }
}

实现AOP切面

package com.example.idempotent.aspect;
import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.exception.IdempotentException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@Aspect
@Component
public class IdempotentAspect {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Around("@annotation(com.example.idempotent.annotation.Idempotent)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Idempotent idempotent = method.getAnnotation(Idempotent.class);
        // 获取请求上下文
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // 生成幂等key
        String idempotentKey = generateIdempotentKey(request, idempotent, joinPoint);
        if (StringUtils.isBlank(idempotentKey)) {
            throw new IdempotentException("无法生成幂等标识");
        }
        // 尝试获取分布式锁
        boolean lockSuccess = tryLock(idempotentKey, idempotent.expireTime());
        if (!lockSuccess) {
            throw new IdempotentException(idempotent.message());
        }
        try {
            // 执行目标方法
            return joinPoint.proceed();
        } finally {
            // 执行完成后删除锁
            // 注意:这里可以根据业务需求决定是否删除锁
            // 如果需要在固定时间内防止重复提交,可以不删除锁使其在过期时间后自动失效
            // releaseLock(idempotentKey);
        }
    }
    /**
     * 生成幂等key
     */
    private String generateIdempotentKey(HttpServletRequest request, Idempotent idempotent, ProceedingJoinPoint joinPoint) {
        StringBuilder keyBuilder = new StringBuilder("idempotent:");
        keyBuilder.append(request.getRequestURI()).append(":");
        // 1. 优先使用自定义key表达式
        if (StringUtils.isNotBlank(idempotent.key())) {
            String customKey = resolveCustomKey(idempotent.key(), request, joinPoint);
            keyBuilder.append(customKey);
        } else {
            // 2. 默认使用token(从请求头或参数中获取)
            String token = request.getHeader("token");
            if (StringUtils.isBlank(token)) {
                token = request.getParameter("token");
            }
            if (StringUtils.isBlank(token)) {
                token = UUID.randomUUID().toString();
            }
            keyBuilder.append(token);
        }
        return keyBuilder.toString();
    }
    /**
     * 解析自定义key
     */
    private String resolveCustomKey(String keyExpression, HttpServletRequest request, ProceedingJoinPoint joinPoint) {
        // 支持格式:#paramName、headerName、pathVariableName
        if (keyExpression.startsWith("#")) {
            // 从参数中获取
            String paramName = keyExpression.substring(1);
            Object[] args = joinPoint.getArgs();
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            String[] parameterNames = signature.getParameterNames();
            if (parameterNames != null) {
                for (int i = 0; i < parameterNames.length; i++) {
                    if (paramName.equals(parameterNames[i])) {
                        return String.valueOf(args[i]);
                    }
                }
            }
        } else if (keyExpression.startsWith("header:")) {
            // 从请求头获取
            return request.getHeader(keyExpression.substring(7));
        } else {
            // 从请求参数获取
            return request.getParameter(keyExpression);
        }
        return null;
    }
    /**
     * 使用Redis SETNX实现分布式锁
     */
    private boolean tryLock(String key, long expireTime) {
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, "1", expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }
    /**
     * 释放锁
     */
    private void releaseLock(String key) {
        stringRedisTemplate.delete(key);
    }
}

全局异常处理

package com.example.idempotent.handler;
import com.example.idempotent.common.Result;
import com.example.idempotent.exception.IdempotentException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(IdempotentException.class)
    public Result<?> handleIdempotentException(IdempotentException e) {
        log.warn("幂等异常:{}", e.getMessage());
        return Result.error(4004, e.getMessage());
    }
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.error(500, "系统异常");
    }
}

创建Controller示例

package com.example.idempotent.controller;
import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequestMapping("/api/order")
public class OrderController {
    /**
     * 创建订单 - 使用请求头token实现幂等
     */
    @PostMapping("/create")
    @Idempotent(key = "header:token", expireTime = 30, message = "订单创建中,请勿重复提交")
    public Result<?> createOrder(@RequestBody OrderRequest request) {
        // 模拟业务处理
        log.info("创建订单:{}", request);
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Map<String, Object> result = new HashMap<>();
        result.put("orderId", "ORDER" + System.currentTimeMillis());
        result.put("orderNo", request.getOrderNo());
        return Result.success(result);
    }
    /**
     * 支付订单 - 使用订单号作为幂等标识
     */
    @PostMapping("/pay")
    @Idempotent(key = "#orderNo", expireTime = 60, message = "支付处理中,请勿重复操作")
    public Result<?> payOrder(@RequestParam String orderNo) {
        log.info("支付订单:{}", orderNo);
        Map<String, Object> result = new HashMap<>();
        result.put("orderNo", orderNo);
        result.put("status", "PAID");
        return Result.success(result);
    }
    /**
     * 退款 - 使用用户ID和订单号组合
     */
    @PostMapping("/refund")
    @Idempotent(key = "requestId", expireTime = 30)
    public Result<?> refund(@RequestParam String requestId,
                           @RequestParam String orderNo,
                           @RequestParam Double amount) {
        log.info("退款请求:requestId={}, orderNo={}, amount={}", requestId, orderNo, amount);
        Map<String, Object> result = new HashMap<>();
        result.put("refundId", "REF" + System.currentTimeMillis());
        result.put("orderNo", orderNo);
        result.put("amount", amount);
        return Result.success(result);
    }
    /**
     * 测试并发场景
     */
    @PostMapping("/concurrent")
    @Idempotent(key = "header:requestId", expireTime = 10, message = "请求正在处理中")
    public Result<?> concurrentTest(@RequestBody Map<String, Object> params) {
        log.info("处理请求:{}", params);
        Map<String, Object> result = new HashMap<>();
        result.put("code", "SUCCESS");
        result.put("data", params);
        return Result.success(result);
    }
    // 内部类
    public static class OrderRequest {
        private String orderNo;
        private String productId;
        private Integer quantity;
        // getter/setter省略...
    }
}

Redis配置

package com.example.idempotent.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // 使用String序列化器处理key
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        // 使用JSON序列化器处理value
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

自定义Token生成工具

package com.example.idempotent.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Component
public class TokenUtil {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 生成幂等token并存储在Redis
     */
    public String generateToken() {
        String token = UUID.randomUUID().toString().replace("-", "");
        // 存储token到Redis,设置30分钟过期
        stringRedisTemplate.opsForValue().set(token, "1", 30, TimeUnit.MINUTES);
        return token;
    }
    /**
     * 校验并删除token(一次性使用)
     */
    public boolean validateAndRemoveToken(String token) {
        Boolean existed = stringRedisTemplate.hasKey(token);
        if (Boolean.TRUE.equals(existed)) {
            stringRedisTemplate.delete(token);
            return true;
        }
        return false;
    }
}

测试用例

package com.example.idempotent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IdempotentTest {
    @Autowired
    private TestRestTemplate restTemplate;
    /**
     * 测试相同token的幂等性
     */
    @Test
    public void testIdempotentWithSameToken() {
        String url = "http://localhost:8080/api/order/create";
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("token", "test-token-001");
        Map<String, Object> body = new HashMap<>();
        body.put("orderNo", "ORDER-TEST-001");
        body.put("productId", "P001");
        body.put("quantity", 1);
        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
        // 第一次请求
        ResponseEntity<String> firstResponse = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
        System.out.println("第一次请求结果: " + firstResponse.getBody());
        // 第二次相同请求(在过期时间内)
        ResponseEntity<String> secondResponse = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
        System.out.println("第二次请求结果: " + secondResponse.getBody());
    }
    /**
     * 测试并发场景
     */
    @Test
    public void testConcurrentRequests() throws InterruptedException {
        String url = "http://localhost:8080/api/order/concurrent";
        int threadCount = 10;
        CountDownLatch latch = new CountDownLatch(threadCount);
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        for (int i = 0; i < threadCount; i++) {
            final int threadNum = i;
            executor.submit(() -> {
                try {
                    HttpHeaders headers = new HttpHeaders();
                    headers.setContentType(MediaType.APPLICATION_JSON);
                    headers.set("requestId", "concurrent-test-001");
                    Map<String, Object> body = new HashMap<>();
                    body.put("thread", threadNum);
                    body.put("timestamp", System.currentTimeMillis());
                    HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
                    ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
                    System.out.println("线程" + threadNum + "请求结果: " + response.getBody());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        executor.shutdown();
    }
}

使用场景说明

前端调用示例

// 获取token
async function getToken() {
    const response = await fetch('/api/token/generate');
    const data = await response.json();
    return data.data;
}
// 创建订单(携带token)
async function createOrder() {
    const token = await getToken();
    const response = await fetch('/api/order/create', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'token': token
        },
        body: JSON.stringify({
            orderNo: 'ORDER123',
            productId: 'P001',
            quantity: 1
        })
    });
    return response.json();
}

配置application.yml

spring:
  redis:
    host: localhost
    port: 6379
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 8
        max-wait: -1ms
        max-idle: 8
        min-idle: 0

注意事项

  1. Redis可用性:确保Redis服务稳定,避免单点故障
  2. 过期时间设置:根据业务场景合理设置过期时间
  3. Key设计:要保证key的唯一性和可读性
  4. 异常处理:处理好Redisson异常,避免业务中断
  5. 性能考虑:精简AOP逻辑,避免过多Redis交互

这个方案能有效防止用户重复提交、网络重试等情况导致的重复处理问题。

抱歉,评论功能暂时关闭!