更好的 java 重试框架 sisyphus 背后的故事

2021年11月26日 阅读数:12
这篇文章主要向大家介绍更好的 java 重试框架 sisyphus 背后的故事,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

sisyphus 综合了 spring-retry 和 gauva-retrying 的优点,使用起来也很是灵活。java

今天,让咱们一块儿看一下西西弗斯背后的故事。git

情景导入

简单的需求

产品经理:实现一个按条件,查询用户信息的服务。github

小明:好的。没问题。web

代码

  • UserService.java
public interface UserService {

    /**
     * 根据条件查询用户信息
     * @param condition 条件
     * @return User 信息
     */
    User queryUser(QueryUserCondition condition);

}
  • UserServiceImpl.java
public class UserServiceImpl implements UserService {

    private OutService outService;

    public UserServiceImpl(OutService outService) {
        this.outService = outService;
    }

    @Override
    public User queryUser(QueryUserCondition condition) {
        outService.remoteCall();
        return new User();
    }

}

谈话

项目经理:这个服务有时候会失败,你看下。算法

小明:OutService 在是一个 RPC 的外部服务,可是有时候不稳定。spring

项目经理:若是调用失败了,你能够调用的时候重试几回。你去看下重试相关的东西编程

重试

重试做用

对于重试是有场景限制的,不是什么场景都适合重试,好比参数校验不合法、写操做等(要考虑写是否幂等)都不适合重试。设计模式

远程调用超时、网络忽然中断能够重试。在微服务治理框架中,一般都有本身的重试与超时配置,好比dubbo能够设置retries=1,timeout=500调用失败只重试1次,超过500ms调用仍未返回则调用失败。安全

好比外部 RPC 调用,或者数据入库等操做,若是一次操做失败,能够进行屡次重试,提升调用成功的可能性网络

V1.0 支持重试版本

思考

小明:我手头还有其余任务,这个也挺简单的。5 分钟时间搞定他。

实现

  • UserServiceRetryImpl.java
public class UserServiceRetryImpl implements UserService {

    @Override
    public User queryUser(QueryUserCondition condition) {
        int times = 0;
        OutService outService = new AlwaysFailOutServiceImpl();

        while (times < RetryConstant.MAX_TIMES) {
            try {
                outService.remoteCall();
                return new User();
            } catch (Exception e) {
                times++;

                if(times >= RetryConstant.MAX_TIMES) {
                    throw new RuntimeException(e);
                }
            }
        }

        return null;
    }

}

V1.1 代理模式版本

易于维护

项目经理:你的代码我看了,功能虽然实现了,可是尽可能写的易于维护一点。

小明:好的。(心想,是说要写点注释什么的?)

代理模式

为其余对象提供一种代理以控制对这个对象的访问。

在某些状况下,一个对象不适合或者不能直接引用另外一个对象,而代理对象能够在客户端和目标对象之间起到中介做用。

其特征是代理与委托类有一样的接口。

实现

小明想到之前看过的代理模式,心想用这种方式,原来的代码改动量较少,之后想改起来也方便些

  • UserServiceProxyImpl.java
public class UserServiceProxyImpl implements UserService {

    private UserService userService = new UserServiceImpl();

    @Override
    public User queryUser(QueryUserCondition condition) {
        int times = 0;

        while (times < RetryConstant.MAX_TIMES) {
            try {
                return userService.queryUser(condition);
            } catch (Exception e) {
                times++;

                if(times >= RetryConstant.MAX_TIMES) {
                    throw new RuntimeException(e);
                }
            }
        }
        return null;
    }

}

V1.2 动态代理模式

方便拓展

项目经理:小明啊,这里还有个方法也是一样的问题。你也给加上重试吧。

小明:好的。

小明心想,我在写一个代理,可是转念冷静了下来,若是还有个服务也要重试怎么办呢?

  • RoleService.java
public interface RoleService {

