Skip to content

NamingServer support survey

WangLiang/王良 edited this page Jan 9, 2024 · 5 revisions

image

用于服务注册与发现的namingserver结构图如图所示。由于在微服务的架构下,系统被拆分成多个微服务,而每个微服务下又有很多实例,不同服务的部署位置和网络拓扑结构复杂,服务之间的通信较为困难,如果每次新增或者删除一个实例都需要人工去修改其它依赖该服务的实例,过于复杂和繁琐。因此,通过namingserver来维护所有服务信息,实现动态地注册和发现服务是解决该问题的方法。

CAP

CAP协议又称CAP定理,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。 分布式系统的CAP理论:理论首先把分布式系统中的三个特性进行了如下归纳: ● 一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本) ● 可用性(A):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)

● 分区容错性(P):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

Eureka 强调最终一致性(在有限的时间内(例如 3s 内)将数据收敛到一致状态)采用AP ,在牺牲数据一致性的情况下最大程度保障服务的可用性,服务端和客户端都是 Java 编写的,针对微服务场景,并且和 Netflix 的其他开源项目以及 Spring Cloud 都有着非常好的整合,具备良好的生态。服务注册相对要快,因为不需要等注册信息replicate到其他节点,也不保证注册信息是否replicate成功,当数据出现不一致时,虽然A, B上的注册信息不完全相同,但每个Eureka节点依然能够正常对外提供服务,这会出现查询服务信息时如果请求A查不到,但请求B就能查到。如此保证了可用性但牺牲了一致性。

Consul 基于强一致性,采用CP,基于 Raft 算法,其是分布式、高可用和可横向扩展的,服务注册相比Eureka会稍慢一些。因为Consul的raft协议要求必须过半数的节点都写入成功才认为注册成功,Leader挂掉时,重新选举期间整个consul不可用。保证了强一致性但牺牲了可用性。

ZooKeeper是按照CP原则构建的,也就是说它能保证每个节点的数据保持一致,但是在选举时不允许客户端读写,牺牲了一定的可用性。而为ZooKeeper加上缓存的做法的目的是为了让ZooKeeper变得更加可靠(available)。

Etcd采用raft算法,基于cp原则,强调强一致性原则。

Paxos协议

Paxos是一种经典的分布式一致性算法,由Lamport在1998年提出。它主要解决的问题是如何在一个分布式环境中就某个值(例如提案)达成一致。Paxos协议包括三个角色:Proposer、Acceptor和Learner。其中Proposer提出提案,Acceptor接受或拒绝提案,并将结果通知给Learner。通过多轮投票,最终所有节点都会达成一致。

Raft协议

Raft是一种新的分布式一致性算法,由Ongaro和Ousterhout在2014年提出。它也是基于多数派原则,但相对于Paxos而言,Raft更加易于理解和实现。Raft协议包括三个角色:Leader、Follower和Candidate。其中Leader负责处理客户端请求并向其他节点发送心跳信号,Follower只能接收来自Leader或Candidate的请求并进行响应,Candidate用于选举新的Leader。

ZAB协议

ZAB(ZooKeeper Atomic Broadcast)是ZooKeeper分布式系统中实现一致性的协议。它基于Paxos算法,但是放弃了Paxos中Leader的角色,而是采用了主备复制的方式。ZAB协议包括两个阶段:广播和提交。在广播阶段,Leader节点将事务发送给所有节点;在提交阶段,Leader节点等待大多数节点的确认,并向所有节点发送最终确认消息。

Gossip协议

Gossip协议是一种去中心化的协议,它不需要全局的控制节点来指导整个系统的运行。Gossip协议的思想是让节点之间相互通信,通过交换信息来更新彼此的状态。当一个节点收到新的信息时,它会向周围的节点进行随机广播,这样每个节点都可以得到最新的信息。Gossip协议的优点是具有较高的容错性和可扩展性,但是它的一致性难以保证。

Paxos协议,Raft协议和zab协议是强一致性协议,基于cp原则,由于其在选主过程中不支持外界访问,所以牺牲了一定的可用性。而Gossip协议是一种去中心化的最终一致性协议,基于ap原则,它只能够保证最终达成一致的状态。

目前我的计划是先实现无状态的namingerver,如果时间充裕,再考虑支持基于最终一致性的有状态通信(初步考虑使用Gossip协议)。

无状态

image

