Java小菜求职记-以前在Dubbo踩的坑,这次全被问到了,这下舒服了
前传
小林求职记(五)上来就一连串的分布式缓存提问,我有点上头....
终于,在小林的努力下,获得了王哥公司那边的offer,但是因为薪水没有谈妥,小林又重新进入了求职的旅途,在经历了多次求职过程之后,小林也大概地对求职的考点掌握地七七八八了,于是这次他重新书写了简历,投递了一家新的互联网企业。
距离面试开始还有大约十分钟,小林已经抵达了面试现场,并开始调整自己的状态。
过了不久,一个稍显消瘦,戴着黑色眼镜框的男人走了过来,估计这家伙就是小林这次的面试官了。
面试官:你好,请简单先做个自我介绍吧。
小林:嗯嗯,面试官你好,我是XXXX(此处省略200个字)
面试官:我看到你的项目里面有提及到dubbo,rpc技术这一技术栈正好和我们这边的匹配,我先问你些关于dubbo和rpc的技术问题吧。首先你能讲解下什么是rpc吗?
小林:好的,rpc技术其实简单地来理解就是不同计算机之间进行远程通信实现数据交互的一种技术手段吧。一个合理的rpc应该要分为server, client, server stub,client stub四个模块部分,
面试官:嗯嗯,你说的server stub,client stub该怎么理解呢?
小林:这个可以通过名字来识别进行理解,client stub就是将服务的请求的参数,请求方法,请求地址通过打包封装给成一个对象统一发送给server端。server stub就是服务端接收到这些参数之后进行拆解得到最终数据的结果。
在以前的单机版架构里面,两个方法进行相互调用的时候都是先通过内存地址查询到对应的方法,然后调用执行,但是分布式环境下不同的进程是可能存在于不同的机器中的,因此在通过原先的寻址方式调用函数就不可行了,这个时候就需要结合网络io的手段来进行服务的”交流“。
面试官:了解,你对rpc本质还是有自己的理解。可以大致讲解下dubbo在工程中启动的时候的一些整体流程吗?
小林:嗯嗯(猛地想起了之前写的一些笔记内容)
在工程进行启动的时候(假设使用spring容器进行bean的托管),首先会将bean注册到spring容器中,然后再将对应的服务注册到zk中,实现对外暴露服务。
面试官:可以说说在源码里面的核心设计吗?假设说某个dubbo服务没有对外暴露成功,你会如何去做分析呢?
小林:嗯嗯。其实可以先通过阅读启动日志进行分析,dubbo的启动顺序并不是直接就进行zk的连接,而是先校验配置文件是否正确,然后是否已经将bean都成功注册到了Spring的ioc容器中,接下来才是连接zk并且将服务进行注册的环节。
如果确保服务的配置无误,那么问题可能就是出在连接zk的过程了。
面试官:嗯嗯,有一定的逻辑依据,挺好的。你有了解过服务暴露的细节点吗?例如说dubbo是如何将自己的服务提供者信息写入到注册中心(zookeeper)的呢?
小林:我在阅读dubbo对外进行服务暴露的源代码时印象中对ServiceConfig这个类比较熟系。在实现对外做服务暴露的时候,这里面的有个加了锁的export函数,内部会先对dubbo的配置进行校验,首先判断是否需要对外暴露,然后是是否需要延迟暴露,如果需要延迟暴露则会通过ScheduledExecutorService去做延迟暴露的操作,否则立即暴露,即执行doExport方法
在往源码里面分析,会看到一个叫做doExportUrls的函数,这里面写明了关于注册的细节点:
private void doExportUrls() { List<URL> registryURLs = loadRegistries(true); for (ProtocolConfig protocolConfig : protocols) { String pathKey = URL.buildKey(getContextPath(protocolConfig).map(p -> p + "/" + path).orElse(path), group, version); ProviderModel providerModel = new ProviderModel(pathKey, ref, interfaceClass); ApplicationModel.initProviderModel(pathKey, providerModel); //暴露对外的服务内容 核心 doExportUrlsFor1Protocol(protocolConfig, registryURLs); } }
实现注册中心的服务暴露核心点:
doExportUrlsFor1Protocol内部的代码 Invoker<?> invoker = PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, registryURL.addParameterAndEncoded(EXPORT_KEY, url.toFullString())); //这里有一个使用了委派模型的invoker DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker, this); //服务暴露的核心点 Exporter<?> exporter = protocol.export(wrapperInvoker); exporters.add(exporter);
最终在org.apache.dubbo.registry.zookeeper.ZookeeperRegistry#doRegister函数里面会有一步熟系的操作,将dubbo的服务转换为url写入到zk中做持久化处理:
并且这里写入的数据节点还是非持久化的节点
面试官: 看来你对服务注册的这些原理还是有过一定深入的理解啊。你以前的工作中是有遇到过源码分析的情况吗?对这块还蛮清晰的。
小林:嗯嗯,之前在工作中有遇到过服务启动异常,一直报错,但是又没人肯帮我,所以这块只好硬着头皮去学习。后来发现了解了原理以后,对于dubbo启动报错异常的分析还是蛮有思路的。
面试官:嘿嘿,挺好的,那你对于使用dubbo的时候又遇到过dubbo线程池溢出的情况吗?
小林:嗯嗯,以前工作中在做这块的时候有遇到过。
面试官: 嘿嘿,跟我讲讲你自己对于dubbo内部的线程池这块的分析吧。
小林:嗯嗯,可以的。
接下来小林进行了一番压测场景的讲解:
其实dubbo的服务提供者端一共包含了两类线程池,一类叫做io线程池,还有一类叫做业务线程池,它们各自有着自己的分工,如下图所示:
dubbo在服务提供方中有io线程池和业务线程池之分。可以通过调整相关的dispatcher参数来控制将请求处理交给不同的线程池处理。(下边列举工作中常用的几个参数:)
all:将请求全部交给业务线程池处理(这里面除了日常的消费者进行服务调用之外,还有关于服务的心跳校验,连接事件,断开服务,响应数据写回等)
execution:会将请求处理进行分离,心跳检测,连接等非业务核心模块交给io线程池处理,核心的业务调用接口则交由业务线程池处理。
假设说我们的dubbo接口只是一些简单的逻辑处理,例如说下方这类:
@Service(interfaceName = "msgService") public class MsgServiceImpl implements MsgService { @Override public Boolean sendMsg(int id, String msg) { System.out.println("msg is :"+msg); return true; } }
并没有过多的繁琐请求,并且我们手动设置线程池参数:
dubbo.protocol.threadpool=fixed dubbo.protocol.threads=10 dubbo.protocol.accepts=5
当线程池满了的时候,服务会立马进入失败状态,此时如果需要给provider设置等待队列的话可以尝试使用queues参数进行设置。
dubbo.protocol.queues=100
但是这个设置项虽然看似能够增大服务提供者的承载能力,却并不是特别建议开启,因为当我们的provider承载能力达到原先预期的限度时,通过请求堆积的方式继续请求指定的服务器并不是一个合理的方案,合理的做法应该是直接抛出线程池溢出异常,然后请求其他的服务提供者。
测试环境:Mac笔记本,jvm:-xmx 256m -xms 256m
接着通过使用jmeter进行压力测试,发现一秒钟调用100次(大于实际的业务线程数目下,线程池并没有发生溢出情况)。这是因为此时dubbo接口中的处理逻辑非常简单,这么点并发量并不会造成过大影响。(几乎所有请求都能正常抗住)
图片
但是假设说我们的dubbo服务内部做了一定的业务处理,耗时较久,例如下方:
@Service(interfaceName = "msgService")
public class MsgServiceImpl implements MsgService {
@Override
public Boolean sendMsg(int id, String msg) throws InterruptedException {
System.out.println("msg is :"+msg);
Thread.sleep(500);
return true;
}
}
此时再做压测,解果就会不一样了。
此时大部分的请求都会因为业务线程池中的数目有限出现堵塞,因此导致大量的rpc调用出现异常。可以在console窗口看到调用出现大量异常:
将jmeter的压测报告进行导出之后,可以看到调用成功率大大降低,
也仅仅只有10%左右的请求能够被成功处理,这样的服务假设进行了线程池参数优化之后又会如何呢?
1秒钟100个请求并发访问dubbo服务,此时业务线程池专心只处理服务调用的请求,并且最大线程数为100,服务端最大可接纳连接数也是100,按理来说应该所有请求都能正常处理
dubbo.protocol.threadpool=fixed dubbo.protocol.dispatcher=execution dubbo.protocol.threads=100 dubbo.protocol.accepts=100
还是之前的压测参数,这回所有的请求都能正常返回。
ps:提出一个小问题,从测试报告中查看到平均接口的响应耗时为:502ms,也就是说其实dubbo接口的承载能力估计还能扩大个一倍左右,我又尝试加大了压测的力度,这次看看1秒钟190次请求会如何?(假设线程池100连接中,每个连接对请求的处理耗时大约为500ms,那么一秒时长大约能处理2个请求,但是考虑到一些额外的耗时可能达不到理想状态那么高,因此设置为每秒190次(190 <= 2*100)请求的压测)
但是此时发现请求的响应结果似乎并没有这么理想,这次请求响应的成功率大大降低了。
jmeter参数:
图片
请求结果:
图片
面试官:哦,看来你对线程池这块的参数还是有一定的研究哈。
面试官:你刚刚提到了请求其他服务提供者,那么你对于dubbo的远程调用过程以及负载均衡策略这块可以讲讲吗?最好能够将dubbo的整个调用链路都讲解一遍?
小林思考了一整子,在脑海中整理了一遍dubbo的调用链路,然后开始了自己的分析:
小林:这整个的调用链路其实是非常复杂的,但是我尝试将其和你阐述清楚。
衔接我上边的服务启动流程,当dubbo将服务暴露成功之后,会在zk里面记录相关的url信息
图片
此时我们切换视角回归到consumer端来分析。假设此时consumer进行了启动,启动的过程中,会触发一个叫做get的函数操作,这个操作位于ReferenceConfig中。
图片
首先是检查配置校验,然后再是进行初始化操作。在init操作中通过断点分析可以看到一个叫做createProxy的函数,在这里面会触发创建dubbo的代理对象。可以通过idea工具分析,此时会传递一个包含了各种服务调用方的参数进入该函数中。
图片
在createProxy这个方法的名字上边可以分析出,这时候主要是创建了一个代理对象。并且还优先会判断是否走jvm本地调用,如果不是的话,则会创建远程调用的代理对象,并且是通过jdk的代理技术进行实现的。
最终会在org.apache.dubbo.registry.support.ProviderConsumerRegTable#registerConsumer里面看到consumer调用服务时候的一份map关系映射。这里面根据远程调用的方法名称来识别对应provider的invoker对象
图片
最后当需要从consumer对provider端进行远程调用的时候,会触发一个叫做:DubboInvoker的对象,在这个对象内部有个叫做doInvoke的操作,这里面会将数据的格式进行封装,最终通过netty进行通信传输给provider。并且服务数据的写回主要也是依靠netty来处理。
ps:
dubbo的整体架构图
面试官:嗯嗯,你大概讲解了一遍服务的调用过程,虽然源码部分讲解地挺细(面试官也听得有点点晕~~),但是我还是想问你一些关于更加深入的问题。你对于netty这块有过相关的了解吗?
小林:好像在底层中是通过netty进行通信的。这块的通信机制原理之前有简单了解过一些。
面试官:能讲解下netty里面的粘包和拆包有所了解吗?
小林:哈哈,可以啊。其实粘包和拆包是一件我们在研发工作中经常可能遇到的一个问题。一般只有在TCP网络上通信时才会出现粘包与拆包的情况。
正常的一次网络通信:
客户端和服务端进行网络通信的时候,client给server发送了两个数据包,分别是msg1,msg2,理想状态下可能数据的发送情况如下:
但是在网络传输中,tcp每次发送都会有一个叫做Nagle的算法,当发送的数据包小于mss(最大分段报文体积)的时候,该算法会尽可能将所有类似的数据包归为同一个分组进行数据的发送。避免大量的小数据包发送,因为发送端通常都是收到前一个报文确认之后才会进行下个数据包的发送,因此有可能在网络传输数据过程中会出现粘包的情况,例如下图:
两个数据包合成一个数据包一并发送
某个数据包的数据丢失了一部分,缺失部分和其他数据包一并发送
为了防止这种情况发生,通常我们会在服务端制定一定的规则来防范,确保每次接收的数据包都是完整的数据信息。
netty里面对于数据的粘包拆包处理机制主要是通过ByteToMessageDecoder这款编码器来进行实现的。常见的手段有:定长协议处理,特殊分隔符,自定义协议方式。
面试官:哦,看来你对这块还是有些了解的哈。行吧,那先这样吧,后边是二面,你先在这等一下吧。
小林长舒一口气,瞬间感觉整个人都轻松多了。
(未完待续...)
《《--扫描二维码关注他!
【Java知音】公众号,每天早上8:30为您准时推送一篇技术文章
在Java知音公众号内回复“面试题聚合”,送你一份Java面试题宝典。