【开源项目实践】分布式重试组件的实现原理

很多人认为电商场景对于一致性要求非常高。一定有很多高深技术保证强一致性。这是一种错误的理解,电商很多业务场景只需要保证最终一致性,往往不需要强一致性。而保证最终一致性的最重要方法就是重试。(电商业务往往要求系统最终严格一致......即最终一致性 + 较强的可靠性)

业务系统中保证最终一致性的主要方式是不断地进行重试。大部分系统的问题都是由于超时导致的,而重试通常可以解决大多数问题。

下面列举 3 个场景------通过重试解决最终一致性。

  1. 会员订单履约:系统给用户发放优惠券失败时,可以通过重试(采用幂等性操作)来保证订单履约最终成功。(用户已经支付成功,难道 1 次履约超时,就给用户退钱吗?)
  2. 库存回滚: 当订单退款成功后,需要回滚库存。如果库存回滚超时,可以通过重试来保证最终回滚成功(幂等)。
  3. 库存回滚: 当订单扣减库存成功后,提单后续流程失败,需要回滚库存。在这种情况下,系统不需要等待库存回滚成功后再返回用户提单失败的结果,而是可以采用异步回滚库存的方式,如果超时,则不断进行重试。
  4. 会员订单退款:先将用户的虚拟资产退回,然后调用订单退款API出现超时。在这种情况下,不需要回滚虚拟资产的状态到正常状态,只需要重试订单退款API(一定要幂等)即可。

接下来我将详细介绍四种非常通用的重试方案。

1. Spring Retryable 框架

Spring 提供 Retryable 注解提供重试能力,通过 Retryable 注解声明的 Aop 切面,在切面抛出异常后触发重试策略,通过重试保证系统的最终一致性。

1.1 使用示例

java 复制代码
Java
 代码解读
复制代码
@Retryable(value = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2))
public int handle(int userId, String orderId) {
   //do something

   return 1;//返回结果
}

@Recover
public int recover(Exception e) {
   //最终失败的处理。一般是打印日志或者上报异常
   return 1;
}

1.2 使用方式

Retryable 配置在失败后需要重试的方法上。

  • value: 指定捕获哪类异常进行重试

  • maxAttempts: 指定最大重试次数。

  • backoff: 指定重试策略,其中

    • delay: 指定了延迟多久重试
    • multiplier: 指定了两次重试的间隔时间的乘积。例如为 2,第一次重试前间隔 100毫秒,第二次重试间隔 200毫秒,第三次间隔 400毫秒。

1.3 最终失败的处理逻辑放在 Recover 中

通过 Retryable 重试超过最大重试次数后,如果依然失败,则会调用被 Recover 修饰的方法。Recover 中不再适合再次重试,因为已经重试多次后依然失败,再重试也没意义,Recover 中上报异常到其他系统或记录到日志中心。稍后需要人工介入,排查问题。

需要注意的是无需在 Retryable 注解中声明 Recover 方法。Recover 方法只需要和 Retryable 方法在同一个类中,方法声明为 public,并且还需要满足保持返回值一致、参数列表一致。

  • 参数列表中第一个是需捕获的异常,后续的其他参数需要和重试函数的参数列表保持一致。(不一致Recover 注解会失效)
  • Recover 方法和重试方法的返回值应该保持一致。

1.4配置方式

1.4.1 pom 依赖

xml 复制代码
xml
 代码解读
复制代码
<dependency>
  <groupId>org.springframework.retry</groupId>
  <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

1.4.2 开启重试

typescript 复制代码
Java
 代码解读
复制代码
@EnableRetry
@SpringBootApplication
public class AppStarter {

    public static void main(String[] args) {
        SpringApplication.run(AppStarter.class, args);
    }

}

1.5 异步重试

如果需要异步重试,可以通过在重试方法中添加 @Async注解,Spring 会自动将目标方法封装为 Task 提交到线程池中执行。

2. 分布式重试组件

Spring 重试可以解决大部分的重试问题,然而遇到复杂的业务场景,例如订单履约系统的重试间隔非常长,例如重试间隔从 1 秒重试到 10 分钟,如此长期的间隔时间要求系统具备较高的可靠性。如果通过 Spring 单机式的重试,当遇到服务发布时,很容易阻断重试过程。为此需要想到其他办法保证长期重试的可靠性。

这需要重试组件需要具备延迟重试的能力,因为上一次重试失败后,需要间隔一段时间才能再次重试。