在该设计下,节点是无状态的,节点之间不进行通信,client与seata-nameserver进行通信时采用http协议,每一个server在做服务注册的时候需要向每个节点发送服务注册请求,client在进行服务发现时只需要选择其中一个节点进行通信即可。当某个节点故障时,只需要换个节点通信即可。当有新节点加入集群时,可以随机选择一个节点,复制其信息。

有状态

六度分隔理论:你和任何一个陌生人之间所间隔的人不会超过五个,也就是说,最多通过五个人你就能够认识任何一个陌生人。

  • 算法过程

img

流程如下:

  • 某个peer节点最先获取服务注册信息。

  • 该peer节点将消息发送给(随机选择的)预定数量的其他peer节点

  • 收到消息的peer节点再将消息发送给(随机选择的)预定数量的其他peer节点

  • 如此不断反复,直到每个peer节点都收到了服务注册信息

    在实际实现的过程中,我们可以通过设置节点之间传播的预定数量大小和传播的频率来控制收敛到最终一致状态的时间

image

在该设计下,seata-namespace之间的节点是有状态的,它们之间通过gossip协议进行消息的广播。

  1. 节点启动并加入网络:当一个新节点启动时,它会向网络内的任意一个节点发送加入请求。该节点接受请求后,将该节点加入到自己的节点列表中,并向其他节点广播新节点的加入消息。其他节点也会将新节点加入到自己的节点列表中,并广播给其他节点。

  2. 服务注册:当一个节点想要注册一个服务时,它将会向网络内的某个节点发送注册请求,请求中包含了该服务的名称、版本、IP 地址和端口号等相关信息。该节点接受请求后,将该服务信息添加到自己的服务列表中,并向其他节点广播新服务的注册消息。其他节点也会将新服务加入到自己的服务列表中,并广播给其他节点。

  3. 服务发现:当一个节点想要发现某个服务时,它会向网络内的某个节点发送服务发现请求,请求中包含了所需服务的名称和版本等信息。该节点接受请求后,将自己的服务列表中匹配到的服务信息返回给请求方。如果该节点未找到匹配的服务,则会向其他节点广播服务发现请求,直到找到匹配的服务或者所有节点都已经被查询过。

兼容第三方注册中心

目前seata配置第三方注册中心的方式是由开发人员手动配置,如下图

image

这样实现的弊端是业务开发人员需要耗费精力去配置使用到的中间件。

因此,我们考虑将业务开发人员与seata第三方注册中心的入口统一,业务人员只需要与seata-nameserver进行交互。server向seata-nameserver注册服务,seata-nameserver保存与第三方注册中心通信的密钥,自动帮其注册到背后的第三方注册中心,这样做的好处是大大减少了开发人员所需要的配置,使其聚焦于业务实现。如下图。

image

对于第三方注册中心,我们可以设置一个监听器监听seata服务注册的情况,并且每隔一段时间将所有已注册的服务全量推送到第三方注册中心中。

image

具体流程如下:

  1. seata-nameserver提供了 REST API 或 SDK 来进行服务注册和发现。
  2. 编写一个自定义的监听器,该监听器从 seata-nameserver获取注册的服务信息,并将其转化为与第三方注册中心适配的数据。
  3. 监听器定期将服务注册的信息推送到第三方注册中心。
  4. client需要进行服务发现时,seata-nameserver从适配的第三方注册中心中拉取数据。

访问协议

  • DNS:使用 DNS 作为服务注册表,服务可以将其 IP 地址和端口号注册到 DNS 中,并通过 DNS 解

析来发现其他服务。

​ 优点:DNS可以根据负载均衡算法返回不同的ip,在此基础上实现简单的负载均衡。DNS的查

询过程比较简单,可以快速地定位到目标服务的IP地址。

​ 缺点:DNS的查询结果可能会被缓存,这可能会导致服务发现不够及时。DNS的安全性较

差,容易受到DNS劫持和DNS污染等攻击。DNS本身只能提供基础的服务发现和负载均衡功

能,

  • HTTP:使用 HTTP REST API 作为服务注册和发现协议。通过向注册中心发送 HTTP 请求来实现对

nameserver的各种操作,例如注册服务、查询健康检查状态等

​ 优点:HTTP协议比较常用,使用起来比较容易。支持RESTful API,可以使用标准的GET、

POST、PUT等请求方法进行操作,方便且灵活。可以通过HTTP头部字段传递额外的信息,例

如版本、授权等信息。

​ 缺点:HTTP的请求和响应体积相对较大,不太适合在高延迟、低带宽的网络中使用。HTTP通

常使用长连接(Keep-Alive)来提高性能,但是这会占用TCP长时间保持的资源。HTTP本身

