转载

设计中心的设计与实现

问题

客户端如何知道某一个服务的可用节点列表?

要求

  • 每个服务的实例都会在一个特定的地址(ip:port)暴露一系列远程接口,比如HTTP/REST、RPC等
  • 服务的实例以及其地址会动态变更(虚拟机或Docker容器的ip地址都是动态分配的)

解决方案

负载均衡器

类似Nginx这类负载均衡器貌似可以解决这个问题,但是只支持静态配置,当我们对服务动态扩容、缩容时,需要联系运维进行对应的配置变更,而且如果你的服务运行在Docker或K8S时,节点的IP都是动态分配的,这时再通过Nginx去做服务发现会变的非常麻烦。另外引入一个中间层,就引入了一个潜在的故障点,虽然Nginx的性能很高,但多经过一层必然会造成一定的性能损耗。

server {
    location / {
        proxy_pass http://localhost:8080;
    }

    location /images/ {
        root /data;
    }
}

注册中心

设计中心的设计与实现

在动态的环境下最好的方式是通过注册中心解决这个问题,实现一个服务注册中心,存储服务实时的地址、元数据、健康状态等信息。注册中心负责处理服务提供者的注册、注销请求,并定时对该服务的实例进行健康检查。客户端通过注册中心暴露的接口查询该服务的可用实例。

设计方案

注册中心其实本质上还是一个存储系统,最早可能就是个静态配置文件,随着系统变得越来越复杂,同时加上现在服务大多都是部署在容器之中,节点IP的变更都是动态的,导致静态配置文件的形式已经完全不可用了,因此我们就需要节点能够动态的注册、注销。那么注册中心就需要能够存储这个信息,并实时的维护更新。

最简单其实可以存储到MySQL中,由一个单机节点负责处理所有的请求,比如注册、注销、健康监测、服务状态变更事件推送等。

但由于单点问题,单机版没有很好的容灾性,那么我们可以缓存来解决,在客户端SDK中通过多级缓存机制: 内存->磁盘快照, 解决因注册中心挂掉而不能获取到数据的问题。

但这样的架构还是有问题,虽然客户端已经和注册中心解耦了,但当注册中心挂掉时,新扩容、缩容或者正常上下线的节点,由于注册中心挂掉了,服务的调用者是不能够获取到这个信息的,因此就会获取到过期的数据。我们很容易就想到冗余,多部署几个节点,但不同于业务应用,注册中心本身是有状态的,不能像业务应用一样简单的部署多个节点解决问题,我们还需要数据同步的问题。

数据同步

数据同步其实有非常多的方式,我们看一下业界一些开源注册中心的解决方案.

Eureka 1.X

设计中心的设计与实现

Eureka client会优先和同一个可用区的eureka server通讯,如果由于网络问题、server挂掉等原因导致通讯异常,那么客户端fail over到其他可用区的eureka server上重试。

Eureka的多副本的一致性协议采用类似“异步多写”的AP协议,Eureka server会将收到的收到的所有请求都转发给它所知道的所有其他eureka server(如果转发失败,会在下一次心跳时继续重试),其他eureka server收到请求会,会在本地重放,从而使得不同eureka server之间的状态保持一致。从这一点也可以看出来,eureka是一个AP系统,保证最终一致,因为eureka所有server都能提供服务,并不是一个leader based的系统,当客户端从eureka server 1获取服务B的数据时,可能服务B是和eureka server 2建立的连接,而此时server 2还没有将最新数据同步到1,因此此时客户端就会获得过期的数据。

看起来一切都很好,但eureka这样类似点对点的同步算法, 会有什么问题呢?

  • 采用广播式的复制模型,所有的server会将所有的数据、心跳复制给其他所有的server,实现起来很简单,但却不失为一种不错的方案,但随着服务节点的增多,广播逐渐会成为系统的瓶颈,因为写入是不能横向扩展的,每次写入请求必须转发给其他所有的server,因此即使你扩容的更多的节点,系统的性能不但不会提升,反而会有很大的下降。
    这里再提一下eureka的其他一些问题:
  • 客户端会获取全量的服务数据,并且不支持只获取某一个单独的服务信息,导致占用客户端大量的内存,即使你可能只需要其中某一个服务的地址。
  • 只支持定时更新:eureka的客户端是通过pull的方式从server获取服务的最新状态,这样会有几个问题:
    • 获取有一定的延迟,具体取决于应用的配置。
    • 如果pull的间隔配置的很低,会导致产生很多无用的请求,比如某个节点可能一天才发布一次,但客户端可能每秒都会pull一次,导致浪费系统的资源。
    • 配置太多。首次注册延迟、缓存定期更新周期、心跳间隔、主动失效检测间隔等等,当然了也可以说是优点。

