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

最近一个同学说遇到了一个 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();}
     
     
    2) 实现 Provider,为了简单,都返回 true,并且打了日志。

     
     
  • @Servicepublic 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; }}

    3) 实现 Consumer。

    为了方便调用,实现了一个 Controller,为了防止本机调用,injvm 设置为 false。这里是经验,injvm 调用逻辑和远程调用区别挺大,为了防止干扰,统一远程调用。

  • @RestControllerpublic 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"; }}
     
     

    4)启动一个 Provider,再启动一个 Consumer 进行测试。

    果然和提问的同学表现一致:

    先调用 isUser(返回 boolean),控制台打印:

  • // client ...client is user = false// server ...server is user : true
     
     

    再调用 isFood(返回 Boolean),控制台打印:
     
     

  • // client ...client is food = true// server ...server is food : true

    问题排查

    Debug

    先猜测一下是哪里的问题,server 端返回 true,应该问题不大,可能是 client 端哪里转换出错了。

    但这都是猜想,我们直接从 client 端接收到的数据开始,如果接收的数据没问题,肯定就是后续处理出了点小差错。

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

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

    我们打 3 个断点:


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

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

    这是为什么?

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

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


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

    看到这里估计有部分小伙伴发现了问题:

    正常情况下,Dubbo 的异步调用,执行调用后不会立马得到结果,只会拿到一个 null或者一个 CompletableFuture,然后在回调方法中等待 server 端的返回。

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

    暂且不管这个逻辑。

    我们先看为什么会返回 false。

    这里的 callable 是 Dubbo 生成的一个代理类,其实就是封装了调用 Provider 的逻辑,有没有办法看看他封装的逻辑呢?有!用 arthas。

    arthas

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

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

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

  • sc *.proxy0
     
     


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

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


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

    看到这里,估计小伙伴们对《Java开发手册》里的规范有了更深的理解,这里的处理成 false 也是无奈之举,不然难道返回 true?

    属于信息丢失了,无法区分是调用的返回还是其他异常情况。

    我们再回头看 asyncCall:


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

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

  • //local invoke will return directly
     
     

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

    其次 local invoke,我理解应该是 injvm 这种调用,为啥要特殊处理?

    这个处理直接就导致了返回基本类型的接口在异步调用时必然会返回 false 的 Bug。

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

  • server is user : trueclient is user = true

    如何修复

    我觉得这应该算是 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());}
     
     

    最后

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

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

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

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


    - EOF -

    推荐阅读   点击标题可跳转

    1、我是一个Dubbo数据包...

    2、《我想进大厂》之Dubbo普普通通9问

    3、长文详解:DUBBO源码使用了哪些设计模式


    看完本文有收获?请转发分享给更多人

    关注「ImportNew」,提升Java技能

    点赞和在看就是最大的支持❤️