不提供服务发现和负载均衡功能,需要借助其他工具或框架来实现

  • gRPC:使用 gRPC 的服务发现和负载均衡功能来管理服务。这种方法需要一个专用的注册中心和

客户端库来实现服务发现和负载均衡。(如dubbo)

​ 优点:grpc 是一款长连接协议,极大的减少了 http 请求频繁的连接创建和销毁过程,能大幅

度提升性能,节约资源。

​ 缺点:gRPC的文档和社区相对比较小,不如HTTP和DNS成熟。

目前业内nacos支持http/dns,eureka支持http,consul支持http/dns,zookeeper支持tcp

元数据

为了支持seata中事务与server集群之间的映射关系,可以采用如下图的transctionGroups的结构,每个单元由两部分组成,一部分是groupId,代表事务id,另一部分是servers,代表与该事务分组关联的服务集群。可以用哈希表维护transctionGroups数据,key是groupId,value是servers,这样可以通过事务来获取事务所关联的服务集群。

{
  "transactionGroups": [
    {
      "groupId": "group1",
      "servers": [
        {
          "serverId": "server1",
          "address": "127.0.0.1:8091"
        },
        {
          "serverId": "server2",
          "address": "127.0.0.1:8092"
        }
      ]
    },
    {
      "groupId": "group2",
      "servers": [
        {
          "serverId": "server3",
          "address": "127.0.0.1:8093"
        },
        {
          "serverId": "server4",
          "address": "127.0.0.1:8094"
        }
      ]
    }
  ]
}

目前业内的注册中心提供了一些机制来做到资源隔离。

  1. Nacos:Nacos提供了命名空间(Namespace)和分组(Group)以及cluster的概念。命名空间可以将不同的资源划分到不同的逻辑空间中,实现资源的隔离。而分组可以对同一命名空间下的资源进行细分和分类,方便进行资源的管理和访问控制。
  2. Apollo:Apollo是一个配置中心,它也提供了命名空间的概念。不同的命名空间可以用于隔离不同的应用或环境的配置。通过使用命名空间,可以将配置分离,并针对不同的命名空间进行不同的配置管理和访问控制。
  3. Consul:Consul支持使用ACL(Access Control Lists)进行资源隔离和访问控制。ACL允许定义不同的策略和权限,以控制对注册表中的服务和其他资源的访问。
  4. ZooKeeper:ZooKeeper可以使用命名空间(Namespace)来实现资源隔离。每个命名空间都有自己独立的树结构,可以在命名空间级别进行权限控制和资源管理。
  5. Etcd:Etcd支持使用角色和权限(Role-based Access Control,RBAC)来实现资源的隔离和访问控制。通过定义角色和分配权限,可以对注册中心中的键值对进行细粒度的访问控制。
  6. Zookeeper、Etcd等注册中心还可以结合虚拟节点(Virtual Nodes)的概念来实现资源的分区和负载均衡。虚拟节点可以将注册中心中的数据分散到不同的物理节点上,从而实现资源的分布式管理和隔离。

以nacos为例,它包含三层隔离,其中,命名空间是最高级别的隔离,用于隔离不同的环境,例如开发、测试和生产环境。组是在命名空间内的分组,用于区分不同的配置集。集群是在组内的集群名称,用于区分不同的实例。其配置如下。

spring:
  cloud:
    nacos:
      server-addr: localhost:8848
      discovery:
        cluster-name: HZ
        group: test_group
        namespace: 492a7d5d-237b-46a1-a99a-fa8e98e4b0f9

我认为对于seata-nameserver而言,可以支持相应的隔离级别。通过命名空间划分不同的环境,使用组对配置进行分类,再利用集群对实例进行分组,以便更好地管理和隔离资源。

当前seata项目支持的服务注册和发现服务主要依赖第三方,如zookeeper、eureka、consul等等,本次任务的目标是兼容目前支持的所有注册中心,并构建原生的seata-nameserver服务。需要实现的方法主要在seata-discovery-core包下的RegistryService接口中。

/*
 *  Copyright 1999-2019 Seata.io Group.
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */
package io.seata.discovery.registry;

import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import io.seata.config.ConfigurationCache;
import io.seata.config.ConfigurationFactory;

/**
 * The interface Registry service.
 *
 * @param <T> the type parameter
 * @author slievrly
 */
public interface RegistryService<T> {

