查看原文
其他

麻了,面试场景题,居然是喊我搞个注册中心!

why技术 2023-04-08

The following article is from 捉虫大师 Author 小楼MrRoshi

你好呀,我是正在响应居家办公的歪歪。

最近有读者反馈说面试的时候遇到一个面试题是:谈谈注册中心。

这个题的范围太大,各个角度都能说几句,反而导致不知道怎么回答的更好井井有条。

所以给大家分享一篇关于注册中心的文章。

但是不是如何设计出一个注册中心,而是如何组装出一个注册中心。

其实我觉得“组装”比“设计”更好。

组装意味着不必从0开始造轮子,这也比较符合许多公司对待自研基础组件的态度。

知道如何组装一个注册中心有什么用呢?

第一可以更深入理解注册中心。

以我个人经历来说,注册中心的第一印象就是Dubbo的Zookeeper(以下简称zk),后来逐渐深入,学会了如何去zk上查看Dubbo注册的数据,并能排查一些问题。后来了解了Nacos,才发现,原来注册中心还可以如此简单,再后来一直从事服务发现相关工作,对一些细枝末节也有了一些新的理解。

第二可以学习技术选型的方法。

注册中心中的每个模块,都会在不同的需求下有不同的选择,最终的选择取决于对需求的把握以及技术视野,但这两项是内功,一时半会练不成,学个选型的方法还是可以的。

本文打算从需求分析开始,一步步拆解各个模块,整个注册中心以一种如无必要,勿增实体的原则进行组装,但也不会是个玩具,向生产可用对齐。

当然在实际项目中,不建议重复造轮子,尽量用现成的解决方案,所以本文仅供学习参考。

需求分析

本文的注册中心需求很简单,就三点:

可注册、能发现、高可用。

服务的注册和发现是注册中心的基本功能,高可用则是生产环境的基本要求,如果高可用不要求,那本文可讲解的内容就很少,上图中的高可用标注只是个示意,高可用在很多方面都有体现。

至于其他花里胡哨的功能,我们暂且不表。

我们这里介绍三个角色,后文以此为基础:

  • 提供者(Provider):服务的提供方(被调用方)
  • 消费者(Consumer):服务的消费方(调用方)
  • 注册中心(Registry):本文主角,服务提供列表、消费关系等数据的存储方

接口定义

注册中心和客户端(SDK)的交互接口有三个:

  • 注册(register),将服务提供方注册到注册中心
  • 注销(unregister),将注册的服务从注册中心中删除
  • 订阅(subscribe),服务消费方订阅需要的服务,订阅后提供方有变更将通知到对应的消费方

注册、注销可以是服务提供方的进程发起,也可以是其他的旁路程序辅助发起。

比如发布系统在发布一台机器完成后,可调用注册接口,将其注册到注册中心,注销也是类似流程,但这种方式并不多见,而且如果只考虑实现一个注册中心,必然是可以单独运行的,所以通常注册、注销由提供方进程负责。

有了这三个接口,我们该如何去定义接口呢?

注册服务到底有哪些字段需要注册?

订阅需要传什么字段?

以什么序列化方式?

用什么协议传输?

这些问题接踵而来,我觉得我们先不急着去做选择,先看看这个领域有没有相关标准,如果有就参考或者直接按照标准实现,如果没有,再来分析每一点的选择。

服务发现还真有一套标准,但又不完全有。

它叫OpenSergo,它其实是服务治理的一套标准,包含了服务发现:

OpenSergo 是一套开放、通用的、面向分布式服务架构、覆盖全链路异构化生态的服务治理标准,基于业界服务治理场景与实践形成通用标准规范。OpenSergo 的最大特点就是以统一的一套配置/DSL/协议定义服务治理规则,面向多语言异构化架构,做到全链路生态覆盖。无论微服务的语言是 Java, Go, Node.js 还是其它语言,无论是标准微服务还是 Mesh 接入,从网关到微服务,从数据库到缓存,从服务注册发现到配置,开发者都可以通过同一套 OpenSergo CRD 标准配置针对每一层进行统一的治理管控,而无需关注各框架、语言的差异点,降低异构化、全链路服务治理管控的复杂度。

官网:https://opensergo.io/

我们需要的服务注册与发现也被纳入其中:

