Davids原理探究:Dubbo註冊中心(ZooKeeper、Redis)實現原理

Dubbo註冊中心(ZooKeeper、Redis)實現原理

註冊中心概述

在Dubbo微服務體系中,註冊中心是其核心組件之一。Dubbo通過註冊中心實現了分佈式環境中各服務之間的註冊和發現,是各分佈式節點之間的紐帶。其主要作用如下:

  1. 動態加入。一個服務提供者通過註冊中心可以動態地把自己暴露給其他消費者,無需消費者逐個去更新配置文件。
  2. 動態發現。一個消費者可以動態的感知新的配置,路由規則和新的服務提供者,無需重啓服務使之生效。
  3. 動態調整。註冊中心支持參數的動態調整,新參數自動更新到所有相關服務節點。
  4. 統一配置。避免了本地配置導致每個服務的配置不一致問題。

工作流程

  1. 服務提供者啓動時,會向註冊中心寫入自己的元數據信息,並訂閱配置元數據信息。
  2. 消費者啓動時,會向註冊中心寫入自己的元數據信息,並訂閱服務提供者,路由和配置元數據信息。
  3. 服務治理中心(dubbo-admin)啓動時,會同時訂閱所有消費者、服務提供者、路由和配置元數據信息。
  4. 當有服務提供者離開或者加入時,註冊中心服務提供者目錄會發生變化,變化信息會動態通知消費者、服務治理中心。
  5. 當消費者發起服務調用時,會異步將統計信息等上報給監控中心(dubbo-monitor-simple)。

原理概述

ZooKeeper原理概述

目錄結構:

+ /dubbo                  // 根目錄,默認dubbo
+-- service               // 服務目錄,如:com.example.dubbo.demo.spi.EchoService
	+-- providers         // 服務提供者目錄,下面包含的接口有多個服務者URL元數據信息(IP、端口、權重和應用名等信息)
	+-- consumers         // 服務消費者目錄,下面包含的接口有多個消費者URL元數據信息(IP、端口、權重和應用名等信息)
	+-- routers           // 路由配置目錄,下面包含多個用於消費者路由策略URL元數據信息
	+-- configurators     // 動態配置目錄,下面包含多個用於服務者動態配置URL元數據信息

目錄包含信息:

目錄名稱 儲存值樣例
/dubbo/service/providers dubbo://192.168.0.1.20880/com.alibaba.demo.Service?category=providers&key=value&…
/dubbo/service/consumers dubbo://192.168.0.1.5002/com.alibaba.demo.Service?category=consumers&key=value&…
/dubbo/service/routers dubbo://0.0.0.0/com.alibaba.demo.Service?category=routers&key=value&…
/dubbo/service/configurators dubbo://0.0.0.0/com.alibaba.demo.Service?category=configurators&key=value&…

Redis原理概述

Redis也沿用了Dubbo抽象的Root、Service、Type、URL四層結構。採用Hash結構存儲。

key field timeout
/dubbo/com.alibaba.demo.Service URL 10000

訂閱/發佈

ZooKeeper

發佈的實現

服務提供者和消費者都需要把自己註冊到註冊中心。服務提供者的註冊是爲了讓消費者感知服務的存在,從而發起遠程調用;也讓服務治理中心感知有新的服務提供者上線。消費者的發佈是爲了讓服務治理中心可以發現自己。

// ZookeeperRegistry 
public class ZookeeperRegistry extends FailbackRegistry {

	// 註冊即調用ZooKeeper客戶端在註冊中心創建了一個目錄
	@Override
	public void doRegister(URL url) {
	    try {
	        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);
	    }
	}
	
	// 取消發佈即調用ZooKeeper客戶端在註冊中心刪除對應目錄
	@Override
	public void doUnregister(URL url) {
	    try {
	        zkClient.delete(toUrlPath(url));
	    } catch (Throwable e) {
	        throw new RpcException("Failed to unregister " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
	    }
	}
}
訂閱的實現
  1. 訂閱通常有pull和push兩種方式,一種是客戶端定時輪詢註冊中心拉取配置,另一種是註冊中心主動推送數據給客戶端。這兩種方式各有利弊,目前Dubbo採用的是第一次啓動pull的方式,後續接收事件重新pull數據。
  2. ZooKeeper註冊中心採用的是“事件通知” + “客戶端拉取”的方式,客戶端第一次連接上註冊中心的時候會獲取對應目錄下的全量數據。並在訂閱的節點上註冊一個watcher,客戶端與註冊中心保持TCP長連接,後續每個節點有任何數據變化的時候,註冊中心會根據watcher的回調主動通知客戶端(事件通知),客戶端接收到通知後,會把對應節點下的全量數據都拉取下來(客戶端拉取)。
  3. ZooKeeper每個節點都有一個版本號,當某個節點數據發生變化(事務操作)的時候,該節點對應的版本號就會發生變化,並觸發watcher事件,推送數據給訂閱方。版本號強調的是變更次數,即使該節點的值沒有變化,只有更新操作,依然會是版本號變化。

