今天想和大家聊聊Dubbo原始碼中實現的一個註冊中心擴充套件。它很特殊,也幫我解決了一個困擾已久的問題,剛剛在生產中用了,效果很好,迫不及待想分享給大家。
Dubbo的擴充套件性非常靈活,可以無侵入原始碼載入自定義擴充套件。能擴充套件協議、序列化方式、註冊中心、執行緒池、過濾器、負載均衡策略、路由策略、動態代理等等,甚至「擴充套件本身」也可以擴充套件。
在介紹今天的這個註冊中心擴充套件之前,先丟擲一個問題,大家思考一下。
如何低成本遷移註冊中心?
有時出於各種目的需要遷移Dubbo的註冊中心,或因為覺得Nacos比較香,想從Zookeeper遷移到Nacos,或因前段時間曝出Consul禁止在中國境內使用。
遷移註冊中心的方案大致有兩種:
-
方案一:使用Dubbo提供的多註冊中心能力,Provider先進行雙註冊,Consumer逐步遷移消費新註冊中心,最後下線老註冊中心。該方案的缺點是修改時有上下游依賴關係。
-
方案二:使用一個同步工具把老註冊中心的資料同步到新註冊中心,Consumer逐步遷移到新註冊中心,最後下線老註冊中心。同步工具有開源的Nacos-sync,我之前的文章《zookeeper到nacos的遷移實踐》就提到了這個方案。這個方案的缺點是架構變得複雜,需要解決同步資料的順序性、一致性、同步元件的高可用等問題。
Nacos-sync 參考 https://github.com/nacos-group/nacos-sync
我們從「業務方成本」和「基礎架構成本」兩個角度考慮一下這兩個方案:
業務方成本我們以每次業務方修改並上線程式碼為1個單位,基礎架構成本以增加一個新服務端元件為2個單位,從複雜度上來說基礎架構成本遠遠高於業務方修改並上線程式碼,但這裡我們認為只是2倍關係,做過基礎元件開發的同學肯定感同身受,推動別人改程式碼比自己埋頭寫程式碼要難。
我們統計下上述方案中,遷移一對Consumer和Provider總共需要的成本是多少:
- 方案一:Provider雙註冊+1;Consumer消費新註冊中心+1;Provider下線舊註冊中心+1;總成本為3
- 方案二:同步元件+2;Consumer消費新註冊中心+1;Provider下線舊註冊中心+1;總成本為4
有沒有成本更低的方案?
首先我們不考慮引入同步元件,其次Provider和Consumer能否不修改就能解決?我覺得理論上肯定可以解決,因為Java的位元組碼是可以動態修改的,肯定能達到這個目的,但這樣的複雜度和風險會非常高。
退一步能否每個應用只修改釋出一次就完成遷移?
Dubbo配置多註冊中心可以參考這篇文章《幾個你不知道的dubbo註冊中心細節》,你會發現多註冊中心是通過配置檔案配置的,如下
dubbo.registries.zk1.address=zookeeper://127.0.0.1:2181
dubbo.registries.zk2.address=zookeeper://127.0.0.1:2182
只修改一次程式碼,就必須把這個配置變成動態的,有點難,但不是做不到,可在應用啟動時遠端載入配置,或者採取替換配置檔案的方式來達到目的。
但這隻解決了部分問題,還有兩個問題需要解決:
- Dubbo註冊、訂閱都發生在應用啟動時,應用啟動後就沒法修改了。也不是完全不能,如果採用了api的方式接入Dubbo可以通過改程式碼來實現,但幾乎這種方式不會被採用;
- Dubbo消費沒法動態切換,多註冊中心消費時,Dubbo預設的行為是挑第一個可用的註冊中心進行呼叫,無法主動地進行切換;如果實現了主動切換還有個好處是穩定性提高了很多,萬一新註冊中心出現問題還可以及時切回去。
這裡針對第二點的消費邏輯做一點簡單說明,老版本(<2.7.5)邏輯比較簡單粗暴,程式碼位於RegistryAwareClusterInvoker
:
- 挑選第一個可用的註冊中心進行呼叫
新版本(>=2.7.5)則稍微豐富一點,程式碼位於ZoneAwareClusterInvoker
:
- 挑選一個可用且帶
preferred
偏好配置的註冊中心進行呼叫,注意這個偏好配置不同版本key還不一樣,有點坑 - 如果都不符合1,則挑選同一個分割槽且可用的註冊中心進行呼叫,分割槽也是通過引數配置,這個主要是為了跨機房的就近訪問
- 如果1、2都不符合,通過一個負載均衡演算法挑選出一個可用的註冊中心進行呼叫
- 如果1、2、3都不合法,則挑選一個可用的註冊中心
- 如果上述都不符合,則使用第一個註冊中心進行呼叫
可以看出新版本功能很豐富,但它是有版本要求的,而且控制的key也變來變去,甚至去搜一下也有Bug存在,所以如果是單一穩定的高版本是可以通過這個來做,但大部分還是達不到這個要求。
很長一段時間以來,我都沒有想到一個好的辦法來解決這個問題,甚至我們公司內部有直接修改Dubbo原始碼來實現動態切換消費的能力,但這種入侵修改無法持續,直到有一天瀏覽Dubbo原始碼時,無意間看到了MultipleRegistry,彷彿發現了新大陸,用醍醐灌頂來形容一點不為過。
MultipleRegistry,有點意思!
MultipleRegistry是Dubbo 2.7.2引入的一個註冊中心擴充套件,註冊中心擴充套件圈起來,要考!意味著這個擴充套件可以在任何>=2.7.0版本上執行,稍微改改也能在2.7以下的版本使用
這究竟是個什麼註冊中心的擴充套件呢?
實際上這個擴充套件並不是一個實際的註冊中心的擴充套件,而是一個包裝,它本身不提供服務註冊發現的能力,它只是把其他註冊中心聚合起來的一個空殼。
為什麼這個「空殼」這麼厲害呢?下面我們就來分析分析原始碼。
由於剛好手上有3.0.0版本的原始碼,所以接下來的原始碼分析基於Dubbo 3.0.0版本,也不用擔心版本問題,這個擴充套件自從2.7.2引入之後幾乎沒有大的改動,只有Bugfix,所以什麼版本基本都差不多。只分析介面級服務發現,應用級的暫時不分析,原理類似。
不過在講原始碼之前,還得說說Dubbo註冊中心外掛的執行原理,否則原始碼可能看不懂,我們以開發一個註冊中心擴充套件為例:
- Dubbo註冊中心擴充套件需實現RegistryService和RegistryFactory介面
public interface RegistryService {
void register(URL url);
void unregister(URL url);
void subscribe(URL url, NotifyListener listener);
void unsubscribe(URL url, NotifyListener listener);
List<URL> lookup(URL url);
}
這裡的五個介面分別是註冊、登出、訂閱、取消訂閱、查詢,在Dubbo應用啟動時會呼叫這些介面。
都比較好理解,需要提一下subscribe介面。
subscribe傳入了一個NotifyListener引數,可以理解為一個回撥,當監聽的的URL發生變化時,呼叫這個NotifyListener通知Dubbo。
public interface NotifyListener {
void notify(List<URL> urls);
}
NotifyListener也是個介面,只有一個notify方法,這個方法傳入的引數是所消費的URL的所有Provider列表。
@SPI("dubbo")
public interface RegistryFactory {
@Adaptive({"protocol"})
Registry getRegistry(URL url);
}
RegistryFactory是描述瞭如何建立Registry擴充套件的工廠類,URL就是配置中
zookeeper://127.0.0.1:2181
- 還需要遵守Dubbo SPI的載入規則擴充套件才能被正確載入
這些內容官方文件中說的比較清楚,如果有疑問可以看看Dubbo的官方文件說明。
簡單介紹到此結束,接下來重點介紹MultipleRegistry。
首先看初始化,程式碼只挑出重點,在初始化MultipleRegistry時,分別對註冊和訂閱的註冊中心進行初始化,這些註冊中心來自MultipleRegistry的URL配置,URL上的key分別為service-registry
、reference-registry
,實際測試下來URL的引數中帶奇怪的字元會導致編譯不通過,不過這並不是重點,基本的還是可用,而且也不一定要採用這種配置。
public MultipleRegistry(URL url, boolean initServiceRegistry, boolean initReferenceRegistry) {
...
Map<String, Registry> registryMap = new HashMap<>();
// 初始化註冊的註冊中心
if (initServiceRegistry) {
initServiceRegistry(url, registryMap);
}
// 初始化訂閱的註冊中心
if (initReferenceRegistry) {
initReferenceRegistry(url, registryMap);
}
...
}
我們再看註冊和訂閱:
註冊比較簡單,只需要對剛剛初始化的serviceRegistries都進行註冊即可
public void register(URL url) {
super.register(url);
for (Registry registry : serviceRegistries.values()) {
registry.register(url);
}
}
訂閱時也是針對referenceRegistries的每個註冊中心都訂閱,但這裡有個不同的點是NotifyListener的妙用。
public void subscribe(URL url, NotifyListener listener) {
MultipleNotifyListenerWrapper multipleNotifyListenerWrapper = new MultipleNotifyListenerWrapper(listener);
multipleNotifyListenerMap.put(listener, multipleNotifyListenerWrapper);
for (Registry registry : referenceRegistries.values()) {
SingleNotifyListener singleNotifyListener = new SingleNotifyListener(multipleNotifyListenerWrapper, registry);
multipleNotifyListenerWrapper.putRegistryMap(registry.getUrl(), singleNotifyListener);
registry.subscribe(url, singleNotifyListener);
}
super.subscribe(url, multipleNotifyListenerWrapper);
}
先用MultipleNotifyListenerWrapper把最原始的NotifyListener包裝起來,NotifyListener傳給每個被包裝的註冊中心。MultipleNotifyListenerWrapper和SingleNotifyListener分別是什麼?
MultipleNotifyListenerWrapper將原始的NotifyListener進行包裝,且持有SingleNotifyListener的引用,它提供了一個方法notifySourceListener
的方法,將持有的SingleNotifyListener中上次變更的URL列表進行merge後呼叫最原始的NotifyListener.notify()
protected class MultipleNotifyListenerWrapper implements NotifyListener {
Map<URL, SingleNotifyListener> registryMap = new ConcurrentHashMap<URL, SingleNotifyListener>(4);
NotifyListener sourceNotifyListener;
...
public synchronized void notifySourceListener() {
List<URL> notifyURLs = new ArrayList<URL>();
URL emptyURL = null;
for (SingleNotifyListener singleNotifyListener : registryMap.values()) {
List<URL> tmpUrls = singleNotifyListener.getUrlList();
if (CollectionUtils.isEmpty(tmpUrls)) {
continue;
}
// empty protocol
if (tmpUrls.size() == 1
&& tmpUrls.get(0) != null
&& EMPTY_PROTOCOL.equals(tmpUrls.get(0).getProtocol())) {
// if only one empty
if (emptyURL == null) {
emptyURL = tmpUrls.get(0);
}
continue;
}
notifyURLs.addAll(tmpUrls);
}
// if no notify URL, add empty protocol URL
if (emptyURL != null && notifyURLs.isEmpty()) {
notifyURLs.add(emptyURL);
}
this.notify(notifyURLs);
}
...
}
再看SingleNotifyListener,它的notify去呼叫MultipleNotifyListenerWrapper的notifySourceListener
class SingleNotifyListener implements NotifyListener {
MultipleNotifyListenerWrapper multipleNotifyListenerWrapper;
Registry registry;
volatile List<URL> urlList;
@Override
public synchronized void notify(List<URL> urls) {
this.urlList = urls;
if (multipleNotifyListenerWrapper != null) {
this.multipleNotifyListenerWrapper.notifySourceListener();
}
}
...
}
仔細思考我們發現:
- MultipleNotifyListenerWrapper是個註冊中心擴充套件的包裝,它本身是沒有通知能力的,只能藉助的真實註冊中心擴充套件的通知能力
- SingleNotifyListener是真實的註冊中心的通知回撥,由它去呼叫MultipleNotifyListenerWrapper的notifySourceListener,呼叫前可將資料進行merge
如果你仔細讀完上面的文章你會發現,這不就是包裝了一下注冊中心擴充套件嗎?就這?哪裡醍醐灌頂了?
不著急,我們先扒一扒作者為什麼寫這樣一個擴充套件,他的初衷是想解決什麼問題?
作者說:我們可以在程式執行時下線(登出)服務,如果有個Dubbo服務同時註冊了Zookeeper和Nacos,而我只想登出其中一個註冊中心,MultipleRegistry就可以解決這種場景。
作者的初衷很簡單,但當我看到這個實現時,靈光乍現,感覺這個實現如果稍微改一改,簡直就是一個Dubbo多註冊中心遷移神器。
Dubbo多註冊中心遷移神器
Dubbo多註冊中心遷移神器具有什麼樣的特性?
- 可以動態(遠端配置)地註冊到一個或多個註冊中心,且在程式不重啟的情況下可以動態調整
- 可以動態(遠端配置)地消費某一個或多個註冊中心,同樣可以在程式不重啟的情況下可以動態調整
- 消費有兜底邏輯,比如配置了消費Zookeeper,但Zookeeper上可能只有A服務,B服務不存在,那麼呼叫B服務時可以用其他註冊中心的Provider來兜底,這就保證了註冊中心遷移過程中沒用上下游的依賴
如果上面說的能夠領會到,這些需求實現起來就很簡單:
- 啟動時,Provider和Consumer都分別監聽對應的配置項,按需註冊和消費,目前MultipleRegistry已經實現
- Dubbo應用執行中,配置項變更事件驅動
- Provider:觸發一個重新註冊、登出的事件,根據最新的配置項將需要註冊的註冊中心再註冊一遍,需要登出的註冊中心登出
- Consumer:觸發重新進行訂閱和取消訂閱,
- 消費兜底邏輯,將MultipleNotifyListenerWrapper中的notifySourceListener的merge邏輯進行重寫,可以實現有線消費、無對應Provider兜底消費。當然如果配置變更也需要觸發一次notify
按照這個思路,我已經實現了一個版本線上上跑了起來!不過耦合了公司內部的配置中心。
如果想不耦合,可以採用Dubbo SPI擴充套件的方式來擴充套件「讀取監聽配置變更部分」,擴充套件中的擴充套件,有點騷~
這篇文章有點長,最後來回顧一下講了啥:
首先文章從一個Dubbo註冊中心遷移成本的問題講起,現有的方案成本都是比較高,一直苦苦找尋更低成本、相容性更強的方案。終於在一次瀏覽Dubbo原始碼過程中發現了MultipleRegistry原始碼,經過研究發現只需要經過稍微的修改就能符合我們對完美動態註冊中心的定義。
在我寫這篇文章的時候,又試圖搜尋了一下Dubbo動態註冊中心,發現了「Kirito的技術分享」的一篇文章《平滑遷移 Dubbo 服務的思考》提到了阿里雲的一個產品的實現和上文提到的方案類似。
如果剛好你也有這個需求,可以用上文的思路實現看看,並不複雜,是不是感覺賺了一個億。
搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐。