本文目录导读:

我将为你提供一个完整的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
注意事项
- Redis可用性:确保Redis服务稳定,避免单点故障
- 过期时间设置:根据业务场景合理设置过期时间
- Key设计:要保证key的唯一性和可读性
- 异常处理:处理好Redisson异常,避免业务中断
- 性能考虑:精简AOP逻辑,避免过多Redis交互
这个方案能有效防止用户重复提交、网络重试等情况导致的重复处理问题。