事務操作
客戶端任何新增、修改、刪除、會話創建和失效操作,都會被認爲是事務操作,會由ZooKeeper集羣中的leader執行,即使客戶端連接的是非leader節點,請求也會被轉發給leader執行,以此來保證所有事務操作的全局時序性。由於每個節點都有一個版本號,因此可以通過CAS操作比較版本號來保證該節點數據操作的原子性。

  1. 客戶端第一次連上註冊中心,訂閱時會獲取全量的數據,後續則通過監聽器事件進行更新。服務治理中心會處理所有service層的訂閱,service被設置成特殊值*。此外,服務治理中心除了訂閱當前節點,還會訂閱這個節點下的所有子節點。
@Override
public void doSubscribe(final URL url, final NotifyListener listener) {
    try {
        // 判斷是否爲全量訂閱
        if (ANY_VALUE.equals(url.getServiceInterface())) {
        	// 獲取根路徑
            String root = toRootPath();
            // listeners 爲空說明緩存沒有命中,這裏把listeners 放入緩存
            ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
            // zkListener 爲空則證明是第一次,新建一個listener
            ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (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)), k);
                    }
                }
            });
            // 創建持久節點,開始訂閱持久節點下的直接子節點
            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<>();
            // 根據url獲取訂閱路徑
            for (String path : toCategoriesPath(url)) {
            	// listeners 爲空說明緩存沒有命中,這裏把listeners 放入緩存
                ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
                // zkListener 爲空則證明是第一次,新建一個listener
                ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (parentPath, currentChilds) -> ZookeeperRegistry.this.notify(url, k, toUrlsWithEmpty(url, parentPath, currentChilds)));
                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);
    }
}

// 根據URL獲取訂閱類型
private String[] toCategoriesPath(URL url) {
    String[] categories;
    if (ANY_VALUE.equals(url.getParameter(CATEGORY_KEY))) {
        categories = new String[]{PROVIDERS_CATEGORY, CONSUMERS_CATEGORY, ROUTERS_CATEGORY, CONFIGURATORS_CATEGORY};
    } else {
    	// 非全量訂閱指定類別provides,DEFAULT_CATEGORY = PROVIDERS_CATEGORY
        categories = url.getParameter(CATEGORY_KEY, new String[]{DEFAULT_CATEGORY});
    }
    String[] paths = new String[categories.length];
    for (int i = 0; i < categories.length; i++) {
        paths[i] = toServicePath(url) + PATH_SEPARATOR + categories[i];
    }
    return paths;
}
  1. URL中的屬性值類別
    1. provides:訂閱方會更新本地Directory管理的Invoker服務列表;
    2. routers:訂閱方會更新本地路由規則列表;
    3. configurators:訂閱方會更新或覆蓋本地動態參數列表。

Redis

發佈訂閱機制

Redis訂閱發佈使用的是過期機制和publish/subscribe通道。服務提供者發佈服務,首先會在Redis中創建一個key,然後在通道中發佈一條register時間消息。但服務的key寫入到Redis後,發佈者需要週期性地刷新key的過期時間,在RedisRegistry構造方法中會啓動一個expireExecutor定時調度線程池,不斷調用deferExpired()方法延續key的超時時間。如果服務提供者服務宕機,沒有續期,則key會因爲超時而被Redis刪除,服務也就被認定爲下線。

主動下線/被動下線
  1. 服務提供者主動下線:會在通道中廣播一條unregister事件消息,訂閱方收到後則從註冊中心拉取數據,更新本地緩存的服務列表。
  2. 服務提供者被動下線:服務器宕機等原因沒有續期,導致key過期,此時是不會有動態消息推送的,在使用Redis爲註冊中心的時候,會依賴於服務治理中心。如果服務治理中心定時調度,則還會觸發清理邏輯:獲取Redis上所有的key進行遍歷,如果發現key已經超時了,則刪除Redis上對應的key。清除完後,還會在通道中發佈對應key的unregister事件,其他消費者監聽到取消註冊事件後會刪除本地對應服務器的數據,從而保證數據的最終一致。
