分布式面试题

CAP原则

CAP理论是分布式系统,特别是分布式存储领域中杯讨论最多的理论,C一致性,A可用性,P分区容错性

  • CA:单点集群,满足一致性可用性的系统,(意义不大)
  • CP:满足一致性,分区容错性的系统,性能不是特别高(Zookeeper选主回短暂停止对外的服务,几秒内选出新主,一致性为最终一致性,也可以配置为强一致)
  • AP:满足可用性,分区容错性(redis)

Redis cluster集群死一个master剩下的master节点还能提供服务吗

一般情况下,对于Redis集群而言,redis主节点主要进行数据的读写操作,而从节点默认为只读权限。如果想要使得从节点也拥有写入权限,也是可以进行设置的。

slave-read-only no

在Redis集群中,当主节点下线或出现故障时,会发生自动故障转移(Automatic Failover)的过程,即从备用节点中选举出一个新的主节点来代替原先的主节点,以保证集群的可用性。

在进行自动故障转移期间,集群仍然可用。在Redis集群中,主节点的下线或故障被检测到后,备用节点中的某个节点会被选举为新的主节点,然后集群会重新分配槽位,以确保数据的正确路由和负载均衡。在这个过程中,虽然会有一段时间内无法进行写操作,但读操作仍然可以继续执行。

需要注意的是,在自动故障转移期间,可能会出现一些数据的丢失或重复,因此在应用程序中需要进行一些处理,以确保数据的一致性。例如,在写入数据之前,可以先检查主节点是否正常工作,如果不正常,可以将数据写入到备用节点中,以避免数据丢失。同时,还可以使用Redis的哨兵机制,实时监控主节点的状态,以便尽早发现并处理故障。

总之,当Redis集群发生自动故障转移时,集群仍然可用,但需要在应用程序中进行一些处理,以确保数据的一致性。

Nacos和Eureka的区别

CAP理论:eureka只支持AP,nacos支持CP和AP俩种

Nacos支持CP+AP模式,即Nacos可以根据配置识别为CP模式或AP模式,默认是AP模式。如果注册Nacos的client节点注册时ephemeral=true,那么Nacos集群对这个client节点的效果就是AP,采用distro协议实现;而注册Nacos的client节点注册时ephemeral=false,那么Nacos集群对这个节点的效果就是CP的,采用raft协议实现。根据client注册时的属性,AP,CP同时混合存在,只是对不同的client节点效果不同。Nacos可以很好的解决不同场景的业务需求。

节点注册时是ephemeral=true即为临时节点

distro协议

Distro协议是一种AP协议,是最终一致性的协议

  • nacos每个节点都是平等的都可以处理写请求,同时把数据同步到其他节点
  • 每个节点只负责部分的数据,定时发送自己负责的数据校验值到其他节点来保持数据一致性
  • 每个节点处理读请求,及时从本地发出响应

新加入的 Distro 节点会进行全量数据拉取。具体操作是轮询所有的 Distro 节点,通过向其他的机器发送请求拉取全量数据。

在dostro集群启动之后各台机器会定期发送心跳来保证数据一致性

写操作

对一个启动完成的Distro集群,在一次客户端发起的写操作中,当注册非持久化的实例的写请求打到某台Nacos服务器时,Distro集群处理流程如下

  1. 前置filter拦截请求,根据请求中包含的ip和port信息计算所属的Distro责任节点,请将请求转发到责任节点
  2. 责任节点将写请求进行解析
  3. Distro协议定期执行sync任务,将所负责的所有实例信息同步到其他节点上

读操作

由于每台机器上的数据都存放了全量数据,因此每一次读操作,Distro机器会直接从本地拉取数据

Distro协议是Nacos对于临时实例数据开发的一致性协议。其数据存储在缓存中,并且会在启动时进行全量数据同步,并定期进行数据校验。

在Distro协议的设计思想下,每个Distro节点可以接收到读写请求。所有的Distro协议的请求场景主要分为三种情况

  1. 当该节点接收到属于该节点负责的实力的写请求时,直接写入
  2. 当该节点接收到不属于该节点负责实例的写请求时,将在集群内部路由,转发给对应的节点,从而完成读写
  3. 当该节点接收到任何读请求时,都直接在本机查询并返回(因为所有实例都被同步到了每台机器上)。

Distro 协议作为 Nacos 的内嵌临时实例⼀致性协议,保证了在分布式环境下每个节点上面的服务信息的状态都能够及时地通知其他节点,可以维持数十万量级服务实例的存储和⼀致性。

  • 平等机制:Nacos 的每个节点是平等的,都可以处理写的请求
  • 异步复制机制:Nacos 把变更的数据异步复制到其他节点。
  • 健康检查机制:每个节点只存了部分数据,定期检查客户端状态保持数据一致性。
  • 本地读机制: 每个节点独立处理读请求,及时从本地发出响应。
  • 新节点同步机制:Nacos 启动时,从其他节点同步数据。
  • 路由转发机制:客户端发送的写请求,如果属于自己则处理,否则路由转发给其他节点。