方法异常后,将重试的内容发送到一个分布式中间件中,该分布式中间件具备延迟触发能力,即指定延迟间隔后,通知业务系统重新处理该请求。 一般情况下可借助于具备延迟能力的 MQ,是非常可靠的办法。

重试消息中应该包括重试次数,每重试失败一次,发送重试消息时,应该递增重试次数。当消费重试消息时,超过最大重试次数应该终止重试,并且设置合理的策略记录重试消息,人工介入排查问题。

2.1 通用的 分布式 重试组件

分布式重试能力是一个通用性的需求,目前 Spring 只有单机版的 Retryable 重试能力。在这里 五阳开发了一个通用性的分布式重试工具,仅供各位参考。

Gitee开源地址:gitee.com/juejinwuyan...

GitHub开源地址: github.com/juejin-wuya...

2.2 定义注解

定义的注解内容和 Spring Retryable注解极其相似,提供了如下能力

  • 可自定义要捕获的异常类型
  • 可配置延迟重试、重试的间隔时间、重试间隔的递增倍数。
  • 可配置 重试最终失败的 fallback 方法。
csharp 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {

    public int initialDelaySeconds() default 5;

    public int maxDelaySeconds() default 10;

    public int maxTimes() default 5;

    public double multiplier() default 1.0;

    public boolean throwException() default false;

    public Class<Exception>[] include() default {Exception.class};

    public Class<Exception>[] exclude() default {};

    public boolean hasFallback() default false;
}

2.3 定义异常捕获的切面

java 复制代码
@Pointcut("@annotation(Retryable) && execution(public * *(..))")
public void pointcut() {
}

2.4 切面的拦截逻辑

ini 复制代码
@Around(value = "pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    //无参方法不处理
    Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
    Object[] args = joinPoint.getArgs();

    //获取注解
    Retryable annotation = method.getAnnotation(Retryable.class);
    if (annotation != null && args != null && args.length > 0) {
        try {
            Object response = joinPoint.proceed();
            return response;
        } catch (Exception e) {
            Object param = joinPoint.getArgs()[0];

            String beanName = toLowerCaseFirst(joinPoint.getSignature().getDeclaringType().getSimpleName());
            String methoName = ((MethodSignature) joinPoint.getSignature()).getMethod().getName();

            int retryTimes = RetryLocalContext.getRetryTimes(beanName, methoName);
            retryTimes++;

            if (retryTimes > annotation.maxTimes()) {
                // TODO: 2024/12/31 fallback逻辑
                CommonLog.error("超过最大重试次数 maxTimes:{}, methodName:{} ,params:{}",
                        annotation.maxTimes(), method.getName(), args);
                throw e;
            }

            if (param instanceof RetryableContext) {
                RetryableContext retryableContext = ((RetryableContext) param);
                retryableContext.setRetryTimes(retryTimes);
            }

            int initialDelayTime = annotation.initialDelaySeconds();
            double multiplier = annotation.multiplier();

            long delayTime = initialDelayTime * (int) Math.pow(multiplier, retryTimes - 1);
            delayTime = delayTime > annotation.maxDelaySeconds() ? annotation.maxDelaySeconds() : delayTime;

            RetryMessage message = new RetryMessage();
            message.setBeanName(beanName);
            message.setBeanClassName(joinPoint.getSignature().getDeclaringType().getName());
            message.setMethodName(methoName);
            message.setThrowException(annotation.throwException());

            List<String> argsList = Lists.newArrayList(args).stream().map(RetryUtil::toJson).collect(Collectors.toList());

            List<String> argsClassList = Arrays.stream(((MethodSignature) joinPoint.getSignature()).getParameterTypes())
                    .map(Class::getName).collect(Collectors.toList());

            message.setArgsList(argsList);
            message.setArgsClassList(argsClassList);
            message.setRetryTimes(retryTimes);
            message.setExpectedTime(TimeUtil.now() + TimeUnit.SECONDS.toMillis(delayTime));

            retryService.addRetryMessage(message);
            if (annotation.throwException()) {
                throw e;
            } else {
                CommonLog.error("执行重试方式异常 message:{}", message, e);
            }
            return Defaults.defaultValue(method.getReturnType());
        }
    } else {
        return joinPoint.proceed();
    }
}


public static String toLowerCaseFirst(String str) {
    if (str == null || str.isEmpty()) {
        return str;
    }
    char firstChar = str.charAt(0);
    char updatedFirstChar = Character.toLowerCase(firstChar);
    String remainder = str.substring(1);
    return updatedFirstChar + remainder;
}