说有但也不是完全有是因为这个标准还在建设中,服务发现相关的标准在写这篇文章的时候还没有给出。

既然没有标准,可以结合现有的系统以及经验来定义,这里我用json的序列化方式给出,以下为笔者的总结,不能囊括所有情形,需要时根据业务适当做一些调整:

1.服务注册入参

{
  "application":"provider_test", // 应用名
  "protocol":"http", // 协议
  "addr":"127.0.0.1:8080", // 提供方的地址
  "meta":{ // 携带的元数据,以下三个为示例
    "cluster":"small",
    "idc":"shanghai",
    "tag":"read"
  }
}

2.服务订阅入参:

{
    "subscribes":[
        {
            "provider":"test_provider1", // 订阅的应用名
            "protocol":"http", // 订阅的协议
            "meta":{ // 携带的元数据,以下为示例
                "cluster":"small",
                "idc":"shanghai",
                "tag":"read"
            }
        },
        {
            "provider":"test_provider2",
            "protocol":"http",
            "meta":{
                "cluster":"small",
                "tag":"read"
            }
        }
    ]
}

3.服务发现出参

{
    "version":"23des4f", // 版本
    "endpoints":[ // 实例
        {
            "application":"provider_test",
            "protocol":"http",
            "addr":"127.0.0.1:8080",
            "meta":{
                "cluster":"small",
                "idc":"shanghai",
                "tag":"read"
            }
        },
        {
            "application":"provider_test",
            "protocol":"http",
            "addr":"127.0.0.2:8080",
            "meta":{
                "cluster":"small",
                "idc":"shanghai",
                "tag":"read"
            }
        }
    ]
}

变更推送 & 服务健康检查

有了定义,我们如何选择序列化方式?

选择序列化方式有两个重要参考点:

  • 语言的适配程度,比如 json 几乎所有编程语言都能适配。除非能非常确定5-10年内不会有多语言的需求,否则我还是非常建议你选择一个跨语言的序列化协议
  • 性能,序列化的性能包含了两层意思,序列化的速度(cpu消耗)与序列化后的体积,设想一个场景,一个服务被非常多的应用订阅,如果此时该服务发布,则会触发非常庞大的推送事件,此时注册中心的cpu和网络则有可能被打满,导致服务不可用

至于编程语言的选择,我觉得应该更加偏向团队对语言的掌握,以能hold住为最主要,这点没什么好说的,一般也只会在 Java / Go 中去选,很少见用其他语言实现的注册中心。

对于注册、订阅接口,无论是基于TCP的自定义私有协议,还是用HTTP协议,甚至基于HTTP2的gRPC我觉得都可以。

但变更推送这个技术点的实现,有多种实现方式:

  • 定时轮询,每隔一段时间向注册中心请求查询订阅的服务提供列表
  • 长轮询,向注册中心查询订阅的服务提供列表,如果列表较上次没有变化,则服务端hold住请求,等待有变化或者超时(较长时间)才返回
  • UDP推送,服务列表有变化时通过UDP将事件通知给客户端,但UDP推送不一定可靠,可能会丢失、乱序,故要配合定时轮询(较长时间间隔)来作为一个兜底
  • TCP长连接推送,客户端与注册中心建立一个TCP长连接,有变更时推送给客户端

从实现的难易、实时性、资源消耗三个方面来比较这四种实现方式:

似乎我们不好抉择到底使用哪种方式来做推送,但以我自己的经验来看,定时轮询应该首先被排除,因为即便是一个初具规模的公司,定时轮询的消耗也是巨大的,更何况这种消耗随着实时性以及服务的规模日渐庞大,最后变得不可维护。

剩下三种方案都可以选择,我们可以继续结合服务节点的健康检查来综合判断。

服务启动时注册到注册中心,当服务停止时,从注册中心摘除,通常摘除会借助劫持kill信号实现,如果是Java则有封装好的ShutdownHook,当进程被 kill 时,触发劫持逻辑,从注册中心摘除,实现优雅退出。

但事情不总是如预期,如果有人执行了kill -9强制杀死进程,或者机器出现硬件故障,会导致提供者还在注册中心,但已无法提供服务。

此时需要一种健康检查机制来确保服务宕机时,消费者能正常感知,从而切走流量,保证线上服务的稳定性。

关于健康检查机制,在之前的文章《服务探活的五种方式》中有专门的总结,这里也列举一下,以便做出正确的选择:

我们暂时无法控制调用动作,故而前2项依赖消费者的方案排除。

提供者上报心跳如果规模较小还好,上点规模也会不堪重任,这点在Nacos中就体现了,Nacos 1.x版本使用提供者上报心跳的方式保持服务健康状态,由于每次上报健康状态都需要写入数据(最后健康检查时间),故对资源的消耗是非常大的,所以Nacos 2.0版本后就改为了长连接会话保持健康状态。

所以健康检查我个人比较倾向最后两种方案:注册中心主动探测与提供者与注册中心会话保持的方式。

结合上述变更推送,我们发现如果实现了长连接,好处将很多,很多情况下,一个服务既是消费者,又是提供者,此时一条TCP长连接可以解决推送和健康检查,甚至在注册注销接口的实现,我们也可以复用这条连接,可谓是一石三鸟。

长连接技术选型

长连接的技术选型,在《Nacos架构与原理》这本电子书中有有详细的介绍,我觉得这部分堪称技术选型的典范,我们参考下,本节内容大量参考《Nacos架构与原理》,如有雷同,那便是真是雷同。

首先是长连接的核心诉求:

图来自《Nacos架构与原理》
  • 低成本快速感知:客户端需要在服务端不可用时尽快地切换到新的服务节点,降低不可用时间
    • 客户端正常重启:客户端主动关闭连接,服务端实时感知
    • 服务端正常重启 : 服务端主动关闭连接,客户端实时感知
  • 防抖:网络短暂不可用,客户端需要能接受短暂网络抖动,需要一定重试机制,防止集群抖动,超过阈值后需要自动切换 server,但要防止请求风暴
  • 断网:断网场景下,以合理的频率进行重试,断网结束时可以快速重连恢复
  • 低成本多语言实现:在客户端层面要尽可能多的支持多语言,降低多 语言实现成本
  • 开源社区:文档,开源社区活跃度,使用用户数等,面向未来是否有足够的支持度

据此,我们可选的轮子有:

我比较倾向gRPC,而且gRPC的社区活跃度要强于Rsocket。

数据存储

注册中心数据存储方案,大致可分为2类:

  • 利用第三方组件完成,如Mysql、Redis等,好处是有现成的水平扩容方案,稳定性强;坏处是架构变得复杂
  • 利用注册中心本身来存储数据,好处是无需引入额外组件;坏处是需要解决稳定性问题

第一种方案我们不必多说,第二种方案中最关键的就是解决数据在注册中心各节点之间的同步,因为在数据存储在注册中心本身节点上,如果是单机,机器故障或者挂掉,数据存在丢失风险,所以必须得有副本。

数据不能丢失,这点必须要保证,否则稳定性就无从谈起了。

保证数据不丢失怎么理解?

在客户端向注册中心发起注册请求后,收到正常的响应,这就意味着数据存储了起来,除非所有注册中心节点故障,否则数据就一定要存在。

如下图,比如提供者往一个节点注册数据后,正常响应,但是数据同步是异步的,在同步完成前,nodeA节点就挂掉,则这条注册数据就丢失了。

所以,我们要极力避免这种情况。

而一致性算法(如raft)就解决了这个问题,一致性算法能保证大部分节点是正常的情况下,能对外提供一致的数据服务,但牺牲了性能和可用性,raft算法在选主时便不能对外提供服务。

有没有退而求其次的算法呢?

还真有,像Nacos、Eureka提供的AP模型,他们的核心点在于客户端可以recover数据,也就是注册中心追求最终一致性,如果某些数据丢失,服务提供方是可以重新将数据注册上来。

比如我们将提供方与注册中心之间设计为长连接,提供方注册服务后,连接的节点还没来得及将数据同步到其他节点就挂了,此时提供方的连接也会断开,当连接重新建立时,服务提供方可以重新注册,恢复注册中心的数据。

对于注册中心选用AP、还是CP模型,业界早有争论,但也基本达成了共识,AP要优于CP,因为数据不一致总比不可用要好吧?

你说是不是?

高可用

其实高可用的设计散落在各个细节点,如上文提到的数据存储,其基本要求就是高可用。除此之外,我们的设计也都必须是面向失败的设计。

假设我们的服务器会全部挂掉,怎样才能保持服务间的调用不受影响?

通常注册中心不侵入服务调用,而是在内存(或磁盘)中缓存一份服务列表,当注册中心完全挂了,大不了这份缓存不再更新,但也不影响现有的服务调用,但新应用启动就会受到影响。

小结

到此,我们已经组装好了一个注册中心了,用一幅图来总结:

上面,就是组装一个线上可用的注册中心最小集,从需求分析出发,每一步都有许多选择,本文通过一些核心的技术选型来描绘出一个大致蓝图,剩下的工作就是用代码将这些组装起来。

锦上添花

有了一个注册中心之后,我们还可以做一些“锦上添花”的功能。

没有它们注册中心可以正常运行,有了它们也不一定变得更强,但一定会更加花里胡哨。

那可能有人就会问了:花里胡哨的有什么用呢?

我觉得主要是了解一些新的、奇怪的知识,说不定哪天能用上呢,是吧?

同时还可以“装逼用”。

另外需要说明一下的是,“锦上添花”的信息量很多,但深度不够,很多地方只是一笔带过,点到即可。

如果你对其中某些点感兴趣,建议自行深入了解。

水很深,但是我相信你可以把握住。

控制台

如果想让注册中心变得花里胡哨,首先肯定是开发一个控制台,控制台的基本功能就是展示服务的消费者与提供者,展示的用处有查找服务,排查问题等等,下图是Nacos的控制台

除了基本的展示功能,我们还可以在控制台上搞些别的事情,比如下面这些。

服务配置

配置本不是注册中心必备的功能,配置一般由配置中心管理,但配置中心似乎又和注册中心脱不了干系,Nacos就是一个集注册中心和配置中心于一体的组件。

注册中心也可以做一点和服务相关配置的事情,比如服务的超时时间、熔断降级等等元数据,不过要注意的是注册中心本身只能保存、修改,至于这些配置真正起作用的还是得和RPC框架配合。

可能你会问,为什么注册中心要去做配置中心的事儿呢?这不是职责不清?

可以这么理解,服务发现基本是个服务都要接入,但配置中心可不一定要接,如果只想做点简单的服务相关的动态配置,引入一个配置中心是有点重。

如果是公司生产级的服务配置,最好再附带上一个灰度的能力,如果一次下发配置到全部机器,可能会出现故障,所以需要一种灰度下发的机制,分批下发,控制风险。

事件追踪

说到问题排查,光展示提供者、消费者可能还不够,有时候启动一个提供者,消费者就是没感知到,或者很久之后才感知到,这时有点摸不着头脑,如果我们拿出这个事件的时间线,哪个环节出问题便一目了然。

在Nacos的企业版中就支持了类似的推送轨迹功能,当然这么好的功能,肯定是收费项。

拓扑关系

可能我们忽略了注册中心的绘制服务之间的拓扑关系的能力,开源注册中心基本没提到这个,一般来说拓扑关系是链路追踪的活。

注册中心其实也大致可以干这个活,不过注册中心是按照服务的订阅关系绘制出来,并不是按照真实的调用关系,但这几乎也近似调用关系了,有了这个,我们就可以去做一些服务治理相关的事了,比如循环依赖、依赖层级太深等问题都可以看出来。

流量控制

流量控制也不一定非要在注册中心上做,比如Dubbo就是在RPC框架上做了很多流量相关的事情,像集群的选择、路由、负载均衡等。

如果RPC框架没这么强大的能力,或者RPC框架是多语言的实现,能力尚未打平,那么在注册中心上实现也是一个不错的选择。

路由偏好

路由偏好简单来说,如果提供者有多个集群,挑选一个更适合的集群来提供服务,这就叫路由偏好。

举个例子,例如消费者在杭州,提供者有两个集群,一个在上海,一个在北京,这两个机房提供的服务完全对等,这时消费者更适合调用本地的集群,这样时延更小。

当然我们还可以根据服务器的性能、甚至自定义的规则来做路由偏好。

动态切流

有了上面路由偏好的铺垫,想必你也能想到一个场景,万一有一天上海的提供者不可用了,我们可以通过对注册中心的干预,手动把北京的提供者下发给消费者,实现一个客户端无侵入的动态切流。

流量劫持

