有赞服务注册与发现架构演进

一、概述

近几年,随着有赞业务的快速发展,应用数目与实例规模在快速地增加。有赞的服务注册与发现架构近几年也一直在快速平稳地演进,以支撑业务的发展。本文主要介绍有赞近几年服务注册与发现架构的演进过程。

有赞的后台业务应用主要是基于 Dubbo 框架开发的,因此,服务注册与发现的方案也都离不开对 Dubbo 服务模型的支持。近几年,Dubbo 社区也一直在演进服务注册与发现解决方案,但有赞的演进路线跟 Dubbo 社区并不相同。有赞根据内部独特的历史背景以及未来规划走出了具有自己特色的演进道路。

本文将分为三个阶段来介绍近几年有赞服务注册与发现架构的演进:接口级服务注册与发现,接口级服务注册与应用级服务发现,应用级服务注册与发现。为了聚焦,本文主要介绍 Dubbo 应用相关的服务注册与发现,但实际上有赞的服务注册与发现方案不仅仅支持 Dubbo 应用。

二、接口级服务注册与发现

2.1 架构

接口级服务注册与发现,也是开源社区 Dubbo 2.7 版本之前的标准方案,有赞 2018 年 ~ 2019 年期间主要处于这种架构阶段。架构如下图所示: 模型上与 Dubbo 社区方案是一致的,注册中心我们采用的是 Etcd v3。

接口级模型示例:

[
  {
    "interface":"com.youzan.java.demo.api.HelloService",
    "instances":[
      {
        "ip_address":"10.10.10.10",
        "port":5000,
        "protocol":"dubbo",
        "az":"qa",
        "weight":100,
        "labels":{
          "version":"stable"
        },
        "application":"java-demo",
        "methods":[
          "hello"
        ]
      }
    ]
  },
  {
    "interface":"com.youzan.java.demo.api.EchoService",
    "instances":[
      {
        "ip_address":"10.10.10.10",
        "port":5000,
        "protocol":"dubbo",
        "az":"qa",
        "weight":100,
        "labels":{
          "version":"stable"
        },
        "application":"java-demo",
        "methods":[
          "echo"
        ]
      }
    ]
  }
]

2.2 问题

接口级服务注册与发现可以说是 Dubbo 框架独有的模型,而业界主流的服务注册与发现模型都是应用级的,如 K8S、Spring Cloud、Consul 等。相比应用级模型,接口级模型的主要问题是粒度太细,服务注册与发现的开销太高。根据有赞的服务注册与发现数据统计,平均每个应用实例的接口注册数量和订阅数量为几十个,同时,该数量也在缓慢增长。粗略估算,在大规模场景中,接口级比应用级服务注册与发现的成本要高 1~2 个数量级。接口级服务注册与发现的弊端业界也基本达成了共识,Dubbo 社区从 Dubbo 2.7.5 开始支持应用级服务注册与发现。

总结一下该架构存在的痛点:

  • 接口级别的服务注册与发现,大大增加了服务注册与发现的压力;
  • 接口级别的服务注册数据冗余度过高,同一个应用实例的多个接口之间有大量的重复数据;同一个应用的不同实例之间同样存在大量冗余数据;
  • Etcd 作为强一致性的 CP 系统,其水平伸缩能力不足,容易成为瓶颈;
  • 服务注册与发现由 SDK 支持,多语言应用支持成本高。

三、接口级服务注册与应用级服务发现

3.1 架构

这一阶段服务注册维持不变,服务发现转变为应用级别,有赞 2020 年期间主要处于这种架构阶段,该架构属于过渡阶段架构。架构如下图所示:

服务发现方面主要有以下两个变化:

  • 引入了Istio Pilot 作为服务发现中心或中间层。由 Istio Pilot 对接各个注册中心平台,抽象并统一服务发现模型,屏蔽注册中心具体实现细节,同时提升可伸缩能力。
  • 所有消费端接入了 Sidecar Tether,由 Tether 进行服务发现、请求路由、负载均衡等,且以应用维度进行服务发现。

下面将进行详细介绍。

3.2应用级服务发现解析