2.5 上报重试消息

重试消息关键内容如下

  • Bean ClassName。要求Retry 方法所在类必须是 Spring 的 Bean,故障消息只记录了 Bean 的类型名,在消费逻辑中,系统需要通过 Bean ClassName 从 Spring容器中获取 Bean。
scss 复制代码
String beanName = toLowerCaseFirst(joinPoint.getSignature().getDeclaringType().getSimpleName());
applicationContext.getBean(ClassUtils.getClass(className)); 根据 className 获取 Bean
  • methodName。 重试方法名。joinPoint.getSignature().getName()
  • 参数类型的列表。获取各个参数类型的全类名。
scss 复制代码
List<String> argsClassList = Arrays.stream(((MethodSignature) joinPoint.getSignature()).getParameterTypes())
        .map(Class::getName).collect(Collectors.toList());
  • 参数值列表。各个参数JSON 序列化
scss 复制代码
List<String> argsList = Lists.newArrayList(args).stream().map(RetryUtil::toJson).collect(Collectors.toList());

需要注意的是,Java中List等集合类依赖泛型,在序列化时,泛型信息后被丢弃,反序列化阶段无法准确找到对应的类型,因此为了更好的支持序列化,需要再JSON 中添加类名,这在应对 List集合类型的参数时非常有效。如下代码是 Jackson的序列化工具。

示例

如下代码中展示了,序列化一个List集合,集合中元素是UserTagDO 类,一个普通JavaBean。

ini 复制代码
public  void testJson(){
    List<UserTagDO> tags= Lists.newArrayList();
    UserTagDO tag =new UserTagDO();
    tag.setKey("hello");
    tag.setCount(22L);
    tags.add(tag);

    System.out.println(RetryUtil.toJson(tags));
}

输出 json内容,包含了List类型和元素类型,因此生成的 json 会更长!

css 复制代码
["java.util.ArrayList",[["com.memberclub.domain.context.usertag.UserTagDO",{"key":"hello","count":22}]]]

标准的 json格式如下。

css 复制代码
[{"key":"hello","count":22}]

标准 json在反序列化阶段,如果未指定 集合中的元素,则无法准确反序列化。

如上图示例,标准Json反序列化需要指定 new TypeReference<List<UserTagDO>>() {},明确声明反序列化的集合类型和元素类型。

然而带元素类型的 json结构,只需要声明类型为 List即可,无需指定元素类型,因为 json 中有明确元素类型。

Jackson 序列化带元素类型的 工具类

java 复制代码
static {
    // 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
    objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
    // 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
    objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
    objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    objectMapper.configure(FAIL_ON_EMPTY_BEANS, false);
    objectMapper.disable(FAIL_ON_EMPTY_BEANS);
    objectMapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);
    objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}

public static String toJson(Object object) {
    try {
        return objectMapper.writeValueAsString(object);
    } catch (JsonProcessingException e) {
        CommonLog.error("转成 JSON异常 ", e);
        throw new RuntimeException(e);
    }
}


