Dubbo(二) 注册中心

1,402 阅读9分钟

注册中心

注册中心,为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,也是官网推荐的版本,公司也没有什么特殊的需求。