    /**
     * 查询
     * @param user 用户信息
     * @return 是否拥有权限
     */
    boolean hasPrivilege(User user);

}

代码实现

  • DynamicProxy.java
public class DynamicProxy implements InvocationHandler {

    private final Object subject;

    public DynamicProxy(Object subject) {
        this.subject = subject;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        int times = 0;

        while (times < RetryConstant.MAX_TIMES) {
            try {
                // 当代理对象调用真实对象的方法时,其会自动的跳转到代理对象关联的handler对象的invoke方法来进行调用
                return method.invoke(subject, args);
            } catch (Exception e) {
                times++;

                if (times >= RetryConstant.MAX_TIMES) {
                    throw new RuntimeException(e);
                }
            }
        }

        return null;
    }

    /**
     * 获取动态代理
     *
     * @param realSubject 代理对象
     */
    public static Object getProxy(Object realSubject) {
        //    咱们要代理哪一个真实对象,就将该对象传进去,最后是经过该真实对象来调用其方法的
        InvocationHandler handler = new DynamicProxy(realSubject);
        return Proxy.newProxyInstance(handler.getClass().getClassLoader(),
                realSubject.getClass().getInterfaces(), handler);
    }

}
  • 测试代码
@Test
public void failUserServiceTest() {
        UserService realService = new UserServiceImpl();
        UserService proxyService = (UserService) DynamicProxy.getProxy(realService);

        User user = proxyService.queryUser(new QueryUserCondition());
        LOGGER.info("failUserServiceTest: " + user);
}


@Test
public void roleServiceTest() {
        RoleService realService = new RoleServiceImpl();
        RoleService proxyService = (RoleService) DynamicProxy.getProxy(realService);

        boolean hasPrivilege = proxyService.hasPrivilege(new User());
        LOGGER.info("roleServiceTest: " + hasPrivilege);
}

V1.3 动态代理模式加强

对话

项目经理:小明,你动态代理的方式是挺会偷懒的,但是咱们有的类没有接口。这个问题你要解决一下。

小明:好的。(谁?写服务居然不定义接口)

  • ResourceServiceImpl.java
public class ResourceServiceImpl {

    /**
     * 校验资源信息
     * @param user 入参
     * @return 是否校验经过
     */
    public boolean checkResource(User user) {
        OutService outService = new AlwaysFailOutServiceImpl();
        outService.remoteCall();
        return true;
    }

}

字节码技术

小明看了下网上的资料,解决的办法仍是有的。

  • CGLIB

CGLIB 是一个功能强大、高性能和高质量的代码生成库,用于扩展JAVA类并在运行时实现接口。

  • javassist

javassist (Java编程助手)使Java字节码操做变得简单。

它是Java中编辑字节码的类库;它容许Java程序在运行时定义新类,并在JVM加载类文件时修改类文件。

与其余相似的字节码编辑器不一样,Javassist提供了两个级别的API:源级和字节码级。

若是用户使用源代码级API,他们能够编辑类文件,而不须要了解Java字节码的规范。

整个API只使用Java语言的词汇表进行设计。您甚至能够以源文本的形式指定插入的字节码;Javassist动态编译它。

另外一方面,字节码级API容许用户直接编辑类文件做为其余编辑器。

  • ASM

ASM 是一个通用的Java字节码操做和分析框架。

它能够用来修改现有的类或动态地生成类,直接以二进制形式。

ASM提供了一些通用的字节码转换和分析算法,能够从这些算法中构建自定义复杂的转换和代码分析工具。

ASM提供与其余Java字节码框架相似的功能,但主要关注性能。

由于它的设计和实现都尽量地小和快,因此很是适合在动态系统中使用(固然也能够以静态的方式使用,例如在编译器中)。

实现

小明看了下,就选择使用 CGLIB。

  • CglibProxy.java
public class CglibProxy implements MethodInterceptor {

    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        int times = 0;

