• 抓到Dubbo异步调用的小BUG,再送你一个贡献开源代码的机会


    hello,大家好呀,我是小楼。

    最近一个技术群有同学at我,问我是否熟悉Dubbo,这我熟啊~

    他说遇到了一个Dubbo异步调用的问题,怀疑是个BUG,提到BUG我可就不困了,说不定可以水,哦不…写一篇文章。

    问题复现

    遇到问题,尤其不是自己遇到的,必须要复现出来才好排查,截一个当时的聊天记录:

    他的问题原话是:

    今天发现一个问题 有一个dubbo接口返回类型是boolean, 把接口从同步改成异步 server 端返回true 消费端却返回false,把boolean改成Boolean就能正常返回结果 有碰到过这个问题吗

    注意几个重点:

    • 接口返回类型是boolean
    • 同步改为异步调用返回的boolean和预期不符合
    • boolean基本类型改成包装类型Boolean就能正常返回

    听到这个描述,我的第一反应是这个返回结果定义为boolean肯定有问题!

    《Java开发手册》中就强调了RPC接口返回最好不要使用基本类型,而要使用包装类型:

    但这个是业务编码规范,如果RPC框架不能使用boolean作为返回值,岂不是个BUG?而且他强调了是同步改为异步调用才出现这种情况,说明同步没问题,有可能是异步调用的锅。

    于是我顺口问了Dubbo的版本,说不定是某个版本的BUG。得到回复,是2.7.4版本的Dubbo。

    于是我拉了个工程准备复现这个问题。

    哎,等等~

    Dubbo异步调用的写法可多了,于是我又问了下他是怎么写的。

    知道怎么写的就好办了,写个Demo先:

    1. 定义Dubbo接口,一个返回boolean,一个返回Boolean
    public interface DemoService {
        boolean isUser();
        Boolean isFood();
    }
    
    • 1
    • 2
    • 3
    • 4
    1. 实现Provider,为了简单,都返回true,并且打了日志
    @Service
    public class DemoServiceImpl implements DemoService {
    
        @Override
        public boolean isUser() {
            System.out.println("server is user : true");
            return true;
        }
    
        @Override
        public Boolean isFood() {
            System.out.println("server is food : true");
            return true;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    1. 实现Consumer,为了方便调用,实现了一个Controller,为了防止本机调用,injvm设置为false,这里是经验,injvm调用逻辑和远程调用区别挺大,为了防止干扰,统一远程调用。
    @RestController
    public class DemoCallerService {
    
        @Reference(injvm = false, check = false)
        private DemoService demoService;
    
        @GetMapping(path = "/isUser")
        public String isUser() throws Exception {
            BlockingQueue<Boolean> q = new ArrayBlockingQueue<>(1);
            RpcContext.getContext().asyncCall(
                    () -> demoService.isUser()
            ).handle(
                    (isUser, throwable) -> {
                        System.out.println("client is user = " + isUser);
                        q.add(isUser);
                        return isUser;
                    });
            q.take();
            return "ok";
        }
    
        @GetMapping(path = "/isFood")
        public String isFood() throws Exception {
            BlockingQueue<Boolean> q = new ArrayBlockingQueue<>(1);
            RpcContext.getContext().asyncCall(
                    () -> demoService.isFood()
            ).handle(
                    (isFood, throwable) -> {
                        System.out.println("client is food = " + isFood);
                        q.add(isFood);
                        return isFood;
                    });
            q.take();
            return "ok";
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    1. 启动一个Provider,再启动一个Consumer进行测试,果然和提问的同学表现一致:
    • 先调用isUser(返回boolean),控制台打印:
    // client ...
    client is user = false
    // server ...
    server is user : true
    
    • 1
    • 2
    • 3
    • 4
    • 再调用isFood(返回Boolean),控制台打印:
    // client ...
    client is food = true
    // server ...
    server is food : true
    
    • 1
    • 2
    • 3
    • 4

    问题排查

    1. Debug

    先猜测一下是哪里的问题,server端返回true,应该问题不大,可能是client端哪里转换出错了。但这都是猜想,我们直接从client端接受到的数据开始,如果接收的数据没问题,肯定就是后续处理出了点小差错。

    如果你非常熟悉Dubbo的调用过程,直接知道大概在这里

    com.alibaba.dubbo.remoting.exchange.support.DefaultFuture#doReceived

    如果你不熟悉,那就比较困难了,推荐读一下之前的文章《我是一个Dubbo数据包…》,知道得越多,干活就越快。

    我们打3个断点:

    • 断点①为了证明我们的请求进来了
    • 断点②为了证明进了回调
    • 断点③为了能从接受到数据包的初始位置开始排查

    按照我们的想法,执行顺序应该是①、③、②,但是这里很奇怪,并没有按照我们的预期执行,而是先执行①,再执行②,最后执行③!

    这是为什么?对于排查问题中的这些没有符合预期的蛛丝马迹,要特别留心,很可能就是一个突破点

    于是我们对asyncCall这个方法进行跟踪:

    发现这里callable调用call返回了false,然后false不为null且不是CompletableFuture的实例,于是直接调用了CompletableFuture.completedFuture(o)

    看到这里估计有部分小伙伴发现了问题,正常情况下,Dubbo的异步调用,执行调用后,不会立马得到结果,只会拿到一个null或者一个CompletableFuture,然后在回调方法中等待server端的返回。

    这里的逻辑是如果返回的结果不为null且不为CompletableFuture的实例就直接将CompletableFuture设置为完成,立马执行回调。

    暂且不管这个逻辑。

    我们先看为什么会返回false。这里的callable是Dubbo生成的一个代理类,其实就是封装了调用Provider的逻辑,有没有办法看看他封装的逻辑呢?有!用arthas。

    1. arthas

    我们下载安装一个arthas,可以参考如下文档:

    https://arthas.aliyun.com/doc/quick-start.html

    attach到我们的Consumer进程上,执行sc命令(查看已加载的类)查看所有生成的代理类,由于我们的Demo就生成了一个,所以看起来很清晰

    sc *.proxy0
    
    • 1

    再使用jad命令反编译已加载的类:

    jad org.apache.dubbo.common.bytecode.proxy0
    
    • 1

    看到这里估计小伙伴们又揭开了一层疑惑,this.handler.invoke就是去调用Provider,由于这里是异步调用,必然返回的是null,所以返回值定义为boolean的方法返回了false

    看到这里,估计小伙伴们对《Java开发手册》里的规范有了更深的理解,这里的处理成false也是无奈之举,不然难道返回true?属于信息丢失了,无法区分是调用的返回还是其他异常情况。

    我们再回头看asyncCall

    圈出来的这段代码令人深思,尤其是最后一行,为啥直接将CompletableFuture设置为完成?

    从这个方法的名字能看出它是执行异步调用,但这里有行注释:

    //local invoke will return directly
    
    • 1

    首先这个注释的格式上下不一,//之后讲道理是需要一个空格的,我觉得这里提个PR改下代码格式肯定能被接受~

    其次local invoke,我理解应该是injvm这种调用,为啥要特殊处理?这个处理直接就导致了返回基本类型的接口在异步调用时必然会返回false的BUG。

    我们测试一下injvm的调用,将demo中injvm参数改为true,Consumer和Provider都在一个进程中,果然和注释说的一样:

    server is user : true
    client is user = true
    
    • 1
    • 2

    如何修复

    我觉得这应该算是Dubbo的一个BUG,虽然这种写法不提倡,但作为一款RPC框架,这个错误还是不应该。

    修复的办法就是在injvm分支这里加上判断,如果是injvm调用还是保持现状,如果不是injvm调用,直接忽略,走最后的return逻辑:

    public <T> CompletableFuture<T> asyncCall(Callable<T> callable) {
        try {
            try {
                setAttachment(ASYNC_KEY, Boolean.TRUE.toString());
                final T o = callable.call();
                //local invoke will return directly
                if (o != null) {
                    if (o instanceof CompletableFuture) {
                        return (CompletableFuture<T>) o;
                    }
                    if (injvm()) { // 伪代码
                        return CompletableFuture.completedFuture(o);
                    }
                } else {
                    // The service has a normal sync method signature, should get future from RpcContext.
                }
            } catch (Exception e) {
                throw new RpcException(e);
            } finally {
                removeAttachment(ASYNC_KEY);
            }
        } catch (final RpcException e) {
            // ....
        }
        return ((CompletableFuture<T>) getContext().getFuture());
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    最后

    排查过程中还搜索了github,但没有什么发现,说明这个BUG遇到的人很少,可能是大家用异步调用本来就很少,再加上返回基本类型就更少,所以也不奇怪。

    而且最新的代码这个BUG也还存在,所以你懂我意思吧?这也是个提交PR的好机会~

    不过话说回来,我们写代码最好还是遵循规范,这些都是前人为我们总结的最佳实践,如果不按规范来,可能就会有意想不到的问题。

    当然遇到问题也不要慌,代码就在那躺着,工具也多,还怕搞不定吗?

    最后,感谢群里小伙伴提供素材,感谢大家的阅读,如果能动动小手帮我点个在看就更好了。我们下期再见~

    对了,标题为什么叫《再送你一次》?因为之前送过呀~

    搜索关注微信公众号"捉虫大师",后端技术分享,架构设计、性能优化、源码阅读、问题排查、踩坑实践。

  • 相关阅读:
    深度解析 | 支付和分账系统的前世今生
    redmine获取cookie和其他系统实现单点登录
    MYSQL事务原理分析
    什么是云计算中的资源调度,解释资源调度的挑战和算法
    Linux电源管理
    Git-快速笔记(持续更新)
    闭式旁流水处理器简介
    java计算机毕业设计学生日常事务管理系统源码+mysql数据库+lw文档+系统+调试部署
    IK学习笔记(2)——TwoBones IK
    阿里云云原生一体化数仓 - 数据安全能力解读
  • 原文地址:https://blog.csdn.net/lkxiaolou/article/details/125596252