    /**
     * The constant PREFIX_SERVICE_MAPPING.
     */
    String PREFIX_SERVICE_MAPPING = "vgroupMapping.";
    /**
     * The constant PREFIX_SERVICE_ROOT.
     */
    String PREFIX_SERVICE_ROOT = "service";
    /**
     * The constant CONFIG_SPLIT_CHAR.
     */
    String CONFIG_SPLIT_CHAR = ".";

    Set<String> SERVICE_GROUP_NAME = new HashSet<>();

    /**
     * Service node health check
     */
    Map<String,List<InetSocketAddress>> CURRENT_ADDRESS_MAP = new ConcurrentHashMap<>();
    /**
     * Register.
     *
     * @param address the address
     * @throws Exception the exception
     */
    void register(InetSocketAddress address) throws Exception;

    /**
     * Unregister.
     *
     * @param address the address
     * @throws Exception the exception
     */
    void unregister(InetSocketAddress address) throws Exception;

    /**
     * Subscribe.
     *
     * @param cluster  the cluster
     * @param listener the listener
     * @throws Exception the exception
     */
    void subscribe(String cluster, T listener) throws Exception;

    /**
     * Unsubscribe.
     *
     * @param cluster  the cluster
     * @param listener the listener
     * @throws Exception the exception
     */
    void unsubscribe(String cluster, T listener) throws Exception;

    /**
     * Lookup list.
     *
     * @param key the key
     * @return the list
     * @throws Exception the exception
     */
    List<InetSocketAddress> lookup(String key) throws Exception;

    /**
     * Close.
     * @throws Exception the exception
     */
    void close() throws Exception;

    /**
     * Get current service group name
     *
     * @param key service group
     * @return the service group name
     */
    default String getServiceGroup(String key) {
        key = PREFIX_SERVICE_ROOT + CONFIG_SPLIT_CHAR + PREFIX_SERVICE_MAPPING + key;
        if (!SERVICE_GROUP_NAME.contains(key)) {
            ConfigurationCache.addConfigListener(key);
            SERVICE_GROUP_NAME.add(key);
        }
        return ConfigurationFactory.getInstance().getConfig(key);
    }

    default List<InetSocketAddress> aliveLookup(String transactionServiceGroup) {
        return CURRENT_ADDRESS_MAP.computeIfAbsent(transactionServiceGroup, k -> new ArrayList<>());
    }

    default List<InetSocketAddress> refreshAliveLookup(String transactionServiceGroup,
        List<InetSocketAddress> aliveAddress) {
        return CURRENT_ADDRESS_MAP.put(transactionServiceGroup, aliveAddress);
    }

}
  • register 接收一个InetSocketAddress类型参数(该参数包含服务的服务名,ip地址,端口号等信息),将该服务实例注册到nameserver中
  • unregister 也是接收一个InetSocketAddress类型参数,在nameserver中将该服务实例摘除
  • subscribe 订阅一个集群的变化,当该集群有变化时,会通知listener。
  • unsubscribe 取消订阅一个集群。
  • lookup(String key) 查找一个key对应的地址列表。
  • close() 关闭注册中心服务。
  • getServiceGroup 这个方法实现中使用了ConfigurationFactory类来获取配置文件中的vgroupMapping的键值对,这个键值对表示事务分组到TC分组的映射。该方法将服务组名拼接到键名中,并使用ConfigurationCache类监听该键,当配置文件更改时会更新缓存中的值,从而获取最新的服务组名。
  • aliveLookup(String transactionServiceGroup) 获取指定事务服务组的地址列表。
  • refreshAliveLookup(String transactionServiceGroup, List aliveAddress) 更新指定事务服务组的地址列表,并返回更新后的地址列表。
具体模块设计

由于本项目的初衷是为了减少对第三方工具的依赖,所以我认为实现的seata-nameserver更加强调高可用性,只需满足最终一致性即可,即满足AP要求,这样做的好处是使得项目更加轻量级,如果用户对强一致性有需求,可以使用兼容的zookeeper等第三方注册中心。

服务注册模块

nameserver需要维护一个Map<String,List>的哈希表,该表保存着某个服务名,以及其对应的服务实例的ip和端口,当某个服务实例调用了register接口时,nameserver将其保存到哈希表中,当调用unregister接口时,nameserver将从哈希表中移除该实例。

心跳模块

为了保证每个服务实例的状态正常,我们可以实现一个心跳机制,nameserver可以维护一个各个节点状态的哈希表,该哈希表的value保存节点的心跳包更新时间、状态等信息,我们设置服务实例每隔30s向nameserver发送心跳包,该心跳包中包含服务实例的状态,cpu占用率,内存占用率等信息,nameserver收到心跳包后遍历哈希表,更新所有实例的心跳更新时间以及他们的状态。此外,nameserver需要新建一个定时任务,每隔一段时间(例如60s)检查每个实例的心跳时间,如果超过阈值,那么就剥离该实例。