        while (times < RetryConstant.MAX_TIMES) {
            try {
                //经过代理子类调用父类的方法
                return methodProxy.invokeSuper(o, objects);
            } catch (Exception e) {
                times++;

                if (times >= RetryConstant.MAX_TIMES) {
                    throw new RuntimeException(e);
                }
            }
        }

        return null;
    }

    /**
     * 获取代理类
     * @param clazz 类信息
     * @return 代理类结果
     */
    public Object getProxy(Class clazz){
        Enhancer enhancer = new Enhancer();
        //目标对象类
        enhancer.setSuperclass(clazz);
        enhancer.setCallback(this);
        //经过字节码技术建立目标对象类的子类实例做为代理
        return enhancer.create();
    }

}
  • 测试
@Test
public void failUserServiceTest() {
   UserService proxyService = (UserService) new CglibProxy().getProxy(UserServiceImpl.class);

   User user = proxyService.queryUser(new QueryUserCondition());
   LOGGER.info("failUserServiceTest: " + user);
}

@Test
public void resourceServiceTest() {
   ResourceServiceImpl proxyService = (ResourceServiceImpl) new CglibProxy().getProxy(ResourceServiceImpl.class);
   boolean result = proxyService.checkResource(new User());
   LOGGER.info("resourceServiceTest: " + result);
}

V2.0 AOP 实现

对话

项目经理:小明啊,最近我在想一个问题。不一样的服务,重试的时候次数应该是不一样的。由于服务对稳定性的要求各不相同啊。

小明:好的。(心想,重试都搞了一周了,今天都周五了。)

下班以前,小明一直在想这个问题。恰好周末,花点时间写个重试小工具吧。

设计思路

  • 技术支持

spring

java 注解

  • 注解定义

注解可在方法上使用,定义须要重试的次数

  • 注解解析

拦截指定须要重试的方法,解析对应的重试次数,而后进行对应次数的重试。

实现

  • Retryable.java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Retryable {

    /**
     * Exception type that are retryable.
     * @return exception type to retry
     */
    Class<? extends Throwable> value() default RuntimeException.class;

    /**
     * 包含第一次失败
     * @return the maximum number of attempts (including the first failure), defaults to 3
     */
    int maxAttempts() default 3;

}
  • RetryAspect.java
@Aspect
@Component
public class RetryAspect {

    @Pointcut("execution(public * com.github.houbb.retry.aop..*.*(..)) &&" +
                      "@annotation(com.github.houbb.retry.aop.annotation.Retryable)")
    public void myPointcut() {
    }

    @Around("myPointcut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        Method method = getCurrentMethod(point);
        Retryable retryable = method.getAnnotation(Retryable.class);

        //1. 最大次数判断
        int maxAttempts = retryable.maxAttempts();
        if (maxAttempts <= 1) {
            return point.proceed();
        }

        //2. 异常处理
        int times = 0;
        final Class<? extends Throwable> exceptionClass = retryable.value();
        while (times < maxAttempts) {
            try {
                return point.proceed();
            } catch (Throwable e) {
                times++;

                // 超过最大重试次数 or 不属于当前处理异常
                if (times >= maxAttempts ||
                        !e.getClass().isAssignableFrom(exceptionClass)) {
                    throw new Throwable(e);
                }
            }
        }

        return null;
    }

    private Method getCurrentMethod(ProceedingJoinPoint point) {
        try {
            Signature sig = point.getSignature();
            MethodSignature msig = (MethodSignature) sig;
            Object target = point.getTarget();
            return target.getClass().getMethod(msig.getName(), msig.getParameterTypes());
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }

}

方法的使用

