文章目录
3 Dubbo 注册中心
3.1 注册中心概述
在 Dubbo 微服务体系中,注册中心是其核心组件之一。Dubbo 通过注册中心实现了分布式环境中各服务之间的注册与发现,是各个分布式节点之间的纽带。其主要作用如下:
- 动态加入。一个服务提供者通过注册中心可以动态地把自己暴露给其他消费者,无须消费者逐个去更新配置文件。
- 动态发现。一个消费者可以动态地感知新的配置、路由规则和新的服务提供者,无须重启服务使之生效。
- 动态调整。注册中心支持参数的动态调整,新参数自动更新到所有相关服务节点。
- 统一配置。避免了本地配置导致每个服务的配置不一致问题。
Dubbo 的注册中心源码在模块 dubbo-registry 中,里面包含了五个子模块:
模块名称 | 模块介绍 |
---|---|
dubbo-registry-api | 包含了注册中心的所有API和抽象实现类 |
dubbo-registry-default | Dubbo 基于内存的默认实现 |
dubbo-registry-multicast | multicast 模式的服务注册与发现 |
dubbo-registry-nacos | 使用 Nacos 作为注册中心的实现 |
dubbo-registry-redis | 使用 Redis 作为注册中心的实现 |
dubbo-registry-zookeeper | 使用 ZooKeeper作为注册中心的实现 |
从 dubbo-registry 的模块中可以看到,Dubbo 主要包含四种注册中心的实现,分别是 Simple,Multicast,,Nacos,Redis,ZooKeeper。
- Sample。一个简单的基于内存的注册中心实现,它本身就是一个标准的 RPC 服务,不支持集群,也可能出现单点故障。
- Multicast。不需要启动任何注册中心,只要通过广播地址,就可以互相发现。服务提供者启动时,会广播自己的地址。消费者启动时,会广播订阅请求,服务提供者收到订阅请求,会根据配置广播或单播给订阅者。
- Nacos。
- Redis。Redis 注册中心并没有经过长时间运行的可靠性验证,其稳定性依赖于 Redis 本身。
- ZooKeeper。官方推荐的注册中心,在生产环境中有过实际使用。
Dubbo 拥有良好的扩展性,如果以上注册中心都不能满足需求,那么用户可以基于 RegistryFactory 和 Registry 自行扩展。
3.1.1 工作流程
- 服务提供者启动时,会向注册中心写入自己的元数据信息,同时会订阅配置元数据信息。
- 消费者启动时,也会向注册中心写入自己的元数据信息,并订阅服务提供者、路由和配置元数据信息。
- 服务治理中心(dubbo-admin)启动时,会同时订阅所有消费者、服务提供者、路由和配置元数据信息。
- 当有服务提供者离开或有新的服务提供者加入时,注册中心服务提供者目录会发生变化,变化信息会动态通知给消费者、服务治理中心。
- 当消费方发起服务调用时,会异步将调用、统计信息等上报给监控中心(dubbo-monitor-simple)。
3.1.2 数据结构
注册中心的总体流程相同,但是不同的注册中心有不同的实现方式,其数据结构也不相同。ZooKeeper. Redis等注册中心都实现了这个流程。
3.1.3 Zookeeper 原理概述
ZooKeeper 是树形结构的注册中心,每个节点的类型分为持久节点、持久顺序节点、临时节点和临时顺序节点。
Dubbo 使用 ZooKeeper 作为注册中心时,只会创建持久节点和临时节点两种,对创建的顺序并没有要求。
/dubbo/com.foo.BarService/providers 是服务提供者在 ZooKeeper 注册中心的路径示例,是一种树形结构,该结构分为四层:root,service,四种服务目录(providers、consumers、routers、configurators)。在服务分类节点下是具体的 Dubbo 服务 URL。树形结构示例如下:
+--dubbo
+-- service
+-- providers
+-- consumers
+-- routers
+-- configurators
树形结构的关系:
(1)树的根节点是注册中心分组,下面有多个服务接口,分组值来自用户配置<dubbo:registry>
中的 group
属性,默认是 /dubbo。
(2)服务接口下包含4类子目录,分别是 providers,consumers,routers,configurators。这个路径是持久节点。
(3)服务提供者目录(/dubbo/service/providers)下面包含的接口有多个服务者 URL 元数据信息。URL 元数据信息是临时节点。
(4)服务消费者目录(/dubbo/service/consumers)下面包含的接口有多个消费者 URL 元数据信息。URL 元数据信息是临时节点。
(5)路由配置目录(/dubbo/service/routers)下面包含多个用于消费者路由策略 URL 元数据信息。URL 元数据信息是临时节点。
(6)动态配置目录(/dubbo/service/configurators)下面包含多个用于服务者动态配置 URL 元数据信息。URL 元数据信息是临时节点。
样例图:
在 Dubbo 框架启动时,会根据用户配置的服务,在注册中心中创建4个目录,在 providers 和 consumers 目录中分别存储服务提供方、消费方元数据信息,主要包括IP、端口、权重和应用名等数据。
在 Dubbo 框架进行服务调用时,用户可以通过服务治理平台(dubbo-admin)下发路由配置。如果要在运行时改变服务参数,则用户可以通过服务治理平台(dubbo-admin)下发动态配置。服务器端会通过订阅机制收到属性变更,并重新更新已经暴露的服务。
Dubbo 存储样例值如下:
目录名称 | 存储值样例 |
---|---|
/dubbo/service/providers | dubbo://192.168.0.1.20880/com.alibaba.demo.Service?key=value&… |
/dubbo/service/consumers | consumer://192.168.0.1.5002/com.alibaba.demo.Service?key=value&… |
/dubbo/service/routers | condition://0.0.0.0/com.alibaba.demo.Service?category=routers&key=value&… |
/dubbo/service/configurators | override://0.0.0.0/com.alibaba.demo.Service?category=configurators&key=value&… |
服务元数据中的所有参数都是以键值对形式存储的。以服务元数据为例:dubbo://192.168.0.1.20880/com.alibaba.demo.Service?category=provider&name=demo-provider&..
。服务元数据中包含 2 个键值对,第 1 个key 为 category,key 关联的值为provider。
在 Dubbo 中启用注册中心可以参考如下方式:
<beans>
<!--适用于ZooKeeper —个集群有多个节点,多个IP和端口用逗号分隔-->
<dubbo:registry protocol="zookeeper" address="ip:port,ip:port" />
<!--适用于ZooKeeper多个集群有多个节点,多个IP和端口用竖线分隔-->
<dubbo:registry protocol="zookeeper" address="ip:port|ip:port" />
</beans>
3.1.4 Redis 原理概述
Redis 注册中心也沿用了 Dubbo 抽象的 Root、Service、Type、URL 四层结构。但是由于 Redis 属于 NoSQL 数据库,数据都是以键值对的形式保存的,并不能像 ZooKeeper 一样直接实现树形目录结构。因此,Redis 使用了 key/Map 结构实现了这个需求,Root、Service、Type 组合成 Redis 的 key。Redis 的 value 是一个 Map 结构,URL 作为 Map 的key,超时时间作为 Map 的 value。
数据结构的组装逻辑在 com.alibaba.dubbo.registry.redis.RedisRegistry#doRegister(URL url)
方法中。主要代码如下:
// Redis key
String key = toCategoryPath(url);
// 生成 URL
String value = url.toFulistring();
...
// 注册到 Redis 注册中心,expire 为超时时间
jedis hset(key, value, expire);
...
3.2 订阅/发布
订阅/发布是整个注册中心的核心功能之一。在传统应用系统中,我们通常会把配置信息写入一个配置文件,当配置需要变更时会修改配置文件,再通过手动触发内存中的配置重新加载,如重启服务等。在集群规模较小的场景下,这种方式也能方便地进行运维。当服务节点数量不断上升的时候,这种管理方式的弊端就会凸显出来。
如果使用了注册中心,当一个已有服务提供者节点下线,或者一个新的服务提供者节点加入微服务环境时,订阅对应接口的消费者和服务治理中心都能及时收到注册中心的通知,并更新本地的配置信息。如此一来,后续的服务调用就能避免调用已经下线的节点,或者能调用到新的节点。整个过程都是自动完成的,不需要人工参与。
3.2.1 Zookeeper 的实现
1)发布的实现
服务提供者和消费者都需要把自己注册到注册中心。服务提供者的注册是为了让消费者感知服务的存在,从而发起远程调用。也让服务治理中心感知有新的服务提供者上线。消费者的发布是为了让服务治理中心可以发现自己。
ZooKeeper 发布代码非常简单,只是调用了 ZooKeeper 的客户端库在注册中心上创建一个目录。
@Override
protected void doRegister(URL url) {
try {
zkClient.create(toUrlPath(url), url.getParameter(Constants.DYNAMIC_KEY, true));
} catch (Throwable e) {
throw new RpcException("Failed to register " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
}
}
取消发布也很简单,只是把ZooKeeper注册中心上对应的路径删除。
@Override
protected 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);
}
}
2)订阅的实现
订阅通常有 pull 和 push 两种方式,一种是客户端定时轮询注册中心拉取配置,另一种是注册中心主动推送数据给客户端。目前 Dubbo 采用的是第一次启动拉取方式,后续接收事件重新拉取数据。
在服务暴露时,服务端会订阅 configurators 用于监听动态配置,在消费端启动时,消费端会订阅 providers、routers 和configurators 这三个目录,分别对应服务提供者、路由和动态配置变更通知。
ZooKeeper 注册中心采用的是事件通知 + 客户端拉取的方式,客户端在第一次连接上注册中心时,会获取对应目录下全量的数据。并在订阅的节点上注册一个 watcher,客户端与注册中心之间保持 TCP 长连接,后续每个节点有任何数据变化的时候,注册中心会根据 watcher 的回调主动通知客户端(事件通知),客户端接到通知后,会把对应节点下的全量数据都拉取过来(客户端拉取),这一点在 NotifyListener#notify(List<URL> urls)
接口上就有约束的注释。
ZooKeeper 的每个节点都有一个版本号,当某个节点的数据发生变化(即事务操作)时,该节点对应的版本号就会发生变化,并触发watcher事件,推送数据给订阅方。只要有更新操作,就会使版本号变化。
客户端第一次连上注册中心,订阅时会获取全量的数据,后续则通过监听器事件进行更新。服务治理中心会处理所有 service层的订阅,service 被设置成特殊值 *。此外,服务治理中心除了订阅当前节点,还会订阅这个节点下的所有子节点,核心代码来自 ZookeeperRegister#doSubscribe(final URL url, final NotifyListener listener)
。
if (Constants.ANY_VALUE.equals(url.getServiceInterface())) {
String root = toRootPath();
// 订阅所有数据
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.get(url);
if (listeners == null) {
// 缓存数据
zkListeners.putIfAbsent(url, new ConcurrentHashMap<NotifyListener, ChildListener>());
listeners = zkListeners.get(url);
}
ChildListener zkListener = listeners.get(listener);
if (zkListener == null) {
// zkListener 为空,说明是第一次,新建一个 listener
listeners.putIfAbsent(listener, new ChildListener() {
@Override
public void childChanged(String parentPath, List<String> currentChilds) {
// 通知,遍历所有的子节点
for (String child : currentChilds) {
child = URL.decode(child);
// 如果存在子节点还未被订阅,说明是新节点进行订阅
if (!anyServices.contains(child)) {
anyServices.add(child);
subscribe(url.setPath(child).addParameters(Constants.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 (services != null && !services.isEmpty()) {
// 遍历所有子节点进行订阅
for (String service : services) {
service = URL.decode(service);
anyServices.add(service);
// 增加当前节点的订阅,并且会返回该节点下所有子节点列表
subscribe(url.setPath(service).addParameters(Constants.INTERFACE_KEY, service,
Constants.CHECK_KEY, String.valueOf(false)), listener);
}
}
}
上述代码主要是要支持 Dubbo 服务治理平台(dubbo-admin),平台在启动时会订阅全量接口,它会感知每个服务的状态。
下面是普通消费者的订阅逻辑。首先根据 URL 的类别得到一组需要订阅的路径。如果类别是 *,则会订阅四种类型的路径(providers、routers、consumers、configurators),否则只订阅 providers 路径
else {
List<URL> urls = new ArrayList<URL>();
// 根据 URL 的类别(catagory参数)获取要订阅的路径列表
for (String path : toCategoriesPath(url)) {
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.get(url);
if (listeners == null) {
// 缓存数据
zkListeners.putIfAbsent(url, new ConcurrentHashMap<NotifyListener, ChildListener>());
listeners = zkListeners.get(url);
}
ChildListener zkListener = listeners.get(listener);
if (zkListener == null) {
// zkListener 为空,说明是第一次,新建一个 listener
listeners.putIfAbsent(listener, new ChildListener() {
@Override
public void childChanged(String parentPath, List<String> 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);
}
上述代码会根据 URL 中的 category 属性值获取具体的类别:providers、routers、consumers、configurators,然后拉取直接子节点的数据进行通知(notify)。如果是 providers 类别的数据,则订阅方会更新本地 Directory 管理的 Invoker 服务列表。如果是 routers 分类,则订阅方会更新本地路由规则列表。如果是 configuators 类别,则订阅方会更新或覆盖本地动态参数列表。
取消订阅由 ZookeeperRegister#doUnsubscribe(URL url, NotifyListener listener)
实现:
@Override
protected void doUnsubscribe(URL url, NotifyListener listener) {
// 获取缓存的监听器
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.get(url);
if (listeners != null) {
ChildListener zkListener = listeners.get(listener);
if (zkListener != null) {
// 全量移除
if (Constants.ANY_VALUE.equals(url.getServiceInterface())) {
String root = toRootPath();
zkClient.removeChildListener(root, zkListener);
} else {
// 根据 URL 的类别(catagory参数)获取要订阅的路径列表
for (String path : toCategoriesPath(url)) {
zkClient.removeChildListener(path, zkListener);
}
}
}
}
}
注意:
(1)Dubbo 中有哪些 ZooKeeper 客户端实现?
无论服务提供者还是消费者,或者是服务治理中心,任何一个节点连接到ZooKeeper注册中心都需要使用一个客户端,Dubbo 在 dubbo-remoting-zookeeper 模块中实现了 ZooKeeper 客户端的统一封装,定义了统一的 Client API,并用两种不同的 ZooKeeper 开源客户端库实现了这个接口: Apache Curator 和 zkClient。
用户可以在 <dubbo:registry>
的 client 属性中设置 curator、zkclient 来使用不同的客户端实现库,如果不设置则默认使用 Curator 作为实现。
(2)什么操作会被认为是事务操作?
客户端任何新增、删除、修改、会话创建和失效操作,都会被认为是事物操作,会由 ZooKeeper 集群中的 leader 执行。即使客户端连接的是非 leader 节点,请求也会被转发给 leader 执行,以此来保证所有事物操作的全局时序性。由于每个节点都有一个版本号,因此可以通过 CAS 操作比较版本号来保证该节点数据操作的原子性。
3.3.2 Redis 的实现
1)总体流程
使用 Redis 作为注册中心,其订阅发布实现方式与 ZooKeeper 不同。Redis 订阅发布使用的是过期机制和 publish/subscrib 通道。服务提供者发布服务,首先会在 Redis 中创建一个 key,然后在通道中发布一条 register 事件消息。 但服务的 key 写入 Redis 后,发布者需要周期性地刷新 key 过期时间,在 RedisRegistry 构造方法中会启动一个 expireExecutor 定时调度线程池,不断调用 deferExpired() 方法去延续 key 的超时时间。如果服务提供者服务宕机,没有续期,则 key 会因为超时而被Redis 删除,服务也就会被认定为下线。
Redis 续期 key 代码:
// RedisRegistry(URL url)
this.expireFuture = expireExecutor.scheduleWithFixedDelay(new Runnable() {
@Override
public void run() {
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);
}
}
}, expirePeriod / 2, expirePeriod / 2, TimeUnit.MILLISECONDS);
// deferExpired()
// 获取本地所有已缓存的 key,并作遍历
for (URL url : new HashSet<URL>(getRegistered())) {
if (url.getParameter(Constants.DYNAMIC_KEY, true)) {
String key = toCategoryPath(url);
// 如果续期返回 1,则说明 key 已经被删除了,这次算重新发布,因此在通道中广播
if (jedis.hset(key, url.toFullString(), String.valueOf(System.currentTimeMillis() + expirePeriod)) == 1) {
jedis.publish(key, Constants.REGISTER);
}
}
}
订阅方首次连接上注册中心,会获取全量数据并缓存在本地内存中。后续的服务列表变化则通过 publish/subscribe 通道广播,当有服务提供者主动下线的时候,会在通道中广播一条 unregister 事件消息,订阅方收到后则从注册中心拉取数据,更新本地缓存的服务列表。新服务提供者上线也是通过通道事件触发更新的。
如果使用 Redis 作为服务注册中心,会依赖于服务治理中心。服务治理中心会定时调度,还会触发清理逻辑。获取 Redis 上所有的 key 并进行遍历,如果发现 key 已经超时,则删除 Redis 上对应的 key。清除完后,还会在通道中发起对应 key 的unregister 事件,其他消费者监听到取消注册事件后会删除本地对应服务的数据,从而保证数据的最终一致。
// 获取本地所有已缓存的 key,并作遍历
for (URL url : new HashSet<URL>(getRegistered())) {
if (url.getParameter(Constants.DYNAMIC_KEY, true)) {
String key = toCategoryPath(url);
// 如果续期返回 1,则说明 key 已经被删除了,这次算重新发布,因此在通道中广播
if (jedis.hset(key, url.toFullString(), String.valueOf(System.currentTimeMillis() + expirePeriod)) == 1) {
jedis.publish(key, Constants.REGISTER);
}
}
}
// 如果是服务治理中心
if (admin) {
clean(jedis);
}
由上面的机制可以得出整个Redis注册中心的工作流程。
(1)Redis 客户端初始化的时候,需要先初始化 Redis 的连接池 jedisPools,此时如果配置注册中心的集群模式<dubbo:registry cluster="replicate"/>
,则服务提供者在发布服务的时候,需要同时向 Redis 集群中所有的节点都写入,是多写的方式。但读取还是从一个节点中读取。在这种模式下,Redis 集群可以不配置数据同步,一致性由客户端的多写来保证。
(2)如果设置为 failover 或不设置,则只会读取和写入任意一个 Redis 节点,失败的话再尝试下一个 Redis 节点。这种模式需要 Redis 自行配置数据同步。
(3)在初始化阶段,还会初始化一个定时调度线程池 expireExecutor,它主要的任务是延长 key 的过期时间和删除过期的key。线程池调度的时间间隔是超时时间的一半。超时时间默认 60 秒。
2)发布的实现
服务提供者和消费者都会使用注册功能,由RedisRegistry#doRegister(URL url)
方法实现。
@Override
public void doRegister(URL url) {
// 构建 key 值
String key = toCategoryPath(url);
// value值
String value = url.toFullString();
// 获取过期时间
String expire = String.valueOf(System.currentTimeMillis() + expirePeriod);
boolean success = false;
RpcException exception = null;
// 遍历连接池(可能有多个 jedis 客户端)中的所有节点
for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
JedisPool jedisPool = entry.getValue();
try {
Jedis jedis = jedisPool.getResource();
try {
// 向 redis 中注册,并在通道中发布注册事件
jedis.hset(key, value, expire);
jedis.publish(key, Constants.REGISTER);
success = true;
// 如果 Redis 使用非 replicate模式,只需要写一个节点
// 否则遍历所有节点,依次写入注册信息
if (!replicate) {
break; // If the server side has synchronized data, just write a single machine
}
} finally {
jedis.close();
}
} 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;
}
}
}
取消发布代码:
@Override
public void doUnregister(URL url) {
String key = toCategoryPath(url);
String value = url.toFullString();
RpcException exception = null;
boolean success = false;
// 遍历连接池(可能有多个 jedis 客户端)中的所有节点
for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
JedisPool jedisPool = entry.getValue();
try {
Jedis jedis = jedisPool.getResource();
try {
// 从 redis 移除,并在通道中发布取消注册事件
jedis.hdel(key, value);
jedis.publish(key, Constants.UNREGISTER);
success = true;
// 如果 Redis 使用非 replicate模式,只需要写一个节点
if (!replicate) {
break; // If the server side has synchronized data, just write a single machine
}
} finally {
jedis.close();
}
} catch (Throwable t) {
exception = new RpcException("Failed to unregister 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;
}
}
}
3)订阅的实现
服务消费者、服务提供者和服务治理中心都会使用注册中心的订阅功能。在订阅时,如果是首次订阅,则会先创建一个Notifier 内部类,这是一个线程类,在启动时会异步进行通道的订阅。在启动 Notifier 线程的同时,主线程会继续往下执行,全量拉一次注册中心上所有的服务信息。后续注册中心上的信息变更则通过 Notifier 线程订阅的通道推送事件来实现。下面是 Notifier 线程中通道订阅的逻辑。
// 以*结尾的进这里,如服务治理中心,订阅所有服务
if (service.endsWith(Constants.ANY_VALUE)) {
if (!first) {
first = false;
Set<String> keys = jedis.keys(service);
if (keys != null && !keys.isEmpty()) {
for (String s : keys) {
doNotify(jedis, s);
}
}
// 由于连接过程允许一定量的失败,会做重置此处则重置了计数器
resetSkip();
}
// 第一次,进行订阅
jedis.psubscribe(new NotifySub(jedisPool), service); // blocking
} else {
// 如果不以*结尾,如服务提供者或消费者,则进来这里,如果不是第一次,则代表已经订阅过
if (!first) {
first = false;
// 触发通知,更新本地缓存,并重置失败计数器
doNotify(jedis, service);
resetSkip();
}
// 订阅一个或多个符合给定模式的频道
jedis.psubscribe(new NotifySub(jedisPool), service + Constants.PATH_SEPARATOR + Constants.ANY_VALUE); // blocking
}
3.3 缓存机制
缓存的存在就是用空间换取时间,如果每次远程调用都要先从注册中心获取一次可调用的服务列表,则会让注册中心承受巨大的流量压力。另外,每次额外的网络请求也会让整个系统的性能下降。因此 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、routes、configurators 四种。value则是对应的服务列表,对于没有服务提供者提供服务的 URL,它会以特殊的 empty:// 前缀开头。
3.3.1 缓存的加载
在服务初始化的时候,AbstractRegistry 构造函数里会从本地磁盘文件中把持久化的注册数据读到 Properties 对象里,并加载到内存缓存中。代码如下。
private void loadProperties() {
if (file != null && file.exists()) {
InputStream in = null;
try {
// 读取磁盘文件
in = new FileInputStream(file);
properties.load(in);
if (logger.isInfoEnabled()) {
logger.info("Load registry store file " + file + ", data: " + properties);
}
} catch (Throwable e) {
logger.warn("Failed to load registry store file " + file, e);
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
logger.warn(e.getMessage(), e);
}
}
}
}
}
Properties 保存了所有服务提供者的 URL,使用 URL#serviceKey() 作为 key,提供者列表、 路由规则列表、配置规则列表等作为 value。由于 value 是列表,当存在多个的时候使用空格隔开。还有一个特殊的 key.registies,保存所有的注册中心的地址。如果应用在启动过程中,注册中心无法连接或宕机,则 Dubbo 框架会自动通过本地缓存加载 Invokers。
3.3.2 缓存的保存与更新
缓存的保存有同步和异步两种方式。异步会使用线程池异步保存,如果线程在执行过程中出现异常,则会再次调用线程池不断重试。代码如下。
// AbstractRegistry#saveProperties(URL url)
if (syncSaveFile) {
doSaveProperties(version);
} else {
registryCacheExecutor.execute(new SaveProperties(version));
}
// SaveProperties#run
@Override
public void run() {
doSaveProperties(version);
}
AbstractRegistry#notify 方法中封装了更新内存缓存和更新文件缓存的逻辑。当客户端第一次订阅获取全量数据,或者后续由于订阅得到新数据时,都会调用该方法进行保存。
3.4 重试机制
FailbackRegistry 继承了AbstractRegistry,并在此基础上增加了失败重试机制作为抽象能力。ZookeeperRegistry 和RedisRegistry 继承该抽象方法后,直接使用即可。
FailbackRegistry 抽象类中定义了一个 ScheduledExecutorService,每经过固定间隔(默认为5秒)调用FailbackRegistry#retry()
方法。另外,该抽象类中还有五个比较重要的集合。
集合名称 | 集合介绍 |
---|---|
Set failedRegistered | 发起注册失败的URL集合 |
Set failedUnregistered | 取消注册失败的URL集合 |
ConcurrentMap<URL, Set> failedSubscribed | 发起订阅失败的监听器集合 |
ConcurrentMap<URL, Set> failedUnsubscribed | 取消订阅失败的监听器集合 |
ConcurrentMap<URL, Map<NotifyListener, List>> failedNotified | 通知失败的URL集合 |
在定时器中调用 retry 方法的时候,会把这五个集合分别遍历和重试,重试成功则从集合中移除。FailbackRegistry 实现了 subscribe、unsubscribe 等通用方法,里面调用了未实现的模板方法,会由子类实现。通用方法会调用这些模板方法,如果捕获到异常,则会把 URL 添加到对应的重试集合中,以供定时器去重试。
3.5 设计模式
Dubbo 注册中心拥有良好的扩展性,用户可以在其基础上,快速开发出符合自己业务需求的注册中心。这种扩展性和 Dubbo中使用的设计模式密不可分。
3.5.1 模板模式
整个注册中心的逻辑部分使用了模板模式,其类的关系。
(1)AbstractRegistry 实现了 Registry 接口中的注册、订阅、查询、通知等方法,还实现了磁盘文件持久化注册信息这一通用方法。但是注册、订阅、查询、通知等方法只是简单地把 URL 加入对应的集合,没有具体的注册或订阅逻辑。
(2)FailbackRegistry 又继承了 AbstractRegistry,重写了父类的注册、订阅、查询和通知等方法,并且添加了重试机制。此外,还添加了四个未实现的抽象模板方法,代码如下。
// ==== Template method ====
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);
以订阅为例,FailbackRegistry 重写了 subscribe 方法,但只实现了订阅的大体逻辑及异常处理等通用性的东西。具体如何订阅,交给继承的子类实现。这就是模板模式的具体实现,代码如下。
@Override
public void subscribe(URL url, NotifyListener listener) {
super.subscribe(url, listener);
removeFailedSubscribed(url, listener);
try {
// 子类实现
doSubscribe(url, listener);
} catch (Exception e) {
...
}
}
3.5.2 工厂模式
所有的注册中心实现,都是通过对应的工厂创建的。工厂类之间的关系如图。
AbstractRegistryFactory 实现了RegistryFactory#getRegistry(URL url)
方法,是一个通用实现,主要完成了加锁,以及调用抽象模板方法createRegistry(URL url)
创建具体实现等操作,并缓存在内存中。抽象模板方法会由具体子类继承并实现,代码如下。
@Override
public Registry getRegistry(URL url) {
url = url.setPath(RegistryService.class.getName())
.addParameter(Constants.INTERFACE_KEY, RegistryService.class.getName())
.removeParameters(Constants.EXPORT_KEY, Constants.REFER_KEY);
String key = url.toServiceStringWithoutResolving();
// Lock the registry access process to ensure a single instance of the registry
// 加锁
LOCK.lock();
try {
Registry registry = REGISTRIES.get(key);
if (registry != null) {
// 缓存中有,直接返回
return registry;
}
// 如果注册中心还没创建过,则调用抽象方法 createRegistry(url)重新创建一个
// createRegistry方法由具体的子类实现
registry = createRegistry(url);
if (registry == null) {
throw new IllegalStateException("Can not create registry " + url);
}
// 创建成功,缓存起来
REGISTRIES.put(key, registry);
return registry;
} finally {
// Release the lock
// 释放锁
LOCK.unlock();
}
}
虽然每种注册中心都有自己具体的工厂类,但是在什么地方判断,应该调用哪个工厂类实现,在代码并没有看到显式的判断。在 RegistryFactory 接口里有一个Registry getRegistry(URL url)
方法,该方法上有@Adaptive({"protocol"))
注解。代码如下。
@SPI("dubbo")
public interface RegistryFactory (
@Adaptive({"protocol"})
Registry getRegistry(URL url);
)
这个注解使用到了 AOP,会自动生成代码实现一些逻辑,它的 value 参数会从 URL 中获取 protocol 键的值,并根据获取的值来调用不同的工厂类。例如,当```url.protocol = redis``时,获得 RedisRegistryFactory 实现类。