如何利用dubbo构建高可用的服务发现能力

1,106 阅读9分钟

1. 导言

注册中心是微服务架构中最关键的组件之一,注册中心用于服务发现和服务注册,以实现微服务间的客户端负载均衡和故障转移。但是当我们引入注册中心之后也会遇到很多问题,微服务要实现一个高可用的、性能良好的服务发现能力并非一件易事,比如:

  • 注册中心全部宕掉后,如何保证现有的服务提供者和服务消费者正常通信?
  • 服务和注册中心如何保活?
  • 注册中心推送数据丢失如何处理?
  • 注册中心推送数据错误(过期数据后来)如何处理?
  • 大规模服务的场景下服务频繁发布造成的MxN的广播风暴如何处理?
  • 大规模服务场景下如何减轻注册中心压力?
  • 注册中心发生网络分区如何处理?

上述的问题在选用不同注册中心的时候解决的方式可能有所不同,本文将结合 dubbo 和 dubbo-go 谈一谈之家云在落地大规模服务治理时,构建高可用的服务发现能力和容灾能力的一些实践和探索。

本文所涉及的dubbo-go版本为 1.5.7-rc1,dubbo版本为2.7.7

注册中心:zookeeper

2. FAQ

2.1. 注册中心完全宕机,服务是否还能正常通信?

这个问题相对来说还是比较好解决的,dubbo 和 dubbo-go都会在RegistryDirectory中缓存服务的节点信息( urlInvokerMap),并且有相应的空闲检测机制对上游节点进行探活和重连。但是需要注意的是因为注册中心完全宕机,服务注册和服务发现的功能是失效的,这意味着 provider 将无法扩容,在 k8s 中 pod 由于无法注册而滚动更新失败要抛出异常终止新的 pod 启动,否则可能会造成服务访问异常。在 consuemr 端,dubbo会在 {user.home}/.dubbo 目录下缓存已定订阅的服务节点列表,如果 consumer 订阅失败,会从该目录下加载相应的缓存,做容灾处理。

2.2. 服务和注册中心如何进行保活?

  • 服务主动探活方式(TTL)

    Eureka,Nacos 等注册中心采用的是TTL方式进行主动探活,也就是发送心跳包向注册中心更新服务的健康状态。一般比较适用于无主的、最终一致性协议。这种方式对注册中心的写压力比较大,尤其是在以接口为粒度进行注册的时候,会发送大量的无用的心跳包,所以如果你使用的是 dubbo3.x 之前的版本,在注册中心选型和容量评估上需要特别注意这点问题。

  • 注册中心主动健康检查

    服务注册后,由注册中心主动对注册应用的端口进行探活。比如TCP-Keeplive,k8s的 probe 机制以及Zookeeper的Session机制。

    采用 TTL 进行续约的方式实现起来比较简单,通过心跳包带有服务的注册信息即可完成续约。当注册中心宕机恢复之后,心跳包即可完成服务的重新注册。而Zookeeper的Session相对来说就比较麻烦了,因为我们需要在Session重连之后将已有的服务重新注册/订阅到注册中心。

2.3. 注册中心推送数据丢失如何处理?

一般来说我们选择的注册中心是需要支持Push模型的,比如Eureka只支持pull模型,就会造成服务上/下线感知时间比较长,从而导致服务调用到已下线的服务上去。但是只使用Push,当发生服务和注册中心发生网络拥塞导致丢包时候会导致客户端的服务节点列表和注册中心真实的节点列表不一致,所以我们还需要定时向注册中心拉取服务的节点列表,来保证数据的一致性。

在dubbo2.7.x版本中如果我们采用了定时pull注册中心节点列表的补偿行为,需要注意的是如果同时pull许多服务的节点列表会在瞬间增加注册中心的网络压力。所以必须要打散定时任务的时间间隔, 比如定时任务增加一个随机时间。

2.4. 注册中心推送数据错误(过期数据后来)如何处理?

由于网络拥塞基于TCP连接的Watcher机制可能会推送过期数据,我们可以在注册中心节点上增加一个timestamp表示服务注册时间,如果

2.5. 大规模服务的场景下服务频繁发布造成的MxN的广播风暴如何处理?

随着k8s的发展,应用上云之后发布模式大多数为滚动发布,这意味着节点是频繁变化的。dubbo在收到push事件之后就会全量拉取一次服务节点列表。假设有500 provider, 500个 consumer,每次更新两个节点(上2个新的,下2旧的), dubbo拉取将会拉取注册中心 2 x 500 x 500 次。

这种情况下对于有些注册中心我们可以采用延迟拉取的策略(比如zk),对于有的注册中心则可以采用增量更新策略(例如Eureka)。

Eureka除了启动时进行全量同步节点信息外,以后都采用增量更新的策略。Eureka通过增量更新只获取发生变化的节点信息和节点列表的hash值,客户端更新节点列表后计算本地节点缓存的hash值和注册中心实际节点列表hash值进行对比,如果不同再回退到全量更新。

