文章目錄
Dubbo註冊中心(ZooKeeper、Redis)實現原理
註冊中心概述
在Dubbo微服務體系中,註冊中心是其核心組件之一。Dubbo通過註冊中心實現了分佈式環境中各服務之間的註冊和發現,是各分佈式節點之間的紐帶。其主要作用如下:
- 動態加入。一個服務提供者通過註冊中心可以動態地把自己暴露給其他消費者,無需消費者逐個去更新配置文件。
- 動態發現。一個消費者可以動態的感知新的配置,路由規則和新的服務提供者,無需重啓服務使之生效。
- 動態調整。註冊中心支持參數的動態調整,新參數自動更新到所有相關服務節點。
- 統一配置。避免了本地配置導致每個服務的配置不一致問題。
工作流程
- 服務提供者啓動時,會向註冊中心寫入自己的元數據信息,並訂閱配置元數據信息。
- 消費者啓動時,會向註冊中心寫入自己的元數據信息,並訂閱服務提供者,路由和配置元數據信息。
- 服務治理中心(dubbo-admin)啓動時,會同時訂閱所有消費者、服務提供者、路由和配置元數據信息。
- 當有服務提供者離開或者加入時,註冊中心服務提供者目錄會發生變化,變化信息會動態通知消費者、服務治理中心。
- 當消費者發起服務調用時,會異步將統計信息等上報給監控中心(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);
}
}
}
訂閱的實現
- 訂閱通常有pull和push兩種方式,一種是客戶端定時輪詢註冊中心拉取配置,另一種是註冊中心主動推送數據給客戶端。這兩種方式各有利弊,目前Dubbo採用的是第一次啓動pull的方式,後續接收事件重新pull數據。
- ZooKeeper註冊中心採用的是“事件通知” + “客戶端拉取”的方式,客戶端第一次連接上註冊中心的時候會獲取對應目錄下的全量數據。並在訂閱的節點上註冊一個watcher,客戶端與註冊中心保持TCP長連接,後續每個節點有任何數據變化的時候,註冊中心會根據watcher的回調主動通知客戶端(事件通知),客戶端接收到通知後,會把對應節點下的全量數據都拉取下來(客戶端拉取)。
- ZooKeeper每個節點都有一個版本號,當某個節點數據發生變化(事務操作)的時候,該節點對應的版本號就會發生變化,並觸發watcher事件,推送數據給訂閱方。版本號強調的是變更次數,即使該節點的值沒有變化,只有更新操作,依然會是版本號變化。
事務操作
客戶端任何新增、修改、刪除、會話創建和失效操作,都會被認爲是事務操作,會由ZooKeeper集羣中的leader執行,即使客戶端連接的是非leader節點,請求也會被轉發給leader執行,以此來保證所有事務操作的全局時序性。由於每個節點都有一個版本號,因此可以通過CAS操作比較版本號來保證該節點數據操作的原子性。
- 客戶端第一次連上註冊中心,訂閱時會獲取全量的數據,後續則通過監聽器事件進行更新。服務治理中心會處理所有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;
}
- URL中的屬性值類別
- provides:訂閱方會更新本地Directory管理的Invoker服務列表;
- routers:訂閱方會更新本地路由規則列表;
- configurators:訂閱方會更新或覆蓋本地動態參數列表。
Redis
發佈訂閱機制
Redis訂閱發佈使用的是過期機制和publish/subscribe通道。服務提供者發佈服務,首先會在Redis中創建一個key,然後在通道中發佈一條register時間消息。但服務的key寫入到Redis後,發佈者需要週期性地刷新key的過期時間,在RedisRegistry構造方法中會啓動一個expireExecutor定時調度線程池,不斷調用deferExpired()方法延續key的超時時間。如果服務提供者服務宕機,沒有續期,則key會因爲超時而被Redis刪除,服務也就被認定爲下線。
主動下線/被動下線
- 服務提供者主動下線:會在通道中廣播一條unregister事件消息,訂閱方收到後則從註冊中心拉取數據,更新本地緩存的服務列表。
- 服務提供者被動下線:服務器宕機等原因沒有續期,導致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×tamp\=${timestamp}
緩存的保存和更新
緩存的保存分爲同步/異步。異步會使用線程池異步保存(registryCacheExecutor)如果線程在執行過程中出現異常,則會再次調用線程池不斷重試。
AbstractRegistry#notify中封裝了內存緩存和更新文件緩存的邏輯,當客戶端第一次訂閱獲取全量數據,或者後續由於訂閱得到新數據時,都會調用該方法進行保存。
// 獲取最後變更版本
long version = lastCacheChanged.incrementAndGet();
if (syncSaveFile) {
// 同步保存
doSaveProperties(version);
} else {
// 異步保存
registryCacheExecutor.execute(new SaveProperties(version));
}
重試機制
ZooKeeperRegistry和RedisRegistry均繼承FailbackRegistry,FailbackRegistry繼承AbstractRegistry。
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>();