Nacos如何保证数据一致性

在Nacos集群模式下,它作为一个完整的注册中心,必须具有高可用的特性。

在集群模式下,客户端只需要和其中一个Nacos节点通信皆可以了,但是每个节点其实是包含所有客户端信息的,这样做的好处是每个Nacos节点只需要负责自己的客户端就可以(分担压力)

那么Nacos集群之间是之间是如何通过Distro协议来保证数据一致性。

V1中,采用的是定期检测元信息的方式。元信息就是当前节点包含的客户端信息的MD5值。

Nacos各个节点会有一个心跳任务,定期向其他机器发送数据检验请求,在校验的过程中,当某个节点发现其他机器上的元信息和本地数据元信息不一致,则会发起一次全量拉取请求,将数据补齐。

V2中定期经验数据已经不用了,采用的是健康检查机制,来和其他节点保持数据的同步

这样设计的好处是保证了高可用,(AP)分为俩个方面

  1. 读操作都能进行及时的响应,不需要到其他节点拿数据。
  2. 当脑裂发生时,Nacos 的节点也能正常返回数据,即使数据可能不一致,当网络恢复时,通过健康检查机制或数据检验也能达到数据一致性。

健康检查机制

Spring Cloud Alibaba Naco作为注册中心不止提供了服务注册和服务发现功能,还提供了服务可用性监测的机制。有了这机制之后,Nacos才能感知服务的健康状态,从而为服务调用者提供健康的服务实例,最终保证了业务系统能够正常的执行。

  1. 客户端主动上报机制
  2. 服务端主动下探机制

如何理解这两种机制呢?可以想象一个场景,你在学校的教室里面,遇到学业上的问题,或者是科目上的问题。那有什么办法让老师知道你有问题?

  • 第一种,你主动去找老师并且告诉老师你的问题和精神状态(健康状态)
  • 第二种,老师自己发现你的状态有问题,及主动询问你的问题和状态

以上这两种方法和Nacos的两种健康检查机制类似,也就是客户端主动上报机制,是客户端每隔一段时间,主动向Nacos服务器端上报自己的健康情况,而服务器端下探机制是Nacos服务器端来检测客户端是否健康

NAcos中的健康检查机制不能主动设置,对应了俩种健康检查类型

  1. 临时实例(非持久化实例):对应客户端主动上报机制
  2. 永久实例(持久化实例):对应服务器端主动下探

问什么需要俩种服务实例呢?

以淘宝为例,11.11大促期间,流量会比平时高很多,此时服务肯定需要增加更多实例来应对高并发,这些实例在双十一之后就无需继续使用了,采用临时实例比较合适。对于服务的一些常备实例,则使用永久实例更加合适。

临时实例每隔5秒主动上报一次自己的健康状态,发送的数据包叫做心跳包,发送心跳包的机制叫做心跳机制。如果心跳包的事件间隔超过了15秒,那么Nacos服务端就会将此服务实例标记为非健康,如果心跳包超过30秒,那么Nacos服务器端将会把此服务从服务列表中剔除出去

永久实例使用的服务器端主动下探机制的方式实现健康检查的,它的探测周期是2000毫秒+随件数(5000毫秒内),如果检测异常会将此服务实例,标记为非健康实例,但不会把服务实例像临时实例那样中服务列表中剔除。Nacos服务器向下探方式目前内置了3种探测协议:HTTP探测、TCP探测和Mysql探测。一般而言HTTP和TCP探测已经可以涵盖绝大多数的健康检查场景,Mysql主要用于特殊的业务场景,列如数据库的主备需要通过服务外对外提供访问,需要确定当前访问数据库是否为主库时,那么我们此时的健康检查接口,是一个检查数据库是否为主库的Mysql命令。

默认情况下,永久实例使用的是TCP探测

集群下的健康检查机制

集群下的健康检查机制可以用一句话概括,各司其职,每个服务对应了一个主注册中心,当注册中心接收到临时心跳包之后,将健康状态同步到这侧中心。而永久实例也是类似的,每个服务对应一个注册中心,当负责的注册中心下探服务实例的健康状态发生改变时,将实例的健康状态同步到其他的注册中心,从而实现了集群下的健康检查机制

Eureka

Eureka Server 启动后,会通过 Eureka Client 请求其他 Eureka Server 节点中的一个节点,获取注册的服务信息,然后复制到其他 peer 节点。

Eureka Server 每当自己的信息变更后,例如 Client 向自己发起注册、续约、注销请求, 就会把自己的最新信息通知给其他 Eureka Server,保持数据同步。

如果自己的信息变更是另一个Eureka Server同步过来的,这是再同步回去的话就出现数据同步死循环了。