  • fiveTimes()

当前方法一共重试 5 次。
重试条件:服务抛出 AopRuntimeExption

@Override
@Retryable(maxAttempts = 5, value = AopRuntimeExption.class)
public void fiveTimes() {
    LOGGER.info("fiveTimes called!");
    throw new AopRuntimeExption();
}
  • 测试日志
2018-08-08 15:49:33.814  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!

java.lang.reflect.UndeclaredThrowableException
...

V3.0 spring-retry 版本

对话

周一来到公司,项目经理又和小明谈了起来。

项目经理:重试次数是知足了,可是重试其实应该讲究策略。好比调用外部,第一次失败,能够等待 5S 在次调用,若是又失败了,能够等待 10S 再调用。。。

小明:了解。

思考

但是今天周一,还有其余不少事情要作。

小明在想,没时间写这个呀。看看网上有没有现成的。

spring-retry

Spring Retry 为 Spring 应用程序提供了声明性重试支持。 它用于Spring批处理、Spring集成、Apache Hadoop(等等)的Spring。

在分布式系统中,为了保证数据分布式事务的强一致性,你们在调用RPC接口或者发送MQ时,针对可能会出现网络抖动请求超时状况采起一下重试操做。 你们用的最多的重试方式就是MQ了,可是若是你的项目中没有引入MQ,那就不方便了。

还有一种方式,是开发者本身编写重试机制,可是大多不够优雅。

注解式使用

  • RemoteService.java

重试条件:遇到 RuntimeException

重试次数:3

重试策略:重试的时候等待 5S, 后面时间依次变为原来的 2 倍数。

熔断机制:所有重试失败,则调用 recover() 方法。

@Service
public class RemoteService {

    private static final Logger LOGGER = LoggerFactory.getLogger(RemoteService.class);

    /**
     * 调用方法
     */
    @Retryable(value = RuntimeException.class,
               maxAttempts = 3,
               backoff = @Backoff(delay = 5000L, multiplier = 2))
    public void call() {
        LOGGER.info("Call something...");
        throw new RuntimeException("RPC调用异常");
    }

    /**
     * recover 机制
     * @param e 异常
     */
    @Recover
    public void recover(RuntimeException e) {
        LOGGER.info("Start do recover things....");
        LOGGER.warn("We meet ex: ", e);
    }

}
  • 测试
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class RemoteServiceTest {

    @Autowired
    private RemoteService remoteService;

    @Test
    public void test() {
        remoteService.call();
    }

}
  • 日志
2018-08-08 16:03:26.409  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Call something...
2018-08-08 16:03:31.414  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Call something...
2018-08-08 16:03:41.416  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Call something...
2018-08-08 16:03:41.418  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Start do recover things....
2018-08-08 16:03:41.425  WARN 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : We meet ex: 

java.lang.RuntimeException: RPC调用异常
	at com.github.houbb.retry.spring.service.RemoteService.call(RemoteService.java:38) ~[classes/:na]
...

三次调用的时间点:

2018-08-08 16:03:26.409 
2018-08-08 16:03:31.414
2018-08-08 16:03:41.416

缺陷

spring-retry 工具虽能优雅实现重试,可是存在两个不友好设计:

一个是重试实体限定为 Throwable 子类,说明重试针对的是可捕捉的功能异常为设计前提的,可是咱们但愿依赖某个数据对象实体做为重试实体,
但 sping-retry框架必须强制转换为Throwable子类。

另外一个就是重试根源的断言对象使用的是 doWithRetry 的 Exception 异常实例,不符合正常内部断言的返回设计。

Spring Retry 提倡以注解的方式对方法进行重试,重试逻辑是同步执行的,重试的“失败”针对的是Throwable,
若是你要以返回值的某个状态来断定是否须要重试,可能只能经过本身判断返回值而后显式抛出异常了。

@Recover 注解在使用时没法指定方法,若是一个类中多个重试方法,就会很麻烦。

guava-retrying

谈话

小华:咱们系统也要用到重试

项目经理:小明前段时间用了 spring-retry,分享下应该还不错

小明:spring-retry 基本功能都有,可是必须是基于异常来进行控制。若是你要以返回值的某个状态来断定是否须要重试,可能只能经过本身判断返回值而后显式抛出异常了。

小华:咱们项目中想根据对象的属性来进行重试。你能够看下 guava-retry,我好久之前用过,感受还不错。

小明:好的。

guava-retrying

guava-retrying 模块提供了一种通用方法, 可使用Guava谓词匹配加强的特定中止、重试和异常处理功能来重试任意Java代码。

