zoukankan      html  css  js  c++  java
  • 更好的 java 重试框架 sisyphus 背后的故事

    sisyphus 综合了 spring-retry 和 gauva-retrying 的优势,使用起来也非常灵活。

    今天,让我们一起看一下西西弗斯背后的故事。

    情景导入

    简单的需求

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

    小明:好的。没问题。

    代码

    • 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 的外部服务,但是有时候不稳定。

    项目经理:如果调用失败了,你可以调用的时候重试几次。你去看下重试相关的东西

    重试

    重试作用

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

    远程调用超时、网络突然中断可以重试。在微服务治理框架中,通常都有自己的重试与超时配置,比如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

    希望本文对你有所帮助,如果喜欢,欢迎点赞收藏转发一波。

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

    在这里插入图片描述

  • 相关阅读:
    【bzoj1878】[SDOI2009]HH的项链
    【bzoj2821】作诗(Poetize)
    【bzoj2120】数颜色
    PAT 乙级真题 1005.德才论
    PAT 乙级真题 1004.福尔摩斯的约会
    博客园使用悬挂猫(上吊猫)置顶插件
    PAT 乙级真题 1002.数字分类
    AcWing 789.数的范围
    AcWing 788.逆序对的数量
    二分查找
  • 原文地址:https://www.cnblogs.com/houbbBlogs/p/15426561.html
Copyright © 2011-2022 走看看