// 構造方法啓動定時調度線程池以(過期時間 / 2)的頻率續簽自己
this.expireFuture = expireExecutor.scheduleWithFixedDelay(() -> {
   try {
        deferExpired(); // Extend the expiration time
    } catch (Throwable t) { // Defensive fault tolerance
        logger.error("Unexpected exception occur at defer expire time, cause: " + t.getMessage(), t);
    }
    // 過期時間 / 2
}, expirePeriod / 2, expirePeriod / 2, TimeUnit.MILLISECONDS);

// 續期
private void deferExpired() {
    for (Map.Entry<String, Pool<Jedis>> entry : jedisPools.entrySet()) {
        Pool<Jedis> jedisPool = entry.getValue();
        try {
            try (Jedis jedis = jedisPool.getResource()) {
                for (URL url : new HashSet<>(getRegistered())) {
                    if (url.getParameter(DYNAMIC_KEY, true)) {
                        String key = toCategoryPath(url);
                        // 不斷續簽自己
                        if (jedis.hset(key, url.toFullString(), String.valueOf(System.currentTimeMillis() + expirePeriod)) == 1) {
                            jedis.publish(key, REGISTER);
                        }
                    }
                }
                // 如果是服務治理中心,則只需clean操作
                if (admin) {
                    clean(jedis);
                }
                // 非replicate只需要寫一個節點
                if (!replicate) {
                    break;
                }
            }
        } catch (Throwable t) {
            logger.warn("Failed to write provider heartbeat to redis registry. registry: " + entry.getKey() + ", cause: " + t.getMessage(), t);
        }
    }
}
發佈的實現
for (Map.Entry<String, Pool<Jedis>> entry : jedisPools.entrySet()) {
	// 獲取連接池
    Pool<Jedis> jedisPool = entry.getValue();
    try {
        try (Jedis jedis = jedisPool.getResource()) {
        	// 設置key和過期時間
            jedis.hset(key, value, expire);
            // 發佈註冊消息
            jedis.publish(key, REGISTER);
            success = true;
            // 非replicate只需要寫一個節點
            if (!replicate) {
                break; 
            }
        }
    } catch (Throwable t) {
        exception = new RpcException("Failed to register service to redis registry. registry: " + entry.getKey() + ", service: " + url + ", cause: " + t.getMessage(), t);
    }
}
訂閱的實現

如果是首次訂閱,則會創建一個Notifier內部類,這是一個線程類,在啓動時會異步進行通道的訂閱。在啓動Notifier線程的同時,主線程會繼續往下執行,全量拉取一次註冊中心上所有的服務信息。後續註冊中心上的信息變更則通過Notifier線程訂閱的通道推送時間來實現。

if (service.endsWith(ANY_VALUE)) {
	// 服務治理中心,訂閱所有服務
    if (first) {
        first = false;
        Set<String> keys = jedis.keys(service);
        if (CollectionUtils.isNotEmpty(keys)) {
            for (String s : keys) {
            	// 首次觸發通知設置本地緩存
                doNotify(jedis, s);
            }
        }
        resetSkip();
    }
    // 訂閱服務
    jedis.psubscribe(new NotifySub(jedisPool), service);
} else {
    if (first) {
        first = false;
        // 首次觸發通知設置本地緩存
        doNotify(jedis, service);
        resetSkip();
    }
    // 訂閱服務
    jedis.psubscribe(new NotifySub(jedisPool), service + PATH_SEPARATOR + ANY_VALUE);
}

緩存機制

消費者或者服務治理中心獲取註冊信息後會做本地緩存。內存中會有一份,保存在Properties對象裏,磁盤上也會持久化一份文件,通過file對象引用。

class AbstractRegistry
// 本地磁盤緩存,其中特殊鍵value.registries記錄註冊表中心列表,其他是已通知服務提供者的列表
private final Properties properties = new Properties();
// 文件緩存異步定時寫入
private final ExecutorService registryCacheExecutor = Executors.newFixedThreadPool(1, new NamedThreadFactory("DubboSaveRegistryCache", true));
// 是否同步保存文件
private boolean syncSaveFile;
// 本地緩存變更版本
private final AtomicLong lastCacheChanged = new AtomicLong();
// 內存中的服務緩存對象,與Redis存儲方式相似,(key:url field:category value:服務列表)
private final ConcurrentMap<URL, Map<String, List<URL>>> notified = new ConcurrentHashMap<>();
// 本地磁盤緩存文件
private File file;

