幂等组件开发设计

465 阅读7分钟

幂等问题

先说下什么是幂等,幂等性是数学和计算机科学中的概念,用于描述操作无论执行多少次,都产生相同结果的特性。在软件行业中,广泛应用该概念。当我们说一个接口支持幂等性时,无论调用多少次,系统的结果都保持一致。

开发中主要有两种场景:接口幂等、消息消费幂等

方案:分布式锁、Token令牌、去重表

幂等组件设计

设计思路:

image.png

首先定义幂等注解:

作用在方法上、运行时有效

 /**
  * 幂等注解
  *
  */
 @Target({ElementType.TYPE, ElementType.METHOD})
 @Retention(RetentionPolicy.RUNTIME)
 @Documented
 public @interface Idempotent {
 ​
     /**
      * 幂等Key,只有在 {@link Idempotent#type()} 为 {@link IdempotentTypeEnum#SPEL} 时生效
      */
     String key() default "";
 ​
     /**
      * 触发幂等失败逻辑时,返回的错误提示信息
      */
     String message() default "您操作太快,请稍后再试";
 ​
     /**
      * 验证幂等场景,支持多种 {@link IdempotentSceneEnum}
      */
     IdempotentSceneEnum scene() default IdempotentSceneEnum.RESTAPI;
 ​
     /**
      * 验证幂等类型,支持多种幂等方式
      * RestAPI 建议使用 {@link IdempotentTypeEnum#TOKEN} 或 {@link IdempotentTypeEnum#PARAM}
      * 其它类型幂等验证,使用 {@link IdempotentTypeEnum#SPEL}
      */
     IdempotentTypeEnum type() default IdempotentTypeEnum.PARAM;
 ​
     /**
      * 设置防重令牌 Key 前缀,MQ 幂等去重可选设置
      * {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
      */
     String uniqueKeyPrefix() default "";
 ​
     /**
      * 设置防重令牌 Key 过期时间,单位秒,默认 1 小时,MQ 幂等去重可选设置
      * {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
      */
     long keyTimeout() default 3600L;
 }

幂等AOP

需要AOP就是拿到幂等注解修饰的方法,进行前置和后置处理(释放资源或者锁之类的)

就是拿到方法上的注解实例,然后在工厂中返回具体的策略模式处理器

 /**
  * 幂等注解 AOP 拦截器
  *
  */
 @Aspect
 public final class IdempotentAspect {
 ​
     /**
      * 增强方法标记 {@link Idempotent} 注解逻辑
      */
     @Around("@annotation(org.opengoofy.index12306.framework.starter.idempotent.annotation.Idempotent)")
     public Object idempotentHandler(ProceedingJoinPoint joinPoint) throws Throwable {
         // 获取到方法上的幂等注解实际数据
         Idempotent idempotent = getIdempotent(joinPoint);
         // 通过幂等场景以及幂等类型,获取幂等执行处理器
         IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type());
         Object resultObj;
         try {
             // 执行幂等处理逻辑
             instance.execute(joinPoint, idempotent);
             // 如果幂等处理逻辑没有抛异常,处理中间业务
             resultObj = joinPoint.proceed();
             // 处理幂等后置逻辑,比如释放资源或者锁之类的
             instance.postProcessing();
         } catch (RepeatConsumptionException ex) {
             /**
              * 该异常为消息队列防重复提交独有,触发幂等逻辑时可能有两种情况:
              *    * 1. 消息还在处理,但是不确定是否执行成功,那么需要返回错误,方便 RocketMQ 再次通过重试队列投递
              *    * 2. 消息处理成功了,该消息直接返回成功即可
              */
             if (!ex.getError()) {
                 return null;
             }
             throw ex;
         } catch (Throwable ex) {
             // 客户端消费存在异常,需要删除幂等标识方便下次 RocketMQ 再次通过重试队列投递
             instance.exceptionProcessing();
             throw ex;
         } finally {
             // 清理幂等容器上下文
             IdempotentContext.clean();
         }
         return resultObj;
     }
 ​
     public static Idempotent getIdempotent(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
         MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
         Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
         return targetMethod.getAnnotation(Idempotent.class);
     }
 }

