Apache Dubbo学习实战-Dubbo服务订阅/发布

1,188 阅读7分钟

服务订阅与发布

  订阅发布是整个注册中心的核心功能之一,在传统的应用系统中,通常会把配置信息写入到一个配置文件中,当配置需要变更的时候,会修改配置文件,在通过手动触发内存中的配置重新加载内容,例如重启服务等操作。在集群规模较小的场景下,这种方式也能方便的进行运维。但是当节点数量上升之后,这种管理方式的弊端就会凸显出来。

  如果使用了注册中心,那么上面的问题就会引刃而解。当一个已有服务提供者节点下线,或者是一个新的服务提供者上线到微服务环境中的时候,订阅对应接口的消费者和服务治理中心能够及时的收到注册中心的通知,并且更新本地的配置信息。如此,后续的服务调用就可以避免调用到已经下线的节点,就能调用到新加入的节点内容。整个的过程都是自动完成的,不需要人工的参与。

  Dubbo在上层抽象了整个流程,对于整个流程也提供的不同的实现方式,这里主要来说一下Zookeeper和Redis两种实现方式。

Zookeeper实现服务订阅发布

1、发布实现

  服务提供者和服务消费者都需要把自己注册到注册中心,服务提供者的注册是为了让服务消费者感知到对应服务的存在,从而发起远程调用;也是让服务治理中心感知有新的服务提供者上线。服务消费者的发布是为了让服务治理中心可以发现自己。Zookeeper发布代码非常简单,只是调用了Zookeeper客户端库在注册中心上创建一个目录。

zkClient.create(toUrlPath(url));
url.getParameter(Constants.DYNAMIC_KEY,true));

  取消发布也是非常简单的操作,只要把Zookeeper上的对应路径删除就可以了。

zkClient.delete(toUrlPath(url));

2、订阅实现

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

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

Dubbo中有哪些Zookeeper客户端实现

  无论服务提供者还是消费者,或者是服务治理中心,任何节点连接到Zookeeper注册中心都需要使用一个客户端,Dubbo在dubbo-remoting-zookeeper模块中实现了Zookeeper客户端的统一封装,定义了统一的Client API,并用了两种不同的开源客户端进行实现

  • Apache Curator
  • ZkClient   用户可以在dubbo:registry的client属性中设置curator、zkclient来使用不同的客户端实现库,如果不设置则默认使用Curator来进行实现。

  Zookeeper注册中心采用的是“事件通知”+“客户端拉领取”的方式,客户端在第一次连接上注册中心时,会对应获取到对应目录下的全量数据。并且在订阅的节点上注册一个watcher,客户端与注册中心之间保持TCP长链接,后续每个节点有任何数据变化的时候,注册中心会根据watcher的回调主动通知客户端(事件通知)客户端接到通知后,会把对应节点下的全量数据都拉取过来(客户端拉取操作),在NotifyListener#notify(List urls) 的接口上有对应的约束条件。这里有个弊端就是如果全量拉取的数据量太大的话,就会对网络造成一定的压力。

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

什么操作会被认为是事务操作?

  客户端任何新增、删除、修改、会话创建和失效操作,都会被认为是事务操作,会由Zookeeper中的leader执行,即使客户端连接的是非leader节点,请求也会被转发到leader节点执行,通过这种方式来保证一致性,由于每个节点都有自己的版本号,因此可以通过CAS操作来比较保证节点数据的原子性。

  客户端第一次连接上注册中心,订阅时会获取全量的数据,后续则是通过监听器事件进行更新。服务治理中心会处理所有service层的订阅信息,service被设置成*。此外,服务治理中心还会订阅当前节点,还会订阅这个节点下的所有子节点,核心代码来自ZookeeperRegistry。

 @Override
    public void doSubscribe(final URL url, final NotifyListener listener) {
        try {
            if (ANY_VALUE.equals(url.getServiceInterface())) {
                String root = toRootPath();
                
                ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
                ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (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)), k);
                        }
                    }
                });
                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 {
                CountDownLatch latch = new CountDownLatch(1);
                List<URL> urls = new ArrayList<>();
                for (String path : toCategoriesPath(url)) {
                    ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
                    ChildListener zkListener = listeners.computeIfAbsent(listener, k -> new RegistryChildListenerImpl(url, k, latch));
                    if (zkListener instanceof RegistryChildListenerImpl) {
                        ((RegistryChildListenerImpl) zkListener).setLatch(latch);
                    }
                    zkClient.create(path, false);
                    List<String> children = zkClient.addChildListener(path, zkListener);
                    if (children != null) {
                        urls.addAll(toUrlsWithEmpty(url, path, children));
                    }
                }
                notify(url, listener, urls);
                // tells the listener to run only after the sync notification of main thread finishes.
                latch.countDown();
            }
        } catch (Throwable e) {
            throw new RpcException("Failed to subscribe " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
        }
    }

  上面代码可以看到,需要支持的Dubbo服务治理平台dubbo-admin,平台在启动时会订阅全量接口,它会感知每个服务的状态。

  普通消费者的订阅逻辑如下,首先根据URL的类别得到一组需要订阅的路径。如果类别是*,则会订阅四种类型的路径(providers、routers、consumers、configurators),否则只订阅providers路径。

                CountDownLatch latch = new CountDownLatch(1);
                List<URL> urls = new ArrayList<>();
                for (String path : toCategoriesPath(url)) {
                    ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
                    ChildListener zkListener = listeners.computeIfAbsent(listener, k -> new RegistryChildListenerImpl(url, k, latch));
                    if (zkListener instanceof RegistryChildListenerImpl) {
                        ((RegistryChildListenerImpl) zkListener).setLatch(latch);
                    }
                    zkClient.create(path, false);
                    List<String> children = zkClient.addChildListener(path, zkListener);
                    if (children != null) {
                        urls.addAll(toUrlsWithEmpty(url, path, children));
                    }
                }
                notify(url, listener, urls);
                // tells the listener to run only after the sync notification of main thread finishes.
                latch.countDown();

  注意 此处会根据URL中的category属性值获取具体的类别;providers、routers、consumers、configurators,然后拉取直接子节点的数据进行通知,如果是providers类别的数据,则订阅方会更新本地Directory管理的Invoker服务列表;如果是routers分类,则订阅方会更新本地路由规则列表;如果是configuators类别,则订阅方会更新本地的参数列表