Istio Pilot 抽象并统一了服务发现模型,屏蔽掉了注册中心具体实现细节,使得消费端应用完全不需要关注服务提供端应用是如何进行服务注册的。Istio Pilot 中间层避免了海量客户端直连注册中心,大大降低了注册中心的压力;同时 Istio Pilot 是无状态的,可以轻松扩缩容,大大提升了可伸缩能力。Istio Pilot 通过 xDS API 向所有 Sidecar 推送服务发现、配置等数据。服务发现 API 是 EDS(Endpoint Discovery Service),客户端通过指定 Cluster 名称(这里可以认为对应的是应用名)列表来订阅对应的服务实例信息,当服务实例信息有更新时,Istio Pilot 推送给订阅的客户端。

我们引入 Istio Pilot 不仅仅是作为服务发现中心,同时也是路由规则配置中心等。Istio Pilot 实际上是作为有赞 Service Mesh 的控制面角色来使用的,我们的数据面是自研组件 Tether。由于有赞整体业务规模庞大,以及 Dubbo 模型的复杂度,是很难直接落地为 Istio 社区那样的 Service Mesh 形态的。因此,我们对 Istio Pilot 进行了大量扩展与适配,来满足内部需求,以及逐步演进的目标。对于服务发现而言,我们扩展支持了 Etcd Registry,同时实现了接口模型到应用模型的转换。为了支持 Dubbo 的服务发现,使用原有的模型还不够,因此我们通过xDS模型的扩展字段来支持 Dubbo 的元数据。

应用级模型示例:

{
  "application":"java-demo",
  "instances":[
    {
      "ip_address":"10.10.10.10",
      "port":5000,
      "protocol":"dubbo",
      "az":"qa",
      "weight":100,
      "labels":{
        "version":"stable"
      },
      "dubbo_rpc_metadata":[
        {
          "interface":"com.youzan.java.demo.api.HelloService",
          "methods":[
            "hello"
          ]
        },
        {
          "interface":"com.youzan.java.demo.api.EchoService",
          "methods":[
            "echo"
          ]
        }
      ]
    }
  ]
}

与前面提到的接口级模型对比,应用级模型大大降低了数据冗余。

我们通过 Istio Pilot 实现了应用级的服务发现,这时我们面临一个问题,原先 Dubbo 框架通过访问的接口直接进行服务发现,现在需要将访问的接口映射为访问的应用,然后以应用名进行服务发现订阅。

Istio Pilot 会根据应用级的注册信息在内存中构建一个接口到应用的反向映射,我们扩展了一个 Interface Mapping 接口,用于查询哪些应用暴露了这个接口。映射信息如下所示:

[
  {
    "interface":"com.youzan.java.demo.api.HelloService",
    "providers":[
      "java-demo"
    ]
  },
  {
    "interface":"com.youzan.java.demo.api.EchoService",
    "providers":[
      "java-demo"
    ]
  }
] 

前面我们提到,由 SDK 进行服务发现,对于多语言应用支持成本较高。在有赞,除了主流的 Dubbo RPC 应用,还有 Node Web 应用,PHP Web 应用,ZanPHP RPC 应用,这些应用都需要进行服务发现访问其他后端应用。所以,我们将这些基础能力下沉到 Sidecar Tether,所有消费端接入 Tether,由 Tether 进行服务发现、请求路由、负载均衡等。接入 Tether 也开启了有赞的 Service Mesh 之路。

Dubbo 社区应用级服务发现方案中,为了使 Dubbo 尽可能的兼容和融入业界已有的应用级服务发现解决方案,元数据是通过一种服务自省的方式来获取的。对于接口到应用的映射,解决思路基本都是一致的。

3.3优化

虽然当前的架构方案已经大大缓解了服务发现的压力,但是仍有几个优化点可以大大提升性能。下面简单介绍一下。

3.3.1服务发现延迟聚合推送

Dubbo 实例在启动时,是一个接口一个接口注册的,因为我们将接口注册数据转换成了应用实例注册数据,这也就意味着,每注册一个接口,应用实例数据(dubbo_rpc_metadata)就会变动。如果每次变动就进行服务发现推送,那成本会很高,无论是对于 Istio Pilot 还是 Tether。根据统计,一个实例一般在几秒钟内会完成所有接口的注册,因此,我们会对一段时间内注册事件响应进行延迟处理。比如,如果实例注册数据有变动,延迟 3s 再推送,如果 3s 时间到达之前期间又有变动,再延迟 3s,最长不超过 10s。该方案大概率地把一个实例的多个接口注册事件聚合成了一次推送,同时,应用发布过程一般是分批次进行的,每个批次会有多个实例同时启动,该方案也有很大概率把多个实例的注册事件聚合成一次推送。