简单工厂获取幂等处理器

 /**
  * 幂等执行处理器工厂
  * <p>
  * Q:可能会有同学有疑问:这里为什么要采用简单工厂模式?策略模式不行么?
  * A:策略模式同样可以达到获取真正幂等处理器功能。但是简单工厂的语意更适合这个场景,所以选择了简单工厂
  *
  */
 public final class IdempotentExecuteHandlerFactory {
 ​
     /**
      * 获取幂等执行处理器
      *
      * @param scene 指定幂等验证场景类型
      * @param type  指定幂等处理类型
      * @return 幂等执行处理器
      */
     public static IdempotentExecuteHandler getInstance(IdempotentSceneEnum scene, IdempotentTypeEnum type) {
         IdempotentExecuteHandler result = null;
         switch (scene) {
             case RESTAPI -> {
                 switch (type) {
                     case PARAM -> result = ApplicationContextHolder.getBean(IdempotentParamService.class);
                     case TOKEN -> result = ApplicationContextHolder.getBean(IdempotentTokenService.class);
                     case SPEL -> result = ApplicationContextHolder.getBean(IdempotentSpELByRestAPIExecuteHandler.class);
                     default -> {
                     }
                 }
             }
             case MQ -> result = ApplicationContextHolder.getBean(IdempotentSpELByMQExecuteHandler.class);
             default -> {
             }
         }
         return result;
     }
 }
 ​

其中:ApplicationContextHolder是实现了ApplicationContextAware重写setApplicationContext拿到Spring容器的上下文方便get某个类型的Bean

 /**
  * Application context holder.
  *
  */
 public class ApplicationContextHolder implements ApplicationContextAware {
 ​
     private static ApplicationContext CONTEXT;
 ​
     @Override
     public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
         ApplicationContextHolder.CONTEXT = applicationContext;
     }

幂等上下文

为什么要使用 IdempotentContext?

幂等组件中把一部分内容放到幂等上下文类,并在不同方法中进行使用,为什么这么使用?

因为如果不这么用,会有大量的参数需要在方法传参中声明以及传递,使用上下文形式可以很好规避该问题,编码较为优雅。比如释放锁资源时需要判断当前线程是否持有该锁

 /**
  * 幂等上下文
  */
 public final class IdempotentContext {
     
     private static final ThreadLocal<Map<String, Object>> CONTEXT = new ThreadLocal<>();
     
     public static Map<String, Object> get() {
         return CONTEXT.get();
     }
     
     public static Object getKey(String key) {
         Map<String, Object> context = get();
         if (CollUtil.isNotEmpty(context)) {
             return context.get(key);
         }
         return null;
     }
     
     public static String getString(String key) {
         Object actual = getKey(key);
         if (actual != null) {
             return actual.toString();
         }
         return null;
     }
     
     public static void put(String key, Object val) {
         Map<String, Object> context = get();
         if (CollUtil.isEmpty(context)) {
             context = Maps.newHashMap();
         }
         context.put(key, val);
         putContext(context);
     }
     
     public static void putContext(Map<String, Object> context) {
         Map<String, Object> threadContext = CONTEXT.get();
         if (CollUtil.isNotEmpty(threadContext)) {
             threadContext.putAll(context);
             return;
         }
         CONTEXT.set(context);
     }
     
     public static void clean() {
         CONTEXT.remove();
     }
 }

三种实现

基于分布式锁

基于分布式锁的实现,就是在方法前加分布式锁具体实现就是把获取到的分布式锁放到幂等上下文中,方法执行结束后释放分布式锁资源

执行器前置执行代码:

 @Override
     public void handler(IdempotentParamWrapper wrapper) {
         String lockKey = wrapper.getLockKey();
         RLock lock = redissonClient.getLock(lockKey);
         if (!lock.tryLock()) {
             throw new ClientException(wrapper.getIdempotent().message());
         }
         IdempotentContext.put(LOCK, lock);
     }

执行器后置执行代码:

     @Override
     public void postProcessing() {
         RLock lock = null;
         try {
             lock = (RLock) IdempotentContext.getKey(LOCK);
         } finally {
             if (lock != null) {
                 lock.unlock();
             }
         }
     }

基于Token的实现

思路就是客户端在第一次调用业务请求之前会发送一个获取 Token 的请求。服务端会生成一个全局唯一的 ID 作为 Token,并将其保存在 Redis 中,同时将该 ID 返回给客户端。

