注册中心是微服务系统的核心组件之一。当前比较流行的注册中心也比较多,如eureka、nacos、consul、zookeeper等。这些主流的注册中心,基本上都是强一致性的 CP 系统,只有eureka是最终一致性的 AP 系统。如果你的微服务需要支持跨区域调用,基本上只能选择 eureka(nacos 虽然声称同时支持 CP 和 AP 应用,但是如果需要支持跨集群(区域)调用,需要额外部署专门的 nacos sync 组件,不建议在跨区域场景中使用 nacos)。而且,注册中心的核心功能是随时可以获取注册服务的实例信息,在可用性(AP)与一致性(CP)之间权衡时偏向可用性,也是比较符合注册中心的场景定位的,eureka 就是只要有一个节点存活,就可以获取注册服务的实例信息,没有 CP 系统必须多数节点存活才能提供服务的限制。
1 eureka 系统架构

eureka系统由存储与记录服务信息的 eureka server 以及使用 eureka server 注册服务的 eureka client 组成。eureka client 可分为提供服务的 serivce provider 和调用服务的 service consumer 两种角色。在后面的描述中,如无特别说明,server特指eureka server,client特指eureka client,provider特指service provider,同时等于与服务一词,consumer特指service consumer。
这几种角色的典型交互过程为:
-
registry
service provider启动时向 eureka server 注册自己,eureka server 将 service provider 信息记录在一个 Map 表中,eureka server 中维护注册信息的这个 map 表名字叫
PeerAwareInstanceRegistry。 -
renew
service provider向 eureka server 更新自己的信息。service provider 会周期性地向 eureka server 发送续约(lease) 信息,表示自己依然存活。service provider 也会周期性的检测自己的相关信息和健康状态,如果发生了变化,则会向 eureka server 更新自己的注册信息和状态。eureka server 在接收到 service provider 的更新信息时(包括 registry、renew 和后面的 cancel),不仅会修改注册表
PeerAwareInstanceRegistry,同时会将变化的部分写入RecentlyChangedQueue中。 eureka 系统中,都是 eureka client 主动发起跟 eureka server 通信的。 -
replicate
eureka server接收到 service provider 的registry 或 renew 信息(已包括后面的 cancel 信息),会将信息同步给它的peer eureka server,以保证所有的 eureka server 包含相同的 service provider 注册信息
-
get registry
service consumer会周期性向自己连接的 eureka server获取servicer provider的注册信息。一般情况下,service consumer 第一次会获取全量(full)的所有的service provider的注册信息,后面则只需要增量获取变化(delta)的注册信息。为提升性能,service consumer 全量获取eureka server 的注册表信息时,也不会直接从注册表
PeerAwareInstanceRegistry中读取,而是从缓存ReadOnlyCache中读取。增量变化信息则从RecentlyChangedQueue中读取。 -
remote call
service consumer 获取到了 seriver provider 的注册信息后,就可以从注册的实例中选择具体的实例发起远程调用了。
-
cancel
service provider 可以主动向 eureka server 注销自己,eureka server 则会从
RecentlyChangedQueue中删除相应的注册信息,并通过 replicate 操作让其他所有eureka server 同步删除,这样 service consumer 就不会再调用该 service provider 了。
2 eureka 应用详述
2.1 选择连接的eureka server节点
eureka client在与eureka server进行registry、renew、get registry、cancel等通信前,需要选择一个具体的server节点。且只要client与选择的server节点保持通信正常,client会一直通过该server节点与server集群交互信息。
2.1.1 配置defaultZone
eureka client可以通过配置项
yaml
eureka.client.serviceUrl.defaultZone: http://192.168.1.1:8001/eureka/,http://192.168.1.2:8001/eureka/
指定eureka server列表。eureka client会先选择第一个server尝试建立通信。如果连续尝试3次都无法正常通信,则会按列表顺序依次选择下一个server进行通信。
2.1.2 preferSameZoneEureka
eureka是支持跨区域的,eureka server和eureka client可能分布在不同的az(available zone),甚至不同的region,一个很自然的想法是client优先选择与自己在相同的az中的server进行通信。 我们可以通过下面的配置来实现这个目的:
yaml
eureka:
client:
# 开启优先选择相同zone的server的功能
preferSameZoneEureka: true
# 自己所属的region
region: region-1
serviceUrl:
# 配置每个zone的server列表
zone-1: http://192.168.1.1:8001/eureka/
zone-2: ,http://192.168.1.2:8001/eureka/
availabilityZones:
# 配置每个region的zone列表
region-1: zone-1
instance:
metadataMap:
# 自己所属的zone
zone: zone-1
通过上面的配置,client会优先从eureka.client.serviceUrl.zone-1配置的server列表中选择可用的server节点。看上去这个配置稍显复杂。不过由于eureka client与eureka server之间(包括eureka server与eureka server之间)没有任何节点发现协议,所以server节点信息(有哪些节点、这些节点所属的region、az等)只能通过配置指定。
当然,我们如果把所有的server列表配置在defaultZone中,但是将与自己在相同az内的server节点放在列表的前面,也能达到相同的效果。只是这样server信息不够清晰,配置也不便于维护
需要注意的是,有些人认为开启了preferSameZoneEureka功能,service consumer会自动优先调用自己所属az内的service provider实例。其实,正如这个配置的名字所显示的,它开启的是eureka client优先选择自己所属az内的eureka server节点,而不是service provider节点。如果要实现consumer优先调用自己所属az内的provider,需要在客户端负载均衡选择provider时实现相应的选择策略,比如通过配置spring cloud loadbalancer的相应策略实现。
2.2 registry
2.2.1 在什么时刻发起注册
如果eureka client设置了配置项
yaml
eureka.client.registerWithEureka: true
它就是一个service provider的角色(该配置项默认值就是true)。service provider在启动时会主动向eureka server进行注册。如果service provider与当前连接的eureka server节点无法通信时(eureka server宕机或网络分区等原因),service provider也会尝试重新选择一个新的eureka server进行注册。
2.2.2 注册的内容
eureka client通过发送以下http请求向server进行注册,serviceName为service provider的服务名:
text
POST http://192.168.1.1:8001/eureka/apps/{serviceName}
该http请求的body就是类com.netflix.appinfo.InstanceInfo实例的内容。我们也可以通过向server发送以下请求获取client注册的具体信息:
text
# 查询eureka server上所有的服务实例信息
GET http://192.168.1.1:8001/eureka/apps
# 查询eureka server上指定服务的所有实例信息,serviceName为服务名
GET http://192.168.1.1:8001/eureka/apps/{serviceName}
# 查询eureka server上指定服务实例注册的信息,instanceId为服务实例标识符
GET http://192.168.1.1:8001/eureka/apps/{serviceName}/{instanceId}
例如,获取服务名为app-client的服务注册信息如下:
xml
<application>
<name>APP-CLIENT</name>
<instance>
<instanceId>hostname.example.com:10.8.1.1:8845@1.0.1</instanceId>
<hostName>hostname.example.com</hostName>
<app>APP-CLIENT</app>
<ipAddr>10.8.1.1</ipAddr>
<status>UP</status>
<overriddenstatus>UNKNOWN</overriddenstatus>
<port enabled="true">8845</port>
<securePort enabled="false">443</securePort>
<countryId>1</countryId>
<dataCenterInfo class="com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo">
<name>MyOwn</name>
</dataCenterInfo>
<leaseInfo>
<renewalIntervalInSecs>3</renewalIntervalInSecs>
<durationInSecs>15</durationInSecs>
<registrationTimestamp>1691227542481</registrationTimestamp>
<lastRenewalTimestamp>1691227578024</lastRenewalTimestamp>
<evictionTimestamp>0</evictionTimestamp>
<serviceUpTimestamp>1691227363533</serviceUpTimestamp>
</leaseInfo>
<metadata>
<region>region-1</region>
<zone>zone-1</zone>
<version>1.0.1</version>
<management.port>8845</management.port>
</metadata>
<homePageUrl>http://10.8.1.1:8845/</homePageUrl>
<statusPageUrl>http://10.8.1.1/actuator/info</statusPageUrl>
<healthCheckUrl>http://10.8.1.1:8845/actuator/health</healthCheckUrl>
<vipAddress>app-client</vipAddress>
<secureVipAddress>app-client</secureVipAddress>
<isCoordinatingDiscoveryServer>false</isCoordinatingDiscoveryServer>
<lastUpdatedTimestamp>1691227542481</lastUpdatedTimestamp>
<lastDirtyTimestamp>1691227541999</lastDirtyTimestamp>
<actionType>ADDED</actionType>
</instance>
</application>
2.2.2.1 instanceId
每一个服务实例有一个instanceId标识,我们可以通过配置指定instantId的值,这样我们在eureka的dashboard(http://192.168.1.1/)上可以看到自定义标识的服务实例。本人一般如下配置,这样从instanceId就能直接知道实例的hostname、ip、port和版本号:
yaml
release.version: 1.0.1
eureka.instance.instanceId: ${spring.cloud.client.hostname}:${spring.cloud.client.ip-address}:${server.port}@${release.version}
2.2.2.2 ipAddr
服务器上可能有多个ip地址,那么注册的ipAddr是哪一个呢?我们可以通过下面的配置确定优选的ipAddr:
yaml
spring:
cloud:
inetutils:
ignored-interfaces:
- lo
# 优选ipAddr的正则表达式
preferred-networks:
- ^10\..*
2.2.2.3 prefer-ip-address和hostname
client注册的hostname默认为服务器的hostname,service consumer调用provider服务时也是取实例的hostname值进行调用的。但是hostname调用需要增加DNS查询开销,且DNS也是可能存在故障的,如果你想直接通过ip地址进行服务调用,可以配置:
yaml
eureka.instance.prefer-ip-address: true
这样client注册的hostname值为优选的ipAddr值(其实还有一种解决思路是,调用服务实例时使用实例注册的ipAddr值,但是现在consumer端都是取的hostname值)。
2.2.2.4 重要的metadata-map
metadata-map支持服务自定义注册信息,本人一般添加服务的region、zone、version信息,这样consumer端可以利用这些信息优化服务调用,如az亲和性、灰度发布等
yaml
eureka:
instance:
metadata-map:
# 所在region
region: region-1
# 所在az
zone: zone-1
# 版本
version: 1.0.1
2.3 renew
2.3.1 续约(lease)
eureka server除了接收client的注册,还需要对注册的client实例进行探活,毕竟service consumer向server获取服务实例时,只需要当前活跃的服务实例。client的保活是通过主动周期性地向server发送续约请求(或者说heartbeat请求)实现的。续约请求的发送周期通过参数:
yaml
# 续约更新时间间隔,单位秒 (默认30秒)
eureka.instance.lease-renewal-interval-in-seconds: 3
进行控制,同时,client还有一个续约超时参数:
makefile
# 续约超时时间,单位秒 (默认90秒)
eureka.instance.lease-expiration-duration-in-seconds: 10
当server检测到某个服务实例超过lease-expiration-duration-in-seconds时间没有收到续约请求,则会从注册表中剔除(evict)该服务实例。
续约请求的url地址为:
ruby
# 服务状态为UP
PUT http://192.168.1.1:8001/eureka/apps/{serviceName}/{instanceId}?status=UP&lastDirtyTimestime=1691246276352
# 服务状态为DOWN
PUT http://192.168.1.1:8001/eureka/apps/{serviceName}/{instanceId}?status=DOWN&lastDirtyTimestime=1691246276352
lastDirtyTimestime即为续约时的时间戳
2.3.2 update instance/status
eureka client除了周期性的发送续约请求,还会周期性的检查实例的信息是否发生了变化,如实例的hostname,ipAddr等,如果变化了,则会通过注册接口向server更新实例信息。client还会周期性检测实例的状态是否发生了变化,如果变化了,也会通知server进行状态更新。
检测周期可以通过以下参数控制:
makefile
# 将实例信息变更同步到Eureka Server的初始时间间隔,单位秒(默认40秒)
eureka.client.initial-instance-info-replication-interval-seconds: 40
# 将实例信息变更同步到Eureka Server的时间间隔,单位秒(默认30秒)
eureka.client.instance-info-replication-interval-seconds: 30
实例状态更新接口:
bash
PUT http://192.168.1.1:8001/eureka/apps/{serviceName}/{instanceId}/status?value=OUT_OF_SERVICE
PUT http://192.168.1.1:8001/eureka/apps/{serviceName}/{instanceId}/status?value=UP
2.3.3 health check
eureka client发送续约请求是在一个单独的线程中进行的,服务实例能正常发送续约请求并不能代表实例是正常的,不代表该实例的服务能正常的被调用。spring boot actuator提供了一个/actuator/health健康检测功能,它会检测服务实例各个组件的状态,包括使用的mysql、redis、kafka等外部组件的连接状态,如果某个组件工作不正常,则整个服务的状态为DOWN。client支持以actuator health检测结果的状态作为服务实例的状态。开启该功能的配置参数为(默认开启该功能):
yaml
eureka.client.healthcheck.enabled: true
2.4 cancel
eureka server除了可以通过周期性地检测服务实例的续约是否过期来删除实例,服务实例也可以主动下线并删除实例,通过调用server的如下接口实现:
ruby
DELETE http://192.168.1.1:8001/eureka/apps/{serviceName}/{instanceId}
2.5 eureka server
2.5.1 维护服务注册表
每一个eureka server维护了一个服务注册表,
java
class PeerAwareInstanceRegistryImpl {
private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry;
}
其实是一个双层hash表,外层表的key为服务名,内层表的key为服务实例的isntanceId,value是服务实例的lease信息
当service provider进行注册、续约、更新实例信息、更新状态、下线等操作时,server就会更新这个表的内容
2.5.2 服务剔除(evict)
注册表维护的服务实例信息存储在Lease<InstanceInfo>中,Lease类的定义如下:
java
public class Lease<T> {
public static final int DEFAULT_DURATION_IN_SECS = 90;
private T holder;
private long evictionTimestamp;
private long registrationTimestamp;
private long serviceUpTimestamp;
private volatile long lastUpdateTimestamp;
private long duration;
}
可见实例的Lease信息维护了实例的过期时间、注册时间、状态变为UP的时间和最后更新时间。同时,server维护了一个定期任务EvictTask,该任务会遍历注册表中的所有服务实例,如果发现某个实例的evictionTimestamp时间小于当前时间,就表示该服务实例续约超期了,将该实例从注册表中删除。EvictTask的执行周期由以下参数进行控制:
yaml
# 默认值为60秒
eureka.server.evictionIntervalTimerInMs=60 * 1000
2.5.3 RecentlyChangedQueue
当server更新注册表时,同时会将变化了服务实例以及更新动作(新增、修改、删除等)写到RecentlyChangedQueue队列中,便于service consumer增量更新服务信息。
java
class AbstractInstanceRegistry {
private ConcurrentLinkedQueue<RecentlyChangedItem> recentlyChangedQueue;
private static final class RecentlyChangedItem {
private long lastUpdateTime;
private Lease<InstanceInfo> leaseInfo;
}
}
该队列的相关参数:
yaml
# queue中消息的保留时间,过期消息将被删除
eureka.server.retentionTimeInMSInDeltaQueue=3 * 60 * 1000
# 检查queue消息是否过期的定期任务的执行周期
eureka.server.deltaRetentionTimerIntervalInMs=30 * 1000
2.5.4 server间的信息同步
eureka server各节点之间如何保持注册表的内容同步?采用了什么一致性协议?答案是没有!当server注册表内容发生了任何变化,包括注册了新的服务实例、服务实例续约更新了续约时间戳、服务实例信息或状态、服务下线或续约超时等等,server都会将变化的服务实例信息发送(replicate)给其他所有的邻居server节点(peer eureka server)。一句话,就是拷贝,就是干!
但是,server节点间也没有节点发现协议,server怎么知道自己有哪些邻居节点呢?答案是直接通过配置,就是这么简单,豪横!所以,server的defaultZone配置项需要把集群中所有的其他server节点都写上:
yaml
eureka.client.serviceUrl.defaultZone: http://192.168.1.1:8001/eureka/,http://192.168.1.2:8001/eureka/
好在作为一个注册中心,集群规模一般不会太大,如果没有跨域的需求,只服务于一个az的话,集群一般2~7个节点就足够了。
直接拷贝又引出一个问题:如果server A将注册表发生变更的服务实例拷贝给server B、server C等,这时server B的注册表也变化了,又将变化的信息拷贝给server A、server C等,这样岂不是在集群里无限制的拷贝下去,发生广播风暴?所以server间拷贝的信息,会携带一个标志位isReplication,接收的server看到这个标志位就不会再拷贝给其他的server节点了。换句话说,server只会将来自于eureka client的变更消息同步给配置里指定的所有server节点。
server间的变更同步(replication),只同步了发生了变化的服务节点信息。如果一个新的server节点刚加入集群,如果尽快地与集群保持一致性?答案是,在eureka集群中,eureka server节点是互为server和client的。如果server使能了以下配置项(默认使能):
yaml
eureka.client.fetchRegistry: true
server在启动时,会向配置的所有邻居server节点拉取全量的注册表信息。(有人认为,server启动后会周期性地向邻居server拉取注册表,就像普通的service consumer那样,但是从实测来看,并没有。如果server A没有配置server B,server B配置server A,server A先启动,然后启动server B。这时,可以看到server B包含server A中的所有的服务节点,但是马上又续约超期剔除了)
server间相互同步信息时,如何避免信息冲突和覆盖呢?还记得服务实例的Lease<InstanceInfo>里有一个lastDirtyTimestamp,它表示实例信息最后更新的时间,相当于实例的版本号,只有lastDirtyTimestamp值大的才会进行更新
从上面的分析,我们还得出一个结论:服务实例的更新时间戳是实例的核心基本信息,整个集群都是基于这个时间戳来判断服务实例是否更新了,是否续约超期剔除。同时,服务实例的任何信息变化,包括续约时间戳的变化,都需要在整个集群中进行同步,也就是说,即使整个集群保持稳定,也会周期性地在整个集群中同步所有信息。这是在维护eureka集群时需要考虑的因素。
server节点间拷贝(replication)的几个相关配置:
yaml
eureka:
server:
# 节点之间的复制是否应批处理以提高网络效率 (默认:false)
batch-replication: true
# 发送复制数据请求中,是否总是压缩
enable-replicated-request-compression: false
# 允许备份到备份池的最大复制事件数量,可以根据内存大小,超时和复制流量,来设置此值得大小(默认是 10000)
max-elements-in-peer-replication-pool: 20000
max-elements-in-status-replication-pool: 20000
# 服务注册中心相互复制数据的最大线程数量(默认是 20、1)
max-threads-for-peer-replication: 20
max-threads-for-status-replication: 10
2.5.5 缓存
上面提到,整个集群任何服务实例的信息变更,都要修改server的注册表,同时,server节点的所有consumer都要从server拉取注册的服务信息,所有注册表是一个高并发的读写场景,需要频繁的加锁和释放锁,为了提升注册表的读写性能,eureka增加了两级缓存ReadWriteCache和ReadOnlyCache(见eureka架构图)
ReadWriteCache的行为:
- 元素加载
ReadWriteCache基于guava的LoadingCache实现,当读取的元素不存在时,尝试从注册表中读取 2. 元素剔除
有几种情况会剔除ReadWriteCache中的相应元素: a)当注册表的服务实例信息发生了变化时,包括续约、超时删除、主动下线等,都会将ReadWriteCache中的相应元素失效 b)ReadWriteCache自身有一个超时任务,定时进行超时剔除。任务周期通过参数控制:
yaml
eureka.server.responseCacheAutoExpirationInSeconds: 180
ReadOnlyCache的行为:
- 元素加载
定期从ReadWriteCache加载全量元素。可以认为,加完后,ReadWriteCache中的内容与注册表的内容一致。元素加载周期由配置指定:
yaml
eureka.server.responseCacheUpdateIntervalMs: 30 * 1000
- 元素剔除
无。ReadOnlyCache底层实现为ConcurrentHashMap,定期从ReadWriteCache加载元素时删除已经不存在的元素
ReadOnlyCache是可以关闭的。关闭后,consumer直接从ReadWriteCache中获取服务信息
yaml
eureka.server.useReadOnlyResponseCache: false
2.5.6 自我保护
当发生网络分区时,server无法与其他的server节点和client进行通信,这样这些client无法正常在当前server上进行续约,然后被server当作续约超时删除,虽然这些client本身是正常的。为了避免这种情况发生,eureka设计了一个自我保护状态,进入自我保护状态后,server不会进行续约超时删除。开启自我保护功能可以打开配置:
yaml
# 默认是否开启自我保护,默认开启
eureka.server.enableSelfPreservation: true
server统计最近一分钟接收到的续约请求数量,与设定的阈值进行比较,如果续约请求数小于阈值,则进入自我保护状态。判断表达式为:
阈值比例通过配置参数指定:
yaml
# 默认值为85%
eureka.server.renewalPercentThreshold: 0.85
这样自我保护阈值为:注册表中服务实例总数 * renewalPercentThreshold
2.6 get regitstry
eureka client如果使能以下配置项(默认是使能的):
yaml
eureka.client.fetchRegistry: true
那么它的角色就是service consumer,启动时,会从server获取全量的服务信息。 获取服务信息的接口为:
text
GET http://192.168.1.1:8001/eureka/apps
然后,consumer会周期性的从server获取增量服务变更信息,相应接口为:
ruby
GET http://192.168.1.1:8001/eureka/apps/delta
获取周期由配置参数指定:
makefile
eureka.client.registryFetchIntervalSeconds: 5
2.7 http通信调优参数
server节点间的http连接参数:
yaml
eureka:
server:
peer-node-connect-timeout-ms: 200
peer-node-read-timeout-ms: 200
peer-node-total-connections: 1000
peer-node-total-connections-per-host: 500
peer-node-connection-idle-timeout-seconds: 30
client与server间的http连接参数:
yaml
eureka:
client:
eureka-server-connect-timeout-seconds: 5
eureka-server-read-timeout-seconds: 8
eureka-server-total-connections: 200
eureka-server-total-connections-per-host: 50
eureka-connection-idle-timeout-seconds: 30
3 eureka配置一览
eureka instance支持的详细配置参数可以参考:org.springframework.cloud.netflix.eureka.EurekaInstanceConfigBean
eureka server支持的详细配置参数可以参考:org.springframework.cloud.netflix.eureka.server.EurekaServerConfigBean
eureka client支持的详细配置参数可以参考:org.springframework.cloud.netflix.eureka.EurekaClientConfigBean
2.1 eureka server配置
bootstrap.yml
yaml
spring:
cloud:
inetutils:
ignored-interfaces:
- lo
preferred-networks:
- ^10\..*
application.yml
yaml
eureka:
server:
# 开启自我保护
enableSelfPreservation: true
# 清理间隔(单位毫秒,默认是 60*1000)
evictionIntervalTimerInMs: 10000
batchReplication: true
client:
# 实例所属的区域
region: region-1
serviceUrl:
defaultZone: http://192.168.1.1:8001/eureka/,http://192.168.1.2:8001/eureka/
preferSameZoneEureka: true
healthcheck:
enabled: true
# 将 EurekaServer 本身也注册服务中心
registerWithEureka: true
fetchRegistry: true
# 获取服务信息时间间隔
registryFetchIntervalSeconds: 5
instance:
instanceId: ${spring.cloud.client.hostname}:${spring.cloud.client.ip-address}:${server.port}@${release.version}
# 续约超时
leaseExpirationDurationInSeconds: 10
# 续约间隔
leaseRenewalIntervalInSeconds: 3
# 注册的hostname用ip地址
prefer-ip-address: true
metadata-map:
region: region-1
zone: zone-1
version: ${release.version}
2.2 eureka client配置
bootstrap.yml
yaml
spring:
cloud:
inetutils:
ignored-interfaces:
- lo
preferred-networks:
- ^10\..*
application.yml
yaml
eureka:
client:
# 实例所属的区域
region: region-1
serviceUrl:
defaultZone: http://192.168.1.1:8001/eureka/,http://192.168.1.2:8001/eureka/
preferSameZoneEureka: true
healthcheck:
enabled: true
# 将 EurekaServer 本身也注册服务中心
registerWithEureka: true
fetchRegistry: true
# 获取服务信息时间间隔
registryFetchIntervalSeconds: 5
instance:
instanceId: ${spring.cloud.client.hostname}:${spring.cloud.client.ip-address}:${server.port}@${release.version}
# 续约超时
leaseExpirationDurationInSeconds: 4
# 续约间隔
leaseRenewalIntervalInSeconds: 1
# 注册的hostname用ip地址
prefer-ip-address: true
metadata-map:
region: region-1
zone: zone-1
version: ${release.version}