前傳
小林求職記(五)上來就一連串的分布式緩存提問,我有點上頭....
終於,在小林的努力下,獲得了王哥公司那邊的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這款編碼器來進行實現的。常見的手段有:定長協議處理,特殊分隔符,自定義協議方式。
面試官:哦,看來你對這塊還是有些了解的哈。行吧,那先這樣吧,后邊是二面,你先在這等一下吧。
小林長舒一口氣,瞬間感覺整個人都輕松多了。
(未完待續...)