Spring Boot实战:基于策略模式+代理模式手写幂等性注解组件

一、为什么需要幂等性?

核心定义:在分布式系统中,一个操作无论执行一次还是多次,最终结果都保持一致。
典型场景

  • 用户重复点击提交按钮
  • 网络抖动导致的请求重试
  • 消息队列的重复消费
  • 支付系统的回调通知

不处理幂等的风险

  • 重复创建订单导致资金损失
  • 库存超卖引发资损风险
  • 用户数据重复插入破坏业务逻辑

二、实现步骤分解

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哈希值)*/@Overridepublic 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哈希计算 -> 压缩存储空间*/@Overridepublic 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请求环境中使用,未登录时可能返回nullLong 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*/@Overridepublic 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 {@Beanpublic IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {return new IdempotentAspect(keyResolvers, idempotentRedisDAO);}@Beanpublic IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {return new IdempotentRedisDAO(stringRedisTemplate);}// ========== 各种 IdempotentKeyResolver Bean ==========@Beanpublic DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {return new DefaultIdempotentKeyResolver();}@Beanpublic UserIdempotentKeyResolver userIdempotentKeyResolver() {return new UserIdempotentKeyResolver();}@Beanpublic ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {return new ExpressionIdempotentKeyResolver();}}

三、核心设计模式解析

1. 策略模式(核心设计)

应用场景:多种幂等Key生成策略的动态切换
代码体现

// 策略接口
public interface IdempotentKeyResolver {String resolver(JoinPoint joinPoint, Idempotent idempotent);
}// 具体策略实现
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {@Overridepublic String resolver(...) { /* MD5(方法+参数) */ }
}public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {@Overridepublic String resolver(...) { /* SpEL解析 */ }
}

UML图示

«interface»
IdempotentKeyResolver
+resolver(JoinPoint, Idempotent) : String
DefaultKeyResolver
ExpressionKeyResolver
UserKeyResolver

2. 代理模式(AOP实现)

应用场景:通过动态代理实现无侵入的幂等控制
代码体现

@Aspect
public class IdempotentAspect {@Around("@annotation(idempotent)") // 切入点表达式public Object around(...) {// 通过代理对象控制原方法执行return joinPoint.proceed(); }
}

执行流程
客户端调用 → 代理对象拦截 → 执行幂等校验 → 调用真实方法

四、这样设计的好处

  1. 业务解耦
    • 幂等逻辑与业务代码完全分离
    • 通过注解实现声明式配置
  2. 灵活扩展
    • 新增Key策略只需实现接口
    • 支持自定义SpEL表达式
  3. 高可靠性
    • Redis原子操作防并发问题
    • 异常时自动清理Key(可配置)
  4. 性能优化
    • MD5压缩减少Redis存储压力
    • 细粒度锁控制(不同Key互不影响)
  5. 易用性
    • 开箱即用的starter组件
    • 三种内置策略覆盖主流场景

五、使用示例

@Idempotent(keyResolver = UserIdempotentKeyResolver.class,timeout = 10,message = "请勿重复提交订单"
)
public void createOrder(OrderDTO dto) {// 业务逻辑
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/web/76181.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

如何恢复极狐GitLab?

极狐GitLab 是 GitLab 在中国的发行版&#xff0c;关于中文参考文档和资料有&#xff1a; 极狐GitLab 中文文档极狐GitLab 中文论坛极狐GitLab 官网 恢复极狐GitLab (BASIC SELF) 极狐GitLab 提供了一个命令行界面来恢复整个安装&#xff0c;足够灵活以满足您的需求。 恢复…

面试高阶问题:android后台任务(如数据同步、定位)消耗过多电量,导致用户投诉。你会如何分析和优化后台任务的执行?

在现代移动设备生态中,安卓系统以其开放性和灵活性占据了全球智能手机市场的绝大部分份额。作为一款功能强大的操作系统,安卓允许应用程序在后台执行各种任务,例如数据同步、定位服务、消息推送以及其他周期性更新。这些后台任务在提升用户体验方面扮演了不可或缺的角色——…

最近在学习web搞大屏看板

人到中年&#xff0c;delphi发展越来越不行&#xff0c;就业环境是真差啊&#xff0c;没办法&#xff0c;学呗 中国地图&#xff1a; // 中国地图function getChinaMapChart() {// 初始化echarts实例var myEcharts echarts.init(document.getElementById("china_box"…

117.在 Vue 3 中使用 OpenLayers 实现 CTRL 控制拖拽和滚动缩放

✨ 前言 在使用 OpenLayers 开发地图类项目时,我们有时会希望用户必须按下 CTRL(或 Mac 的 Command ⌘ 键)才能拖拽地图或使用鼠标滚轮缩放。这种交互方式能够避免用户在浏览页面时意外滑动或拖动地图,尤其是在地图嵌入页面中时非常有用。 本文将带你一步一步实现在 Vue …

MATLAB 控制系统设计与仿真 - 34

多变量系统知识回顾 - MIMO system 这一章对深入理解多变量系统以及鲁棒分析至关重要 首先,对于如下系统: 当G(s)为单输入,单输出系统时: 如果: 则: 所以 因此,对于SISO,系统的增益跟w有关系, 当G(s)为MIMO时,例如2X2时, 假设输入信号为:

ARCGIS PRO DSK 利用两期地表DEM数据计算工程土方量

利用两期地表DEM数据计算工程土方量需要准许以下数据&#xff1a; 当前地图有3个图层&#xff0c;两个栅格图层和一个矢量图层 两个栅格图层&#xff1a;beforeDem为工程施工前的地表DEM模型 afterDem为工程施工后的地表DEM模型 一个矢量图层&#xf…

最快打包WPF 应用程序

在 Visual Studio 中右键项目选择“发布”&#xff0c;目标选“文件夹”&#xff0c;模式选“自包含”&#xff0c;生成含 .exe 的文件夹&#xff0c;压缩后可直接发给别人或解压运行&#xff0c;无需安装任何东西。 最简单直接的新手做法&#xff1a; 用 Visual Studio 的“…

物联网通信协议——TCP与MQTT的对比

在物联网通信中&#xff0c;MQTT和TCP的实现方式和原理完全不同&#xff0c;因为两者属于协议栈的不同层级&#xff0c;解决的问题也不同。以下从协议层级、工作机制和典型场景三个角度详细解释&#xff1a; 1. 协议层级与定位 特性TCPMQTT协议层级传输层&#xff08;第4层&am…

【信息系统项目管理师】高分论文:论信息系统项目的成本管理(媒体融合采编平台)

更多内容请见: 备考信息系统项目管理师-专栏介绍和目录 文章目录 论文1、规划项目成本管理2、估算成本3、制订项目预算4、控制成本论文 2017年7月,我作为项目经理参与了 XX省媒体融合采编平台的建设,该项目总共投资530万元,其中服务器、存储、网络等硬件设备投资200万元、软…

策略模式简单介绍

什么是策略模式&#xff1f;一般用于什么场景&#xff1f; 策略模式一种行为型设计模式&#xff0c;它定义了一系列算法&#xff0c;并将每个算法封装起来&#xff0c;使得它们可以相互替换&#xff0c;这样&#xff0c;客户端可以根据需要在运行时选择合适的算法&#xff0c;…

基于PAI+专属网关+私网连接:构建全链路 Deepseek 云上私有化部署与模型调用架构

DeepSeek - R1 是由深度求索公司推出的首款推理模型&#xff0c;该模型在数学、代码和推理任务上的表现优异&#xff0c;市场反馈火爆。在大模型技术商业化进程中&#xff0c;企业级用户普遍面临四大核心挑战&#xff1a; 算力投入成本高昂&#xff1a;构建千亿参数级模型的训…

【APM】How to enable Trace to Logs on Grafana?

系列文章目录 【APM】Observability Solution 【APM】Build an environment for Traces, Metrics and Logs of App by OpenTelemetry 【APM】NET Traces, Metrics and Logs to OLTP 【APM】How to enable Trace to Logs on Grafana? 前言 本文将介绍如何在Grafana上启用 …

在 Excel 中使用通义灵码辅助开发 VBA 程序

VBA 简介 VBA 是一种用于微软办公套件&#xff08;如 Word、Excel、PowerPoint 等&#xff09;的编程语言&#xff0c;它本质上是一种内嵌的脚本&#xff0c;或者可以认为是一段命令&#xff0c;其标准叫法被称为宏。 VBA 只能依赖于对应的软件进行开发&#xff0c;例如本文就…

vscode终端运行windows服务器的conda出错

远程windows服务器可以运行&#xff0c;本地vscode不能。 打开vscode settings.json文件 添加conda所在路径

紫外相机的应用范围及介绍

&#xff08;一&#xff09;工业领域 半导体制造&#xff1a;在晶圆制造和检测过程中&#xff0c;紫外相机起着关键作用。它可用于裸晶圆检测&#xff0c;能准确识别出制造过程中偶然引入的微粒&#xff08;如灰尘&#xff09;或因处理不当造成的划痕等缺陷。对于图案晶圆检查…

08软件测试需求分析案例-删除用户

删除用户是后台管理菜单的一个功能模块&#xff0c;只有admin才有删除用户的权限。不可删除admin。 1.1 通读文档 通读需求规格说明书是提取信息&#xff0c;提出问题&#xff0c;输出具有逻辑、规则、流程的业务步骤。 信息&#xff1a;此功能应为用户提供确认删除的功能。…

Oracle DBMS_SCHEDULER 与 DBMS_JOB 的对比

Oracle DBMS_SCHEDULER 与 DBMS_JOB 的对比 一 基本概述对比 特性DBMS_JOB (旧版)DBMS_SCHEDULER (新版)引入版本Oracle 7 (1992年)Oracle 10g R1 (2003年)当前状态已过时但仍支持推荐使用的标准设计目的基础作业调度企业级作业调度系统 二 功能特性对比 2.1 作业定义能力 …

Linux网络编程实战:从字节序到UDP协议栈的深度解析与开发指南

网路通信的三大要素&#xff1a;协议&#xff0c;端口和IP 知识点1【字节序】 多字节在主机中的存放数据 把多字节看成一个整体存储的顺序。 为什么我们在文件中没有这个概念呢&#xff1f; 因为文件是字节流&#xff08;流指针&#xff09;&#xff0c;流是以一个字节为操…

mvccc

. MVCC (多版本并发控制) 概念&#xff1a; MVCC 是一种并发控制技术&#xff0c;用于在数据库中实现并发事务的读写操作&#xff0c;同时保证事务的隔离性。MVCC 的核心思想是&#xff0c;在数据库中维护数据的多个版本&#xff0c;每个事务在读取数据时&#xff0c;读取的是…

Kotlin整数相除精度损失roundToInt

Kotlin整数相除精度损失roundToInt import kotlin.math.roundToIntfun main() {val a 0.0fval delta 0.1ffor (i in 0..10) {val r a i * deltaprintln("float${r} toInt${r.toInt()} (0.5 toInt)${(r 0.5).toInt()} round${Math.round(r)} roundToInt${r.roundToInt…