Eureka Server 在执行复制操作的时候,使用 HEADER_REPLICATION 这个 http header 来区分普通应用实例的正常请求,说明这是一个复制请求,这样其他 peer 节点收到请求时,就不会再对其进行复制操作,从而避免死循环。

还有一个问题,就是数据冲突,比如 server A 向 server B 发起同步请求,如果 A 的数据比 B 的还旧,B 不可能接受 A 的数据,那么 B 是如何知道 A 的数据是旧的呢?这时 A 又应该怎么办呢?

数据的新旧一般是通过版本号来定义的,Eureka 是通过 lastDirtyTimestamp 这个类似版本号的属性来实现的。
lastDirtyTimestamp 是注册中心里面服务实例的一个属性,表示此服务实例最近一次变更时间

  1. Eureka 是弱数据一致性,选择了 CAP 中的 AP。
  2. Eureka 采用 Peer to Peer 模式进行数据复制。
  3. Eureka 通过 lastDirtyTimestamp 来解决复制冲突。
  4. Eureka 通过心跳机制实现数据修复。

微服务注册中心的注册表如何更好的防止读写并发冲突?

微服务注册中心结构如下图所示

注册中心维护了一块内存,一般是一个map数据结构,key:服务名称, value:注册服务的路径列表。

读:指读取查找服务,即读取内存中的服务列表。

写:注册服务,在内存中添加数据。

那么题目主题可以归结为,如何解决多线程情况下,内存中数据读写并发冲突的问题

为什么会冲突?

因为在多线程情况下,共享内存如果没有互斥锁一定会存在线程安全的问题。

注册中心的注册表的服务实例信息,会随着生产者的改变发生写操作,而消费者会从注册中心拉取注册表信息,这是读操作。当读写同时进行时,就会产生读写并发问题了。常见的解决方案有:

  • 加悲观锁同步。这种性能太差了,不推荐。如果加锁粒度比较小,还能凑合下。
  • 加读写锁。这种性能是比上一种方案好,但是一旦需要扯上锁,永远快不到哪里去。
  • COW 写时复制。这个技术不错,没有锁介入。写时复制一份副本出来操作,读操作还是操作原始数据。Redis的在bgsave 主从数据复制时,主实例继续接受处理指令,而数据又能同时复制传输,用到的技术就是这个COW写时复制。

接下来我们细细了解Nacos与Eureka的在架构设计师如恶化提高读写并发性能的

Nacos

nacos客户独断想服务端注册,将注册信息封装成instance对象,服务端接收后,存入一个内部阻塞队列,就成功响应给客户端我那成注册。这样可以极大提高 客户端的注册速度以及启动速度,不会对客户端所属服务带来影响。

Nacos服务端齐了一个只有一个线程的线程池定时调度一个任务。那就是异步从阻塞队列里拉取注册信息进行处理,完成注册信息的注册。注册时,是利用Cow写实复制,拷贝一个副本出来进行写处理,之后再回原注册表,来提高读写并发性能

还有一个亮点就是 ,我们都知道 这么多服务集群实例,肯定会存在多个同时写并发问题,难道一个实例要完成写入注册,就COW拷贝一份,那这么多实例节点,Nacos服务端怎么能够扛得住呢?

显然Nacos设计者考虑到了这一点,阿里中间件设计有这么一句话,解决问题的最好办法不是去解决他,而是规避他。所以Nacos在实现上,并没有去花精力在怎么解决同时写并发问题,而是直接选择了一个线程池只有一个线程去解决处理。这样单个想爱你成从阻塞队列里拉取一个处理一个。因为都是基于内存操作,这个处理速度是相当快的。所以不用担心一个线程消费能不能跟得上的问题。

但是这样设计也不是十全十美,会存在一个问题 :虽然提高了Nacos注册表读写性能,但是也带来客户端(消费者)拉取到的注册表信息不是最实时或者说不是最新的。但是由于客户端会定时向Nacos拉取注册表信息,可以解决这个问题。顶多就是服务没那么及时被发现,并不影响整个系统的可用性。

Eureka读写并发架构设计

eureka读写并发架构设计比较简单暴力,它是利用多级缓存来提高读写并发性能的。Eureka里的服务注册表发生变更时,同步readwrite缓存时,不是做响应条目的变更更新,而是直接把readwrite缓存清空。这点跟我们平时做业务缓存架构比较像,不去比较条目变更更新,而是直接清空它这个缓存。这样后台线程判断比较readwrite缓存与readonly缓存不一致时,就会同步更新空的给readonly缓存。这样客户端拉取时,发现readonly缓存也为空,就会从服务注册表拉取数据,再填充给这俩个缓存。

但是带来的弊端也是比较明显,那就是Eureka一直被吐槽的 服务发现太慢,太不及时了!检查同步都是30秒级别,三个一加就得1分半钟。所以Eureka部署时必须得调优,调低检查时间。但是调太低了 又会导致线程检查过于频繁,浪费CPU。要是注册表没啥变动情况下,就是“徒劳”在那里检查个寂寞。