3.3.2服务发现预加载

最初 Tether 的服务发现是延迟加载的,即当应用的请求到达 Tether 后,如果还没有订阅过目标访问应用,进行服务发现订阅。刚启动的时候,访问不同应用的请求会陆陆续续到来,每个请求访问一个本地服务发现数据不存在的应用时,就需要更新服务发现订阅列表,发起新的 EDS 订阅请求,会加大Istio Pilot 的负载,同时会一定程度增加请求的 RT。一个应用的需要访问的其他应用的列表是比较稳定的,我们称之为服务依赖列表。我们通过 Tether 定时上报最近一段时间(如 30 分钟)访问过的应用列表到 Istio Pilot 来实现服务发现预加载。Tether 启动初始化阶段拉取该应用最近访问过的应用列表,然后一次性的完成服务发现订阅,大大降低了应用刚启动时首次请求的 RT。

3.3.3客户端接口与应用映射关系构建

前面我们讨论过,请求到来时,我们需要根据接口查询对应的应用,那是不是每个接口的首次请求都需要通过 Istio Pilot 的 Interface Mapping 接口查询呢?其实没必要的,当我们拿到一个应用的服务发现数据时,本地可以根据该应用的服务元数据构建出来所有的接口到应用的映射关系。根据局部性原理,如果一个应用访问了某个应用的一个接口,短时间内大概率也会访问该应用的其他接口。通过该优化,实际上只会发生很少的 Interface Mapping 查询请求。当然,应用的服务元数据都是在变化的,因此,我们也需要定期的异步刷新,异步刷新时,只会刷新最近有请求的接口,且我们实现了批量处理的接口以提升性能。对于一段时间内没有访问过的接口,当新请求到来时,会尝试同步去请求 Interface Mapping。

3.3.4接口元数据聚合分组

一般来说,真实生产环境中,一个应用的大部分实例的服务元数据是相同的,那么也就没必要为每个应用实例关联一份完整的元数据。在有赞的场景中,每个机房,每个应用一般最多有 2 个版本的服务元数据,主要出现在发布过程中,如普通滚动发布、灰度/蓝绿发布。因此,我们可以进行相应的优化。Istio Pilot 在推送服务发现数据前会对应用实例服务元数据进行聚合分组,以减少网络带宽 IO,以及客户端解析开销等。 聚合示例:

{
  "application":"java-demo",
  "instances":[
    {
      "ip_address":"10.10.10.10",
      "port":5000,
      "protocol":"dubbo",
      "az":"qa",
      "weight":100,
      "labels":{
        "version":"stable"
      },
      "dubbo_rpc_metadata":{
        "type":"metadata",
        "data":[
          {
            "interface":"com.youzan.java.demo.api.HelloService",
            "methods":[
              "hello"
            ]
          },
          {
            "interface":"com.youzan.java.demo.api.EchoService",
            "methods":[
              "echo"
            ]
          }
        ]
      }
    },
    {
      "ip_address":"10.10.10.20",
      "port":5000,
      "protocol":"dubbo",
      "az":"qa",
      "weight":100,
      "labels":{
        "version":"stable"
      },
      "dubbo_rpc_metadata":{
        "type":"metadata_reference",
        "data":{
          "ip_address":"10.10.10.10",
          "port":5000
        }
      }
    }
  ]
}

应用实例10.10.10.10:500010.10.10.20:5000服务元数据完全一致,因此10.10.10.20:5000dubbo_rpc_metadata中并不需要保存完整的服务元数据信息,仅需要保存一个引用的应用实例信息即可。客户端在构建时,会根据引用关系,关联到正确的服务元数据信息。

该优化将服务发现推送的网络 IO 降低到原来的 30% 以下。

3.4 问题