当我们扩容一个新的eureka server时,服务启动后,会优先从临近的节点中获取全量的服务数据,如果失败了会继续尝试其他所有节点,如果成功了,那么这个节点就可以开始正式对外提供服务。

Eureka 2.x

设计中心的设计与实现

eureka 2.x主要就是为了解决以上几个问题而诞生的,主要包含以下几点:

  • 支持按需订阅:eureka客户端支持只订阅自己感兴趣的服务数据,eureka server将只会推送客户端感兴趣的数据。
  • 数据推送从pull改成push模式。
  • 优化同步算法。跟eureka1.x一样,eureka2.x也会将数据广播给其他节点,但与其不同的是,2.x不会将每一个服务实例的心跳也发送给其他节点,这个简单的优化大大减少了系统整体的流量,提升了系统的扩展性。
  • 读写分离。Eureka2.x将eureka集群分为了写集群和读集群,注册中心是一个典型的写少读多的系统,不管是手动扩容还是自动扩容,扩容之前都可以大概预估一下系统当前的压力,并针对性的对写、读集群扩容。
  • 审计日志以及控制台。

设计中心的设计与实现

eureka2.x虽然进行了大量的优化,但其实还是有些问题,写集群仍然存储的是全量的服务数据,如果服务规模非常大的话,仍然造成瓶颈,需要考虑其他一些分片的方案。

Zookeeper

设计中心的设计与实现

Zookeeper的基于ZAB协议,ZAB是一个类Paxos的分布式一致性算法,因此zk的复制其实是交由zab协议来保证的,当leader收到写请求后,会将整个请求消息复制给其他节点,其他节点收到消息后,会交由本机的状态机处理,从而实现数据的复制,

设计中心的设计与实现

很多人都说ZK是一个CP系统,其实个人觉得单纯的用CAP来描述一个分布式系统已经不太准确了,比如Zookeeper, 默认情况下客户端会连接到不同的节点,

而节点之间的数据和leader是不同步的,存在一定的延迟,因此会导致读取到的数据不一致,可能存在一定的延迟,但是可以通sync调用,强制同步一把,从而实现更强的一致性。那么zk到底是个AP系统还是CP系统呢?

这里再提一下zk的扩展性,zk基于ZAB协议,写入都必须经过leader,并同步到其他follower节点,因此增加更多的写入节点,意味着写入需要同步到更多的节点,从而引起性能下降,由此也可以看出zk并不具备横向扩展性,因此如果简单的通过zk去做服务发现,随着服务规模的增长,比如会遇到瓶颈。

但我们可以换一种思路,把zk当成一个存储,基于一个CP系统构建一个AP的注册中心,相较于客户端直连zk集群,改成server与zk集群建立连接,当server收到客户端的写请求时,转换成zk对应的操作,其他server节点设置对应的watch,监听服务状态的变更,从而实现数据的同步,对于其他类似健康监测、服务状态变更事件推送等则由注册中心的server完成。

但其实选择zk最需要考虑的问题是运维,因为zk相对来说是一个非常复杂的系统,你能不能用得好、除了问题能不能hold得住,这都是一个疑问,比如zk的状态机你真的理解了么?ZAB协议知道咋回事么?临时节点知道原理么?事件推送、连接管理都有哪些坑?

设计中心的设计与实现

Alibaba Nacos

设计中心的设计与实现

Nacos是阿里巴巴开源的动态服务发现、配置管理和服务管理平台。对于注册中心这块来说,其一致性算法是基于Raft实现的,Raft类似Paxos,也是一种一致性协议算法,但是相对Paxos来说,要容易理解的多。类似上面说的基于zk的方案,nacos也是基于一个CP协议打造的一个AP系统,客户端本地是支持快照的,即使服务端挂掉,也不影响客户端的使用。

小结

可以看出数据同步其实有非常多的解决方案,具体如何选择其实还是要看业务场景、服务规模等,大部分情况下完全没必要自己造轮子,无脑选择nacos、eureka就可以了。

CP or AP

CAP理论指出,在分布式存储系统中,不可能同时满足以下三种条件中的两种:

一致性
可用性
分区容忍性

数据一致性

注册中心最核心的功能其实就三个:

  • 对于调用者来说,能够根据服务的ID查询到服务的地址、元数据、健康程度等信息
  • 对于服务提供者来说,能够注册、注销自身提供的服务
  • 注册中心能够检测到服务实例的健康程度,并能够通知给客户端

