一个注解解决接口幂等问题
在现代软件架构设计中,接口的幂等性、限流策略、以及重复请求机制是确保系统稳定性和高效性的几大基石。是保证系统稳定、高效运行的重要手段,尤其是在高并发和分布式系统中。正确应用这些策略可以显著提升系统的可靠性和用户体验。 接下来我将用一系列文章来分别介绍通过注解+
AOP
来实现幂等、限流、重复提交等。
如果您觉得本文对您有帮助,请 👍
➕ 关注
,非常感谢(🙏🙏🙏)您的支持。
概念
接口幂等性是指一个接口无论调用多少次,其结果和影响都是一致的,给客户端的响应也是相同的,这样可以避免因网络重传、误操作等原因导致的数据不一致或错误。 实现接口幂等主要是为了保障系统的稳定性和数据的一致性,尤其是在面对不可预测的网络环境和用户行为时。
为什么要实现幂等
- 网络不确定性:在网络传输中,数据包可能丢失、延迟或重复发送,导致客户端不确定请求是否成功,可能重试请求,这时接口幂等可以确保重试不会产生额外的副作用。
- 用户误操作:用户可能因为误点击或软件的自动重试机制导致同一操作被多次触发,幂等性可以保护用户免受因此产生的不良后果,如重复购买、多次扣费等。
- 系统重试机制:在分布式系统中,为了提高可靠性,经常会有请求重试的机制。幂等性确保重试时不会重复执行业务逻辑。
- 异步处理和消息队列:使用消息队列进行异步处理时,消息可能因故障而被重新投递,幂等性可以确保消息即使被多次处理也不会影响最终结果。
常见的场景
- 支付接口:支付操作是最典型的需要幂等性的场景,确保用户不会因重复请求而被多次扣款。
- 订单创建:防止用户点击多次“提交订单”按钮导致创建多个相同的订单。
- 资金转账:确保转账操作不会因为网络问题或用户重复操作而发生多次转账。
- 库存操作:减少库存或增加库存的操作,重复操作可能导致库存数量错误。
- 用户注册/登录:虽然通常这些操作本身具备一定的幂等性(如登录失败重试),但在更复杂的认证流程中,幂等性依然重要,避免用户被多次创建或认证状态混乱。
- 消息发送:特别是通知类消息,重复发送可能对用户造成骚扰或信息误导。
接口幂等实现
为保证系统高可用,接口服务必须能支持同一请求多次调用,尤其对数据有改变的操作,如:增加、修改、删除。需要保证同一次业务处理多次调用时,其效果要求与调用一次是相同的。
接下来我们用注解+AOP给出一个实现接口幂等的列子。
-
- 定义一个注解类
Idempotent
,示例代码如下:
- 定义一个注解类
/**
* @author 公众号-吴农软语
*/
@Inherited
@Target(ElementType.METHOD)
@Retention(value = RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等操作的唯一标识,使用spring el表达式 用#来引用方法参数
* @return Spring-EL expression
*/
String key() default "";
/**
* 有效期 默认:1 有效期要大于程序执行时间,否则请求还是可能会进来
* @return expireTime
*/
int expireTime() default 1;
/**
* 时间单位 默认:s
* @return TimeUnit
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 提示信息,可自定义
* @return String
*/
String info() default "重复请求,请稍后重试";
/**
* 是否在业务完成后删除key true:删除 false:不删除
* @return boolean
*/
boolean delKey() default false;
}
idempotent 注解 配置详细说明
a. 幂等操作的唯一标识,使用Spring EL
表达式 用#来引用方法参数 。 可为空则取 当前 url
+ args
做表示
String key();
b. 有效期 默认:1 有效期要大于程序执行时间,否则请求还是可能会进来
int expireTime() default 1;
c. 时间单位 默认:s (秒)
TimeUnit timeUnit() default TimeUnit.SECONDS;
d.幂等失败提示信息,可自定义
String info() default "请稍后重试";
e. 是否在业务完成后删除key true:删除 false:不删除
boolean delKey() default false;
- 2)定义获取幂等Key的接口类
/**
* @author 公众号-吴农软语
* 幂等唯一标志(Key)处理接口
*/
public interface KeyResolver {
/**
* 解析处理 key
*
* @param idempotent 接口注解标识
* @param point 接口切点信息
* @return 处理结果
*/
String resolver(Idempotent idempotent, JoinPoint point);
}
-
- 上述接口的实现
/**
* @author 公众号-吴农软语
* 从注解的方法的参数中解析出用于幂等性处理的键值(key)
*/
public class ExpressionResolver implements KeyResolver {
private static final SpelExpressionParser PARSER = new SpelExpressionParser();
private static final LocalVariableTableParameterNameDiscoverer DISCOVERER = new LocalVariableTableParameterNameDiscoverer();
@Override
public String resolver(Idempotent idempotent, JoinPoint point) {
//获取被拦截方法的所有参数
Object[] arguments = point.getArgs();
//从字节码的局部变量表中解析出参数名称
String[] params = DISCOVERER.getParameterNames(getMethod(point));
//SpEL表达式执行的上下文环境,用于存放变量
StandardEvaluationContext context = new StandardEvaluationContext();
//遍历方法参数名和对应的参数值,将它们一一绑定到StandardEvaluationContext中。
//这样SpEL表达式就可以引用这些参数值
if (params != null && params.length > 0) {
for (int len = 0; len < params.length; len++) {
context.setVariable(params[len], arguments[len]);
}
}
//使用SpelExpressionParser来解析Idempotent注解中的key属性,将其作为SpEL表达式字符串
Expression expression = PARSER.parseExpression(idempotent.key());
//转换结果为String类型返回
return expression.getValue(context, String.class);
}
/**
* 根据切点解析方法信息
*
* @param joinPoint 切点信息
* @return Method 原信息
*/
private Method getMethod(JoinPoint joinPoint) {
//将joinPoint.getSignature()转换为MethodSignature
//Signature是AOP中表示连接点签名的接口,而MethodSignature是它的具体实现,专门用于表示方法的签名。
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
//获取到方法的声明。这将返回代理对象所持有的方法声明。
Method method = signature.getMethod();
//判断获取到的方法是否属于一个接口
//因为在Java中,当通过Spring AOP或其它代理方式调用接口的方法时,实际被执行的对象是一个代理对象,直接获取到的方法可能来自于接口声明而不是实现类。
if (method.getDeclaringClass().isInterface()) {
try {
//通过反射获取目标对象的实际类(joinPoint.getTarget().getClass())中同名且参数类型相同的方法
//这样做是因为代理类可能对方法进行了增强,直接调用实现类的方法可以确保获取到最准确的实现细节
method = joinPoint.getTarget().getClass().getDeclaredMethod(joinPoint.getSignature().getName(),
method.getParameterTypes());
} catch (SecurityException | NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
return method;
}
}
-
- 自定义幂等异常类
/**
* @author 公众号-吴农软语
*/
public class IdempotentException extends RuntimeException {
public IdempotentException() {
super();
}
public IdempotentException(String message) {
super(message);
}
public IdempotentException(String message, Throwable cause) {
super(message, cause);
}
public IdempotentException(Throwable cause) {
super(cause);
}
protected IdempotentException(String message, Throwable cause, boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
- 5)幂等性切面(
AOP
)实现类
/**
* @author 公众号-吴农软语
*/
@Aspect
@Slf4j
public class IdempotentAspect {
private static final ThreadLocal<Map<String, Object>> THREAD_CACHE = ThreadLocal.withInitial(HashMap::new);
private static final String KEY = "key";
private static final String DEL_KEY = "delKey";
@Resource
private Redisson redisson;
@Resource
private KeyResolver keyResolver;
@Pointcut("@annotation(com.XXX.XXX.Idempotent)") //Idempotent注解的包路径
public void pointCut() {
}
@Before("pointCut()")
public void beforePointCut(JoinPoint joinPoint) {
//获取到当前请求的属性,进而得到HttpServletRequest对象,以便后续获取请求URL和参数信息。
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
//从JoinPoint中获取方法签名,并确认该方法是否被@Idempotent注解标记。如果是,则继续执行幂等性检查逻辑;如果不是,则直接返回,不进行幂等处理。。
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
if (!method.isAnnotationPresent(Idempotent.class)) {
return;
}
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String key;
// 若没有配置 幂等 标识编号,则使用 url + 参数列表作为区分;如果提供了key规则,则利用keyResolver根据提供的规则和切点信息生成键
if (!StringUtils.hasLength(idempotent.key())) {
String url = request.getRequestURL().toString();
String argString = Arrays.asList(joinPoint.getArgs()).toString();
key = url + argString;
} else {
// 使用jstl 规则区分
key = keyResolver.resolver(idempotent, joinPoint);
}
//从注解中读取并设置幂等操作的过期时间、描述信息、时间单位以及是否删除键的标志。
long expireTime = idempotent.expireTime();
String info = idempotent.info();
TimeUnit timeUnit = idempotent.timeUnit();
boolean delKey = idempotent.delKey();
//尝试从RMapCache(基于Redis的并发安全映射缓存)中获取键对应的值,如果存在,则说明重复操作,抛出IdempotentException
RMapCache<String, Object> rMapCache = redisson.getMapCache(CommonConstants.RMAP_CACHE_KEY);
String value = LocalDateTime.now().toString().replace("T", " ");
Object v1;
if (null != rMapCache.get(key)) {
throw new IdempotentException(info);
}
//使用synchronized关键字保证多线程环境下操作的原子性,然后使用putIfAbsent方法尝试添加键值对到缓存中,若添加成功(即返回null),则记录日志并继续执行业务逻辑;若添加失败(即键已存在),同样抛出IdempotentException。
synchronized (this) {
v1 = rMapCache.putIfAbsent(key, value, expireTime, timeUnit);
if (null != v1) {
throw new IdempotentException(info);
} else {
log.info("[idempotent]:has stored key={},value={},expireTime={}{},now={}", key, value, expireTime,
timeUnit, LocalDateTime.now().toString());
}
}
//将幂等键和是否删除键的标志存储到THREAD_CACHE(线程局部变量)中,供后续afterPointCut方法使用
Map<String, Object> map = THREAD_CACHE.get();
map.put(KEY, key);
map.put(DEL_KEY, delKey);
}
@After("pointCut()")
public void afterPointCut(JoinPoint joinPoint) {
//尝试从THREAD_CACHE(线程局部变量)中获取之前存储的幂等相关信息,如果为空或者不存在,则直接返回,不做进一步处理
Map<String, Object> map = THREAD_CACHE.get();
if (CollectionUtils.isEmpty(map)) {
return;
}
//检查RMapCache(基于Redis的并发安全映射缓存)是否为空,如果为空(即没有任何条目),表明无需执行任何清理动作,直接返回
RMapCache<Object, Object> mapCache = redisson.getMapCache(CommonConstants.RMAP_CACHE_KEY);
if (mapCache.size() == 0) {
return;
}
//检查RMapCache(基于Redis的并发安全映射缓存)是否为空,如果为空(即没有任何条目),表明无需执行任何清理动作,直接返回
String key = map.get(KEY).toString();
boolean delKey = (boolean) map.get(DEL_KEY);
if (delKey) {
mapCache.fastRemove(key);
log.info("[idempotent]:has removed key={}", key);
}
//无论是否移除了键,最后都会清空当前线程局部变量THREAD_CACHE中的数据,避免内存泄漏
THREAD_CACHE.remove();
}
}
- 6)自定义一个
Spring
条件类
定义这个类的目的是,对于有些服务不需要做幂等校验,通过这个条件类来控制幂等是否生效,比如:如果是gateway,那就不生效幂等。
/**
* @author 公众号-吴农软语
*/
public class IdempotentCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
//如果服务配置文件中配置spring.cloud.gateway属性,则返回flase,否则为true
return !context.getEnvironment().containsProperty("spring.cloud.gateway") ;
}
}
-
- 自定义一个Spring自动配置类,幂等插件初始化
/**
* @author 公众号-吴农软语
*/
//条件评估为true时,该配置类中的bean才会被注册到Spring容器中
@Conditional(IdempotentCondition.class)
//proxyBeanMethods = false表示不使用代理来调用配置类中的bean方法,这样可以提高性能,但如果你在配置类内部依赖于其他@Bean方法的副作用,则需谨慎使用。
@Configuration(proxyBeanMethods = false)
//在RedisAutoConfiguration之后自动配置本类
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class IdempotentAutoConfiguration {
/**
* 切面 拦截处理所有 @Idempotent
* @return Aspect
*/
@Bean
public IdempotentAspect idempotentAspect() {
return new IdempotentAspect();
}
/**
* key 解析器
* @return KeyResolver
*/
@Bean
@ConditionalOnMissingBean(KeyResolver.class)
public KeyResolver keyResolver() {
return new ExpressionResolver();
}
}
至此,注解+AOP
实现幂等的示例代码已完成。
幂等组件使用示例代码
在需要实现幂等的借口上添加以下注解:
@Idempotent(key = "#user.id", expireTime = 5, timeUnit=imeUnit.S>ECONDS,info = "请勿重复查询",delKey= true)
使用示例代码如下:
/**
* Map
*
* @param request 请求
* @param response 响应
* @param millis 延时,毫秒
* @return 返回 Map
*/
@SneakyThrows
@Idempotent(key = "#user.id", expireTime = 5, timeUnit=imeUnit.SECONDS,info = "请勿重复查询",delKey= true)
@RequestMapping("/user")
public Map<String, Object> map(HttpServletRequest request, HttpServletResponse response, Long millis,User user) {
if (millis != null) {
// 延时
Thread.sleep(millis);
}
Map<String, Object> map = new HashMap<>(8);
map.put("uuid", UUID.randomUUID().toString());
log.info(String.valueOf(map));
return map;
}
————Then End————
本次分享就结束,如果你觉得文章对你有帮助,请点击右下角“在看”,如果想了解更多的IT知识,关注公众号“吴农软语”。
转载自:https://juejin.cn/post/7374616052947157044