服务发现模块

当一个组件需要调用服务时,可以通过发现模块查询指定服务的实例列表。发现模块根据负载均衡算法选择一个合适的服务实例返回给调用方。这里初步考虑采用一致性哈希算法策略。其核心思想是将所有的服务器节点映射到一个虚拟环上,同时将请求也映射到这个虚拟环上,然后通过顺时针查找离请求最近的服务器节点进行路由。这样做的好处是当增加或减少服务器节点时,只会影响到它周围的一小段区域,而不会像传统的哈希算法那样影响到整个负载均衡系统。

实现步骤如下:

  1. 构建虚拟环:将所有的服务器节点和请求映射到一个0-2^32-1的整数范围内,构建出一个虚拟环。
  2. 计算哈希值:对于每个服务器节点和每个请求,计算它们在虚拟环上的哈希值。
  3. 查找节点:对于每个请求,在虚拟环上顺时针查找离它最近的服务器节点,即第一个大于等于该请求哈希值的服务器节点。
  4. 路由请求:将请求路由到查找到的服务器节点上。
  5. 均衡负载:为了均衡负载,可以在虚拟环上增加多个虚拟节点,即将一个服务器节点映射到多个哈希值上。这样做可以使得服务器节点在虚拟环上分布更加均匀,从而使得整个负载均衡系统的负载也更加均衡。
  6. 处理节点变化:当增加或减少服务器节点时,只需要重新计算所有节点的哈希值,并将请求路由到新的节点上即可。这样做可以使得系统具有很好的扩展性和容错性。

为了调整不同服务器节点能够获取到的请求数多少,还可以考虑虚拟节点,也就是一个物理节点对应多个虚拟节点(性能高的机器对应的虚拟节点多),将虚拟节点映射到哈希环上,这样可以初步实现负载均衡。

除了一致性哈希的方法实现负载均衡外,业内的其他第三方注册中心通常还采用Ribbon、Fabio、RoundRobin等算法进行负载均衡。

质量要求

本项目除了一些功能性需求,还需要满足非功能性需求如高可用、高并发等。nameserver应当考虑从单机到集群部署,每个节点之间应当做好数据同步,即某个服务实例在其中一台节点注册了,其他节点也能快速同步信息。不同节点之间做到满足最终一致性原则。

集群部署

我的设想是,nameserver采用集群部署,节点可以是无状态的,也可以是有状态的。

如果是无状态的情况,它们仅仅将数据保存在内存中,如果节点宕机,那么将会丢失所有信息,这样做的坏处是牺牲了一定的数据一致性,但是优点是使系统更加轻量级。如果节点重启,那么它将复制nameserver集群中某个节点的所有信息。服务注册时,需要遍历nameserver中的节点列表,向每个节点都发送注册信息,这点类似Rocketmq的注册中心。

如果是有状态的话,节点之间需要基于一致性协议(如gossip协议)进行通信,从而达到最终一致的状态。此时无论是client还是server都只需要与nameserver中的一个节点进行通信。

容灾
  • seata-nameserver容灾

    • 对于有状态的情况,可以将数据持久化到磁盘中,以确保数据的持久性和可恢复性,这样即使系统发生故障或者重启,数据也能够被恢复而不会丢失。

    • 对于无状态的情况,通过心跳机制对节点进行保活,如果心跳停止,则将该节点从集群中剥离,并将请求转发到其他健康节点。

    • 冗余节点,配置主节点和多个备节点。主节点负责处理请求和数据的写入,而备节点则作为冗余节点,负责备份主节点的数据并在主节点故障时接管服务。主备模式可以实现快速的故障切换和数据恢复,保证系统的连续性。

  • 服务提供方server容灾

    • 负载均衡采用的是一致性哈希算法,数据在节点之间的均衡分布,避免了出现热点节点,从而也避免了热点节点故障导致的性能下降。
缓存

nameserver不主动推送消息,而是由客户端定时请求请求数据并缓存在客户端中。(后续可以考虑服务订阅的模式,将服务实例的变化主动推送到客户端,类似Nacos)

限流+负载均衡

如果要防止nameserver节点被打挂,可以考虑使用负载均衡将客户端的服务发现的请求流量分发到不同的nameserver集群节点中去。另外,可以采用令牌桶等限流方法限流。

Clone this wiki locally