从 eureka 看看如何设计一个注册中心

1,664 阅读5分钟

前言

一个注册中心的核心功能非常简单,启动的服务需要注册上来,宕机下线的服务要及时通知客户端,同时需要保证注册中心的高性能、高可用

我们从以下几个方面来看看 eureka 是如何实现的

(1)网络通信

(2)存储注册列表的数据结构

(3)如何高效的处理并发读写

(4)如何感知服务上下线

(5)如何让客户端得到的服务列表和服务端的注册列表保值一致

(6)如何处理抖动的网络导致的服务上下线频繁波动

(7)如何保持集群结点的数据一致性

网络

高效的网络模型一定是采用 reactor 模式来实现,在 Linux 底层的网络模型选择 epoll 的 nio 实现,用一个 acceptor 线程接收客户端连接连接请求,用 process 线程来处理客户端的读写请求

eureka 的服务端采用的是 jersey2.x 来实现的,对这个技术也没有太大必要去了解了

不过是使用 servlet 和 http1.1 协议,由于 http1.1 协议本身的特性比如数据包大、无法多路复用等缺陷,其在性能是一般般的,其它注册中心比如

(1)zookeeper 也可以实现注册中心,其网络模型采用的自定义的 rpc 协议基于 Nio epoll 通信,效率很高

(2)nacos2.x 基于 http2.0 通信,解决了 http1.1 存在的数据包大、无法多路复用的缺陷

数据结构

服务注册到注册中心的时候,采用何种方式进行存储呢,这得看看这个数据的特征

(1)会存在同时读写的情况,要求线程安全

(2)再大量服务上下线的情况下,要保证性能

(3)启动的时候全量拉取注册表

(4)启动后需要增量拉取注册表

基于 ConcurrentHashMap 来存储注册表,底层基于 CAS 等无锁机制保证高性能,相比直接使用锁来保证线程安全性能更高

如果存在大量的服务频繁上下线的话,那么 ConcurrentHashMap 锁的争用会加剧,同时要在其中获取增量变化的服务实现也是非常的困难,此时可以考虑在修改了全量注册表的情况下,同时再采用另外一个数据结构来记录增量变化的服务,同时这个数据结构只记录一段时间的增量服务(界定缓存大小)

从上面来考虑来说,可以再设计一个队列来保存这个服务,为了保证线程安全,有以下一些原生队列

(1)ArrayBlockingQueue,基于数组和一把锁来实现线程安全,数组实现在 CPU 缓存遍历时候表现性能优异,但是在获取和添加的时候基于一把锁实现,锁的争抢会加剧

(2)LinkedBlockingQueue,基于链表和2把锁来实现线程安全,获取和添加分别使用不同的锁降低锁粒度,性能会更高,但是在 CPU 访问的由于链表记录的信息可能分布在不同的内存快,无法利用 CPU 高速缓存,但是在大量的插入和获取的时候由于2把锁性能远高于 ArrayBlockingQueue

(3)ConcurrentLinkedQueue,无锁阻塞队列,底层基于链表和 CAS 机制来实现,CAS 在 CPU 访问的时候基于 MESI 协议保证数据可见性,会对高速缓存中的锁加锁但是这个底层的锁性能远远高于应用层的锁

eureka 选用的 ConcurrentLinkedQueue 来实现的阻塞队列保存增量变化的服务(增删)

同时如果客户端已经获取了一次增量服务注册表,会采用了 google 的 LoadingCache 来保存,避免每次都去队列中重新计算(计算:取出队列数据组装为响应客户端的数据结构、merge 注册表计算一个 hash 值)

在每次服务变更的时候都是实时的去修改注册表、最近变更队列,然后将 LoadingCache 缓存置为无效,如果基于这 2 个数据结构来实现的话只是解决了记录最近变更服务的问题,但是锁争抢粒度问题没有解决,所以又实现了一个 readOnlyCacheMap 这是一个只读缓存,这个数据结构,在每次拉取的时候发现没有就会添加进去,然后30S 会去遍历同步一次 readOnlyCacheMap 和 LoadingCache 中的数据,减少锁争抢进一步提升性能

如何高效的处理并发读写

基于上节所讲,采用高效的安全的数据结构,除此之外,还有另外一些必须加锁的操作,想想如下场景

拉取增量变化的注册表的时候,需要从最近变更队列中获取,然后将其和注册表 merge 计算出来一个所有服务的 hash 值,传递给前端,前端需要依据拉取到的服务跟本地注册表也做一个 merge 计算 hash 值然后跟服务端传回来的 hash 值比对,如果不一致则需要全量拉取

在这里注册中心增量 merge 的时候,有可能服务刚好上线导致最近变更队列少了服务,而计算的 hash 多了一个服务,导致客户端 hash 和注册中心 hash 值不一致,触发全量拉取逻辑

所以在这里需要使用读写锁,在增量拉取这个访问比较频繁的场景中(每隔 30S 拉取一次)采用读锁,读锁可以重复加锁,不存在性能问题,在服务上下线的时候需要加同一把锁的写锁,隔离 2 者的操作

如何感知服务上下线

服务下线

客户端每隔 30S 发起一次心跳请求,服务端会更新注册表的更新时间

注册中心启动一个定时任务每隔 60S 检测判断一下当前时间和最近更新时间超时了,就将服务从注册中心拆除

一个服务从宕机下线到被感知到最多需要 60S(定时检测)+30S(readOnlyCache刷新) = 90S

服务上线

当客户端启动的时候向向注册中心发起注册请求

如何保持集群结点的数据一致性