虽然该架构解决了服务发现的一些问题,但仍然有以下问题:

  • 应用与注册中心耦合,多语言服务注册支持成本高;
  • 服务注册数据维度过细,注册、健康检查开销与复杂度都较高;
  • 服务注册数据冗余度过高。

四、应用级服务注册与发现

4.1架构

这是有赞确定的长期架构,从 2021 年开始,有赞转换到该架构。架构图如下所示: 此架构服务与前一阶段架构相比,客户端服务发现没有变化,只有服务注册有变化。

应用服务注册通过 Sidecar Tether 来完成,可以主动调用 Tether 服务注册接口,也可以由 Tether 主动获取实例服务注册信息进行注册,目前我们优先支持了前一种方式。

Dubbo 启动时,等待所有接口暴露完成,聚合成应用级的实例信息,发起一次服务注册请求到 Tether,Tether 判断应用部署环境,向 Istio Pilot 发起相应的注册请求。对于 VM 部署应用,需要注册完整的信息;对于 K8S 部署应用,仅需要注册服务元数据信息即可,其他实例信息、标签等可以由 Istio Pilot 根据 K8S Service 以及 Pod 信息获得。有赞业务应用基本都实现了 K8S 部署,所以,这里只介绍 K8S 部署应用的注册流程。Istio Pilot 会将注册请求中的服务元数据,以 CRD 的方式存储到K8S中。Istio Pilot 在服务发现时,会根据 Service/Endpoints/Pod/ServiceMetadata 信息,生成完整的服务发现数据。此过程中,Istio Pilot 与客户端之间的服务发现数据模型完全没有变化,因此,客户端对于该服务注册的变动是完全无感知的。这样,我们又平滑地演进到了新的架构。

可能有人会考虑到运行时动态暴露接口的场景,会对该注册方案有影响。我们通过统计,发现没有该使用场景,所有应用都可以在启动的时候确定需要注册的接口信息。同时,我们通过 Dubbo 框架约束,一个实例的注册数据一旦完成服务注册后是不能变化的。如果后续确实出现了该场景,我们后续会再对该场景进行支持,或者先使用老的注册方式,毕竟 Istio Pilot 可以轻松支持多种注册中心,而客户端无感知。

4.2服务元数据管理

服务元数据管理这一块有些细节值得介绍一下。

初步考虑,可以将每个实例的服务元数据写到到对应的 K8S Pod 注解里。该方案每个实例注册都需要写一份完整的服务元数据,但事实上大部分实例的服务元数据都是相同的,或仅存在非常少数的几个版本。因此,存在极大的优化空间。

这里优化的主要思路是,当某个实例注册元数据时,可以先检查一下对应版本的服务元数据是否已存在,如果已存在,则不需要再写入了。那如何确定服务元数据的版本呢?根据 K8S Pod 的 Labels。因为相同 Labels 的 Pod 实例的镜像、配置等都是相同的,则他们的服务能力、服务元数据也必定一致。为什么呢?因为,Labels 相同的 Pod 都是由同一个 ReplicaSet 创建的,按照云原生的理念它们的服务能力必定是一致的。既然每个 ReplicaSet 产生的 Pod 它们的服务注册元数据是一致的,那是不是该 ReplicaSet 创建的 Pod 的元数据可以写入到 ReplicaSet 的注解里。这个方案是没问题的。但是我们基于 K8S API Server 权限最小化的考虑,没有采用该方案,毕竟 ReplicaSet 是 K8S 核心资源,最好不要放开权限。我们采用 CRD 单独保存元数据的方式,即 ServiceMetadata CRD。一个 ServiceMetadata 资源里,管理一个应用的多个版本的服务元数据。

服务元数据模型如下所示:

{
  "subset_metadata":[
    {
      "subset_id":"1",
      "selector":{
        "pod-template-hash":"f845f5775",
        "app":"java-demo",
        "zone":"qa"
      },
      "dubbo_rpc_metadata":{
        "interfaces":[
          {
            "name":"com.youzan.java.demo.api.EchoService",
            "methods":[
              "echo"
            ]
          }
        ]
      }
    },
    {
      "subset_id":"2",
      "selector":{
        "pod-template-hash":"67bd5c9db9",
        "app":"java-demo",
        "zone":"qa"
      },
      "dubbo_rpc_metadata":{
        "interfaces":[
          {
            "name":"com.youzan.java.demo.api.EchoService",
            "methods":[
              "echo"
            ]
          },
          {
            "name":"com.youzan.java.demo.api.HelloService",
            "methods":[
              "hello"
            ]
          }
        ]
      }
    }
  ]
}