ReadOnlyCacheMap就是一个普通的ConcurrentHashMap,而ReadWriteCacheMap是guava cache,如果ReadWriteCacheMap读不到数据,就会通过ClassLoader的load方法直接从注册表获取数据再返回

主动过期:当服务实例发生注册、下线、故障的时候,ReadWriteCacheMap中所有的缓存过期掉
定时过期:readWriteCacheMap在构建的时候,指定了一个自动过期的时间,默认值就是180秒,所以你往readWriteCacheMap中放入一个数据,180秒过后,就将这个数据给他过期了
被动过期:默认是每隔30秒,执行一个定时调度的线程任务,对readOnlyCacheMap和readWriteCacheMap中的数据进行一个比对,如果两块数据是不一致的,那么就将readWriteCacheMap中的数据放到readOnlyCacheMap中来
通过过期的机制,可以发现一个问题,就是如果ReadWriteCacheMap发生了主动过期或定时过期,此时里面的缓存就被清空或部分被过期了,但是在此之前readOnlyCacheMap刚执行了被动过期,发现两个缓存是一致的,就会接着使用里面的缓存数据

所以可能会存在30秒的时间,readOnlyCacheMap和ReadWriteCacheMap的数据不一致

Nacos高并发异步注册架构如何设计

首先分析Spring Cloud集成Nacos client的服务注册和服务拉取的逻辑。

细心的同学可能已经发现NacosAutoServiceRegistration的继承的AbstractServiceRegistration类实现了ApplicationListener接口,那么必定在AbstractAutoServiceRegistration类中监听某个event方法(实现方法:onApplicationEvent),源码中最终会调用一个register方法,这个方法就是真正向NacosServer注册了当前实例,源码中可以看出最终调用了reqApi方法,向Naocs Server/nacos/v1/ns/instance接口发哦送你通过一个post请求,把当前实例注册进去,到这里整个客户端的核心注册流程就分析完成了。

现在接着分析一下NacosServer注册中心的核心功能逻辑以及源码,首先来分析Nacos怎么能支持高并发的Intance的注册的。采用内存队列的方式进行服务注册

从源码可以看出最终会执行listener.onChange()这个方法,并把instances传入,然后进行真正的注册逻辑,这里的设计就是为了提高Nacos并发注册量。

Nacos2.X为什么性能提升了接近10倍?

Nacos2.0新架构不仅将性能大幅提升10倍,而且内核进行了分层抽象,并且实现插件扩容机制

通信层统一到gRpc协议,同时完善了客户端和服务端的流量控制和负载均衡能力,提升的整体吞吐量。将存储和一致性模型做了抽象分层,架构更简单清晰,代码更加健壮,性能更加强悍。

Nacos2.0架构下的服务发现客户端通过gRPC发起注册服务或订阅请求。服务端使用Client对象来记录客户端使用gRpc连接发布了哪些服务,又订阅了哪些服务,并将client进行服务间同步。由于实际的使用习惯是服务到客户端的映射,即服务下有哪些客户端实例;因此 2.0 的服务端会通过构建索引和元数据,快速生成类似 1.X 中的 Service 信息,并将 Service 的数据通过 gRPC Stream 进行推送。

配置管理之前用 Http1.1 的 Keep Alive 模式 30s 发一个心跳模拟长链接,协议难以理解,内存消耗大,推送性能弱,因此 2.0 通过 gRPC 彻底解决这些问题,内存消耗大量降低。

Sentinel底层滑动时间窗限流算法怎么实现的?

Sentinel的限流原理

限流效果,对应有DefaultController快速失败

WarmUpController慢启动(令牌桶算法)

RateLimiterController(漏桶算法)

固定时间窗口算法

即比如每一秒作为一个固定的时间窗口,在一秒内最多可以通过100个请求,那么在统计数据的时候,如果0-500ms没有请求,而500-1000ms有100个请求,那么这一百个请求都能通过,在1000-1500ms的时候,又有100个请求过来了,它依然能够通过,因为在1000ms的时候又开启了一个新的固定时间窗口。这样,500-1500ms这一秒内有了200个请求,但是它依然能够通过,所以这就会造成数据统计的不准确性,并不能保证在任意的一秒内都使得通过请求数小于100,。
因为固定时间窗口带来的数据同的不准确性,就会造成局部的事件压力过高,所以就需要

普通的滑动窗口做法

因为固定时间窗口带来的数据同的不准确性,就会造成可能局部的时间压力过高,所以就需要采用滑动窗口算法来进行统计,滑动窗口时间算法意思就是,从请求过来的时刻开始,统计往前一秒中的数据,通过这个数据来判断是否进行限流等操作。这样的话准确性就会有很大的提升,但是由于每一次请求过来都需要重新统计前一秒的数据,就会造成巨大的性能损失。所以这也是他的不合理的地方。

