分布式系统容错必杀技:从架构到代码,深度剖析分布式重试组件

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

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

下面列举 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 上下文工具类
相关推荐
战族狼魂11 分钟前
CSGO 皮肤交易平台后端 (Spring Boot) 代码结构与示例
java·spring boot·后端
杉之2 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
hycccccch2 小时前
Canal+RabbitMQ实现MySQL数据增量同步
java·数据库·后端·rabbitmq
bobz9653 小时前
k8s 怎么提供虚拟机更好
后端
bobz9653 小时前
nova compute 如何创建 ovs 端口
后端
用键盘当武器的秋刀鱼3 小时前
springBoot统一响应类型3.5.1版本
java·spring boot·后端
Asthenia04124 小时前
从迷宫到公式:为 NFA 构造正规式
后端
Asthenia04124 小时前
像整理玩具一样:DFA 化简和状态等价性
后端
Asthenia04125 小时前
编译原理:打包思维-NFA 怎么变成 DFA
后端
非ban必选5 小时前
spring-ai-alibaba第五章阿里dashscope集成mcp远程天气查询tools
java·后端·spring