public static <T> T fromJson(String json, Class<T> tClass) {
    try {
        return objectMapper.readValue(json, tClass);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

public static <T> T fromJson(String json, TypeReference<T> reference) {
    try {
        return objectMapper.readValue(json, reference);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

2.6 重试消息的消费逻辑

重试消息发送到 MQ 后,当达到延迟时间后,消费者可以消费消息,实现重试。下面放一些关键代码(只包含了重试的关键代码,没有MQ 消费订阅代码)

  • 获取 Bean

applicationContext.getBean(ClassUtils.getClass(className));

  • 获取要重试的方法
ini 复制代码
Class<?>[] parameterClazzs = null;
if (CollectionUtils.isNotEmpty(message.getParameterClazzs())) {
    parameterTypes = new Class[message.getParameterClazzs().size()];
    for(int i = 0; i < message.getParameterClazzs().size(); ++i) {
        parameterClazzs[i] = ClassUtils.getClass(message.getParameterClazzs().get(i));
    }
}
// 获取方法
Method method = ReflectionUtils.findMethod(bean, message.getMethodName(), parameterClazzs);
  • 获取参数列表
ini 复制代码
Object[] arguments = null;
if (CollectionUtils.isNotEmpty(message.argumentList())) {
    arguments = new Object[message.getParameterClazzs().size()];

    for(int i = 0; i < parameterClazzs.length; ++i) {
        arguments[i] = JSONObject.parseObject(message.argumentList().get(i), parameterClazzs[i]);
    }
}
  • 调用 方法

ReflectionUtils.invokeMethod(method, bean, argsArray);

2.7 完整消息重试代码

java 复制代码
public void consumeMessage(RetryMessage msg) {
    try {
        LOG.info("开始消费重试消息 currentTimes:{}, msg:{}", msg.getRetryTimes(), msg);
        RetryLocalContext.setRetryTimes(msg.getBeanName(), msg.getMethodName(), msg.getRetryTimes());
        Object bean = ApplicationContextUtils.getContext().getBean(msg.getBeanName());
        Class<?> clazz = Class.forName(msg.getBeanClassName());

        Class<?>[] argsClassArray = new Class[msg.getArgsClassList().size()];

        for (int i = 0; i < msg.getArgsClassList().size(); i++) {
            argsClassArray[i] = ClassUtils.getClass(msg.getArgsClassList().get(i));
        }

        Method m = ReflectionUtils.findMethod(clazz, msg.getMethodName(), argsClassArray);

        Object[] argsArray = new Object[msg.getArgsClassList().size()];
        for (int i = 0; i < msg.getArgsList().size(); i++) {
            argsArray[i] = RetryUtil.fromJson(msg.getArgsList().get(i), argsClassArray[i]);
        }

        try {
            ReflectionUtils.invokeMethod(m, bean, argsArray);
        } catch (Exception e) {
            if (!msg.isThrowException()) {
                LOG.error("调用重试方法异常:{}", argsArray, e);
            }
        }
    } catch (Exception e) {
        LOG.error("调用重试消息方法,重试组件异常:{}", msg, e);
    } finally {
        RetryLocalContext.clear();
    }
}

以上是 分布式重试组件的关键代码。此外还有很多代码细节需要解决,例如 消息重试的次数如何保存?我的做法是在线程本地变量中保存重试次数,然后我遇到了一个问题。

当代码调用栈上有两个方法均声明了 重试注解,上层方法抛出异常重试成功后,会执行下层方法,此时线程本地变量还未清除,因此下层方法若抛出异常,则会重复使用线程本地变量~ 从而产生错误的执行结果~

此外开源中间件中有哪些 延迟队列的实现方案呢,这些内容都在 Memberclub中~

想要查看完整的代码实现方案,可以参考我的开源项目 memberclub,分布式重试组件代码地址

MemberClub是托管在Gitee/Github 平台的开源项目,提供了付费会员的交易解决方案,在各类购买场景下提供各类会员形态的履约及售后结算能力,一个非常好的项目,适合用来学习电商业务中台系统的建设,具体介绍可参见

Gitee开源地址:gitee.com/juejinwuyan...

GitHub开源地址: github.com/juejin-wuya...

在这个项目中你可以学习到 SpringBoot 集成 以下框架或组件。

  1. Mybatis-plus
  2. Sharding-sphere 多数据源分库分表
  3. Redis/redisson
  4. Apollo
  5. Springcloud(feign/enreka)
  6. RabbitMQ
  7. H2 内存数据库
  8. Swagger
  9. Lombok+MapStruct

同时你也可以学习到以下组件的实现原理

  1. 流程引擎
  2. 扩展点引擎
  3. 分布式重试组件
  4. 通用日志组件
  5. 商品库存
  6. 分布式锁组件
  7. Redis Lua的使用
  8. Spring 上下文工具类
相关推荐
黄名富9 分钟前
Spring Cloud — 深入了解Eureka、Ribbon及Feign
分布式·spring·spring cloud·微服务·eureka·ribbon
小丑西瓜66635 分钟前
分布式简单理解
linux·redis·分布式·架构·架构演变
Gopher39 分钟前
C语言程序设计知识8
后端
m0_748248231 小时前
Spring Framework 中文官方文档
java·后端·spring
先睡1 小时前
Spring MVC的基本概念
java·spring·mvc
m0_748240541 小时前
Springboot项目:使用MockMvc测试get和post接口(含单个和多个请求参数场景)
java·spring boot·后端
LUCIAZZZ2 小时前
SkyWalking快速入门
java·后端·spring·spring cloud·微服务·springboot·skywalking
m0_748245172 小时前
SpringCloud-使用FFmpeg对视频压缩处理
spring·spring cloud·ffmpeg
方圆想当图灵2 小时前
高性能缓存设计:如何解决缓存伪共享问题
后端·代码规范
Long_poem2 小时前
【自学笔记】Spring Boot框架技术基础知识点总览-持续更新
spring boot·笔记·后端