61

设计中心的设计与实现

 5 years ago
source link: https://github.com/aCoder2013/blog/issues/32?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

问题

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

要求

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

解决方案

负载均衡器

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

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

    location /images/ {
        root /data;
    }
}

注册中心

ieYfIna.png!web

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

设计方案

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

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

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

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

数据同步

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

Eureka 1.X

FzIFbyn.png!web

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

YzaaMfm.png!web

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

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

ZRb6VbU.png!web

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

Zookeeper

VJ3eA3I.png!web

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

QJZ3MfI.png!web

很多人都说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协议知道咋回事么?临时节点知道原理么?事件推送、连接管理都有哪些坑?

zUf2ueF.png!web

Alibaba Nacos

MZVv2qj.png!web

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

小结

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

CP or AP

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

一致性
可用性
分区容忍性

数据一致性

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

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

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

VJf2quQ.png!web

比如说典型的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预热等。

IjYry2z.png!web

探针类型

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

TCP

m6rMvuR.png!web

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

HTTP

yUrIjav.png!web

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

其他

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

探针执行策略

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

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

Service Mesh

EZ7r2mZ.png!web

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

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

DNS

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

K8S基于DNS的寻址方案

63mQFni.png!web

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

http://userservice/id/1000221

Service Mesh Istio基于DNS寻址方案

qEbmIna.png!web

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

Alibaba Nacos DNS-F

7biIj2J.png!web

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

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

小结

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

总结

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

参考资料


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK