一、前言
目前,携程大部分业务已经完成了微服务改造,基本架构如图。每一个微服务的实例都需要和注册中心进行通讯:服务端实例向注册中心注册自己的服务地址,客户端实例通过向注册中心查询得知服务端地址,从而完成远程调用。同时,客户端会订阅自己关心的服务端地址,当服务端发生变更时,客户端会收到变更消息,触发自己重新查询服务端地址。
疫情刚过去那会,公司业务回暖迹象明显,微服务实例总数在1个月左右的时间里上涨30%,个别服务的单服务实例数在业务高峰时可达万级别。按照这个势头,预计全公司实例总数可能会在短时间内翻倍。
实例数变大会引起连接数变大,请求量变高,网络报文变大等一系列现象,对注册中心的性能产生挑战。
如果注册中心遇到性能瓶颈或是运行不稳定,从业务视角看,这会导致新增的实例无法及时接入流量,以至被调方紧急扩容见效慢;或者导致下线的实例不能被及时拉出,以至调用方业务访问到已下线的实例产生报错。
如今,业务回暖已经持续接近2年,携程注册中心稳定运行,强劲地支撑业务复苏与扩张,特别是支撑了业务日常或紧急情况下短时间内大量扩缩容的场景。今天就来简单介绍一下携程注册中心的整体架构和设计取舍。
二、整体架构
携程注册中心采用两层结构,分为和数据层(Data)和会话层(Session)。Data负责存放被调方的元信息与实例状态、计算RPC调用相关的路由策略。Session与SDK直接通讯,负责扛连接数,聚合转发SDK发起的心跳/查询请求。
注册 – 定时心跳
微服务架构下,服务端的一个实例( 被调方)想要被客户端(调用方)感知,它需要将自己注册到注册中心里。服务端实例会发起5秒1次的心跳请求,由Session转发到对应分片的Data。如果数据层能够持续不断的收到一个实例的心跳请求,那么数据层就会判断这个实例是健康的。
与此同时,数据层会对这一份数据设置TTL,一旦超过TTL没有收到后续的心跳请求,那么这份数据也就会被判定为过期。也就是说,注册中心认为对应的这个实例不应再被调方继续访问了。
发现 - 事件推送/保底轮询
当收到新实例的第一个心跳时,数据层会产生一个NEW事件,相对应地,当实例信息过期时,数据层会产生一个DELETE事件。NEW/DELETE事件会通过SDK发起的订阅连接通知到调用方。
由于网络等一些不可控的因素,事件推送是有可能丢失,因而SDK也会定时地发起全量查询请求,以弥补可能丢失的事件。
多分片方案
如图所示,Data被分成了多分片,不同分片的数据互不重复,从而解决了单台Data的垂直瓶颈问题(比如内存大小、心跳QPS等)。
Session会对服务ID进行哈希,根据哈希结果将心跳请求、订阅请求、查询请求分发到对应的Data分片中。调用方SDK对多个被调方进行信息查询时,可能会涉及到多个Data分片,那么Session会发起多个请求,并最终负责将所有必要信息聚合起来一并返回给客户端。
单点故障
与很多其他系统类似,注册中心也会遇到故障/维护等场景从而遭遇单点故障。我们把具体情况分为Data单点故障和Session单点故障,在两种情况下,我们都需要保证系统整体的可用性。
单点故障 – Data
如图所示,SDK发起的心跳请求会被复制到多台Data上,以保证同一分片中每一台Data的数据完整性。也就是说,同一个分片的每台Data都会拥有该分片对应的所有服务的数据。当任一Data出现故障,或是参与到日常运维被踢出集群的情况下,其他任一Data能够很好的接替它的工作。
这样的多写机制相比于之前版本注册中心采用的Data间复制机制更加简单。在Data层发生故障时,当前方案对于集群的物理影响会更小,可以做到无需物理切换,因而也更加可靠。
在当前多写机制下,Data层的数据是最终一致的。心跳请求被分成多个副本后是陆续到达各个Data实例的,在实例发生上线或者下线时,每台data变更产生的时间点通常会略有不同。
为了尽可能避免上述情况对调用方产生影响,每台Session会在每个Data分片中选择一台Data进行粘滞。同时,SDK对Session也会尽可能地粘滞。
单点故障 – Session
参考上文提到Data分片方案,任一Session都可以获取到所有Data分片的数据,所有Session节点都具备相同的能力。
因此,任一Session故障时,SDK只需要切换到其他Session即可。
集群自发现
携程注册中心是基于Redis做集群自发现的。如下图所示,Redis维护了所有注册中心实例的信息。当一个注册中心实例被创建时,新实例首先会向Redis索要所有其他实例的信息,同时开始持续对Redis发起心跳请求,于是Redis维护的实例信息中也会新增新实例。新实例还会根据从Redis拿到的数据向其他注册中心实例发起内部的心跳请求。一旦其他实例从Redis获得了新实例的信息,再加上收到的心跳,就会认可新实例加入集群。
如下图所示,当时注册中心实例需要维护或故障时,实例停止运行后不再发起内部心跳。其他实例在该节点的内部心跳过期后,标记该节点为unhealthy,并在任何功能中都不会再使用该节点。这里有一个细节,节点下线不会参考Redis侧的数据,Redis故障无法响应查询请求时,所有注册中心实例都以两两心跳为准。
我们可以了解到,注册中心实例的上线是强依赖Redis的,但是运行时并不依赖Redis。在Redis故障和运维时,注册中心的基本功能不受影响,只是无法进行扩容。
三、设计取舍
新增代理还是Smart SDK?
注册中心设计之初只有Data一层,由于要引入分片机制,才有了Session。那么是不是也可以把分片的逻辑做到SDK,而不引入Session这一层呢?
这也是一种方式,业界也一直有着代理和Smart SDK之争。我们基于注册中心所对应的业务场景,认为新增一层代理是更加合适的。
最重要的一点,注册中心的相关功能运行不在BU业务逻辑主链路上,其响应时间并非直接影响业务的响应时间。因此我们对注册中心的请求响应时间并没有极致的要求,代理层引入的几百微秒的延迟可以被接受。
其次注册中心的请求是一定程度容忍失败的,SDK请求数据失败后可以继续使用内存中的老数据,不会对业务线产生致命影响。因此代理层引入的失败率也可以被接受。
另一侧,代理的加入带来了诸多好处。最直接地,落地分片逻辑不需要所有的SDK升级,分片逻辑迭代时,对业务也是无感。
其次,代理层也隔离了连接数这一瓶颈,当SDK层的实例不断变多,连接数不断增加时,只需要扩容代理层就能解决连接数的问题。这也是我们将它取名为Session的原因。
同时,我们也希望作为物理层的SDK逻辑更加轻量,比较重的逻辑放在逻辑层,这样稳定性更强更不容易出错。比如后续会提到的“Data按业务隔离分组”就是在Session层实现的。
普通哈希还是一致性哈希?
携程注册中心的数据分片是采用普通哈希的,并没有采用一致性哈希。
我们知道,一致性哈希相比普通哈希的最大卖点是当节点数量变化时,不需要迁移所有数据。
结合注册中心的场景,我们用服务ID做哈希,而服务数量(也包括实例数量)是相对稳定的,因此哈希节点的扩容周期会比较长,基本用不到一致性哈希的优势特性。哪怕一段时间内业务迅速扩张,只要提前做好预估,留好余量一次性扩容就好了。
我们选择普通的固定的哈希,并让每一个分片都具备多个备份节点,这样就基本可以认为每个分片都不会彻底挂掉,不用去实现数据迁移的逻辑,整个机制更简单了。
要知道,数据迁移需要对注册请求、查询请求和订阅请求进行同步切换,要处理好各种状态,避免在数据迁移过程中错查到空数据或者丢失变更事件,非常复杂危险。
自发现是否强依赖Redis?
前面也提到,注册中心自发现的运行时是不依赖Redis的。有的同学可能会想到,如果运行时强依赖Redis,就可以去掉两两注册了。
两两注册确实是一个不好的设计,随着集群的节点数越来越大,其产生的性能开销肯定也会更大,影响整个注册中心集群的拓展能力。
但在目前规模下,内部心跳占用的系统资源并不可观。哪怕规模再拓展,通过降低心跳的频率,进一步降低资源开销。
最大的好处是,Redis集群故障或者维护时,并不会对注册中心的功能产生影响。
基于Redis还是用Java写?
目前注册中心的Data是用Java实现的。有的同学可能会想,Data层主要就是维护微服务实例的存活状态,能不能直接用Redis实现呢?如果用Redis,不就可以直接复用Redis体系的扩容/切换能力了吗?
比如基于Redis 6.0的Client Cache功能,通过Invalidate机制通知SDK重新更新服务信息。
不过在携程注册中心设计之初,Redis版本还比较老,没有这些新feature,感觉基于pub/sub机制做注册中心还挺麻烦的。现在注册中心已经稳定运行了好久,加了很多功能,比如路由策略一部分的计算过程就是在Data层完成的,暂时没有必要推倒重建。
总的来说,用Java写更可控,后续自定义程度更高。
四、需要注意的场景
突发流量
在遇到节假日,或是公司促销活动,亦或是友商故障的情况下,公司集群会因为业务量急剧上升而迅速自动扩容,因而注册中心会受到强劲的流量冲击。
期间因为系统资源被榨干,注册/发现请求可能会偶发失败,事件推送延迟和丢失率会上升。严重时,部分调用方业务会无法及时感知到被调方的变动,从而导致请求失败,或流量无法被分摊到新扩容的被调方实例。
我们发现,这些场景产生的流量有着很高的重复度,比如某个被调方实例扩容,调用方的众多实例需要知道的信息是完全一样的,又比如调用方实例扩容,这些新扩的实例部署着相同的代码,它们依赖的被调方信息也是完全一样的。
因此我们针对性的做了不少聚合与去重,大大降低了突发流量情况下的资源开销。
流量不均衡
关于Data粘滞,这里有一个细节。那么多Data机器,Session选谁呢?目前Session是用类似随机的方式选择Data的。那就会有一个场景,我们对Data层进行版本更替,逐个实例重新发布,当一个实例被重置时,Session就会因为丢失粘滞对象而重新随机选择。
我们会发现,最后一个Data实例完成发布时,它不会被任何Session选中。而第一个发布的Data实例,它倾向于被更多的Session选中。
通常来说,越早发布的Data实例,就会被越多的Session选中。也正因为如此,更早发布的Data会承担更多的流量,而最后发布的Data一般不承担流量。这显然是不合理的。
解决这个问题的方法也很简单,我们引入拥有全局视角的第三者,整体调控Session的粘滞,保证Data尽可能地被相同数量的Session选中。
全局风险
前面也提到,Data层被分成了多分片,Session会对服务ID进行哈希,将心跳请求、订阅请求、查询请求分发到对应的Data层分片中。
当程序出现预期外的问题(程序bug,OOM等等)导致某个Data无法正常的履行功能职责时,那些被分配到这个Data实的服务就会受到影响。
如果调配方式是对服务ID做哈希,那么所有业务线的任意服务都可能参与其中,从业务视角去看,就是整个公司都受到了影响。
对服务ID做哈希是有它的优势的,它无需引入过多的外部依赖,只需要一小段代码就能工作。但我们还是认为避免全局故障更加重要。
因此我们最近对Data引入了业务语义,将Data分为多个组,以各个业务线命名。且我们可以按服务粒度对数据进行分配。默认情况下,我们会将服务分配到自己BU的分组上。
这样,我们就具备了以下能力:
1)不同业务线的数据可以被很好的隔离,任一业务线的Data数据出现问题,不会影响到其他业务线。
2)注册中心将获得故障切换的能力,当个别服务的数据出现问题时,我们可以将它单独切走。
3)我们可以将一些不重要的应用单独隔离到一个灰度分组,新代码可以先发布到灰度分组上,尽可能避免新代码引入的问题直接影响核心业务分组。
4)注册中心将获得应用粒度的部署能力。在集群分配上,具备更强的灵活度,针对业务规模的大小合理分配系统资源。
从图中可以看到,我们在引入分组逻辑的同时也兼容老的分片逻辑,这样做是为了在分组逻辑上线过程初期,服务信息在Data层的分布可以尽可能保持不变,可以让少数的服务先灰度切换到新增的分组上进行验证。
当然,从去复杂度的角度考虑,最终分片逻辑还是要下线,垂直扩容的能力也可以由分组实现。
五、后续规划
因为注册中心引入了分组机制,并对各个业务线数据进行了隔离,注册中心的集群规模也在因此膨胀,分组数量较多,运维成本也随之上升。
后续我们计划进一步优化优化单机性能,精简优化一些不必要的机制,降低机器数量。
同时,我们也希望注册中心能够支持弹性,能够在业务高峰时自动扩容,在业务低峰时自动缩容。