架构系列十三(服务注册中心设计实现思考)

205 阅读11分钟

1.引子

在微服务架构体系中,除了业务微服务,还需要相关的应用支撑服务,都有哪些呢?我们数一数

  • 注册中心
  • 配置中心
  • 网关
  • 监控告警(指标、日志、链路)

我们一起来聊一聊注册中心,注册中心常用选型组件有:zookeeper、Eureka、nacos。我们团队目前选择了nacos组件作为注册中心,nacos是阿里开源的spring cloud alibaba中的一个组件,可以作为注册中心与配置中心使用,后续有空可以跟小伙伴们分享一下关于nacos。

那么这篇文章的主要关注点,就不在各个注册中心组件的使用上了,单纯在应用上,参考官方提供的api文档,相信小伙伴们都能够很好的使用起来。

今天这篇文章,我想我们换一个视角,从架构设计的角度来探讨,比如说我们选择这么几个关注点

  • 为什么需要注册中心,注册中心解决了什么问题?
  • 注册中心组件,需要提供哪些能力?
  • 翻一翻Eureka源码,看一看优秀开源组件的设计实现

那就让我们开始吧!

2.案例

2.1.为什么需要注册中心

在单体应用架构中,模块组件之间的调用,基本上都是进程内调用,这个时候软件架构中还没有那么多烦心的事情!

就算部署了集群环境,通过nginx做反向代理,运维小伙伴感觉还是比较轻松的。不必考虑上线新节点,或者下线节点,不必考虑弹性扩缩容问题,哪怕真的需要,改一改nginx配置,然后平滑重启问题解决,运维小伙伴觉得so easy啊!

但是在分布式应用架构中,一个个独立的微服务,微服务之间都是跨进程调用,且每个微服务都是集群部署,于是问题就来了

  • 微服务架构以后,服务之间的关系变成了服务消费方,与服务提供方,角色分明
  • 服务之间跨进程调用,服务消费方需要知道服务提供方的ip+端口
  • 有很多服务提供方,服务消费方如何记得住那么多服务提供方的信息(ip+端口)
  • 当服务提供方的信息发生变化以后,比如说上线了新的节点,或者下线节点,如何通知服务消费方

直观的看一个图

image.png

我们看到,假设此时有3个微服务:用户、订单、商品,且每个微服务都是多节点集群部署。服务之间的调用需要知道对方的ip+端口信息,存在问题

  • 每个服务,都需要存储维护其它服务节点信息(ip、端口)
  • 当某个微服务上线新节点,或者下线节点,如何通知其它微服务该信息的变化
  • 对多多编程模型,架构复杂度太高

那么该如何解决上面这些问题呢?这个时候注册中心默默的站了起来说,让在下来看一看!我们再看一个图,观察有了注册中心后,如何简化编程模型

image.png

我们看到,增加注册中心服务以后,由注册中心统一存储,管理其它服务节点信息

  • 用户微服务,将自身节点信息(ip、端口)注册到注册中心
  • 订单微服务,将自身节点信息(ip、端口)注册到注册中心
  • 当用户微服务,需要调用订单微服务的时候,首先从注册中心获取订单微服务节点信息,再通过获取到的服务节点信息,直接调用订单微服务
  • 这个时候,不管是用户微服务,还是订单微服务,都不再需要维护彼此节点信息,统一由注册中心进行维护
  • 当用户微服务,或者订单微服务节点信息发生变化,比如说上线新节点,只需要将新的节点信息注册到注册中心即可,解决了弹性扩缩容问题

分析到这里,我们应该可以很好的理解为什么需要注册中心了。

2.2.注册中心需要提供哪些能力

通过上面的分析,我们知道在微服务架构体系中,由注册中心统一存储、管理微服务节点信息。且在角色划分上,有

  • 服务提供方
  • 服务消费方
  • 注册中心

该怎么理解这三个角色呢?我们可以类比现实生活,其实软件编程的世界,也是来源于现实生活世界。现实生活中有三类角色

  • 供货商
  • 顾客
  • 商店

