Dubbo进阶(四):Dubbo注册中心

探讨Dubbo微服务框架中注册中心的作用及Zookeeper的实现细节,包括服务注册、发现、缓存机制及配置实现。

首先可以先了解一下市面上常见的注册中心

eurekaconsulzookeeperetcd
服务健康检查可配支持服务状态、内存、硬盘等长链接心跳链接
多数据中心-支持--
kv存储服务-支持支持支持
一致性算法-raftpaxosraft
CAPapcpcpcp
使用接口(多语言功能)httphttp、dns客户端http、grpc
watch支持支持long polling大部分增量全量支持long polling支持支持long polling
自身监控metricsmetrics-metrics
安全-acl、httpsaclhttps

在Dubbo微服务体系中,注册中心是其核心的组件之一。Dubbo通过注册中心实现了分布式环境中各服务之间的注册与发现,是各个分布式节点之间的纽带。主要作用有:

  • 动态加入:一个服务提供者通过注册中心可以动态的把自己暴露给其他消费者,无需消费者逐个去更新配置文件。
  • 动态发现:一个消费者可以动态感知新的配置、路由规则和新的服务提供者,无需重启服务使之生效。
  • 动态调整:注册中心支持参数的动态调整,新参数自动更新到所有相关服务节点。
  • 统一配置:避免了本地配置导致每个服务的配置不一致问题。

dubbo主要包含四种注册中心的实现,分别是:ZookeeperRedisSimpleMulticast

其中Zookeeper是官方推荐的注册中心实现,在生产环境中已经有大量实际使用,具体的实现在Dubbo的源码 dubbo-registry-zookeeper模块中。而Redis注册中心在稳定性方面相比ZK就差了一些,其稳定性主要是依赖Redis本身,阿里内部并没有使用Redis作为注册中心。Simple是一个基于内存的简单的注册中心实现,它本身就是一个标准的RPC服务,不支持集群,可能出现单点故障。Multicast模式则不需要启动任何注册中心,只要通过广播地址,就可以互相发现。服务提供者启动时,会广播自己的地址,消费者启动时,会广播订阅请求,服务提供者收到订阅请求,会根据配置广播或单播给订阅者。

注册中心的工作流程

在这里插入图片描述

  • Provider注册:Provider启动时,会向注册中心写入自己的元数据信息,同时会订阅配置元数据信息。
  • Consumer订阅:这里的订阅指Consumer启动时也会向注册中心写入自己的元数据信息,并订阅服务提供者、路由和配置元数据信息。
  • 服务治理中心启动:dubbo-admin启动时,会同时订阅所有消费者、服务提供者、路由和配置元数据信息。
  • 动态注册、发现:当有新的Provider加入或者有离开时,注册中心服务提供者目录会发生变化,变化信息会动态通知给消费者、服务治理中心。
  • 监控中心采集:当Consumer发起调用时,会异步将调用、统计信息等上报给监控中心。
zookeeper的原理

zookeeper是树形节点的注册中心,每个节点的类型分为持久节点、持久顺序节点、临时节点、临时顺序节点。

  • 持久节点:服务注册后保证节点不会丢失,注册中心重启也会存在。
  • 持久顺序节点:在持久节点特性的基础上增加了节点先后顺序的能力。
  • 临时节点:服务注册后连接丢失或者session超时,注册的节点会自动消失。
  • 临时节点顺序:在临时节点特性的基础上增加了节点先后顺序的能力。

Dubbo使用ZK作为注册中心时,只会创建临时节点和持久节点两种,对创建顺序并没有要求。

/dubbo/com.foo.BarService/providers是服务提供者在Zookeeper注册中心的路径示例,是一种属性结构,该结构分为四层:root(根节点,对应示例中的dubbo)、service(接口名称,对应示例中的com.foo.BarService)、四种服务目录(对应示例中的providers,其他目录还有consumers、routers、configurators)。在服务分类节点下是具体的Dubbo服务URL。属性结构示例如下:

+ /dubbo
+-- service
		+-- providers
		+-- consumers
		+-- routers
		+-- configurators

树形结构的关系:
(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>
zookeeper发布的实现

providerConsumer需要将自己注册到Zookeeper。服务提供者的注册是为了让消费者订阅(准确来说应该叫感知服务的存在),从而发起远程调用;也上服务治理中心感知有新的服务提供者上线。消费者的发布是为了让服务治理中心可以发现自己。Zookeeper发布订阅代码非常简单,只是调用Zookeeper 的 Client 库在注册中心创建一个目录而已,如下代码所示:

@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);
	}
}

取消发布对应也很简单,只是把ZK注册中心上对应的路径删除,如下代码所示:

@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);
	}
}
zookeeper订阅的实现

订阅通常有pull和push两种方式,一种是客户端定时轮训注册中心拉取配置,另一种是注册中心主动推送数据给客户端。这两种方式各有利弊,目前Dubbo采用的是第一次启动拉取方式,后续接收事件重新拉取数据。

在服务暴露时,服务端会订阅configurators用于监听动态配置,在消费端启动时,消费端会订阅providers、routers和configurators这三个目录,分别对应服务提供者、路由和动态配置变更通知。

Dubbo提供了两种不同ZK开源客户端库的封装,分别对应接口:

  • Apache Curator
  • zkClient

我们可以在<dubbo:registry>的client属性中设置curator、zkClient来使用不同的客户端实现库,如果不设置默认使用Curator作为实现。

Zookeeper客户端采用的是“事件通知” + “客户端拉取”的方式,客户端在第一次连接上注册中心时,会获取对应目录下全量的数据。并在订阅的节点上注册一个watcher,客户端与注册中心之间保持TCP长连接,后续每个节点有任何数据变化的时候,注册中心会根据watcher的回调主动通知客户端(事件通知),客户端接到通知后,会把对应节点下的全量数据都拉取过来(客户端拉取),这一点在NotifyListener#notify(List<URL> urls)接口上就有说明。全量拉取有一个局限,当服务节点较多时会对网络造成很大的压力。

Zookeeper每个节点都有一个版本号,当某个节点的数据发生变化时,对应的版本号就会变化,并触发watcher事件,推送数据给订阅方。版本号强调的是变化次数,即使该节点的值没有变化,只要有更新操作,依然会使版本号变化。

Zookeeper实现服务订阅的核心代码在ZookeeperRegistry中:

	@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);
        }
    }
zookeeper的缓存机制

缓存的存在就是用空间换取时间的一种机制。如果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://前缀了开头。

zookeeper缓存的加载

在服务初始化时候,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。

zookeeper缓存的保存与更新

缓存的保存有同步和异步两种方式。异步会使用线程池异步保存,如果线程在执行过程中出现异常,则会再次调用线程池不断重试,代码如下:

if(syncSaveFile){
    // 同步保存
    doSaveProperties(version);
} else {
    // 异步保存,放入线程池。会传入一个AtomicLong的版本号保证数据是最新的
    registryCacheExecutor.execute(new SaveProperties(version));
}

AbstractRegistry#notify 方法中封装了更新内存和本地文件缓存的逻辑。当客户端第一次订阅获取全量数据的时候,或者后续由于订阅的数据发生变更时,都会调用该方法进行保存。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值