Sentinel的滑动时间窗口算法

由于固定时间窗口带来的不确定性和普通滑动窗口带来的性能损失的缺点,所以Sentinel对这俩中方案采取了这种的方法。

在Sentinel中会将原本的固定时间窗口划分成更多更小的样本窗口,每一次请求的数据都会被保存在小的样本窗口中去,而每一次获取的时候都会去获取这些样本时间窗口中的数据,从而不需要进行重新统计,就减小了性能损耗,同时时间窗口被细粒度化了,不准确性也会降低很多。

Nacos中的如何保证CP和AP

Nacos临时节点是AP(ephemeral=true)distro,持久节点是CP(raft)

如何实现Raft算法

Nacos server启动时,会通过 RunningConfig.onApplicationEvent()方法调用RaftCore.init()方法

public static void init() throws Exception {
    
            Loggers.RAFT.info("initializing Raft sub-system");
            // 启动Notifier,轮询Datums,通知RaftListener
            executor.submit(notifier);
            // 获取Raft集群节点,更新到PeerSet中
            peers.add(NamingProxy.getServers());
        
             long start = System.currentTimeMillis();
        
             // 从磁盘加载Datum和term数据进行数据恢复
             RaftStore.load();
        
             Loggers.RAFT.info("cache loaded, peer count: {}, datum count: {}, current term: {}",
                 peers.size(), datums.size(), peers.getTerm());
        
             while (true) {
                 if (notifier.tasks.size() <= 0) {
                     break;
                 }
                 Thread.sleep(1000L);
                 System.out.println(notifier.tasks.size());
             }
        
             Loggers.RAFT.info("finish to load data from disk, cost: {} ms.", (System.currentTimeMillis() - start));
        
             GlobalExecutor.register(new MasterElection()); // Leader选举
             GlobalExecutor.register1(new HeartBeat()); // Raft心跳
             GlobalExecutor.register(new AddressServerUpdater(), GlobalExecutor.ADDRESS_SERVER_UPDATE_INTERVAL_MS);
        
             if (peers.size() > 0) {
                 if (lock.tryLock(INIT_LOCK_TIME_SECONDS, TimeUnit.SECONDS)) {
                     initialized = true;
                     lock.unlock();
                 }
             } else {
                 throw new Exception("peers is empty.");
             }
        
             Loggers.RAFT.info("timer started: leader timeout ms: {}, heart-beat timeout ms: {}",
                 GlobalExecutor.LEADER_TIMEOUT_MS, GlobalExecutor.HEARTBEAT_INTERVAL_MS);
         }
}

在 init方法主要做了如下几件事:

获取 Raft集群节点 peers.add(NamingProxy.getServers());
Raft集群数据恢复 RaftStore.load();
Raft选举 GlobalExecutor.register(new MasterElection());
Raft心跳 GlobalExecutor.register(new HeartBeat());
Raft发布内容
Raft保证内容一致性

其中,raft集群内部节点间是通过暴露的 Restful接口,代码在 RaftController 中。RaftController控制器是 Raft集群内部节点间通信使用的,具体的信息如下

 1 POST HTTP://{ip:port}/v1/ns/raft/vote : 进行投票请求
 2 
 3 POST HTTP://{ip:port}/v1/ns/raft/beat : Leader向Follower发送心跳信息
 4 
 5 GET HTTP://{ip:port}/v1/ns/raft/peer : 获取该节点的RaftPeer信息
 6 
 7 PUT HTTP://{ip:port}/v1/ns/raft/datum/reload : 重新加载某日志信息
 8 
 9 POST HTTP://{ip:port}/v1/ns/raft/datum : Leader接收传来的数据并存入
10 
11 DELETE HTTP://{ip:port}/v1/ns/raft/datum : Leader接收传来的数据删除操作
12 
13 GET HTTP://{ip:port}/v1/ns/raft/datum : 获取该节点存储的数据信息
14 
15 GET HTTP://{ip:port}/v1/ns/raft/state : 获取该节点的状态信息{UP or DOWN}
16 
17 POST HTTP://{ip:port}/v1/ns/raft/datum/commit : Follower节点接收Leader传来得到数据存入操作
18 
19 DELETE HTTP://{ip:port}/v1/ns/raft/datum : Follower节点接收Leader传来的数据删除操作
20 
21 GET HTTP://{ip:port}/v1/ns/raft/leader : 获取当前集群的Leader节点信息
22 
23 GET HTTP://{ip:port}/v1/ns/raft/listeners : 获取当前Raft集群的所有事件监听者
24 RaftPeerSet

Raft中使用心跳机制来触发 Leader选举。心跳定时任务是在 GlobalExecutor 中,通过 GlobalExecutor.register(new HeartBeat())注册心跳定时任务,具体操作包括:

重置 Leader节点的heart timeout、election timeout;
sendBeat()发送心跳包

 1  public class HeartBeat implements Runnable {
 2         @Override
 3         public void run() {
 4             try {
 5 
 6                 if (!peers.isReady()) {
 7                     return;
 8                 }
 9 
10                 RaftPeer local = peers.local();
11                 local.heartbeatDueMs -= GlobalExecutor.TICK_PERIOD_MS;
12                 if (local.heartbeatDueMs > 0) {
13                     return;
14                 }
15 
16                 local.resetHeartbeatDue();
17 
18                 sendBeat();
19             } catch (Exception e) {
20                 Loggers.RAFT.warn("[RAFT] error while sending beat {}", e);
21             }
22 
23         }
24 }

Distro

Distro协议。Distro是阿里巴巴的私有协议,目前流行的 Nacos服务管理框架就采用了 Distro协议。Distro 协议被定位为 临时数据的一致性协议 :该类型协议, 不需要把数据存储到磁盘或者数据库 ,因为临时数据通常和服务器保持一个session会话, 该会话只要存在,数据就不会丢失 。

Distro 协议保证写必须永远是成功的,即使可能会发生网络分区。当网络恢复时,把各数据分片的数据进行合并。

  • 专门为了注册中心创造出来的协议
  • 客户端与服务端有俩个重要的交互,服务注册与心跳发送
  • 客户端以服务为维度向服务端注册,注册后每隔一段时间向服务端发送一次心跳,心跳包需要带上注册服务的全部信息,在客户端看来,服务端节点对等,所有请求的节点是随机的
  • 客户端请求失败则换一个节点重新请求
  • 服务端节点都存储所有数据,但每个节点只负责其中一部分服务,在接收到客户端的“写”(注册、心跳、下线等)请求后,服务端节点判断请求的服务是否为自己负责,如果是,则处理,否则交由负责的节点处理;
  • 每个服务端节点主动发送健康检查到其他节点,响应的节点被该节点视为健康节点;
  • 服务端在接收到客户端的服务心跳后,如果该服务不存在,则将该心跳请求当做注册请求来处理;
  • 服务端如果长时间未收到客户端心跳,则下线该服务;
  • 负责的节点在接收到服务注册、服务心跳等写请求后将数据写入后即返回,后台异步地将数据同步给其他节点;
  • 节点在收到读请求后直接从本机获取后返回,无论数据是否为最新。

Distro协议是阿里的私有协议,但是对外开源框架只有Nacos。所有我们只能从Nacos中一窥Distro协议。Distro协议是一个比较简单的最终一致性协议。整体由节点寻址、数据全量同步、异步增量同步、定时上报client所有信息、心跳探活其他节点等组成。

一个比较简单的最终一致性协议。整体由节点寻址、数据全量同步、异步增量同步、定时上报client所有信息、心跳探活其他节点等组成。

  • 当该节点接收到属于该节点负责的服务时,直接写入。
  • 当该节点接收到不属于该节点负责的服务时,将在集群内部路由,转发给对应的节点,从而完成写入。

读取操作则不需要路由,因为集群中的各个节点会同步服务状态,每个节点都会有一份最新的服务数据。

而当节点发生宕机后,原本该节点负责的一部分服务的写入任务会转移到其他节点,从而保证 Nacos 集群整体的可用性。

一个比较复杂的情况是,节点没有宕机,但是出现了网络分区,即下图所示:

这个情况会损害可用性,客户端会表现为有时候服务存在有时候服务不存在。

综上,Nacos 的 distro 一致性协议可以保证在大多数情况下,集群中的机器宕机后依旧不损害整体的可用性。该可用性保证存在于 nacos-server 端。

注册中心发生故障最坏的一个情况是整个 Server 端宕机,这时候 Nacos 依旧有高可用机制做兜底。

当 Dubbo 应用运行时,Nacos 注册中心宕机,会不会影响 RPC 调用。这个题目大多数应该都能回答出来,因为 Dubbo 内存里面是存了一份地址的,一方面这样的设计是为了性能,因为不可能每次 RPC 调用时都读取一次注册中心,另一面,注册中心宕机后内存会有一份数据,这也起到了可用性的保障(尽管可能 Dubbo 设计者并没有考虑这个因素)。

那如果,我在此基础上再抛出一个问题:Nacos 注册中心宕机,Dubbo 应用发生重启,会不会影响 RPC 调用。如果了解了 Nacos 的 Failover 机制,应当得到和上一题同样的回答:不会。

Nacos 存在本地文件缓存机制,nacos-client 在接收到 nacos-server 的服务推送之后,会在内存中保存一份,随后会落盘存储一份快照。snapshot 默认的存储路径为:{USER_HOME}/nacos/naming/ 中:

这份文件有两种价值,一是用来排查服务端是否正常推送了服务;二是当客户端加载服务时,如果无法从服务端拉取到数据,会默认从本地文件中加载。