2.6. 节点的保护模式

类似于Eureka Server的保护模式,但是大多数的注册中心是不提供保护模式的,如果我们只使用增量更新。随着微服务规模的增大,注册中心很有可能遇到瓶颈。一旦出现高负载,会使服务和注册中心之间的健康检查或保活出现问题,注册中心节点异常下线,只推送部分节点数据到消费者,会造成请求都打到少量的提供者上。

对于这种情况我们可以采取类似于Eureka Server的保护模式,也可以在客户端使用类似于Eureka客户端的增量更新模式(对比hash值)

2.7. 大规模服务场景下如何减轻注册中心压力?

  1. 按应用维度进行注册
  2. 使用增量更新

3. 实践

Zookeeper 是 Apache Hadoop 的子项目,是一个树型的目录服务,支持变更推送,下图是dubbo 使用zk作为注册中心并以接口为粒度注册服务的数据模型。

image.png

3.1. dubbo服务发现流程

-->RegistryProtocol#refer() # 引入服务
  |--> getRegistry(url) # 获取注册中心实例
  |--> RegistryDirectory directory = new RegistryDirectory<T>(type, url) # 创建服务字典
  |--> directory.subscribe(toSubscribeUrl(subscribeUrl)) # 订阅 provider
 -->FailbackRegistry#subscribe
   |--> removeFailedSubscribed() # 删除订阅失败的url
     |--> ZookeeperRegistry#doSubscribe #执行具体的订阅动作
        |--> List<String> children =zkClient.addChildListener(path, zkListener) # 设置watcher并获取子节点
        |--> List urls = toUrlsWithEmpty(url, path, children) //将节点内容转化为url 
        |--> notify(url, listener, urls); 刷新invokers
 exception |--> urls=getCacheUrls() # 从本地缓存中获取已订阅服务列表
           |--> notify(urls) # 刷新invoker
           |--> saveProperties(url) # 缓存已订阅的urls
           or|--> check #是否跳过检查
           |--> addFailedSubscribed(url, listener); #增加到重试列表中

ZookeeperRegistry#doSubscribe的订阅流程

public void doSubscribe(final URL url, final NotifyListener listener) {
    try {
           // 省略部分代码
           List<URL> urls = new ArrayList<>();
           // /dubbo/org.apache.dubbo.demo.DemoService/providers
           for (String path : toCategoriesPath(url)) {
                ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
                ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (parentPath, currentChilds) -> ZookeeperRegistry.this.notify(url, k, toUrlsWithEmpty(url, parentPath, currentChilds)));
                zkClient.create(path, false);
                List<String> children = zkClient.addChildListener(path, zkListener);
                if (children != null) {
                    urls.addAll(toUrlsWithEmpty(url, path, children));
                }
            }
            notify(url, listener, urls);
        }
    } catch (Throwable e) {
        throw new RpcException("Failed to subscribe " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
    
}

static class CuratorWatcherImpl implements CuratorWatcher {

    @Override
    public void process(WatchedEvent event) throws Exception {
        // if client connect or disconnect to server, zookeeper will queue
        // watched event(Watcher.Event.EventType.None, .., path = null).
        if (event.getType() == Watcher.Event.EventType.None) {
            return;
        }
        // 因为zk的watcher是一次性的,所以需要重新设置  
        if (childListener != null) {
            childListener.childChanged(path, client.getChildren().usingWatcher(this).forPath(path));
        }
    }
}

上述这段代码是dubbo2.7.x,consumer处理zk作为注册中心的最核心的流程,它主要做了以下几件事:

  1. 将url根据category转化为要订阅的nodePath,例如 /dubbo/com.foo.BarService/providers

  2. 订阅 /dubbo/com.foo.BarService/providers 目录下的提供者 URL 地址,并设置childPath Watcher,listener 对应关系如下:

    
                   |--serviceA/groupA/versionA -> [NotifyListener(RegistryDirectory),ChildListener] -> TargetChildListener(CuratorWatcherImpl)
    Application--> |--serviceB/groupB/versionB -> [NotifyListener(RegistryDirectory),ChildListener] -> TargetChildListener(CuratorWatcherImpl)
                   |--serviceB/groupC/versionC -> [NotifyListener(RegistryDirectory),ChildListener] -> TargetChildListener(CuratorWatcherImpl)
                                                   
    

    以上对应关系为 服务层-> dubbo adapter层 -> 具体注册中心监听实现

  3. 将childPath转化为解码后的URL地址列表,代码如下:

     private List<URL> toUrlsWithoutEmpty(URL consumer, List<String> providers) {
        List<URL> urls = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(providers)) {
            for (String provider : providers) {
                if (provider.contains(PROTOCOL_SEPARATOR_ENCODED)) {
                    // 解码为url
                    URL url = URLStrParser.parseEncodedStr(provider);
                    if (UrlUtils.isMatch(consumer, url)) {
                        urls.add(url);
                    }
                }
            }
        }
        return urls;
    }
    
  4. 通知NotifyListener(RegistryDirectory)刷新invoker列表。

