深入理解Dubbo原理系列(二)- Dubbo注册中心Zookeeper
一. Zookeeper注册中心
1.1 Zookeeper原理概述
首先,注册中心是Dubbo的核心组件之一,而Dubbo一般而言用的都是Nacos或者Zookeeper,这里以Zookeeper为例来进行说。(Nacos我有过相关的原理文章,Zookeeper确实还没有写过,这里写个大概)
Zookeeper是一个树形结构的注册中心,每个节点的类型分为:
- 持久节点:服务注册后保证节点不会丢失,注册中心重启时也会存在。
- 持久顺序节点:在持久节点特性的基础上增加了节点先后顺序的能力。
- 临时节点:服务注册后连接丢失或者Session超时的话,注册的节点会自动被移除。
- 临时顺序节点:在临时节点特性的基础上增加了节点先后顺序的能力。
对于Dubbo相关节点的创建,只会创建两种节点:持久节点和临时节点,Dubbo结合Zookeeper后,创建的数据树为:
xxxService代表服务暴露的接口,这里是该接口的全路径地址,例如下图中的com.pro.service.UserService
:
树形结构的关系:
-
树的根节点是注册中心分组。下面有多个服务接口,分组值来自用户配置的
<dubbo:registry>
中的group属性,默认是/dubbo
。 -
每个服务接口下包含4类子目录,分别是
providers
、consumers
、configurators
、routers
。该路径是持久节点。 -
服务提供者目录
/xxx/providers/
下包含的接口中存在多个服务者URL元数据的信息,该路径下是临时节点。,若服务没有启动,数据如下:
若服务启动了,数据如下:
-
服务消费者目录
/xxx/consumers/
下包含的接口有多个消费者URL元数据的信息,同理,是个临时节点。 -
路由配置目录
/xxx/routers/
下包含多个用于消费者路由策略URL元数据信息。 -
动态配置目录
/xxx/configurators/
下包含多个用于服务者动态配置URL元数据信息。
二. Zookeeper订阅/发布
既然说注册中心是Dubbo的核心组件之一,那么,对于注册中心而言,其订阅发布功能又是他的一个核心关注点。
在使用RPC调用的时候,服务提供者的上下线需要发布出去,从而订阅者才能够调用服务,同时避免服务调用已经下线的节点。而这就需要订阅发布功能的实现,并且该过程也是全自动的。对于Dubbo而言,Dubbo抽象了这么一个工作流程,同时可以有不同的实现,这里主要从Zookeeper的实现来讲。
从官网下载Dubbo的源码,可以看到有这么一个maven项目(附带Test自动化,改一下自己的IP地址即可,很方便测试):
2.1 发布源码分析
1.其中有一个类叫做ZookeeperRegistry
,它主要负责服务的一个注册,看下他的继承关系:
我们再来看下AbstractRegistry
类中的几个重要属性:
// 保存注册的服务
private final Set<URL> registered = new ConcurrentHashSet<URL>();
// 保存订阅url的通知事件
private final ConcurrentMap<URL, Set<NotifyListener>> subscribed = new ConcurrentHashMap<URL, Set<NotifyListener>>();
// 保存通知的url列表,其实是内存中的服务缓存对象
private final ConcurrentMap<URL, Map<String, List<URL>>> notified = new ConcurrentHashMap<URL, Map<String, List<URL>>>();
// 注册中心的url
private URL registryUrl;
紧接着我们从Test类出发:
public class ZookeeperRegistryTest {
private TestingServer zkServer;
private ZookeeperRegistry zookeeperRegistry;
private String service = "org.apache.dubbo.test.injvmServie";
private URL serviceUrl = URL.valueOf("zookeeper://192.168.237.130/" + service + "?notify=false&methods=test1,test2");
private URL anyUrl = URL.valueOf("zookeeper://192.168.237.130/*");
private URL registryUrl;
private ZookeeperRegistryFactory zookeeperRegistryFactory;
// 在Test方法执行之前先执行该方法setUp
@BeforeEach
public void setUp() throws Exception {
int zkServerPort = NetUtils.getAvailablePort();
this.zkServer = new TestingServer(zkServerPort, true);
this.zkServer.start();
this.registryUrl = URL.valueOf("zookeeper://192.168.237.130:" + 2181);
// 初始化一个Zookeeper注册工厂
zookeeperRegistryFactory = new ZookeeperRegistryFactory();
zookeeperRegistryFactory.setZookeeperTransporter(new CuratorZookeeperTransporter());
// 从注册工厂中获得一个Zookeeper注册器
this.zookeeperRegistry = (ZookeeperRegistry) zookeeperRegistryFactory.createRegistry(registryUrl);
}
// 测试方法完成后执行的方法
@AfterEach
public void tearDown() throws Exception {
zkServer.stop();
}
@Test
public void testRegister() {
Set<URL> registered;
for (int i = 0; i < 2; i++) {
// 进行注册,传入的参数值:zookeeper://192.168.237.130/org.apache.dubbo.test.injvmServie?methods=test1,test2¬ify=false
zookeeperRegistry.register(serviceUrl);
registered = zookeeperRegistry.getRegistered();
assertThat(registered.contains(serviceUrl), is(true));
}
registered = zookeeperRegistry.getRegistered();
assertThat(registered.size(), is(1));
}
}
2.从zookeeperRegistry.register()
方法开始:
// ZookeeperRegistry是FailbackRegistry的子类
// 此时调用的是其父类FailbackRegistry重写的register()方法
public abstract class FailbackRegistry extends AbstractRegistry {
@Override
public void register(URL url) {
// 检查URL的合法性
if (!acceptable(url)) {
logger.info("URL " + url + " will not be registered to Registry. Registry " + url + " does not accept service of this protocol type.");
return;
}
// 调用AbstractRegistry的register()方法,往registryUrl集合中添加一条URL数据
super.register(url);
// 删除失败的已经注册过的URL
removeFailedRegistered(url);
// 删除失败的未注册过的URL
removeFailedUnregistered(url);
try {
// 发送一个注册请求
doRegister(url);
} catch (Exception e) {
// ..若失败的处理逻辑
}
}
}
3.看下doRegister(url)
这个发送注册请求的方法,其有4种实现,进入ZookeeperRegistry
类:
public class ZookeeperRegistry extends FailbackRegistry {
private final ZookeeperClient zkClient;// Zookeeper客户端
@Override
public void doRegister(URL url) {
try {
//url:zookeeper://192.168.237.130/org.apache.dubbo.test.injvmServie?methods=test1,test2¬ify=false
// toUrlPath()方法主要是将url进行一个数据的格式化,比如 ”:“转变成 “%3A”
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);
}
}
}
4.ZookeeperClient
是一个接口,其接口方法由抽象子类AbstractZookeeperClient
来实现,即zkClient.create()
方法实则调用的是AbstractZookeeperClient.create()
方法。(注意看注释的顺序)
// ZookeeperClient是一个接口,其接口方法由抽象子类AbstractZookeeperClient来实现
public abstract class AbstractZookeeperClient<TargetDataListener, TargetChildListener> implements ZookeeperClient {
// 已经注册的服务集合
private final Set<String> persistentExistNodePath = new ConcurrentHashSet<>();
@Override
public void create(String path, boolean ephemeral) {
// 1.如果说是保存为持久节点,那么先进入下面的递归调用,ephemeral默认传入的是true
// 1.1若第一次调用,那么举个path路径的例子,此时为:/dubbo/org.apache.dubbo.test.injvmServie/providers/zookeeper%3A%2F%2F192.168.237.130%2Forg.apache.dubbo.test.injvmServie%3Fmethods%3Dtest1%2Ctest2%26notify%3Dfalse
// 2.1第二次通过递归调用时,path变为:/dubbo/org.apache.dubbo.test.injvmServie/providers,
// 2.2此时传入的参数ephemeral==false
if (!ephemeral) {
// 3.此时检查注册过的节点中是否已经存在该节点,若存在则return
if(persistentExistNodePath.contains(path)){
return;
}
if (checkExists(path)) {
persistentExistNodePath.add(path);
return;
}
}
// 2.获取path路径中,从后往前第一个字符为/的下标
int i = path.lastIndexOf('/');
if (i > 0) {
// 2.递归再次调用create()方法
create(path.substring(0, i), false);
}
// 4. 递归结束,创建临时节点,调用createEphemeral()方法
if (ephemeral) {
createEphemeral(path);
} else {
// 否则创建持久节点,调用createPersistent()方法
createPersistent(path);
persistentExistNodePath.add(path);
}
}
}
此时可以看看Zookeeper上的数据树(还未创建节点):
5.以创建默认的临时节点为例,即查看createEphemeral(path)
方法,该方法最终执行了CuratorZookeeperClient
类中的createEphemeral()
:
public class CuratorZookeeperClient extends AbstractZookeeperClient<CuratorZookeeperClient.CuratorWatcherImpl, CuratorZookeeperClient.CuratorWatcherImpl> {
@Override
public void createEphemeral(String path) {
try {
client.create().withMode(CreateMode.EPHEMERAL).forPath(path);
}
// ...
}
}
执行完该方法后,再次查看Zookeeper上的数据树,可以发现path已经注册成功:
到这里,服务的注册(发布)也就成功了,本质上也就是对Zookeeper上数据的一个添加而已(若注册中心上有,那么就用已存在的),而较为复杂的功能则是订阅。接下来来看看订阅的代码。
2.2 订阅源码分析
订阅通常有Pull和Push两种方式:
- Pull:客户端定时轮询注册中心拉取配置。
- Push:注册中心主动推送数据给客户端。
目前Dubbo采用的是:第一次启动采用拉取,后续接收事件重新拉取数据。在服务暴露的时候,服务端会订阅configurators,用于监听动态配置,在消费端启动的时候,消费端则订阅providers
、routers
、configurators
三个Zookeeper目录,分别对应服务提供者、路由、动态配置变更通知。
无论是服务的提供者还是消费者、服务治理中心,任何一个节点连接到Zookeeper注册中心上都需要一个客户端。,而Dubbo就封装了两种Zookeeper的客户端接口:
- Apache Curator。(默认,所以我们发布代码中,最终的create()方法用的是CuratorZookeeperClient类下的)
- zkClient
用户可以在<dubbo:registry>
中的client属性设置curator、zkclient来使用不同的客户端实现。默认是curator。
订阅的原理如下:
- 客户端在第一次连接上Zookeeper注册中心时,会获取对应目录下全量的数据。
- 同时在订阅的节点上注册一个Watcher,客户端和注册中心之间保持TCP的长连接。
- 后续每个节点发生任何的数据变化时,注册中心则根据Watcher的回调来主动通知客户端(一次事件通知)。
- 客户端接收到通知,把对应节点下的全量数据再进行一次拉取(全量)。
备注:
Zookeeper的每个节点都有一个版本号,只有某个节点的数据发生变化(事务操作)时,该节点的版本号才会发生变化,并触发Watcher事件,推送给订阅方。
说了这么多,接下来从源码角度再来看看:
1.在客户端启动的时候,订阅获取全量数据,会执行到ZookeeperRegistry.doSubscribe()
方法:
@Override
public void doSubscribe(final URL url, final NotifyListener listener) {
try {
// 1.处理所有服务的订阅,客户端第一次连接上注册中心上时,进行全量数据的拉取
if (ANY_VALUE.equals(url.getServiceInterface())) {
String root = toRootPath();
// 2.获取URL的所有监听器,若url对应的监听器为空,那么初始化一个监听器返回 zkListeners是一个监听器集合
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
// 3.获取ChildListener,若第一次连接时为空,根据computeIfAbsent()方法肯定会新建一个ChildListener
ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (parentPath, currentChilds) -> {
// 4.遍历所有的子节点,
for (String child : currentChilds) {
child = URL.decode(child);
// 5.若有新增服务,那么发起对这个服务的订阅。
if (!anyServices.contains(child)) {
anyServices.add(child);
subscribe(url.setPath(child).addParameters(INTERFACE_KEY, child,
Constants.CHECK_KEY, String.valueOf(false)), k);
}
}
});
// 6.创建持久化节点,订阅持久化节点的子节点
zkClient.create(root, false);
List<String> services = zkClient.addChildListener(root, zkListener);
// 7.获取到全量service,对每一个service进行订阅
if (CollectionUtils.isNotEmpty(services)) {
for (String service : services) {
service = URL.decode(service);
// anyServices是一个接口全名集合,订阅整个Service层
anyServices.add(service);
subscribe(url.setPath(service).addParameters(INTERFACE_KEY, service,
Constants.CHECK_KEY, String.valueOf(false)), listener);
}
}
} else {
// 1.客户端非第一次连接上注册中心,对于具体的服务进行订阅
List<URL> urls = new ArrayList<>();
// 2.根据URL的类别,获取一组要订阅的路径,类别:providers、routers、consumers、configurators
for (String path : toCategoriesPath(url)) {
// 3.获取url对应的监听器,没有则创建
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
// 4.获取ChildListener,没有则创建,并且在子节点数据发生变更时则调用notify方法进行通知这个listener
ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (parentPath, currentChilds) -> ZookeeperRegistry.this.notify(url, k, toUrlsWithEmpty(url, parentPath, currentChilds)));
// 5.创建type的持久化节点,且对于这个节点进行订阅
zkClient.create(path, false);
// 6.订阅,返回该节点下的子路径并且保存
List<String> children = zkClient.addChildListener(path, zkListener);
if (children != null) {
urls.addAll(toUrlsWithEmpty(url, path, children));
}
}
// 7.回调,更新本地缓存信息
notify(url, listener, urls);
}
} catch (Throwable e) {
throw new RpcException("Failed to subscribe " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
}
}
2.来看下回调方法notify()
:
protected void notify(URL url, NotifyListener listener, List<URL> urls) {
// ...
// 用于分类URL
Map<String, List<URL>> result = new HashMap<>();
for (URL u : urls) {
if (UrlUtils.isMatch(url, u)) {
String category = u.getParameter(CATEGORY_KEY, DEFAULT_CATEGORY);
List<URL> categoryList = result.computeIfAbsent(category, k -> new ArrayList<>());
categoryList.add(u);
}
}
if (result.size() == 0) {
return;
}
// 1.获取url在notified集合中的数据,若notified无则创建一个且放入
Map<String, List<URL>> categoryNotified = notified.computeIfAbsent(url, u -> new ConcurrentHashMap<>());
for (Map.Entry<String, List<URL>> entry : result.entrySet()) {
String category = entry.getKey();
List<URL> categoryList = entry.getValue();
categoryNotified.put(category, categoryList);
// 2.对于所有url进行分类的通知
listener.notify(categoryList);
// 3.将url进行缓存,因为当我们的注册表由于网络抖动而出现订阅失败时,我们可以返回现有的缓存URL。
saveProperties(url);
}
}
3.再回到doSubscribe()
方法,可以看到其中传入了一个参数类型是NotifyListener
,一旦发生了数据的变更,则发生通知,而消费者则会去进行一次数据拉取:
public interface NotifyListener {
/**
* 当收到服务变更通知时触发。
* <p>
* 通知需处理契约:<br>
* 1. 总是以服务接口和数据类型为维度全量通知,即不会通知一个服务的同类型的部分数据,用户不需要对比上一次通知结果。<br>
* 2. 订阅时的第一次通知,必须是一个服务的所有类型数据的全量通知。
* 3. 中途变更时,允许不同类型的数据分开通知,比如:providers, consumers, routers, overrides,允许只通知其中一种类型,但该类型的数据必须是全量的,不是增量的。
* 4. 如果一种类型的数据为空,需通知一个empty协议并带category参数的标识性URL数据。
* 5. 通知者(即注册中心实现)需保证通知的顺序,比如:单线程推送,队列串行化,带版本对比。
*
* @param urls 已注册信息列表,总不为空,含义同{@link com.alibaba.dubbo.registry.RegistryService#lookup(URL)}的返回值。
*/
void notify(List<URL> urls);
//..
}
小总结
总的来说,对于Zookeeper的发布流程:
- 若发现注册中心上已经注册了某个需要发布的URL,那么返回。
- 若没注册,则对需要发布的URL进行一定程度上的格式转换。(/、:等符号的转化)
- 根据用户配置的client属性,选择用curator或者zkclient客户端向Zookeeper上创建一个临时节点(即订阅者的相关数据)。
对于Zookeeper的订阅流程:
- 客户端第一次启动,会针对
/.../providers/
这个路径,全量的获取每一个子节点并进行订阅。 - 后续则
根据URL的具体类别,获取要订阅的路径,通过不同类型的监听器来进行监听
,若数据发生了变化,则通过NotifyListener
这个监听器的notify()
方法进行回调。 notify()
则进行分类通知,并且将URL进行本地缓存,同时消费者进行数据的拉取。
对于Zookeeper的订阅,说白了就是对xxx/providers进行监听,一旦数据发生变更,则执行回调函数,让消费者自己去拉取一遍数据。
这里只是一个普通的服务发布即服务订阅的大致流程,对于具体服务的暴露、远程服务的调用则放在后续的博客来讲。
三 缓存
其实1.2小节有提及:
- 回调的时候,会对URL进行一次缓存,避免因为网络波动等原因获取不到URL。
- 说明消费者或服务治理中心获取注册信息后会做本地缓存。
首先,在服务进行初始化的时候,AbstractRegistry
构造函数里会从本地磁盘文件中把持久化的注册数据读到Properties
对象里,并加载到内存缓存中。再来看下AbstractRegistry类中的几个重要属性:
// 内存中的服 务缓存对象,URL是消费者的URL,内层Map的key是分类,包括providers、consumers等
// value则对应的服务列表,对于没有服务提供者提供服务的URL,它会以特殊的empty:// 前缀开头。
private final ConcurrentMap<URL, Map<String, List<URL>>> notified = new ConcurrentHashMap<>();
// 盘文件服务缓存对象
private File file;
3.1 缓存的加载
缓存的加载依赖于AbstractRegistry的构造,来看下:
public AbstractRegistry(URL url) {
// ....
// 启动订阅中心时,我们需要读取本地缓存文件,以便将来进行注册表容错处理
loadProperties();
// ....
}
private void loadProperties() {
// ....
// 读取磁盘上的文件并加载
in = new FileInputStream(file);
// 保存服务提供者的URL,key:URL#serviceKey()。value:提供者列表、 路由规则列表、配置规则列表等
properties.load(in);
// ....
}
}
3.2 缓存的保存和更新
缓存的保存有同步和异步两种方式。异步会使用线程池异步保存,如果线程在执行过程中出现异常,则会再次调用线程池不断重试,前面说过,订阅流程中最后一步会对URL进行缓存,也就是调用了saveProperties(url)
方法:
private void saveProperties(URL url) {
// ....
if (syncSaveFile) {
// 同步保存,同时传入一个AtomicLong类型的版本号,为了保证数据是最新的。
doSaveProperties(version);
} else {
// 异步保存,通过线程池来执行,同时传入一个AtomicLong类型的版本号,为了保证数据是最新的。
registryCacheExecutor.execute(new SaveProperties(version));
// ....
}