前提是构建 NacosNaming 时传入了该参数:namingLoadCacheAtStart=true
Dubbo 2.7.4 及以上版本支持该 Nacos 参数;开启该参数的方式:dubbo.registry.address=nacos://127.0.0.1:8848?namingLoadCacheAtStart=true

在生产环境,推荐开启该参数,以避免注册中心宕机后,导致服务不可用,在服务注册发现场景,可用性和一致性 trade off 时,我们大多数时候会优先考虑可用性。

细心的读者还注意到
{USER_HOME}/nacos/naming/{namespace} 下除了缓存文件之外还有一个 failover 文件夹,里面存放着和 snapshot 一致的文件夹。这是 Nacos 的另一个 failover 机制,snapshot 是按照某个历史时刻的服务快照恢复恢复,而 failover 中的服务可以人为修改,以应对一些极端场景。

该可用性保证存在于 nacos-client 端。

心跳服务

心跳机制一般广泛存在于分布式通信领域,用于确认存活状态。一般心跳请求和普通请求的设计是有差异的,心跳请求一般被设计的足够精简,这样在定时探测时可以尽可能避免性能下降。而在 Nacos 中,出于可用性的考虑,一个心跳报文包含了全部的服务信息,这样相比仅仅发送探测信息降低了吞吐量,而提升了可用性,怎么理解呢?考虑以下的两种场景:

  • nacos-server 节点全部宕机,服务数据全部丢失。nacos-server 即使恢复运作,也无法恢复出服务,而心跳包含全部内容可以在心跳期间就恢复出服务,保证可用性。
  • nacos-server 出现网络分区。由于心跳可以创建服务,从而在极端网络故障下,依旧保证基础的可用性。

MSE Nacos 的高可用最佳实践

阿里云微服务引擎 MSE 提供了 Nacos 集群的托管能力,实现了集群部署模式的高可用。

  • 当创建多个节点的集群时,系统会默认分配在不同可用区。同时,这对于用户来说又是透明的,用户只需要关心 Nacos 的功能即可,MSE 替用户兜底可用性。
  • MSE 底层使用 K8s 运维模式部署 Nacos。历史上出现过用户误用 Nacos 导致部分节点宕机的问题,但借助于 K8s 的自运维模式,宕机节点迅速被拉起,以至于用户可能都没有意识到自己发生宕机。

高并发的理解

先搞清楚高并发系统设计的目标,在此基础上再讨论设计方案和实践经验才有意义和针对性。

宏观目标

高并发绝不意味着只追求高性能,这是很多人片面的理解。从宏观角度看,高并发系统设计的目标有三个:高性能、高可用,以及高可扩展。

高性能:性能体现了系统的并行处理能力,在有限的硬件投入下,提高性能意味着节省成本。同时,性能也反映了用户体验,响应时间分别是100毫秒和1秒,给用户的感受是完全不同的。
高可用:表示系统可以正常服务的时间。一个全年不停机、无故障;另一个隔三差五出线上事故、宕机,用户肯定选择前者。另外,如果系统只能做到90%可用,也会大大拖累业务。
高扩展:表示系统的扩展能力,流量高峰时能否在短时间内完成扩容,更平稳地承接峰值流量,比如双11活动、明星离婚等热点事件。
这3个目标是需要通盘考虑的,因为它们互相关联、甚至也会相互影响。比如说:考虑系统的扩展能力,你会将服务设计成无状态的,这种集群设计保证了高扩展性,其实也间接提升了系统的性能和可用性。再比如说:为了保证可用性,通常会对服务接口进行超时设置,以防大量线程阻塞在慢请求上造成系统雪崩,那超时时间设置成多少合理呢?一般,我们会参考依赖服务的性能表现进行设置。

可用性指标

  • 平均响应时间:最常用,但是缺陷很明显,对于慢请求不敏感。比如1万次请求,其中9900次是1ms,100次是100ms,则平均响应时间为1.99ms,虽然平均耗时仅增加了0.99ms,但是1%请求的响应时间已经增加了100倍。
  • TP90、TP99等分位值:将响应时间按照从小到大排序,TP90表示排在第90分位的响应时间, 分位值越大,对慢请求越敏感。
  • 吞吐量:和响应时间呈反比,比如响应时间是1ms,则吞吐量为每秒1000次。

通常,设定性能目标时会兼顾吞吐量和响应时间,比如这样表述:在每秒1万次请求下,AVG控制在50ms以下,TP99控制在100ms以下。对于高并发系统,AVG和TP分位值必须同时要考虑。另外,从用户体验角度来看,200毫秒被认为是第一个分界点,用户感觉不到延迟,1秒是第二个分界点,用户能感受到延迟,但是可以接受。因此,对于一个健康的高并发系统,TP99应该控制在200毫秒以内,TP999或者TP9999应该控制在1秒以内。

可用性