从上述流程我们可以发现dubbo2.7.x版本的服务发现机制相对来说是比较完善的,但是也存在几个问题:

  1. 没有pull动作,完全依赖注册中心的push机制,在dubbo的FailbackRegistry中提供了loop方法对注册中心service节点进行拉取,我们可以通过方法利用qos或者定时任务进行扩展。

  2. 收到注册中心节点变化事件,对这个服务的节点列表进行一次全量拉取。这种方式的好处是一致性强,我想这也是为什么dubbo不使用定时pull更新节点列表的原因,因为每次收到push事件都会进行一次全量拉取,只要我们重新发布节点或者扩缩容都能保证服务发现的一致性。但是在大规模服务治理(500+节点)场景下进行滚动更新会造成对注册中心造成网络风暴,可能会瞬间将注册中心网络打满。

  3. 每次都要全量更新服务url,toUrlsWithoutEmpty每次都要对ur进行全量的Encoded去生成urls。假设我只增加/删除了一个节点,却要更新全部的url。这比较消耗内存和CPU很容易造成full gc。dubbo3 对URL进行了全面的优化,参考浅析 Dubbo 3.0 中接口级地址推送性能的优化

  4. 不同的group和version,在注册中心对应一个节点。在dubbo中对应了两个RegistryDirectory, 也就是说维护两个完全相同的invoker列表,在同一个服务的分组和版本比较多的时候对内存浪费还是比较严重的。这里我们可以通过缓存RegistryDirectory,对不同group和version的服务使用同一个RegistryDirectory进行优化。

3.2. dubbo-go 服务发现流程

接下来我们看下dubbo go的服务发现流程

       --> registrtProtocol.getRegistry() # 获取注册中心实例
         |--> r := &zkRegistry{} # 初始化ZK 注册中心
         |--> zookeeper.ValidateZookeeperClient(r)# 创建ZK客户端
         |--> go zookeeper.HandleClientRestart(r) # 初始化session重连监听
         |--> r.listener = zookeeper.NewZkEventListener(r.client) # 创建zk eventListener
         |--> r.dataListener = NewRegistryDataListener() # 创建dataListener
       --> extension.getRegistryDirectory() # 通过扩展点获取服务字典
         |--> dir := &RegistryDirectory{} # 初始化服务字典
         |--> go dir.subscribe(url.SubURL) # 订阅provider
         |--> dir.registry.Subscribe(url, dir)
         |--> listener, err := r.facadeBasedRegistry.DoSubscribe(url) # zkRegistry进行订阅
            |--> zkListener = NewRegistryConfigurationListener(r.client, r, url) # 创建服务监听器
            |--> r.dataListener.SubscribeURL(conf, zkListener) # 存储映射关系
            |-->  go r.listener.ListenServiceEvent(url,path, r.dataListener) # 处理具体订阅逻辑
     loop|--> serviceEvent, err := listener.Next() // loop注册中心节点变化
         |--> notifyListener.Notify(serviceEvent)
         

RegistryDataListener

type RegistryDataListener struct {
	subscribed map[string]config_center.ConfigurationListener
	mutex      sync.Mutex
	closed     bool
}

dataListener用于存储应用对注册中心所有服务的监听器。每个服务都有一个监听器,监听注册中心对应节点的变化。

                                           |--serviceA ->ConfigurationListener
                                           |
Appliaction -> Registry -> dataListener -> |--serviceB ->ConfigurationListener
                                           |
                                           |--serviceB ->ConfigurationListener

这里的代码有些歧义,在于ConfigurationListener是从名字上来看是给配置中心使用的监听器,但是在注册中心的处理流程中也使用的是这个监听器。

dubbo-go 详细的服务发现流程可以参考dubbo-go zk 服务发现逻辑

dubbo-go 更新invokers的方式和也和dubbo有很大的不同,dubbo-go采用了一种增量更新的方式,即在获取应用全量的节点后生成ServiceEvent,只对增加或者删除的url进行生成,以此避免了dubbo 2.7.x中全量更新url带来的内存占用和性能问题,在大规模服务治理场景下还是有很大的优势的。

4. 总结

服务发现在微服务中是非常关键的功能,我们要从容灾、性能上充分考量一个服务治理中间件是否能够提供可靠的服务发现能力。总的来说注册中心的服务发现主要分为增量更新全量更新两种模式。类似于Eureka的增量更新模式似乎更适合大规模的服务场景,而zk由于其本身的特性,在大规模服务场景下,会存在一定的性能瓶颈。接下来我们会逐步将dubbo2.7.x 过渡到 dubbo3.0,使用全新的服务发现模型,为注册中心减负,并探索更适合大规模服务和接近云原生的注册中心。