注册中心
注册中心,为Dubbo 中非常重要的一环,主要提供的功能如下。
- 服务提供者启动时,向注册中心写入自己的元数据信息,同时会订阅配置元数据信息
- 消费者启动时,也会想注册中心写入自己的元数据信息,并订阅提供者、路由和元数据信息
- 服务治理中心(dubbo-admin)启动时,会同事订阅所有消费者、提供者、路由和配置元数据信息
- 当有服务提供者离开或有新的服务提供者加入时,注册中心服务提供者目录会发送变化,变化信息会动态通知消费者、服务治理中心
- 当消费方发起服务调用时,会异步将调用、统计等上报给监控中心(dubbo-monitor-simple)
源码中注册中心模块的结构目录如下:
可以清楚的看到,当前版本中,dubbo官方支持了multicast nacos redis zk 这几种方式,主要分析zk 和redis这2种常用的注册中心。
ZooKeeper 注册中心
以接口 com.alibaba.dubbo.demo.DemoService 为例子,通过zk发布该服务,在zk的节点中
-dubbo/
-com.alibaba.dubbo.demo.DemoService/
-providers
-consumers
-configurators
-routers
1、根节点为dubbo,是由配置项dubbo:registry 中的group属性,默认为/dubbo
2、服务接口下包含4个子类目,分别是providers consumer configurators consumers,都是持久化节点,在provide和consumer目录下,就是具体的url例如下
dubbo://127.0.0.1:20880/com.alibaba.dubbo.demo.DemoService?....,这是一个临时节点。持久化节点,需要手动删除后才会消失,而临时节点是会话级别的,所以当dubbo的服务宕机时,会话被关闭,此时,临时节点也就消失了,该服务便被踢出了列表。
3、由于在生产环境中,由多个消费者和多个服务者来完成。服务提供者目录(/dubbo/service/providers) 下面包含的接口有多个服务者 URL 元数据信息。服务消费者目录(/dubbo/service/consumers) 下面包含的接口有多个消费者 URL 元数据信息
4、 路由配置目录(/dubbo/service/routers)下面包含多个用于消费者路由策略 URL 元数据信息
5 、 动态配置目录(/dubbo/service/configurators)下面包含多个用于服务者动态配置 URL元数据信息
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);
}
}
@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);
}
}
此处,主要在于订阅如何处理,主要方法为com.alibaba.dubbo.registry.zookeeper.ZookeeperRegistry#doSubscribe。
该方法分为2部分,一为dubbo-admin 服务治理平台订阅所有的消息,感知每个服务的状 态,二为普通消费者订阅相关的消息。由于存在zk的订阅操作,不了解zk的可以先了解一下zk的订阅是怎么操作的
try {
if (Constants.ANY_VALUE.equals(url.getServiceInterface())) {
// 作为治理平台,订阅所有的节点消息
String root = toRootPath();
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.get(url);
if (listeners == null) {
// Listeners为空说明缓存中没有,这里把1isteners放入缓存,说明改节点还没有订阅
zkListeners.putIfAbsent(url, new ConcurrentHashMap<NotifyListener, ChildListener>());
listeners = zkListeners.get(url);
}
ChildListener zkListener = listeners.get(listener);
if (zkListener == null) {
// 说明第一次订阅,需要新建一个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);
}
}
} else {
// 普通的消费者订阅指定的节点
List<URL> urls = new ArrayList<URL>();
// 根据组别,获取要订阅的一组节点,分别有providers、routers、 consumers, configurator
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) {
listeners.putIfAbsent(listener, new ChildListener() {
@Override
public void childChanged(String parentPath, List<String> currentChilds) {
//回调NotifyListener,更新本地缓存信息,当节点发生变化时,会触发下面的回调,也就是当新服务发布或就服务下线时,在此处被感知到
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);
}
其中 回调NotifyListener,更新本地缓存信息,此处最后调用的方法为RegistryDirectory#notify,监听的节点变化了,主要分为3种,
- 如果是providers 类别的数据,则订阅方会更新本地Directory管理的Invoker服务列表;
- 如果是routers分类,则 订阅方会更新本地路由规则列表;
- 如果是configuators类别,则订阅方会更新或覆盖本地动态参 数列表
代码如下
public synchronized void notify(List<URL> urls) {
// 需要变更的invoker列表
List<URL> invokerUrls = new ArrayList<URL>();
// 需要变更的 routerUrls 列表
List<URL> routerUrls = new ArrayList<URL>();
// 需要变更的 配置 列表
List<URL> configuratorUrls = new ArrayList<URL>();
for (URL url : urls) {
String protocol = url.getProtocol();
String category = url.getParameter(Constants.CATEGORY_KEY, Constants.DEFAULT_CATEGORY);
if (Constants.ROUTERS_CATEGORY.equals(category)
|| Constants.ROUTE_PROTOCOL.equals(protocol)) {
routerUrls.add(url);
} else if (Constants.CONFIGURATORS_CATEGORY.equals(category)
|| Constants.OVERRIDE_PROTOCOL.equals(protocol)) {
configuratorUrls.add(url);
} else if (Constants.PROVIDERS_CATEGORY.equals(category)) {
invokerUrls.add(url);
} else {
logger.warn("Unsupported category " + category + " in notified url: " + url + " from registry " + getUrl().getAddress() + " to consumer " + NetUtils.getLocalHost());
}
}
// configurators
if (configuratorUrls != null && !configuratorUrls.isEmpty()) {
this.configurators = toConfigurators(configuratorUrls);
}
// routers
if (routerUrls != null && !routerUrls.isEmpty()) {
List<Router> routers = toRouters(routerUrls);
if (routers != null) { // null - do nothing
setRouters(routers);
}
}
List<Configurator> localConfigurators = this.configurators; // local reference
// merge override parameters
this.overrideDirectoryUrl = directoryUrl;
if (localConfigurators != null && !localConfigurators.isEmpty()) {
for (Configurator configurator : localConfigurators) {
this.overrideDirectoryUrl = configurator.configure(overrideDirectoryUrl);
}
}
// providers
refreshInvoker(invokerUrls);
}
可以看到,在dubbo中,一旦zk的节点产生了变化,它所采取的操作,是重新拉取一次对应节点的数据然后刷新。
官网补充
阿里内部并没有采用 Zookeeper 做为注册中心,而是使用自己实现的基于数据库的注册中心,即:Zookeeper 注册中心并没有在阿里内部长时间运行的可靠性保障,此 Zookeeper 桥接实现只为开源版本提供,其可靠性依赖于 Zookeeper 本身的可靠性。
至于为什么不选,大致来讲就是注册中心希望的能力是ap,而zk是cp的,即,注册中心不需要数据的强一致性,而是希望可以提高他的可用性。具体的可以看这里讲的非常的详细,同时采用了辩证的思维来分析比较客观。
Redis注册中心
同样,按照zk中那样的分层,分为Root、Service、Type、URL 结构,不同的是zk是按树形目录结构展示,而redis中采用是多层拼接成一个key来存储。
以例子com.alibaba.dubbo.demo.DemoService,如下
key : /dubbo/com.alibaba.dubbo.demo.DemoService/providers value : (key :url value: 10000 )
采用了redis的map类型的数据结构,同时value是时期的时间。
主要是利用了redis的过期和publish/subscribe通道。当服务发布时,写入一个key,然后会在通道中发布一个register消息。在服务运行的过程中,服务方会周期性的刷新key的过期时间,保证key一直存活在redis中。同理,当服务宕机时,就无法去刷新这个key,于是时间到期后,key会消失,此时服务被认定为下线。次数,从服务宕机到key过期,中间会存在一定的时间间隔,此时并不是很友好。
redis注册中心源码
发布代码为com.alibaba.dubbo.registry.redis.RedisRegistry#doRegister 如下,就是往redis中写入
public void doRegister(URL url) {
String key = toCategoryPath(url);
String value = url.toFullString();
String expire = String.valueOf(System.currentTimeMillis() + expirePeriod);
boolean success = false;
RpcException exception = null;
for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
JedisPool jedisPool = entry.getValue();
try {
Jedis jedis = jedisPool.getResource();
try {
jedis.hset(key, value, expire);
// 在通道中广播消息
jedis.publish(key, Constants.REGISTER);
success = true;
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;
}
}
}
续期key,在类中有一个ScheduledExecutorService,由该定时调度线程池来完成续期key的操作,如下
private void deferExpired() {
for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
JedisPool jedisPool = entry.getValue();
try {
Jedis jedis = jedisPool.getResource();
try {
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);
}
}
}
// 服务治理中心的任务,会定时清理宕机的key
if (admin) {
clean(jedis);
}
if (!replicate) {
break;// If the server side has synchronized data, just write a single machine
}
} finally {
jedis.close();
}
} catch (Throwable t) {
logger.warn("Failed to write provider heartbeat to redis registry. registry: " + entry.getKey() + ", cause: " + t.getMessage(), t);
}
}
}
如果此时有服务提供者主动下线时,则会广播一个过期的消息,订阅方收到后则从注册中心拉取数据,更新本地缓存的服务列表。新服务提供者上线也是通过通道事件触发更新的。
但是redis的key过期是不会有动态消息推送的,而且Redis的publish/subscribe通道并不是消息可靠的。此时需要借助服务治理中心admin来完成redis中过期key的清理,治理中心有定时调度的任务会触发清理的逻辑,清理掉一件宕机的key,并且发送下线消息。
代码如下
private void clean(Jedis jedis) {
Set<String> keys = jedis.keys(root + Constants.ANY_VALUE);
if (keys != null && !keys.isEmpty()) {
for (String key : keys) {
Map<String, String> values = jedis.hgetAll(key);
if (values != null && values.size() > 0) {
boolean delete = false;
long now = System.currentTimeMillis();
for (Map.Entry<String, String> entry : values.entrySet()) {
URL url = URL.valueOf(entry.getKey());
if (url.getParameter(Constants.DYNAMIC_KEY, true)) {
long expire = Long.parseLong(entry.getValue());
if (expire < now) {
jedis.hdel(key, entry.getKey());
delete = true;
if (logger.isWarnEnabled()) {
logger.warn("Delete expired key: " + key + " -> value: " + entry.getKey() + ", expire: " + new Date(expire) + ", now: " + new Date(now));
}
}
}
}
if (delete) {
jedis.publish(key, Constants.UNREGISTER);
}
}
}
}
}
注册中心的缓存
在开始的类图中,AbstractRegistry模板类提供了缓存的功能,保证了注册中心无法连接或宕机,则Dubbo框架会自动通过本地缓存加载Invokers。
分为2种保存的方式
- 同步保存
- 异步保存
启动时加载缓存
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);
}
}
}
}
}
写入缓存
private void saveProperties(URL url) {
if (file == null) {
return;
}
try {
StringBuilder buf = new StringBuilder();
Map<String, List<URL>> categoryNotified = notified.get(url);
if (categoryNotified != null) {
for (List<URL> us : categoryNotified.values()) {
for (URL u : us) {
if (buf.length() > 0) {
buf.append(URL_SEPARATOR);
}
buf.append(u.toFullString());
}
}
}
properties.setProperty(url.getServiceKey(), buf.toString());
long version = lastCacheChanged.incrementAndGet();
if (syncSaveFile) {
// 同步保存
doSaveProperties(version);
} else {
// 异步保存,同时使用版本号,来防止覆盖
registryCacheExecutor.execute(new SaveProperties(version));
}
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
重试机制
还是回到一开始的类图,zk和redis的注册中心都是继承了FailbackRegistry,这个类提供了重试的机制。也是采用一个定时调度线程池来定时的重试那些失败的操作。 主要提供了如下5中重试的场景
// 注册失败
private final Set<URL> failedRegistered = new ConcurrentHashSet<URL>();
// 注销失败
private final Set<URL> failedUnregistered = new ConcurrentHashSet<URL>();
// 订阅失败
private final ConcurrentMap<URL, Set<NotifyListener>> failedSubscribed = new ConcurrentHashMap<URL, Set<NotifyListener>>();
// 取消订阅失败
private final ConcurrentMap<URL, Set<NotifyListener>> failedUnsubscribed = new ConcurrentHashMap<URL, Set<NotifyListener>>();
// 通知失败
private final ConcurrentMap<URL, Map<NotifyListener, List<URL>>> failedNotified = new ConcurrentHashMap<URL, Map<NotifyListener, List<URL>>>();
定时调用com.alibaba.dubbo.registry.support.FailbackRegistry#retry来完成重试的操作
额外
注册中心在网上拥有其他的扩展版本,在《深入理解Apache Dubbo与实战》中介绍了一种基于etcd的注册中心。还有基于nacos等官方支持的注册中心,也比较有意思。
平时的话,主要还是用zk,也是官网推荐的版本,公司也没有什么特殊的需求。