1. 概述
1.1 重复提交的问题
用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。
1.2 解决思路
从本质上来说,idempotent
包提供的幂等特性,本质上也是基于 Redis 实现的分布式锁。
- 在方法执行前,根据参数对应的 Key 查询是否存在。
- 如果
存在
,说明正在执行中,则进行报错。 - 如果
不在
,则计算参数对应的 Key,存储到 Redis 中,并设置过期时间,即标记正在执行中。
默认参数的 Redis Key 的计算规则使用MD5(方法名 + 方法参数)
,避免 Redis Key 过长。
-
方法执行完成,不会主动删除参数对应的 Key。
-
如果方法执行时间较长,超过 Key 的过期时间,则 Redis 会自动删除对应的 Key。因此,需要大概评估下,避免方法的执行时间超过过期时间。
2. 具体实现
2.1 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.18</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.2 自定义注解
IdempotentAspect
@Target(value = {ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 超时时间,默认一秒
*/
int timeout() default 1;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 重复提示
*/
String message() default "重复提交";
/**
* key解析器
*/
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
/**
* 使用的key参数
*/
String keyArg() default "";
}
2.3 切入点
@Aspect
@Slf4j
@Component
public class IdempotentAspect {
/**
* IdempotentKeyResolver 集合
*/
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
private final IdempotentRedisDAO idempotentRedisDAO;
public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> resolverHashMap = new HashMap<>();
for (IdempotentKeyResolver keyResolver : keyResolvers) {
resolverHashMap.put(keyResolver.getClass(), keyResolver);
}
this.keyResolvers = resolverHashMap;
this.idempotentRedisDAO = idempotentRedisDAO;
}
@Before("@annotation(idempotent)")
public void beforePointCut(JoinPoint joinPoint, Idempotent idempotent) {
// 获得 IdempotentKeyResolver
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
// 解析 Key
String key = keyResolver.resolver(joinPoint, idempotent);
// 锁定 Key。
boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
// 锁定失败,抛出异常
if (!success) {
log.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
}
}
}
2.4 解析器接口
public interface IdempotentKeyResolver {
/**
* 解析一个 Key
*
* @param idempotent 幂等注解
* @param joinPoint AOP 切面
* @return Key
*/
String resolver(JoinPoint joinPoint, Idempotent idempotent);
}
2.5 默认解析器
@Component
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
return SecureUtil.md5(methodName + argsStr);
}
}
2.6 SPEL解析器
@Component
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();
private final ExpressionParser expressionParser = new SpelExpressionParser();
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
// 获得被拦截方法参数名列表
Method method = getMethod(joinPoint);
Object[] args = joinPoint.getArgs();
String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
// 准备 Spring EL 表达式解析的上下文
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
if (ArrayUtil.isNotEmpty(parameterNames)) {
for (int i = 0; i < parameterNames.length; i++) {
evaluationContext.setVariable(parameterNames[i], args[i]);
}
}
// 解析参数
Expression expression = expressionParser.parseExpression(idempotent.keyArg());
return expression.getValue(evaluationContext, String.class);
}
private static Method getMethod(JoinPoint point) {
// 处理,声明在类上的情况
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
if (!method.getDeclaringClass().isInterface()) {
return method;
}
// 处理,声明在接口上的情况
try {
return point.getTarget().getClass().getDeclaredMethod(
point.getSignature().getName(), method.getParameterTypes());
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
}
2.7 redis存入
@Component
@AllArgsConstructor
public class IdempotentRedisDAO {
/**
* 幂等操作
*
* KEY 格式:idempotent:%s // 参数为 uuid
* VALUE 格式:String
* 过期时间:不固定
*/
private static final String IDEMPOTENT = "idempotent:%s";
private final StringRedisTemplate redisTemplate;
public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
String redisKey = formatKey(key);
return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
}
private static String formatKey(String key) {
return String.format(IDEMPOTENT, key);
}
}
2.8 使用
@Service
public class OrderService {
@Idempotent(keyResolver = ExpressionIdempotentKeyResolver.class, timeout = 60, keyArg = "#userId + '-' + #productId")
public String createOrder(int userId, int productId) {
// 创建订单的业务逻辑
return "成功";
}
}
2.9 两种解析器
DefaultIdempotentKeyResolver
:- 默认实现 :DefaultIdempotentKeyResolver 是默认的键解析器,它是最简单的实现方式。
- 解析方式 :它使用方法名和方法参数组合成字符串,然后通过 MD5 进行哈希处理,生成唯一的关键信息。
- 固定逻辑 :它的解析逻辑是固定的,无法根据具体的业务需求进行自定义。它适合那些不需要复杂关键信息逻辑的幂等操作。
ExpressionIdempotentKeyResolver
:- 自定义实现 :ExpressionIdempotentKeyResolver 允许开发者使用 Spring EL 表达式来自定义关键信息的生成方式。
- 解析方式 :它根据 idempotent.keyArg() 表达式来动态生成关键信息。开发者可以在表达式中使用方法参数、返回值或其他上下文信息。
- 灵活性 :它非常灵活,允许根据具体需求定义复杂的关键信息生成逻辑。这对于需要根据不同的方法或业务场景生成不同关键信息的情况非常有用。
在上述示例中,createOrder
方法被标记为 @Idempotent
,并且使用 ExpressionIdempotentKeyResolver
作为键解析器,同时指定了一个 Spring EL 表达式 #userId + '-' + #productId
作为 keyArg
。
这里,我们使用 Spring EL 表达式来动态生成关键信息,以确保对于每个不同的 userId
和 productId
组合,都会生成不同的幂等性关键。这样,即使相同的用户和产品尝试多次调用 createOrder
方法,只有第一次调用会生效,后续的调用会被幂等性控制机制拦截。