  • 优点

guava retryer工具与spring-retry相似,都是经过定义重试者角色来包装正常逻辑重试,可是Guava retryer有更优的策略定义,在支持重试次数和重试频度控制基础上,可以兼容支持多个异常或者自定义实体对象的重试源定义,让重试功能有更多的灵活性。

Guava Retryer也是线程安全的,入口调用逻辑采用的是 java.util.concurrent.Callablecall() 方法

代码例子

入门案例

遇到异常以后,重试 3 次中止

  • HelloDemo.java
public static void main(String[] args) {
    Callable<Boolean> callable = new Callable<Boolean>() {
        @Override
        public Boolean call() throws Exception {
            // do something useful here
            LOGGER.info("call...");
            throw new RuntimeException();
        }
    };

    Retryer<Boolean> retryer = RetryerBuilder.<Boolean>newBuilder()
            .retryIfResult(Predicates.isNull())
            .retryIfExceptionOfType(IOException.class)
            .retryIfRuntimeException()
            .withStopStrategy(StopStrategies.stopAfterAttempt(3))
            .build();
    try {
        retryer.call(callable);
    } catch (RetryException | ExecutionException e) {
        e.printStackTrace();
    }

}
  • 日志
2018-08-08 17:21:12.442  INFO  [main] com.github.houbb.retry.guava.HelloDemo:41 - call...
com.github.rholder.retry.RetryException: Retrying failed to complete successfully after 3 attempts.
2018-08-08 17:21:12.443  INFO  [main] com.github.houbb.retry.guava.HelloDemo:41 - call...
2018-08-08 17:21:12.444  INFO  [main] com.github.houbb.retry.guava.HelloDemo:41 - call...
	at com.github.rholder.retry.Retryer.call(Retryer.java:174)
	at com.github.houbb.retry.guava.HelloDemo.main(HelloDemo.java:53)
Caused by: java.lang.RuntimeException
	at com.github.houbb.retry.guava.HelloDemo$1.call(HelloDemo.java:42)
	at com.github.houbb.retry.guava.HelloDemo$1.call(HelloDemo.java:37)
	at com.github.rholder.retry.AttemptTimeLimiters$NoAttemptTimeLimit.call(AttemptTimeLimiters.java:78)
	at com.github.rholder.retry.Retryer.call(Retryer.java:160)
	... 1 more

总结

优雅重试共性和原理

正常和重试优雅解耦,重试断言条件实例或逻辑异常实例是二者沟通的媒介。

约定重试间隔,差别性重试策略,设置重试超时时间,进一步保证重试有效性以及重试流程稳定性。

都使用了命令设计模式,经过委托重试对象完成相应的逻辑操做,同时内部封装实现重试逻辑。

spring-retry 和 guava-retry 工具都是线程安全的重试,可以支持并发业务场景的重试逻辑正确性。

优雅重试适用场景

功能逻辑中存在不稳定依赖场景,须要使用重试获取预期结果或者尝试从新执行逻辑不当即结束。好比远程接口访问,数据加载访问,数据上传校验等等。

对于异常场景存在须要重试场景,同时但愿把正常逻辑和重试逻辑解耦。

对于须要基于数据媒介交互,但愿经过重试轮询检测执行逻辑场景也能够考虑重试方案。

谈话

项目经理:我以为 guava-retry 挺好的,就是不够方便。小明啊,你给封装个基于注解的吧。

小明:……

更好的实现

因而小明含泪写下了 sisyphus.

java 重试框架——sisyphus

但愿本文对你有所帮助,若是喜欢,欢迎点赞收藏转发一波。

我是老马,期待与你的下次重逢。

在这里插入图片描述