我们设想一下,假如必须要满足一致性的话,那么当发生网络分区时,注册中心集群被一分为二:多数区、少数区。那么多数区因为大多数节点仍然能够选出leader,仍然能够正常处理服务实例的注册、注销、健康监测请求,分区内的客户端也能正常的获取到对应的节点。但是在少数区的节点,由于不能够组成大多数节点,因此不能正常的选举出leader,而由于我们选择了一致性,就不能处理客户端的读写请求,如果我们处理注册、注销请求的话,就必然会造成数据不一致,而如果我们处理读请求的话,那么这个时候读取的其实是过期的数据,也不能满足一致性。

设计中心的设计与实现

比如说典型的ZK3地5节点部署架构,当发生网络分区时,机房1和机房2能够正常通讯,但机房3和其他两个机房发生了网络分区,由于zk的特性,只要大多数节点能够正常通讯,那么就能够保证整个zk集群正常正常对外提供服务,但是位于机房3的zk节点5由于不能和其他节点通讯,是不能够对外提供服务的,读写请求都不能够处理,对应于服务发现的场景来说,就是扩容、缩容的节点不能够正常的注册、注销,另外正常的节点心跳检测也会异常。

但我们发现,虽然机房3不能和其他两个机房正常通讯,但机房3内所有的服务是能够正常通讯的,机房内的服务调用其实是完全正常的,但由于发生了网络分区,我们优先选择了一致性,对应服务发现的场景来说,也就是服务调用者是获取不到服务实例列表的,即使是同机房内能够正常通讯的节点也不行,这样的行为对于业务方来说通常是不可接受的。

但对于服务发现的场景来说,一致性其实并没有那么重要,当发生网络分区,客户端获取到的是不完整的节点列表,比如说可能不包含部分节点(因为不能和另一个分区的leader节点通讯,新注册上来的节点也就获取不到),另外也可能包含其实已经下线的节点(因为发生了网络分区,心跳监测也会发生异常),但这个其实问题不大,客户端可以监测对应的异常,对于幂等的读取请求可以failover到其他节点上重试,对于写请求,需要对应的服务提供者处理好去重,保证幂等,客户端可以根据自己的业务场景决定具体的策略。但如果选择了一致性,客户端从注册中心获取不到节点,服务整体是不可用的。

可用性

对于服务发现的场景来说,其实大部分业务方的需求其实一个AP系统,也就是发生网络分区时,优先选择可用性,一段时间内的数据不一致其实完全在可接受的范围之内。

比如上面说的场景,当发生网络分区时,机房3的zk节点不能和其他机房的leader节点通讯,但如果我选择了A, 那么也就是说注册中心可以返回给客户端过期的数据,比如客户端A获取服务B的节点列表,注册中心可能返回了10个节点,但这个10个节点中可能就有一些节点已经下线了,因为不能够此时注册中心不能处理写请求,如果能够处理写请求的话,情况会更复杂一些,等网络分区恢复之后,我们还需要处理数据冲突的问题,另外这个10个节点的数据可能也不全,可能没有包含新扩容的节点(比如机房2扩容了5个节点,并注册到了机房1或者2的leader,但zk5以为不能和leader正常通讯,是获取不到这个数据的)。

健康检查

在微服务式的架构之下,每一个服务都会依赖大量其他的服务实例,当其中任何一个实例出现了故障时,系统必须能够在一定是时间内监测到异常,并通知给对应的调用方。大部分系统都是通过心跳机制去监测服务的健康程度。

健康检查大致分为两类:

liveness check

Liveness检查主要是用来监测服务的存活状态,例如进程是否还在、端口是否能够Ping通等,如果系统挂掉,那么这个时候监测不到进程id,注册中心会将对应的节点标为异常,并通知对应的节点。

readiness check

Readiness检查的作用通常是用来监测服务是否能够对外提供服务,比如说即使能够监测到应用的进程id,但可能应用还在启动中、缓存还没有预热、代码还没经过jit预热等。

设计中心的设计与实现

探针类型

一般来说探针大致分为两种:

TCP

设计中心的设计与实现

注册中心会定时尝试和对应的ip:port建立tcp连接,如果能够正常建立连接,则表明服务当前处于健康状态,否则则为异常。

HTTP

设计中心的设计与实现

注册中心会定时调用对应的接口,如果状态码、header或者响应满足对应的要求,那么则认为该服务当前健康,我们可以在这个接口中针对自己的业务场景检测对应的组件,比如数据库连接是否已经建立、线程池是不是已经被打满了等。