緩存的加載

在服務初始化的時候,AbstractRegistry構造函數會從本地磁盤文件中把持久化的註冊數據督導Properties對象裏,並加載到內存緩存中。Properties保存了所有服務提供者的URL,使用URL#serviceKey()作爲key,提供者列表、路由規則列表、配置規則列表等作爲value。由於value是列表,當存在多個的時候使用空格隔開。還有一個特殊的key.registies,保存所有的註冊中心的地址,如果應用在啓動過程中,註冊中心無法連接或者宕機,則Dubbo框架會自動通過本地緩存加載Invokers

// 緩存文件命名規則:
String defaultFilename = System.getProperty("user.home") + "/.dubbo/dubbo-registry-" + url.getParameter(APPLICATION_KEY) + "-" + url.getAddress().replaceAll(":", "-") + ".cache";

// 示例
dubbo-registry-demo-provider-127.0.0.1-2181.cache

// 文件內容示例
#Dubbo Registry Cache
#${time}
com.example.dubbo.demo.service.EchoService=empty\://192.168.0.113\:20880/com.example.dubbo.demo.service.EchoService?anyhost\=true&application\=demo-provider&bind.ip\=192.168.0.113&bind.port\=20880&category\=configurators&check\=false&deprecated\=false&dubbo\=2.0.2&dynamic\=true&generic\=false&interface\=com.example.dubbo.demo.service.EchoService&metadata-type\=remote&methods\=hello&pid\=10192&release\=2.7.6&side\=provider&timestamp\=${timestamp}

緩存的保存和更新

緩存的保存分爲同步/異步。異步會使用線程池異步保存(registryCacheExecutor)如果線程在執行過程中出現異常,則會再次調用線程池不斷重試。
AbstractRegistry#notify中封裝了內存緩存和更新文件緩存的邏輯,當客戶端第一次訂閱獲取全量數據,或者後續由於訂閱得到新數據時,都會調用該方法進行保存。

// 獲取最後變更版本
long version = lastCacheChanged.incrementAndGet();
if (syncSaveFile) {
	// 同步保存
    doSaveProperties(version);
} else {
	// 異步保存
    registryCacheExecutor.execute(new SaveProperties(version));
}

重試機制

ZooKeeperRegistry和RedisRegistry均繼承FailbackRegistry,FailbackRegistry繼承AbstractRegistry。
ZooKeeperRegistry
RedisRegistry
FailbackRegistry在AbstractRegistry基礎上增加了失敗重試機制作爲抽象能力,子類可以直接使用。
FailbackRegistry抽象類中定義了一個ScheduledThreadPoolExecutor,每經過固定間隔(默認5s)調用FailbackRegistry#retry()方法,對失敗集合進行重試,成功則移出隊列。FailbackRegistry實現了subscribe,unsubscribe等通用方法,裏面調用了未實現的模板方法,會由子類實現。通用方法會調用這些模板方法,如果捕獲到異常,則會把URL添加到對應的重試集合中,以供定時器去重試。

// 發起註冊失敗的URL集合
private final ConcurrentMap<URL, FailedRegisteredTask> failedRegistered = new ConcurrentHashMap<URL, FailedRegisteredTask>();

// 取消註冊失敗的URL集合
private final ConcurrentMap<URL, FailedUnregisteredTask> failedUnregistered = new ConcurrentHashMap<URL, FailedUnregisteredTask>();

// 發起訂閱失敗的URL集合
private final ConcurrentMap<Holder, FailedSubscribedTask> failedSubscribed = new ConcurrentHashMap<Holder, FailedSubscribedTask>();

// 取消訂閱失敗的URL集合
private final ConcurrentMap<Holder, FailedUnsubscribedTask> failedUnsubscribed = new ConcurrentHashMap<Holder, FailedUnsubscribedTask>();

// 通知失敗的URL集合
private final ConcurrentMap<Holder, FailedNotifiedTask> failedNotified = new ConcurrentHashMap<Holder, FailedNotifiedTask>();
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章