前言
這篇文章主要記錄學習Dubbo注冊中心的相關內容,包括:注冊中心的工作原理;注冊中心的數據結構;訂閱發布的實現;緩存機制;重試機制;服務注冊與發現流程中Dubbo使用了哪些設計模式。
注冊中心概述
在Dubbo微服務體系中,注冊中心是其核心的組件之一。Dubbo通過注冊中心實現了分布式環境中各服務之間的注冊與發現,是各個分布式節點之間的紐帶。主要作用有:
(1)動態加入:一個服務提供者通過注冊中心可以動態的把自己暴露給其他消費者,無需消費者逐個去更新配置文件。
(2)動態發現:一個消費者可以動態感知新的配置、路由規則和新的服務提供者,無需重啟服務使之生效。
(3)動態調整:注冊中心支持參數的動態調整,新參數自動更新到所有相關服務節點。
(4)統一配置:避免了本地配置導致每個服務的配置不一致問題。
Dubbo注冊中心源碼在dubbo-registry中,里面包含了五個子模塊:
從dubbo-registry模塊可以看出,dubbo主要包含四種注冊中心的實現,分別是:Zookeeper、Redis、Simple、Multicast.
其中Zookeeper是官方推薦的注冊中心實現,在生產環境中已經有大量實際使用,具體的實現在Dubbo的源碼 dubbo-registry-zookeeper模塊中。而Redis注冊中心在穩定性方面相比ZK就差了一些,其穩定性主要是依賴Redis本身,所以被使用的應該也不多吧。Simple是一個基于內存的簡單的注冊中心實現,它本身就是一個標準的RPC服務,不支持集群,可能出現單點故障。Multicast模式則不需要啟動任何注冊中心,只要通過廣播地址,就可以互相發現。服務提供者啟動時,會廣播自己的地址,消費者啟動時,會廣播訂閱請求,服務提供者收到訂閱請求,會根據配置廣播或單播給訂閱者。
Dubbo擁有良好的擴展性,如果以上注冊中心都不滿足需求,那么我們還可以基于RegistryFactory和Registry自行擴展。
工作流程
注冊中心的總體流程比較簡單,Dubbo官方也有比較詳細的說明,總體流程如下:
Provider注冊:Provider啟動時,會向注冊中心寫入自己的元數據信息,同時會訂閱配置元數據信息。
Consumer訂閱:這里的訂閱指Consumer啟動時也會向注冊中心寫入自己的元數據信息,并訂閱服務提供者、路由和配置元數據信息。
服務治理中心啟動:dubbo-admin啟動時,會同時訂閱所有消費者、服務提供者、路由和配置元數據信息。
動態注冊、發現:當有新的Provider加入或者有離開時,注冊中心服務提供者目錄會發生變化,變化信息會動態通知給消費者、服務治理中心。
監控中心采集:當Consumer發起調用時,會異步將調用、統計信息等上報給監控中心。
數據結構
注冊中心的總體流程相同,但是不同的注冊中心有不同的實現方式,其數據結構也不相同。Zookeeper、Redis等注冊中心都實現了這個流程。由于有些注冊中心并不常用,因此我們下面將只重點關注下Zookeeper與Redis兩種實現的數據結構。
Zookeeper注冊中心實現原理概述
Zookeeper是樹形結構的注冊中心,每個節點的類型分為持久點、持久順序節點、臨時節點和臨時順序節點。Zookeeper節點的基本了解,可以查看我的這篇文章:http://www.relaxheart.cn/to/master/blog?uuid=140 。
Dubbo使用ZK作為注冊中心時,只會創建臨時節點和持久節點兩種,對創建順序并沒有要求。
/dubbo/com.foo.BarService/providers是服務提供者在Zookeeper注冊中心的路徑示例,是一種屬性結構,該結構分為四層:root(根節點,對應示例總的dubbo)、service(接口名稱,對應示例中的com.foo.BarService)、四種服務目錄(對應示例中的prividers,其他目錄還有consumers、routers、configurators)。在服務分類節點下是具體的Dubbo服務URL。屬性結構示例如下:
樹形結構的關系:
(1)樹的根節點是注冊中心分組,下面有多個服務接口,分組值來自用戶配置<dubbo:registry>中的group屬性,默認是/dubbo.
(2)服務接口下包含四類子目錄,分別是providers、consumers、routers、configurators,這個路徑時持久節點。
(3)服務提供者目錄(/dubbo/service/providers)下面包含的接口有多個服務提供者URL元數據信息。
(4)服務消費者目錄(/dubbo/service/consumers)下面包含的解耦有多個消費者URL元數據信息。
(5)路由配置目錄(/dubbo/service/routers)下面包含多個用于消費者路由策略URL元數據信息。
(6)動態配置目錄(/dubbo/service/configurators)下面包含多個用于服務者動態配置URL元數據信息。
樹形示意圖,如下:
配置實現:
<beans>
<!-- 適用于Zookeeper一個集群有多個節點,多個IP和端口逗號分割-->
<dubbo:registry protocol="zookeeper" address="ip:port,ip:port,ip:port" />
<!-- 適用于Zookeeper多個集群有多個節點,多個IP和端口用豎線分割-->
<dubbo:registry protocol="zookeeper" address="ip:port|ip:port|ip:port" />
</beans>
Redis注冊中心實現原理概述
Redis注冊中心也沿用了Dubbo抽象的Root、Service、Type、URL四層結構,但是由于Redis屬于NoSQL數據庫,數據都是以鍵值對形式保存的,并不能像ZK一樣直接實現樹形目錄結構。因此Redis使用了key/Map結構實現了這個需求,Root、Service、Type組合成Redis的key,Redis的value是一個Map結構,URL作為Map的key,超時時間作為Map的value,如下示意圖:
數據結構組裝邏輯在 org.apache.dubbo.registry.redis.RedisRegistry # doRegister(URL url)方法中,總要的幾行代碼以用注釋標記如下:
@Override
public void doRegister(URL url) {
// 生成 Redis key
String key = toCategoryPath(url);
// 生成 URL
String value = url.toFullString();
// 計算過期時間
String expire = String.valueOf(System.currentTimeMillis() + expirePeriod);
boolean success = false;
RpcException exception = null;
for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
JedisPool jedisPool = entry.getValue();
try {
try (Jedis jedis = jedisPool.getResource()) {
// 注冊到注冊中心, expire是超時時間
jedis.hset(key, value, expire);
jedis.publish(key, REGISTER);
success = true;
if (!replicate) {
break; // If the server side has synchronized data, just write a single machine
}
}
} catch (Throwable t) {
exception = new RpcException("Failed to register service to redis registry. registry: " + entry.getKey() + ", service: " + url + ", cause: " + t.getMessage(), t);
}
}
if (exception != null) {
if (success) {
logger.warn(exception.getMessage(), exception);
} else {
throw exception;
}
}
}
發布/訂閱
發布/訂閱是整個注冊中心的核心功能之一。在傳統的應用系統中,我們通常會把配置信息寫入一個配置文件,當配置需要變更時修改配置文件,在通過手動觸發內存中的配置重新加載,比如重啟服務等。在集群模式較小的情況下,這種方式到也可以方便運維,但是當服務節點數量不斷上升時候,這種管理方式的的維護成本越來越高,同時手動觸發內存更新(重載內置)有一定的風險,對服務的可用性有可能造成短時間的破壞。
但是如果使用了注冊中心,上面的問題就可以很好的得到解決。通過監聽-通知的機制實現節點變化是即使的通過到相應的服務省去了服務列表維護成本,整個過程是自動完成的。
Zookeeper的實現
1.發布的實現
provider和Consumer需要將自己注冊到Zookeeper。服務提供者的注冊是為了讓消費者訂閱(準確來說應該叫感知服務的存在),從而發起遠程調用;也上服務治理中心感知有新的服務提供者上線。消費者的發布是為了讓服務治理中心可以發現自己。Zookeeper發布訂閱代碼非常簡單,只是調用Zookeeper 的 Client 庫在注冊中心創建一個目錄而已,如下代碼所示:
@Override
public void doRegister(URL url) {
try {
// 調用zkClient的create方法創建一個目錄即可
zkClient.create(toUrlPath(url), url.getParameter(DYNAMIC_KEY, true));
} catch (Throwable e) {
throw new RpcException("Failed to register " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
}
}
取消發布對應也很簡單,只是把ZK注冊中心上對應的路徑刪除,如下代碼所示:
@Override
public void doUnregister(URL url) {
try {
// 刪除ZK相應的目錄
zkClient.delete(toUrlPath(url));
} catch (Throwable e) {
throw new RpcException("Failed to unregister " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
}
}
2.訂閱的實現
訂閱通常有pull和push兩種方式,一種是客戶端定時輪訓注冊中心拉取配置,另一種是注冊中心主動推送數據給客戶端。這兩種方式各有利弊,目前Dubbo采用的是第一次啟動拉取方式,后續接收事件重新拉取數據。
在服務暴露時,服務端會訂閱configurators用于監聽動態配置,在消費端啟動時,消費端會訂閱providers、routers和configurators這三個目錄,分別對應服務提供者、路由和動態配置變更通知。
** Dubbo 中有哪些Zookeeper客戶端實現? **
提供了兩種不同ZK開源客戶端庫的封裝,分別對應接口:
- Apache Curator
- zkClient
我們可以在<dubbo:registry> 的client屬性中設置curator、zkClient來使用不同的客戶端實現庫,如果不設置默認使用Curator作為實現。
Zookeeper客戶端采用的是“事件通知” + “客戶端拉取”的方式,客戶端在第一次連接上注冊中心時,會獲取對應目錄西安全量的數據。并在訂閱的節點上注冊一個watcher,客戶端與注冊中心之間保持TCP長連接,后續每個節點有任何數據變化的時候,注冊中心會根據watcher的回調主動通知客戶端(事件通知),客戶端接到通知后,會把對應節點下的全量數據都拉取過來(客戶端拉取),這一點在NotifyListener#notify(List<URL> urls)接口上就有說明。全量拉取有一個局限,黨委服務節點較多時會對網絡造成很大的壓力。
Zookeeper每個節點都有一個版本號,當某個節點的數據發生變化時,對應的版本號就會變化,并 觸發watcher事件,推送數據給訂閱方。版本號強調的是變化次數,即使該節點的值沒有變化,只要有更新操作,依然會是版本號變化。
Zookeeper實現服務訂閱的核心代碼在ZookeeperRegistry中,包含COnsumer與Dubbo服務治理中心訂閱的邏輯:
@Override
public void doSubscribe(final URL url, final NotifyListener listener) {
try {
if (ANY_VALUE.equals(url.getServiceInterface())) {
/*** 服務治理中心訂閱全部服務 ***/
// 訂閱所有數據
String root = toRootPath();
// 獲取Listeners
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.get(url);
if (listeners == null) {
// 為獲取到監聽器,這里創建一個監聽器并放入緩存。
zkListeners.putIfAbsent(url, new ConcurrentHashMap<>());
listeners = zkListeners.get(url);
}
ChildListener zkListener = listeners.get(listener);
if (zkListener == null) {
// zkListener為空,說明是第一次,新建一個listener
listeners.putIfAbsent(listener, (parentPath, currentChilds) -> {
// 這是一個內部類實現,不會立即執行,只會在觸發變更通知時執行
// 如果子節點有變化則會接收到通知,遍歷所有子節點
for (String child : currentChilds) {
child = URL.decode(child);
// 如果存在子節點還未被訂閱,說明是新增節點嗎,則進行訂閱
if (!anyServices.contains(child)) {
anyServices.add(child);
// 訂閱新節點
subscribe(url.setPath(child).addParameters(INTERFACE_KEY, child,
Constants.CHECK_KEY, String.valueOf(false)), listener);
}
}
});
zkListener = listeners.get(listener);
}
// 創建持久節點,接下來訂閱持久節點的直接子節點
zkClient.create(root, false);
List<String> services = zkClient.addChildListener(root, zkListener);
if (CollectionUtils.isNotEmpty(services)) {
// 遍歷所有子節點進行訂閱
for (String service : services) {
service = URL.decode(service);
anyServices.add(service);
subscribe(url.setPath(service).addParameters(INTERFACE_KEY, service,
Constants.CHECK_KEY, String.valueOf(false)), listener);
}
}
} else {
/*** 普通消費者服務訂閱 ***/
List<URL> urls = new ArrayList<>();
//toCategoriesPath(url): 根據url類別,獲取一組要訂閱的路徑
for (String path : toCategoriesPath(url)) {
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.get(url);
if (listeners == null) {
// 如果listeners緩存為空則創建緩存
zkListeners.putIfAbsent(url, new ConcurrentHashMap<>());
listeners = zkListeners.get(url);
}
ChildListener zkListener = listeners.get(listener);
// 如果zkListener緩存為空則創建緩存
if (zkListener == null) {
listeners.putIfAbsent(listener, (parentPath, currentChilds) -> ZookeeperRegistry.this.notify(url, listener, toUrlsWithEmpty(url, parentPath, currentChilds)));
zkListener = listeners.get(listener);
}
zkClient.create(path, false);
// 訂閱,返回該節點下的子路徑并緩存
List<String> children = zkClient.addChildListener(path, zkListener);
if (children != null) {
urls.addAll(toUrlsWithEmpty(url, path, children));
}
}
// 回調NotifyListener, 更新本地緩存信息
notify(url, listener, urls);
}
} catch (Throwable e) {
throw new RpcException("Failed to subscribe " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
}
}
Redis的發布訂閱
redis實現注冊中心的數據結構與Zookeeper不同,其在發布訂閱的方式上也是跟ZK不同的,關于Redis如何實現的我也只是粗略的看了下,所以這里就不展開了。
緩存機制
緩存的存在就是用空間換取時間的一種機制。想想,如果Consumer每次遠程調用都要先去注冊中心拉取一次可調用的服務列表,則會讓注冊中心承受巨大的流量壓力。另外,每個額外的網絡請求也會讓整個系統的性能下降,同時服務列表變化的頻率本身并不是很高,除非服務提供商對接口做了升級、或是服務節點新增或下線(從某各角度來看這并不是一個很高頻的操作),所以每次都拉取也就顯得并不那么必要。
緩存的實現
因此針對這個問題,dubbo的注冊中心實現了通用的緩存機制,在抽象類AbstractRegistry中實現。AbstractRegistry類結構關系圖如下所示:
消費者或者服務治理中心獲取注冊信息后會做本地緩存。內存中會有一份,保存在Properties對象里,磁盤里也會有一份文件,通過file對象引用。在AbstractRegistry抽象類中有如下定義:
/** 本地緩存對象 **/
private final Properties properties = new Properties();
/** 磁盤文件服務緩存對象 **/
private File file;
/** 內存中的服務緩存對象 **/
private final ConcurrentMap<URL, Map<String, List<URL>>> notified = new ConcurrentHashMap<URL, Map<String, List<URL>>>();
其中內存中的緩存notified是ConcurrentHashMap里面又封裝了一個Map,外層Map的key是消費者的URL,內層Map的key是分類,包含了providers、consumers、routers、configutators四種,value則對應的服務列表,對于沒有服務提供者提供服務的URL,它會以特殊的empty://前綴了開頭。
緩存的加載
在服務初始化時候,AbstractRegistry構造器函數里會從本地磁盤文件中把持久化的注冊數據到Properties對象里,并加載到內存緩存中,核心代碼如下:
private void loadProperties() {
if (file != null && file.exists()) {
InputStream in = null;
try {
// 讀取磁盤文件
in = new FileInputStream(file);
// 把數據寫入到內存緩存中
properties.load(in);
……
} catch (Throwable e) {
……
} finally {
……
}
}
}
Properties保存了所有服務提供者的URL,使用URL#serviceKey()作為key,提供者列表、路由規則列表、配置規則列表等作為value。如果應用在啟動過程中注冊中心無法連接或巖機,則Dubbo框架會自動通過本地緩存加載Invokers。
緩存的保存與更新
緩存的保存有同步和異步兩種方式。異步會使用線程池異步保存,如果線程在執行過程中出現異常,則會再次調用線程池不斷重試,代碼如下:
if(syncSaveFile){
// 同步保存
doSaveProperties(version);
} else {
// 異步保存,放入線程池。會傳入一個AtomicLong的版本號保證數據是最新的
registryCacheExecutor.execute(new SaveProperties(version));
}
AbstractRegistry#notify 方法中封裝了更新內存和本地文件緩存的邏輯。當客戶端第一次訂閱獲取全量數據的時候,或者后續由于訂閱的數據發生變更時,都會調用該方法進行保存。
重試機制
上面緩存實現部分給了一張類關系圖,可以看出FailbackRegistry繼承了AbstarctRegistry,并在此基礎上增加了失敗重試機制作為抽象能力。ZookeeperRegistry與RegisRegistry繼承該抽象法方法后,直接使用即可。
FailbackRegistry中定義了一個ScheduledExecutorService,每經過固定間隔(默認為5秒)調用FailbackRegistry#retry()方法。關于ScheduledExecutorService的用法可以查看我的這篇文章:初識ScheduledExecutorService。
Dubbo注冊中心使用了那些設計模式
模板模式與工廠模式,Dubbo注冊中心擁有良好的擴展性,我們可以在其基礎上快速開發出符合我們自己業務需求的注冊中心。這種擴展性和Dubbo中使用的設計模式莫不可分,學習這里提到的兩個主要的設計模式有助于我們對注冊中心源碼的閱讀。
模板模式
AbstractRegistry實現了Registry接口中的注冊、訂閱、查詢、通知等方法,還實現了磁盤文件持久化注冊信息這一通用方法。但是注冊、訂閱、查詢、通知等方法只是簡單地把URL加入對應的集合,沒有具體的注冊或訂閱邏輯。
FailbackRegistry 又集成了AbstractRegistry,重寫了父類的注冊、訂閱、查詢、通知等方法,并且添加了重試機制。此外,還添加了四個尉氏縣的抽象模板方法,如下:
protected abstract void doRegister(URL url);
protected abstract void doUnregister(URL url);
protected abstract void doSubscribe(URL url, NotifyListener listener);
protected abstract void doUnsubscribe(URL url, NotifyListener listener);
工廠模式
所有的注冊中心實現都是通過工廠創建的。類圖:
AbstractRegistryFactory 實現了 RegistryFactory 接口的 getRegistry(URL url)方法,是一個通用實現,主要完成了加鎖,以及調用抽象模板方法 createRegistry(URL url)創建具體實現等操作,并緩存在內存中。抽象模板方法會有具體子類繼承實現。
雖然每種注冊中心都有自己的工廠實現,但是在什么地方判斷,應該調用哪個工廠實現呢?? 代碼中并沒有顯式的判斷。答案就在RegistryFactory接口中,該接口里有一個Registry getRegistry(URL url)方法,該方法上有@Adaptive({"protocol"})注解,如下:
@SPI("dubbo")
public interface RegistryFactory {
@Adaptive({"protocol"})
Registry getRegistry(URL url);
}
了解AOP的話,應該知道這個注解會自動織入一些邏輯,它的value參數會從URL中獲取protocol鍵的值,并根據獲取的值來調用不同的工廠類。
總結
本文從注冊中心的工作原理;注冊中心的數據結構;訂閱發布的實現;緩存機制;重試機制;服務注冊與發現流程中Dubbo使用了哪些設計模式幾個方面對Dubbo注冊中心組件的核心內容做了說明。
更多個人博客,歡迎訪問我的個人博客網:Tec博客