其他

其他还有一些比如说针对数据库,可以通过发送一个sql,校验数据库是否能在一定的时间内返回结果,从而监测数据库的健康状况。

探针执行策略

当然这里还有一些其他的策略,比如超时时间、调用间隔、几次检测失败才将服务视为异常等。这方面可以参考一下Nginx:

upstream backend {
    server backend1.example.com;
    server backend2.example.com max_fails=3 fail_timeout=30s;
}

Service Mesh

设计中心的设计与实现

蹭下热点简单说一下Service Mesh,service mesh的要解决的一个很重要的痛点就是多语言的问题,用java的做微服务一般来说直接用Spring Cloud这一套就可以了,限流、熔断、服务发现、负载均衡等都有对应的组件支持,如果团队中技术栈是统一的,到时没什么问题,但是在微服务的架构下,每个团队负责维护自身的服务,这个时候你并不能确保所有的服务都是用同一个语言实现的,但限流、熔断、服务发现等特性是每个微服务都需要的特性,这个时候你就需要将eureka、Hystrix用各个不同的语言实现一次,这是一件非常复杂、繁琐且有挑战的事情,很难保证你的代码没有bug。因此就出现了Service Mesh,将一个agent/sidecar和服务部署在同一个节点,并接管服务的流量,并能够分析流量,从而得知其协议、要调用的服务等信息,并针对该服务进行服务发现、限流等措施。

那么在多语言的情况下如何去做服务发现呢?给每个语言开发一个单独的SDK? 也是一种可行的方案,但正如上文所说,非常复杂,而且工作量很大。

DNS

DNS可以说是目前应用最广泛、最通用、支持最广泛的寻址方式。所有的编程语言、平台都支持。因此使用DNS作为服务发现的方案是一个非常好的思路,这也正是K8S和Service Mesh( Istio )的寻址方案。

K8S基于DNS的寻址方案

设计中心的设计与实现

K8S的基础概念这里不再累述,如图所示,我们在k8s集群中部署一个uservice,并指定3个pod(实例/节点),应用部署之后,k8s会给应用分配ClusterIP和域名,并生成一条对应的DNS记录,将域名映射到ClusterIP。

http://userservice/id/1000221

Service Mesh Istio基于DNS寻址方案

设计中心的设计与实现

Istio的方案其实和K8S几乎是一样的,只不过说service mesh会部署一个sidecar,而sidecar会接管应用所有的流入、流出流量,因此中间会过两层sidecar(客户端、服务器端都会部署一个sidecar)。如图所示,除了红色部分外其他步骤都是一致的。

Alibaba Nacos DNS-F

设计中心的设计与实现

Nacos也支持通过dns进行服务发现,dns-f客户端和应用部署在同一节点,并拦截应用的dns查询请求:

  • 首先,应用ServiceA直接通过域名调用ServiceB的接口
  • DNS-F会拦截到ServiceA的请求,通过注册中心查询,是否拥有该服务的注册信息,
    若有则根据一定的复杂均衡策略,返回ip
  • 如果没有查询到,则交给底层的操作系统处理

小结

如果继续DNS做服务发现,那么应用就不再需要关心注册中心等细节,对调用方来说就和普通的HTTP调用一样,传入一个域名,具体的域名解析交给底层的基础设施,比如K8S、Istio等,这样的话比如Dubbo、配置中心等应用,甚至是数据库的地址,都只需要配置成一个域名,这样的话Dubbo就不再需要配置中心了,只要传入一个服务的表示 com.xxxxx.UserService:version , k8s/istio会解析出最终的地址,并且能够针对应用的流量,做限流、重试、监控等,应用能够专注于业务逻辑,这些事情都不需要关心,也不用耦合在代码里,都交给底层基础设施统一管控、升级等。

总结

本文大概讲了一下注册中心的设计,其中还有非常多的组件、细节没有涉及到,比如多数据中心、服务事件通知风暴等等问题,后面有时间会继续补充。

参考资料

  • https://skyao.io/post/
  • https://nacos.io/en-us/blog/alibaba-configserver.html
  • https://medium.com/knerd/eureka-why-you-shouldnt-use-zookeeper-for-service-discovery-4932c5c7e764
  • http://jm.taobao.org/2018/06/13/%E5%81%9A%E6%9C%8D%E5%8A%A1%E5%8F%91%E7%8E%B0%EF%BC%9F/
  • https://github.com/Netflix/eureka/wiki/Eureka-2.0-Architecture-Overview
原文  https://github.com/aCoder2013/blog/issues/32
正文到此结束
Loading...