Redis实现订阅发布

1、总体流程

  使用Redis作为注册中心,其订阅发布实现方式与Zookeeper不同。Redis订阅发布使用的是过期机制和publish/subscribe通道。服务提供者发布服务。首先会在Redis中创建一个key,然后再通道中发布一条register事件消息。但服务的key写入Redis后,发布者需要周期性的刷新key过期事件,在RedisRegistry构造方法中会启动一个expireExecutor定时调度线程池,不断调用deferExpired()方法去延续key超时时间,如果服务提供者宕机,没有续约,则key会因为超时被Redis删除,则被认为是服务下线。

    private void deferExpired() {
        for (URL url : new HashSet<>(getRegistered())) {
            if (url.getParameter(DYNAMIC_KEY, true)) {
                String key = toCategoryPath(url);
                if (redisClient.hset(key, url.toFullString(), String.valueOf(System.currentTimeMillis() + expirePeriod)) == 1) {
                    redisClient.publish(key, REGISTER);
                }
            }
        }

        if (doExpire) {
            for (Map.Entry<URL, Long> expireEntry : expireCache.entrySet()) {
                if (expireEntry.getValue() < System.currentTimeMillis()) {
                    doNotify(toCategoryPath(expireEntry.getKey()));
                }
            }
        }

        if (admin) {
            clean();
        }
    }

  订阅方首次连接上注册中心,会获取全量的数据并缓存到本地内存中,后续的服务列表发生变化则是通过publish/subscribe通道广播,当有服务提供者主动下线的时候,会在广播通道中广播一条unregister事件消息,订阅方收到后则从注册中心拉取数据,更新本地缓存的服务列表,新服务提供者上线也是通过通道事件触发更新。

  Redis的key超时是不会有动态消息推送的,如果服务提供者宕机而不是主动下线,则造成没有广播unregister事件消息,订阅方是如何知道服务的发布方已经下线呢?另外Redis的publish/subscribe通道并不是消息可靠的,如果Dubbo注册中心使用了failover的集群容错模式,并且消费者订阅了从节点,但主节点并没有完成数据同步给从节点就宕机了,后续订阅方要如何知道服务发布方已经下线呢?

  使用Redis作为服务注册中心,会依赖于服务治理中心,如果服务治理中心定时调度,则还会触发清理逻辑:Redis获取所有的key进行遍历,如果发现key以超时,则删除Redis上对应的key。清除完成之后,还会在通道中发起对应key的unregister事件,其他消费者监听到取消事件会删除本地的对应服务数据,保证数据的一致性。

image.png

  如图所示,就是Redis注册中心的整个的工作机制。

  Redis客户端初始化的时候,需要先初始化Redis的连接池jedisPools,此时如果配置了注册中心为集群模式,则服务提供者在发布的时候,需要同时向Redis集群中所有的节点都写入,是多写的方式进行操作。但读取的时候还是从其中一个节点上进行读取,Redis集群模式下可以不配置数据同步,一致性由客户端的多写操作来保证。

  如果设置为failover或者是不设置,则会读取和写入任意一个Redis节点,失败的话再尝试下一个Redis节点,这种模式需要Redis自信配置数据同步。另外在初始化的阶段,还会初始化一个调度线程池expireExecutor,它的主要任务是延长key的过期时间和删除过期的key,线程调度的时间间隔是超时时间的一半。

2、发布的实现

  服务提供者和消费者都会使用注册功能,Redis注册部分的代码如下

    @Override
    public void doRegister(URL url) {
        String key = toCategoryPath(url);
        String value = url.toFullString();
        String expire = String.valueOf(System.currentTimeMillis() + expirePeriod);
        try {
            redisClient.hset(key, value, expire);
            redisClient.publish(key, REGISTER);
        } catch (Throwable t) {
            throw new RpcException("Failed to register service to redis registry. registry: " + url.getAddress() + ", service: " + url + ", cause: " + t.getMessage(), t);
        }
    }

3、订阅的实现

  服务消费者、服务提供者和服务注册中心,都会使用注册中心的订阅功能,在订阅的时候,如果是第一次订阅,则会创建一个Notifier的内部类,这是一个线程类,在启动时会异步进行通道订阅。在启动的时候Notifier线程也会启动,主线程会继续往下执行,全量拉取一次注册中心上的所有信息,后续注册中心上的信息变化则通过Notifier线程订阅的通道来推送实现。代码如下。

   if (service.endsWith(ANY_VALUE)) {
         if (first) {
             first = false;
             Set<String> keys = redisClient.scan(service);
             if (CollectionUtils.isNotEmpty(keys)) {
                 for (String s : keys) {
                     doNotify(s);
                 }
             }
             resetSkip();
         }
         redisClient.psubscribe(new NotifySub(), service);
     } else {
         if (first) {
             first = false;
             doNotify(service);
             resetSkip();
         }
         redisClient.psubscribe(new NotifySub(), service + PATH_SEPARATOR + ANY_VALUE); // blocking
                                }