在客户端进行第二次业务请求时,必须携带这个 Token。

服务端会验证这个 Token,如果验证成功,则执行业务逻辑并从 Redis 中删除该 Token。

如果验证失败,说明 Redis 中已经没有对应的 Token,表示重复操作,服务端会直接返回指定的结果给客户端

基于Token的实现就是拿到请求头或者请求体看看是否有Token,如果有则删除缓存

执行器前置执行代码:

     @Override
     public void handler(IdempotentParamWrapper wrapper) {
         HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
         String token = request.getHeader(TOKEN_KEY);
         if (StrUtil.isBlank(token)) {
             token = request.getParameter(TOKEN_KEY);
             if (StrUtil.isBlank(token)) {
                 throw new ClientException(BaseErrorCode.IDEMPOTENT_TOKEN_NULL_ERROR);
             }
         }
         Boolean tokenDelFlag = distributedCache.delete(token);
         if (!tokenDelFlag) {
             String errMsg = StrUtil.isNotBlank(wrapper.getIdempotent().message())
                     ? wrapper.getIdempotent().message()
                     : BaseErrorCode.IDEMPOTENT_TOKEN_DELETE_ERROR.message();
             throw new ClientException(errMsg, BaseErrorCode.IDEMPOTENT_TOKEN_DELETE_ERROR);
         }
     }

基于Spel方法验证请求幂等性

image-20240408134755450

就是在消费者的类的onMessage方法上添加注解:

 @Idempotent(
             uniqueKeyPrefix = "index12306-order:pay_result_callback:",
             key = "#message.getKeys()+'_'+#message.hashCode()",
             type = IdempotentTypeEnum.SPEL,
             scene = IdempotentSceneEnum.MQ,
             keyTimeout = 7200L
 )
 public void onMessage(MessageWrapper<PayResultCallbackOrderEvent> message) {...

message对象为什么用MessageWrapper封装了一层,

通过请求入参 message 对象,获取属性 keys 值(业务标识指),然后再获取 message 对象的 hashCode 值,通过 _ 的方式拼接在一起,就得到了本次请求的唯一幂等 Key

然后执行器中:

 @Override
 public void handler(IdempotentParamWrapper wrapper) {
     // 拼接前缀和 SpEL 表达式对应的 Key 生成最终放到 Redis 中的唯一标识
     String uniqueKey = wrapper.getIdempotent().uniqueKeyPrefix() + wrapper.getLockKey();
     // 向 Redis 触发命令,如果值不存在则存储返回 True,值存在返回 False
     Boolean setIfAbsent = ((StringRedisTemplate) distributedCache.getInstance())
             .opsForValue()
             .setIfAbsent(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMING.getCode(), TIMEOUT, TimeUnit.SECONDS);
     // 如果值为 False,那么就代表要么消息已经执行完成了或者执行中
     // 两个不同的状态需要执行不同的逻辑
     // 为此,需要再进行判断
     if (setIfAbsent != null && !setIfAbsent) {
         // 获取幂等标识对应的值,判断是否为已执行成功
         String consumeStatus = distributedCache.get(uniqueKey, String.class);
         // 如果已经执行成功了,那么 error 为 false;执行中 error 为 true
         boolean error = IdempotentMQConsumeStatusEnum.isError(consumeStatus);
         LogUtil.getLog(wrapper.getJoinPoint()).warn("[{}] MQ repeated consumption, {}.",
                 uniqueKey,
                 error ? "Wait for the client to delay consumption" : "Status is completed");
         // 将异常抛出到上层
         throw new RepeatConsumptionException(error);
     }
     IdempotentContext.put(WRAPPER, wrapper);
 }

获取StringRedisTemplate的实例,使用setIfAbsent是否能设置成功,可以设置成功则之前没有消费过,将key放到幂等的上下文中,如果不能设置成功,那么就代表要么消息已经执行完成了或者执行中,两个不同的状态需要执行不同的逻辑

StringRedisTemplate中获取幂等标识对应的值,根据注解的状态判断是否消费成功,并向上抛出,根据抛出异常的error变量来决定让 RocketMQ 重试,还是将该消息吞掉,不执行具体的消费流程

这种本质上还是基于缓存