当注册请求达到 Istio Pilot 时,Istio Pilot 通过本地 K8S Client Cache 查询 ServiceMetadata 中是否包含对应版本的服务元数据(通过对应实例的 Pod Labels 与 ServiceMetadata 所有版本的 Selector 进行匹配),如果存在,直接返回即可。不存在,向 K8S API Server 发起请求更新 ServiceMetadata,写入对应版本的服务元数据。如果 K8S API Server 返回版本冲突错误,说明有其他注册请求修改了 ServiceMetadata,则需要客户端重试,重试时大概率会通过 K8S Client Cache 发现对应版本的服务元数据已经存在。这里我们可以发现,通过该优化,一个 ReplicaSet 下的所有 Pod 实例只需要写一次服务元数据,即使有写冲突,概率也是很低的。并且,一个 Deployment 创建新版本的实例集时,都是会分多个批次创建新的实例集的 Pod 的,取决于 MaxSurge 参数,冲突也仅会出现在第一个批次。由于大部分场景都不需要直接跟 K8S API Server 直接交互,大大降低了服务注册的开销。

老的版本的元数据如何删除呢?因为每个版本的元数据和 ReplicaSet 是一一对应的,所以,只要实现一个 ReplicaSet 的自定义 Controller,当 ReplicaSet 对象被删除时,删除对应的版本的元数据即可。关联关系我们采用 K8S 的 Selector 机制来处理。

4.3多机房服务发现

请求路由时,我们一般都有本机房路由优先原则,即如果本机房内有对应的服务实例,请求路由到本机房的实例。一般来说,每个机房内的应用部署是完备的,很少需要进行跨机房访问。如果有比较大的故障,一般也会切掉整个机房的流量到其他机房。但也有如下特殊情况:

  • 内部控制台应用,无需多机房容灾,只部署在一个机房;
  • 大数据应用,成本太高,也有很多离线计算服务不需要高可用,基于成本考虑只部署在一个机房;
  • 某个机房应用实例异常,或突发流量不均衡等,需要调度部分流量或全部流量到其他机房;
  • 机房迁移时,短期内无法全量部署所有应用,需要有能力将部分应用流量调度到其他机房。

多机房服务发现的支持,一般有三种思路:

  • 注册中心层支持,也就是注册中心包含所有机房实例的注册数据,实现方案是实例启动的时候注册到所有注册中心。该方案有不具备可伸缩性,注册中心很容易出现瓶颈。
  • 中间层支持,如 Istio Pilot,监听所有机房的注册中心。同样不具备可伸缩性。
  • 客户端支持,如 Tether,监听所有机房的注册中心或中间层,因为每个应用需要订阅的应用数目相对是有限的,所以可伸缩性方面没有瓶颈。

有赞的多机房服务发现采用的是客户端支持方案。架构图如下所示:

应用服务注册只注册到本机房注册中心,Istio Pilot 只对接本机房注册中心,Tether 对接多个机房的 Istio Pilot,默认情况下只访问本机房 Istio Pilot,当因本机房应用实例不存在或异常,需要将部分流量或全部流量切至其他机房实例时,Tether 再访问其他机房 Istio Pilot,获取其他机房的服务实例,进行后续的路由调度。该方案即实现了多机房服务发现的可伸缩能力,也避免了大部分场景下不必要的服务发现开销。

五、总结

本文介绍了有赞近几年服务注册与发现架构演进过程,主要包括三个阶段:接口级服务注册与发现、接口级服务注册与应用级服务发现、应用级服务注册与发现。虽然这期间整体架构变化比较大,但是我们做到了平稳、平滑地演进,期间上层业务应用无感知,所有功能由 Dubbo 框架、Service Mesh 等基础组件来支持。虽然,有赞的演进之路不一定适合其他公司场景,但也希望能为大家提供一种思路。

感谢阅读。

参考资料

欢迎关注我们的公众号