高可用性是指系统具有较高的无故障运行能力,可用性 = 平均故障时间 / 系统总运行时间,一般使用几个9来描述系统的可用性。

对于高并发系统来说,最基本的要求是:保证3个9或者4个9。原因很简单,如果你只能做到2个9,意味着有1%的故障时间,像一些大公司每年动辄千亿以上的GMV或者收入,1%就是10亿级别的业务影响。

可扩展性指标

面对突发流量,不可能临时改造架构,最快的方式就是增加机器来线性提高系统的处理能力。

对于业务集群或者基础组件来说,扩展性 = 性能提升比例 / 机器增加比例,理想的扩展能力是:资源增加几倍,性能提升几倍。通常来说,扩展能力要维持在70%以上。

但是从高并发系统的整体架构角度来看,扩展的目标不仅仅是把服务设计成无状态就行了,因为当流量增加10倍,业务服务可以快速扩容10倍,但是数据库可能就成为了新的瓶颈。

像MySQL这种有状态的存储服务通常是扩展的技术难点,如果架构上没提前做好规划(垂直和水平拆分),就会涉及到大量数据的迁移。

因此,高扩展性需要考虑:服务集群、数据库、缓存和消息队列等中间件、负载均衡、带宽、依赖的第三方等,当并发达到某一个量级后,上述每个因素都可能成为扩展的瓶颈点。

分布式存储

分布式存储是一个大的概念,其包含的种类繁多,除了传统意义上的分布式文件系统、分布式块存储和分布式对象存储外,还包括分布式数据库和分布式缓存等。下面我们探讨一下分布式文件系统等传统意义上的存储架构,实现这种存储架构主要有三种通用的形式,其它存储架构也基本上基于上述架构,并没有太大的变化。

中间控制节点架构

分布式存储最早是由谷歌提出的,其目的是通过廉价的服务器来提供使用与大规模,高并发场景下的Web访问问题。下图是谷歌分布式存储(HDFS)的简化的模型。在该系统的整个架构中将服务器分为两种类型,一种名为namenode,这种类型的节点负责管理管理数据(元数据),另外一种名为datanode,这种类型的服务器负责实际数据的管理。

上图分布式存储中,如果客户端需要从某个文件读取数据,首先从namenode获取该文件的位置(具体在哪个datanode),然后从该位置获取具体的数据。在该架构中namenode通常是主备部署,而datanode则是由大量节点构成一个集群。由于元数据的访问频度和访问量相对数据都要小很多,因此namenode通常不会成为性能瓶颈,而datanode集群可以分散客户端的请求。因此,通过这种分布式存储架构可以通过横向扩展datanode的数量来增加承载能力,也即实现了动态横向扩展的能力。

完全无中心架构—计算模式(Ceph)

下图是Ceph存储系统的架构,在该架构中与HDFS不同的地方在于该架构中没有中心节点。客户端是通过一个设备映射关系计算出来其写入数据的位置,这样客户端可以直接与存储节点通信,从而避免中心节点的性能瓶颈。

在Ceph存储系统架构中核心组件有Mon服务、OSD服务和MDS服务等。对于块存储类型只需要Mon服务、OSD服务和客户端的软件即可。其中Mon服务用于维护存储系统的硬件逻辑关系,主要是服务器和硬盘等在线信息。Mon服务通过集群的方式保证其服务的可用性。OSD服务用于实现对磁盘的管理,实现真正的数据读写,通常一个磁盘对应一个OSD服务。
客户端访问存储的大致流程是,客户端在启动后会首先从Mon服务拉取存储资源布局信息,然后根据该布局信息和写入数据的名称等信息计算出期望数据的位置(包含具体的物理服务器信息和磁盘信息),然后该位置信息直接通信,读取或者写入数据。

一致性哈希

与Ceph的通过计算方式获得数据位置的方式不同,另外一种方式是通过一致性哈希的方式获得数据位置。一致性哈希的方式就是将设备做成一个哈希环,然后根据数据名称计算出的哈希值映射到哈希环的某个位置,从而实现数据的定位。

HTTP和RPC的区别

传输协议

RPC,可以基于TCP协议,也可以基于HTTP协议。
HTTP,基于HTTP协议。
传输效率

RPC,使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2协议,也可以很好的减少报文的体积,提高传输效率。
HTTP,如果是基于HTTP1.1的协议,请求中会包含很多无用的内容,如果是基于HTTP2.0,那么简单的封装一下是可以作为一个RPC来使用的,这时标准RPC框架更多的是服务治理。
性能消耗

RPC,可以基于thrift实现高效的二进制传输。
HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能。
负载均衡

RPC,基本都自带了负载均衡策略。
HTTP,需要配置Nginx,HAProxy来实现。
服务治理

RPC,能做到自动通知,不影响上游。
HTTP,需要事先通知,修改Nginx/HAProxy配置。

Last modification:August 27, 2023
如果觉得我的文章对你有用,请随意赞赏