一、为什么需要幂等性?
核心定义:在分布式系统中,一个操作无论执行一次还是多次,最终结果都保持一致。
典型场景:
- 用户重复点击提交按钮
- 网络抖动导致的请求重试
- 消息队列的重复消费
- 支付系统的回调通知
不处理幂等的风险:
- 重复创建订单导致资金损失
- 库存超卖引发资损风险
- 用户数据重复插入破坏业务逻辑
二、实现步骤分解
1. 定义幂等注解
/**
* 幂等注解
*
* @author dyh
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等的超时时间,默认为 1 秒
*
* 注意,如果执行时间超过它,请求还是会进来
*/
int timeout() default 1;
/**
* 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 提示信息,正在执行中的提示
*/
String message() default "重复请求,请稍后重试";
/**
* 使用的 Key 解析器
*
* @see DefaultIdempotentKeyResolver 全局级别
* @see UserIdempotentKeyResolver 用户级别
* @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算
*/
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
/**
* 使用的 Key 参数
*/
String keyArg() default "";
/**
* 删除 Key,当发生异常时候
*
* 问题:为什么发生异常时,需要删除 Key 呢?
* 回答:发生异常时,说明业务发生错误,此时需要删除 Key,避免下次请求无法正常执行。
*
* 问题:为什么不搞 deleteWhenSuccess 执行成功时,需要删除 Key 呢?
* 回答:这种情况下,本质上是分布式锁,推荐使用 @Lock4j 注解
*/
boolean deleteKeyWhenException() default true;
}
2. 设计Key解析器接口
/**
* 幂等 Key 解析器接口
*
* @author dyh
*/
public interface IdempotentKeyResolver {
/**
* 解析一个 Key
*
* @param idempotent 幂等注解
* @param joinPoint AOP 切面
* @return Key
*/
String resolver(JoinPoint joinPoint, Idempotent idempotent);
}
3. 实现三种核心策略
- 默认策略:方法签名+参数MD5(防全局重复)
- 用户策略:用户ID+方法特征(防用户重复)
- 表达式策略:SpEL动态解析参数(灵活定制)
3.1 默认策略
/**
* 默认(全局级别)幂等 Key 解析器,使用方法名 + 方法参数,组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author dyh
*/
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
/**
* 核心方法:生成幂等Key(基于方法特征+参数内容)
* @param joinPoint AOP切入点对象,包含方法调用信息
* @param idempotent 方法上的幂等注解对象
* @return 生成的唯一幂等Key(32位MD5哈希值)
*/
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
// 获取方法完整签名(格式:返回值类型 类名.方法名(参数类型列表))
// 示例:String com.example.UserService.createUser(Long,String)
String methodName = joinPoint.getSignature().toString();
// 将方法参数数组拼接为字符串(用逗号分隔)
// 示例:参数是 [123, "张三"] 将拼接为 "123,张三"
String argsStr = StrUtil.join(",", joinPoint.getArgs());
// 将方法签名和参数字符串合并后计算MD5
// 目的:将可能很长的字符串压缩为固定长度,避免Redis Key过长
return SecureUtil.md5(methodName + argsStr);
}
}
3.2 用户策略
/**
* 用户级别的幂等 Key 解析器,使用方法名 + 方法参数 + userId + userType,组装成一个 Key
* <p>
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author dyh
*/
public class UserIdempotentKeyResolver implements IdempotentKeyResolver {
/**
* 生成用户级别的幂等Key
*
* @param joinPoint AOP切入点对象(包含方法调用信息)
* @param idempotent 方法上的幂等注解
* @return 基于用户维度的32位MD5哈希值
* <p>
* 生成逻辑分四步:
* 1. 获取方法签名 -> 标识具体方法
* 2. 拼接参数值 -> 标识操作数据
* 3. 获取用户身份 -> 隔离用户操作
* 4. MD5哈希计算 -> 压缩存储空间
*/
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
// 步骤1:获取方法唯一标识(格式:返回类型 类名.方法名(参数类型列表))
// 示例:"void com.service.UserService.updatePassword(Long,String)"
String methodName = joinPoint.getSignature().toString();
// 步骤2:将方法参数转换为逗号分隔的字符串
// 示例:参数是 [1001, "新密码"] 会拼接成 "1001,新密码"
String argsStr = StrUtil.join(",", joinPoint.getArgs());
// 步骤3:从请求上下文中获取当前登录用户ID
// 注意:需确保在Web请求环境中使用,未登录时可能返回null
Long userId = WebFrameworkUtils.getLoginUserId();
// 步骤4:获取当前用户类型(例如:0-普通用户,1-管理员)
// 作用:区分不同权限用户的操作
Integer userType = WebFrameworkUtils.getLoginUserType();
// 步骤5:将所有要素拼接后生成MD5哈希值
// 输入示例:"void updatePassword()1001,新密码1231"
// 输出示例:"d3d9446802a44259755d38e6d163e820"
return SecureUtil.md5(methodName + argsStr + userId + userType);
}
}
3.3 表达式策略
/**
* 基于 Spring EL 表达式,
*
* @author dyh
*/
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
// 参数名发现器:用于获取方法的参数名称(如:userId, orderId)
// 为什么用LocalVariableTable:因为编译后默认不保留参数名,需要这个工具读取调试信息
private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();
// 表达式解析器:专门解析Spring EL表达式
// 为什么用Spel:Spring官方标准,支持复杂表达式语法
private final ExpressionParser expressionParser = new SpelExpressionParser();
/**
* 核心方法:解析生成幂等Key
*
* @param joinPoint AOP切入点(包含方法调用信息)
* @param idempotent 方法上的幂等注解
* @return 根据表达式生成的唯一Key
*/
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
// 步骤1:获取当前执行的方法对象
Method method = getMethod(joinPoint);
// 步骤2:获取方法参数值数组(例如:[订单对象, 用户对象])
Object[] args = joinPoint.getArgs();
// 步骤3:获取方法参数名数组(例如:["order", "user"])
String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
// 步骤4:创建表达式上下文(相当于给表达式提供变量环境)
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
// 步骤5:将参数名和参数值绑定到上下文(让表达式能识别#order这样的变量)
if (ArrayUtil.isNotEmpty(parameterNames)) {
for (int i = 0; i < parameterNames.length; i++) {
// 例如:将"order"参数名和实际的Order对象绑定
evaluationContext.setVariable(parameterNames[i], args[i]);
}
}
// 步骤6:解析注解中的表达式(例如:"#order.id")
Expression expression = expressionParser.parseExpression(idempotent.keyArg());
// 步骤7:执行表达式计算(例如:从order对象中取出id属性值)
return expression.getValue(evaluationContext, String.class);
}
/**
* 辅助方法:获取实际执行的方法对象
* 为什么需要这个方法:处理Spring AOP代理接口的情况
*
* @param point AOP切入点
* @return 实际被调用的方法对象
*/
private static Method getMethod(JoinPoint point) {
// 情况一:方法直接定义在类上(非接口方法)
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
if (!method.getDeclaringClass().isInterface()) {
return method; // 直接返回当前方法
}
// 情况二:方法定义在接口上(需要获取实现类的方法)
try {
// 通过反射获取目标类(实际实现类)的方法
// 例如:UserService接口的create方法 -> UserServiceImpl的create方法
return point.getTarget().getClass().getDeclaredMethod(
point.getSignature().getName(), // 方法名
method.getParameterTypes()); // 参数类型
} catch (NoSuchMethodException e) {
// 找不到方法时抛出运行时异常(通常意味着代码结构有问题)
throw new RuntimeException("方法不存在: " + method.getName(), e);
}
}
}
4. 编写AOP切面
/**
* 拦截声明了 {@link Idempotent} 注解的方法,实现幂等操作
* 幂等切面处理器
*
* 功能:拦截被 @Idempotent 注解标记的方法,通过Redis实现请求幂等性控制
* 流程:
* 1. 根据配置的Key解析策略生成唯一标识
* 2. 尝试在Redis中设置该Key(SETNX操作)
* 3. 若Key已存在 → 抛出重复请求异常
* 4. 若Key不存在 → 执行业务逻辑
* 5. 异常时根据配置决定是否删除Key
*
* @author dyh
*/
@Aspect // 声明为AOP切面类
@Slf4j // 自动生成日志对象
public class IdempotentAspect {
/**
* Key解析器映射表(Key: 解析器类型,Value: 解析器实例)
* 示例:
* DefaultIdempotentKeyResolver.class → DefaultIdempotentKeyResolver实例
* ExpressionIdempotentKeyResolver.class → ExpressionIdempotentKeyResolver实例
*/
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
/**
* Redis操作工具类(处理幂等Key的存储)
*/
private final IdempotentRedisDAO idempotentRedisDAO;
/**
* 构造方法(依赖注入)
* @param keyResolvers 所有Key解析器的Spring Bean集合
* @param idempotentRedisDAO Redis操作DAO
*/
public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
// 将List转换为Map,Key是解析器的Class类型
this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);
this.idempotentRedisDAO = idempotentRedisDAO;
}
/**
* 环绕通知:拦截被@Idempotent注解的方法
* @param joinPoint 切入点(包含方法、参数等信息)
* @param idempotent 方法上的@Idempotent注解实例
* @return 方法执行结果
* @throws Throwable 可能抛出的异常
*
* 执行流程:
* 1. 获取Key解析器 → 2. 生成唯一Key → 3. 尝试锁定 → 4. 执行业务 → 5. 异常处理
*/
@Around(value = "@annotation(idempotent)") // 切入带有@Idempotent注解的方法
public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 步骤1:根据注解配置获取对应的Key解析器
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
// 断言确保解析器存在(找不到说明Spring容器初始化有问题)
Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
// 步骤2:使用解析器生成唯一Key(例如:MD5(方法签名+参数))
String key = keyResolver.resolver(joinPoint, idempotent);
// 步骤3:尝试在Redis中设置Key(原子性操作)
// 参数说明:
// key: 唯一标识
// timeout: 过期时间(通过注解配置)
// timeUnit: 时间单位(通过注解配置)
boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
// 步骤4:处理重复请求
if (!success) {
// 记录重复请求日志(方法签名 + 参数)
log.info("[幂等拦截] 方法({}) 参数({}) 存在重复请求",
joinPoint.getSignature().toString(),
joinPoint.getArgs());
// 抛出业务异常(携带注解中配置的错误提示信息)
throw new ServiceException(
GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(),
idempotent.message());
}
try {
// 步骤5:执行原始业务方法
return joinPoint.proceed();
} catch (Throwable throwable) {
// 步骤6:异常处理(参考美团GTIS设计)
// 配置删除策略:当deleteKeyWhenException=true时,删除Key允许重试
if (idempotent.deleteKeyWhenException()) {
// 记录删除操作日志(实际生产可添加更详细日志)
log.debug("[幂等异常处理] 删除Key: {}", key);
idempotentRedisDAO.delete(key);
}
// 继续抛出异常(由全局异常处理器处理)
throw throwable;
}
}
}
5. 实现Redis原子操作
/**
* 幂等 Redis DAO
*
* @author dyh
*/
@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);
}
public void delete(String key) {
String redisKey = formatKey(key);
redisTemplate.delete(redisKey);
}
private static String formatKey(String key) {
return String.format(IDEMPOTENT, key);
}
}
6. 自动装配
/**
* @author dyh
* @date 2025/4/17 18:08
*/
@AutoConfiguration(after = DyhRedisAutoConfiguration.class)
public class DyhIdempotentConfiguration {
@Bean
public IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
return new IdempotentAspect(keyResolvers, idempotentRedisDAO);
}
@Bean
public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {
return new IdempotentRedisDAO(stringRedisTemplate);
}
// ========== 各种 IdempotentKeyResolver Bean ==========
@Bean
public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {
return new DefaultIdempotentKeyResolver();
}
@Bean
public UserIdempotentKeyResolver userIdempotentKeyResolver() {
return new UserIdempotentKeyResolver();
}
@Bean
public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {
return new ExpressionIdempotentKeyResolver();
}
}
三、核心设计模式解析
1. 策略模式(核心设计)
应用场景:多种幂等Key生成策略的动态切换
代码体现:
// 策略接口
public interface IdempotentKeyResolver {
String resolver(JoinPoint joinPoint, Idempotent idempotent);
}
// 具体策略实现
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(...) { /* MD5(方法+参数) */ }
}
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(...) { /* SpEL解析 */ }
}
UML图示:
2. 代理模式(AOP实现)
应用场景:通过动态代理实现无侵入的幂等控制
代码体现:
@Aspect
public class IdempotentAspect {
@Around("@annotation(idempotent)") // 切入点表达式
public Object around(...) {
// 通过代理对象控制原方法执行
return joinPoint.proceed();
}
}
执行流程:
客户端调用 → 代理对象拦截 → 执行幂等校验 → 调用真实方法
四、这样设计的好处
- 业务解耦
- 幂等逻辑与业务代码完全分离
- 通过注解实现声明式配置
- 灵活扩展
- 新增Key策略只需实现接口
- 支持自定义SpEL表达式
- 高可靠性
- Redis原子操作防并发问题
- 异常时自动清理Key(可配置)
- 性能优化
- MD5压缩减少Redis存储压力
- 细粒度锁控制(不同Key互不影响)
- 易用性
- 开箱即用的starter组件
- 三种内置策略覆盖主流场景
五、使用示例
@Idempotent(
keyResolver = UserIdempotentKeyResolver.class,
timeout = 10,
message = "请勿重复提交订单"
)
public void createOrder(OrderDTO dto) {
// 业务逻辑
}