那么这里角色对应关系有

  • 服务提供方:供货商
  • 服务消费方:顾客
  • 注册中心:商店

举这个例子,方便大家能够更好的理解注册中心。这一小节的重点,我们需要搞清楚注册中心需要提供哪些服务能力?来尝试分析一下

  • 首先服务提供方,需要注册到注册中心,有一个注册的需求:registry
  • 服务消费方,需要从注册中心获取服务节点信息,有一个拉取的需求:fetch
  • 为了避免服务消费方,获取到无效的服务提供方信息,服务提供方需要定期与注册中心通信,有一个续约的需求:renew
  • 如果服务提供方主动下线,需要通知注册中心,有一个取消的需求:cancel
  • 如果服务提供方长时间不能与注册中心正常通信,有一个剔除的需求:evict
  • 注册中心需要存储、管理服务节点信息,有一个存储需求:注册表

以上就是我们尝试分析的注册中心,需要提供的服务能力。期望通过这样的分析,给你带来一些通用组件架构设计方面的思考,这才是最重要的!

2.3.Eureka注册中心

2.3.1.什么是Eureka

Eureka是netflix公司开源的注册中心组件,已经整合在spring cloud体系中。Eureka采用的CS架构,即客户端服务器架构模式,包含两个组件

  • Eureka Server注册中心服务端
  • Eureka Client注册中心客户端

Eureka的整体架构图

image.png

上图是官网给的整体架构图,我们看到

  • 图中Eureka Server,即注册中心,是在集群模式下,集群节点之间通过副本机制传递注册表信息
  • Application Service,即服务提供者,需要将自己注册到注册中心,服务提供者与注册中心交互动作:注册(retister)、续约(renew)、下线(cancel)、抓取(get)
  • Application Client,即服务消费者,它从注册中心获取服务提供者节点信息,然后远程调用服务提供者

2.3.2.Eureka自我保护机制分析

Eureka有很多好的设计实现,自我保护机制是其中一个。我们知道,分布式架构应用中,服务提供者、服务消费者、注册中心之间都是跨进程通信,自然网络成为了x因素,即首先假定网络是最不可靠的

当服务提供者,将自身注册到注册中心后,需要定期通过心跳机制(默认是30秒),告诉注册中心服务正常(续约renew)。

默认情况下,如果注册中心在3次心跳间隔内(90秒),没有收到某服务节点的续约心跳,则会将该服务节点剔除(evict),避免服务消费者拿到不可用的服务节点信息。

但是因为网络分区故障,或者说网络异常波动。有这么一种情况,服务提供者本身正常,由于网络原因,没有及时与注册中心续约,如果这个时候注册中心,将该服务节点剔除,其实是不合适的,因为服务本身正常,会导致误伤!

那注册中心该如何处理呢?这里的关键是如何判断网络发生了异常波动,如果确定是网络异常波动,那么不需要剔除服务节点信息。这就是Eureka的自我保护机制,默认情况下,它是根据如果15分钟内,85%的服务节点都没有正常心跳续约,则判定为发生了网络故障,则开启自我保护模式

接下来,我们通过跟踪源码看一看Eureka自我保护机制的设计实现。在Eureka中,服务端提供了一个核心的api:com.netflix.eureka.registry.InstanceRegistry,它的抽象实现是:com.netflix.eureka.registry.AbstractInstanceRegistry

另外我们知道,当Eureka开启自我保护模式后,不会在进行剔除服务节点动作,因此我们去找一找该剔除方法

//Eureka剔除服务节点方法 
public void evict() {
        // 继续调用重载的evict方法
        this.evict(0L);
 }

重载提出服务节点方法

public void evict(long additionalLeaseMs) {
        logger.debug("Running the evict task");
        // 判断是否开启了自我保护,如果开启了自我保护,则不剔除服务节点
        if (!this.isLeaseExpirationEnabled()) {
            logger.debug("DS: lease expiration is currently disabled.");
        } else {
            // 剔除服务节点逻辑,代码量太多我省略了这部分实现
            ......
        }
    }

判断是否开启自我保护模式方法

