Eureka 是AP服务, 无 master/slave 之分,每一个 Peer 都是对等的。只要有一台Eureka还在,就能保证注册服务可用, 只不过每个Server的注册表信息可能不一致。为了保障注册中心的高可用性,容忍了数据的非强一致性。在集群环境中如果某台 EurekaServer 宕机,EurekaClient 的请求会自动切换到新的 EurekaServer 节点上,服务提供者有多个时,Eureka Client 客户端会通过 Ribbon 自动进行负载均衡。
代码语言:yaml复制eureka:
instance:
hostname: localhost
prefer-ip-address: false
lease-renewal-interval-in-seconds: 10 #每10S给其他服务发次请求,监测心跳
lease-expiration-duration-in-seconds: 30 #如果其他服务没心跳,30秒后剔除该服务
client:
registerWithEureka: true
fetchRegistry: true
healthcheck.enabled: true
serviceUrl:
defaultZone: http://localhost:2100/eureka/,http://localhost:2000/eureka/
- @EnableEurekaServer : 表明当前服务是用来做注册中心的。 通过application.yml配置“eureka.client.registerWithEureka” 为true(默认) 表示否将自己注册到EurekaServer。
- @EnableEurekaClient : 表明当前服务是会注册到EurekaServer上。还有个@EnableDiscoveryClient可以使用其他注册中心
- 高可用的实现是: 在配置“eureka.client.service-url.defaultZone” 指定多个eurekaServer的地址互相注册
这里的Server和Client的概念是针对于Eureka注册中心,没有类似dubbo里具体的consumer、provider角色之分。provider提供了哪些接口Api是不体现的,所以需要通过Swagger等外部文档来记录接口方法和参数。
代码语言:javascript复制<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
<version>2.2.3.RELEASE</version>
</dependency>
组件的工作过程
客户端
- Client向Server进行服务注册; DiscoveryClient#register
- 每隔30s进行续期renew() , Server服务返回NOT_FOUND会重新注册 ; DiscoveryClient#HeartbeatThread
- 每隔30s拉取Server的注册表 ,分全量和增量, 增量数据应用后计算出的hashCode不匹配Server响应回的hash值,则发起新的全量拉取;DiscoveryClient#CacheRefreshThread
服务端
- renew() 续期,app注册信息有效时间向后延长90s 。 AbstractInstanceRegistry#renew
- register、cancel、statusUpdate 等会产生注册表信息变动的操作, 使用的是读锁控制。 直接操作注册表底层Map后,将变动的情况放到一个变动队列, 该队列被异步任务30s一次清理3分钟前的数据 。 最后会删除二级缓存中的指定key数据。
- 读请求: 使用写锁来控制一致性。 增量请求会将变动队列数据返回,同时还会返回按规则生产一个全量注册表的hashCode. 优先从只读缓存取, 取不到再从loadingCache拿, 最后才是底层注册表 (请求的key是 “查询类型”)
- 默认60s一次清除90s内还没有renew()的注册信息(但最长可能要经过2*90s才能剔除该服务)。 删除过程是: 筛选出已经过期的appList,随机删除直到留下的实例数不低于总的85%(min(expired, total - 85% * total) ) , 这个是单次任务执行的逻辑, 如果下次任务又开始了, 没有开启“失效保护”, 又会如此处理。 按javadoc上的说法: (为了补偿GC暂停或本地时间漂移导致不这么做会情况注册表, 要保留一定的基准) AbstractInstanceRegistry#EvictionTask.evict
- 自我保护特性: 在每次renew后都会计数(统计一分钟内的 ), 在EvictionTask任务逻辑一开始就来判定: 如果开启了自我保护,则判定: 该续约数小于期望续约数(注册实例数 * (60s /续约间隔30s) * 0.85)就不再删除注册信息。
三级缓存结构
最底层是在org.springframework.cloud.netflix.eureka.server.InstanceRegistry (AbstractInstanceRegistry
):
// 第一层的key是spring.application.name,第二层的key是instanceInfoId,value是服务详情和服务治理相关的属性
private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();
其他两层在响应处理类:com.netflix.eureka.registry.ResponseCacheImpl:
代码语言:txt复制// 只读缓存
private final ConcurrentMap<Key, Value> readOnlyCacheMap = new ConcurrentHashMap<Key, Value>();
//google的一个缓存框架,key不存在值则调用CacheLoader从InstanceRegistry拿并保存
private final LoadingCache<Key, Value> readWriteCacheMap;
- readWriteCacheMap : 这是一个guava提供的缓存框架。180s过期, key不存在值则执行指定的CacheLoader从InstanceRegistry拿并缓存。
- 在ResponseCacheImpl的构造函数里定义了一个Timer,每 30s一次以readOnlyCacheMap为锚定,从readWriteCacheMap匹配相同的key刷新value到readOnlyCacheMap;
在 AbstractInstanceRegistry 提供的 注册register、取消Cancel、状态更新statusUpdate 等方法里都是
- 先去:直接操作最底层的注册表“registry”。
- 然后:将改变的实例信息存放到一个队列中
recentlyChangedQueue
,这个队列只会存储最近三分钟有变化的节点信息(标记了变化类型 ActionType)。(AbstractInstanceRegistry.getDeltaRetentionTask()会每30s一次执行任务去删除队列里超过3分钟的数据) - 最后:也会 ResponseCache.invalidate 失效 readWriterCacheMap。
这个过程用的是读锁
!
ResponseCacheImpl.getValue 获取过程:
- 优先从readOnlyCacheMap获取指定key的值,
- 没有则从readWriteCacheMap获取并写入readOnlyCacheMap;
- 还是没有再执行CacheLoader从InstanceRegistry 拿并封装为Value对象(含null和“”的情况)保存。这里会执行方法AbstractInstanceRegistry.getApplicationDeltasFromMultipleRegions,它逻辑里用的是
写锁
!
Q1: 为什么要用三级缓存结构?
因为对于最底层的ConcurrentHashMap使用了读写锁,三级缓存结构也是为了减少并发下的锁竞争,增强查询性能。
Q2: 为什么写时用读锁,读时用写锁?
首先,这是一个典型的读多写少的场景。
因为是要从保存最近3分钟的增量队列recentlyChangedQueue里获取增量(变动)Delta数据记录,还要计算全量注册apps的hashCode一起响应回客户端,用读写锁来保证查询的这2步能数据一致性。
其次 ,变化队列实现是ConcurrentLinkedQueue、最底层注册表实现ConcurrentHashMap ,它两是支持并发写的, 那如果更新用的写锁, 没必要。
renew续约操作没有使用锁,那是因为它不会向最近更新队列中添加元素的,不会影响增量更新数据的拉取。
客户端注册信息同步过程
- 从server端拿服务注册列表时,也就是AbstractInstanceRegistry.getApplicationDeltasFromMultipleRegions方法的执行结果有两部分
- recentlyChangedQueue里变化的增量数据;
- Applications.getReconcileHashCode()得到一个所有注册实例数据按规则计算的hasCode。
在最后,根据全量apps的信息TreeMap排序后得到一个hashCode
- 在eurekaClient端的处理逻辑是在DiscoveryClient.fetchRegistry,分全量拉取getAndStoreFullRegistry() 和增量拉取getAndUpdateDelta(localRegionApps),这里主要看增量部分。
在DiscoveryClient.getAndUpdateDelta的逻辑里:
拿回了delta数据后,根据变化的类型来更新本地原有的localRegionApps数据,接着也会按相同规则计算本地全量注册信息allApps的hashCode,拿来和delta返回的hashCode 比对;若不一样,会执行方法reconcileAndLogDifference再次请求eurekaServer端全量拉取来过滤打散并覆盖本地缓存。
app信息过期最长需要 2*duration 才会被剔除
代码语言:yaml复制# 服务续约任务的调用间隔时间,默认为30秒
eureka.instance.lease-renewal-interval-in-seconds=30
# 服务续约的时间,默认为90秒。
eureka.instance.lease-expiration-duration-in-seconds=90
# 清理无效节点的时间间隔(单位毫秒,默认是60*1000)
eureka.server.eviction-interval-timer-in-ms=60
# 关闭自我保护
eureka.server.enable-self-preservation=false
- renew 续约自己:
DiscoveryClient#HeartbeatThread
执行renew()的间隔时间默认30s 。 EurekaServer处理new时,注册app信息InstanceInfo以当前时间为锚定,向后默认续期90s 。 - 更新注册表信息:
DiscoveryClient#CacheRefreshThread
默认30s一次 调用方法DiscoveryClient.fetchRegistry从server拉取注册信息(全量 or 增量)。 "client.refresh.interval" AbstractInstanceRegistry#EvictionTask
默认60s一次删除无效服务。
这里来看个过期问题:
EvictionTask 每隔60s来删除无效服务, 它的判定逻辑是Lease.isExpired
代码语言:java复制 public boolean isExpired(long additionalLeaseMs) {
// 当前时间要大于 上次更新时间 90s
return (evictionTimestamp > 0 || System.currentTimeMillis() > (lastUpdateTimestamp duration additionalLeaseMs));
}
AbstractInstanceRegistry#renew | statusUpdate 时执行Lease.renew():
代码语言:javascript复制 public void renew() {
lastUpdateTimestamp = System.currentTimeMillis() duration; // 更新时间是:以当前时间延长了90s
}
所以,如果1个服务在续约之后立马宕机了,这个判定过期的时间是 2*duration
自我保护机制
renew 在续约成功后会给一个 “MeasuredRate renewsLastMin” 计数值 1 , 它会统计当前1分钟内的续约成功数。
Renews thresshold > Renews(last min): 当续约阀值大于当前最后一分钟的续约数。此时Eureka将进入自我保护机制。
代码语言:java复制// AbstractInstanceRegistry
protected void updateRenewsPerMinThreshold() {
this.numberOfRenewsPerMinThreshold = (int) (this.expectedNumberOfClientsSendingRenews
* (60.0 / serverConfig.getExpectedClientRenewalIntervalSeconds())
* serverConfig.getRenewalPercentThreshold());
}
expectedNumberOfClientsSendingRenews: 注册的实例数
getExpectedClientRenewalIntervalSeconds:服务端期望的客户端续约间隔,既服务端每分钟期望接收的心跳间隔
getRenewalPercentThreshold(): 阀值系数 默认为0.85
// PeerAwareInstanceRegistryImpl
public boolean isLeaseExpirationEnabled() {
if (!isSelfPreservationModeEnabled()) {
return true;
}
// 开启失效保护后, 最近1分钟的续约值要大于阈值,则不会进入自我保护
return numberOfRenewsPerMinThreshold > 0 && getNumOfRenewsInLastMin() > numberOfRenewsPerMinThreshold;
}
即使有过期,也不是把所有过期的都删除,而是取 过期列表size 和 最大可删除(25%)的最小值, 从过期列表里随机删除。
使用方式
代码语言:txt复制server.port: 2000
spring.application.name: eureka_server # 其他服务指定Service-Id是需要用大写, 控制台显示的也是大写
eureka:
instance:
hostname: localhost
prefer-ip-address: false #Eureka默认使用 hostname 进行服务注册: eg."DESKTOP-G3BHFUS:eureka_server:2000", 以IP地址注册到服务中心,相互注册使用IP地址
instance-id: 186.198.7.24:2300 #指定向注册中心注册的服务ip地址。 ip地址也可以是"http://域名地址"(http://eureka.com). eureka后台在status处会显示这个,但访问还是取的真实IP
lease-renewal-interval-in-seconds: 30 # 服务续约任务的调用间隔时间,默认为30秒
lease-expiration-duration-in-seconds: 90 # renew续约时更新lastUpdateTimestamp = currentTimeMillis 这个duration时间
client:
registerWithEureka: true #将自己注册到EurekaServer
fetchRegistry: true
healthcheck.enabled: true
serviceUrl:
defaultZone: http://localhost:2100/eureka/,http://localhost:2000/eureka/ #高可用 互相注册
server:
enable-self-preservation: true #自我保护,
eviction-interval-timer-in-ms: 10000 # 失效实例检测任务调度间隔
#开启@FeignClient的fallback
feign:
hystrix:
enabled: true
#设置feign客户端超时时间(OpenFeign默认支持ribbon)
ribbon:
ReadTimeout: 15000
ConnectTimeout: 15000
访问: http://localhost:2000/eureka/apps 这里会有服务者真正的ipAddr (还可以访问:localhost:2000/eureka/status )
代码语言:txt复制<applications>
<versions__delta>1</versions__delta>
<apps__hashcode>UP_1_</apps__hashcode>
<application>
<name>EUREKA_SERVER</name>
<instance>
<instanceId>186.198.7.24:2300</instanceId>
<hostName>localhost</hostName>
<app>EUREKA_SERVER</app>
<ipAddr>192.168.99.1</ipAddr> <!--真正的访问ip地址-->
<status>UP</status>
<overriddenstatus>UNKNOWN</overriddenstatus>
<port enabled="true">2000</port>
<securePort enabled="false">443</securePort>
<countryId>1</countryId>
<dataCenterInfo class="com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo">
<name>MyOwn</name>
</dataCenterInfo>
<leaseInfo>
<renewalIntervalInSecs>30</renewalIntervalInSecs>
<durationInSecs>90</durationInSecs>
<registrationTimestamp>1625580417331</registrationTimestamp>
<lastRenewalTimestamp>1625580626801</lastRenewalTimestamp>
<evictionTimestamp>0</evictionTimestamp>
<serviceUpTimestamp>1625580361035</serviceUpTimestamp>
</leaseInfo>
<metadata>
<management.port>2000</management.port>
</metadata>
<homePageUrl>http://localhost:2000/</homePageUrl>
<statusPageUrl>http://localhost:2000/actuator/info</statusPageUrl>
<healthCheckUrl>http://localhost:2000/actuator/health</healthCheckUrl> <!--探活检测url-->
<vipAddress>eureka_server</vipAddress>
<secureVipAddress>eureka_server</secureVipAddress>
<isCoordinatingDiscoveryServer>true</isCoordinatingDiscoveryServer>
<lastUpdatedTimestamp>1625580417331</lastUpdatedTimestamp>
<lastDirtyTimestamp>1625580356703</lastDirtyTimestamp>
<actionType>ADDED</actionType>
</instance>
</application>
</applications>
这些个访问入口在代码:org.springframework.cloud.netflix.eureka.server.EurekaController
访问: http://localhost:2000/lastn
手动删除实例:
代码语言:txt复制curl -XDELETE http://eurekaserver地址/eureka/apps/要删除的服务名app/要删除的instanceId
postman执行 http://localhost:2000/eureka/apps/EUREKA_SERVER/186.198.7.24:2300 后 :
代码语言:txt复制2021-07-06 23:25:09.566 [http-nio-2000-exec-9] INFO [AbstractInstanceRegistry.java:internalCancel:328] [trace=20109255f7fd4e40,span=20109255f7fd4e40,parent=] c.n.e.r.AbstractInstanceRegistry - Cancelled instance EUREKA_SERVER/186.198.7.24:2300 (replication=false)
2021-07-06 23:25:18.025 [Eureka-EvictionTimer] INFO [AbstractInstanceRegistry.java:run:1247] [trace=,span=,parent=] c.n.e.r.AbstractInstanceRegistry - Running the evict task with compensationTime 0ms
2021-07-06 23:25:28.026 [Eureka-EvictionTimer] INFO [AbstractInstanceRegistry.java:run:1247] [trace=,span=,parent=] c.n.e.r.AbstractInstanceRegistry - Running the evict task with compensationTime 0ms
2021-07-06 23:25:37.413 [http-nio-2000-exec-3] WARN [AbstractInstanceRegistry.java:renew:360] [trace=95a0152e6a038ce0,span=95a0152e6a038ce0,parent=] c.n.e.r.AbstractInstanceRegistry - DS: Registry: lease doesn't exist, registering resource: EUREKA_SERVER - 186.198.7.24:2300
2021-07-06 23:25:37.413 [http-nio-2000-exec-3] WARN [InstanceResource.java:renewLease:116] [trace=95a0152e6a038ce0,span=95a0152e6a038ce0,parent=] c.n.e.r.InstanceResource - Not Found (Renew): EUREKA_SERVER - 186.198.7.24:2300
2021-07-06 23:25:37.414 [DiscoveryClient-HeartbeatExecutor-0] INFO [DiscoveryClient.java:renew:878] [trace=,span=,parent=] c.n.d.DiscoveryClient - DiscoveryClient_EUREKA_SERVER/186.198.7.24:2300 - Re-registering apps/EUREKA_SERVER
2021-07-06 23:25:37.414 [DiscoveryClient-HeartbeatExecutor-0] INFO [DiscoveryClient.java:register:854] [trace=,span=,parent=] c.n.d.DiscoveryClient - DiscoveryClient_EUREKA_SERVER/186.198.7.24:2300: registering service...
2021-07-06 23:25:37.420 [http-nio-2000-exec-4] INFO [AbstractInstanceRegistry.java:register:266] [trace=c3080a2e264ec1cd,span=c3080a2e264ec1cd,parent=] c.n.e.r.AbstractInstanceRegistry - Registered instance EUREKA_SERVER/186.198.7.24:2300 with status UP (replication=false)
2021-07-06 23:25:37.422 [DiscoveryClient-HeartbeatExecutor-0] INFO [DiscoveryClient.java:register:863] [trace=,span=,parent=] c.n.d.DiscoveryClient - DiscoveryClient_EUREKA_SERVER/186.198.7.24:2300 - registration status: 204
2021-07-06 23:25:38.027 [Eureka-EvictionTimer] INFO [AbstractInstanceRegistry.java:run:1247] [trace=,span=,parent=] c.n.e.r.AbstractInstanceRegistry - Running the evict task with compensationTime 0ms
从执行日志里可以看到:
- Cancel完成了, 但该服务又被Renew是注册回来了。
- AbstractInstanceRegistry 执行的EvictionTask每隔10s一次,和配置文件中的配置值是一致的。