流量劫持和动态切流的原理一样,实现也基本差不多,只不过下发的数据不太一样,原先的提供者列表,被注册中心偷天换日,换成了本地的一个端口127.0.0.1:8001。

这样替换有什么作用呢?

比如用agent来承接流量,像service mesh都有这种需求,注册中心就可以完成流量劫持。

其实劫持还有其他作用,如果服务的提供方压力太大,想降级,但消费者和提供者都没有降级能力,眼看着服务快挂了,千钧一发之际,你想到了注册中心,手动下发一个不存在的提供者地址,让消费者请求报错,以保护其他服务正常运行,这些奇奇怪怪的想法说不定都可以在注册中心上实现。

探活

探活算是注册中心的一个小功能,我们看看在这个小功能上还能玩出什么花样。

探活扩展

最简单的探活是端口探活,即注册中心向提供者注册的端口发起TCP连接请求,如果能成功建立连接说明服务正常。但有时又不是这样,比如服务僵死,端口还能连接,但服务没法提供了,这时我们需要语义级的探活。

根据提供者提供的服务和配置发起一个请求,如果返回和预期相符合,则判定为服务存活。

我们通常将这个探活留出扩展点,一般可以扩展出HTTP、MySQL、Redis、Thrift等协议的语义探活,以HTTP为例,服务提供方配置探活的URI,注册中心把提供方的ip、port与URI进行拼接、发起请求,如果响应符合预期(如返回码为2xx),则这次探活成功,同理,也可扩展出其他协议的语义级探活。

探活兜底

探活虽好,但有时候又很危险,如果注册中心与提供者的网络闪断,则可能将提供者全部摘除,这是个非常危险的操作,为了防止这种情况,探活兜底是很有必要的一种行为,比如同一个服务集群不能摘除超过1/3,当然这个比例是个经验值,也最好可以配置化。

生态建设

优雅发布

优雅发布包括优雅退出和优雅上线,优雅是指在应用退出和上线过程中没有报错。

注册中心结合发布系统来做优雅发布是最好的搭配。发布系统在停止应用前,向注册中心发起禁用请求(停止接流),注销后再停止应用,服务上线后启动完成后,再将服务开启,接受流量。

框架适配

一个注册中心如果想要更多的人来使用,则需要适配各种主流开发语言如Go/Java/Cpp等,适配一些主流框架如Dubbo/SpringCloud/gRPC等,这样用户用起来才更加方便,缺点是维护成本变高。

DNS 服务发现

对于无法接入服务发现SDK的用户,如果也想享受服务发现能力,怎么做呢?

业界有一种做法是自定义一个DNS拦截器,将DNS请求拦截,通过域名(对应到服务名)去注册中心找提供者。但这样做有一个缺点是DNS只能发现ip,端口没法自动发现。

一般这种拦截器可通过中心的DNS服务器或者本地的DNS agent代理来实现,也可以自定义编程语言的DNS解析插件来实现,像Go/Java都可以自定义DNS解析插件,但这种就属于入侵比较强了。

就好了,本文的技术部分就到这里了。

下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。

荒腔走板

你知道的,昨天成都静默了,“原则居家”了。

然后网上出现了很多在菜市场、超市哄抢食物的图片。但是在这之前,我已经经历了“热带雨林”和“摆渡人”购物节,所以食物存储还是比较充足的。

站在我个人的视角上来看,其实并没有图片上展示的那么夸张。

而且“原则居家”的这四天,是可以每天一户出去一个人购物的,感觉根本不需要去抢购。

你知道抢购导致了什么后果吗?

它导致的后果就是:有些猪,明明就不应该死在昨天。

调侃归调侃,我是真心的相信成都,相信政府,就这几天,共渡难关。

··············  END  ··············

推荐👍踩坑了!0作为除数,不一定会抛出异常!

推荐👍九月,从居家办公开始。

推荐👍啥是有“技术含量”的代码啊?

推荐👍 :千万不要在方法上打断点,有大坑!

推荐👍 :2021,我这一年。

你好呀,我是歪歪。我没进过一线大厂,没创过业,也没写过书,更不是技术专家,所以也没有什么亮眼的title。

当年高考,随缘调剂到了某二本院校计算机专业。纯属误打误撞,进入程序员的行列,之后开始了运气爆棚的程序员之路。

说起程序员之路还是有点意思,可以点击蓝字,查看我的程序员之路


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存