public boolean isLeaseExpirationEnabled() {
        // 如果自我保护机制开关关闭,直接返回true进行后续服务节点剔除
        // 该开关通过参数控制:
        //eureka.server.enable-self-preservation=true/false
        if (!this.isSelfPreservationModeEnabled()) {
            return true;
        } else {
            // 自我保护机制开关打开
            // 每分钟续约数量:numberOfRenewsPerMinThreshold 大于0
            // 且最后一分钟的续约数量 大于  每分钟续约数量,说明没有发生网络故障,进入剔除逻辑
            // 否则,发生了网络故障,不进行剔除
            return this.numberOfRenewsPerMinThreshold > 0 && this.getNumOfRenewsInLastMin() > (long)this.numberOfRenewsPerMinThreshold;
        }
    }

你看到了这就是Eureka中自我保护机制,与剔除机制的代码实现。有小伙伴可能会有一些疑问

  • 剔除方法evict是如何执行的?
  • 当Eureka开启自我保护模式后,会发生什么呢?

首先我们来看第一个问题,剔除方法evict的执行,它是通过周期性任务执行的,实现原理比较简单,即通过Timer,与TimerTask方式调度执行,具体你可以参考com.netflix.eureka.registry.AbstractInstanceRegistry.EvictionTask,这里我就不贴源码了,它默认1分钟调度执行一次。

看第二个问题,当Eureka开启自我保护模式后,会发生什么呢?

  • Eureka Server不再从注册表中,剔除长时间没有与注册中心续约的服务节点
  • Eureka Server注册中心,任然可以接收新的服务节点的注册、查询请求,但是不会同步到其它Eureka Server节点
  • 当网络恢复正常后,Eureka Server会将服务节点信息,同步到其它Eureka Server节点

2.3.3.Eureka多级缓存机制分析

Eureka Server注册中心,提供了多级缓存机制。为什么会需要多级缓存机制呢?我们尝试考虑这样的业务场景

  • 注册中心,接收服务提供者的注册请求,更新注册表
  • 注册中心,接收服务消费者的查询请求,从注册表获取服务节点信息

Eureka的注册表存储在内存中,事实上它是一个ConcurrentHashMap

// 注册中心注册表
private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry = new ConcurrentHashMap();

显然注册表是一个共享资源,存在读写冲突(更新、查询),Eureka为了提升注册中心的响应能力,提供了多级缓存机制

  • 一级缓存:readOnlyCacheMap,它是只读缓存,提供查询请求服务能力
  • 二级缓存:readWriteCacheMap,它是读写缓存,对应注册表更新

具体我建议你看一看com.netflix.eureka.registry.ResponseCacheImpl

// 一级缓存,是ConcurrentHashMap,缓存更新间隔,默认30秒
private final ConcurrentMap<Key, ResponseCacheImpl.Value> readOnlyCacheMap = new ConcurrentHashMap();
// 二级缓存,是Guava提供的本地缓存,缓存过期时间,默认180秒
private final LoadingCache<Key, ResponseCacheImpl.Value> readWriteCacheMap;

我们说Eureka提供多级缓存机制,是为了提升注册中心的响应能力,那么它们之间是如何协同工作的呢?

服务消费者:服务拉取

  • 默认每30秒,服务消费者拉取注册表信息
  • 直接从readOnlyCacheMap中获取信息
  • 如果从一级缓存中获取为空,则从readWriteCacheMap中获取信息
  • 如果从二级缓存中获取为空,则将注册表中的信息,加载到各级缓存,返回注册表信息

服务提供者:注册、下线、过期

  • 更新注册表信息
  • 过期二级缓存readWriteCacheMap,你需要注意,此时只会清空二级缓存,一级缓存不会更新,一级缓存的更新,始终是通过周期任务来执行的更新,默认30秒
  • 默认30秒周期时间后,周期任务线程发现readWriteCacheMap为空,则将一级缓存readOnlyCacheMap清空
  • 当有服务消费者拉取注册表信息时,再将注册表信息加载到一级、二级各级缓存中