目錄
Eureka是Netflix組件的一個子模塊,也是核心模塊之一。雲端服務發現,一個基於 REST 的服務,用於定位服務,以實現雲端中間層服務發現和故障轉移(來源springcloud中文網的介紹:https://www.springcloud.cc/)。下圖總結了Eureka服務端(以下簡稱服務端)與Eureka客戶端(以下簡稱客戶端)之間協同工作的流程:
流程說明:
- Eureka客戶端(以下簡稱客戶端)啓動後,定時向Eureka服務端(以下簡稱服務端)註冊自己的服務信息(服務名、IP、端口等);
- 客戶端啓動後,定時拉取服務端以保存的服務註冊信息;
- 拉取服務端保存的服務註冊信息後,就可調用消費其他服務提供者提供的服務。
雖然流程比較簡單,但是在這樣的簡單流程下,Eureak究竟做了哪些工作,我們會有如下問題:
- 客戶端啓動時如何註冊到服務端?
- 服務端如何保存客戶端服務信息?
- 客戶端如何拉取服務端已保存的服務信息(是需要使用的時候再去拉取,還是先拉取保存本地,使用的時候直接從本地獲取)?
- 如何構建高可用的Eureka集羣?
- 心跳和服務剔除機制是什麼?
- Eureka自我保護機制是什麼?
要徹底搞清楚Eureka的工作流程,必須需要弄清楚這些問題,也是面試中常遇到的問題,接下來我將結合源碼的方式對這些問題一一解答,源碼版本說明:
- springboot:2.2.1.RELEASE
- springcloud:Hoxton.SR1
- Eureka:1.9.13
1、客戶端啓動時如何註冊到服務端
1.1、源碼分析
Eureka客戶端在啓動後,會創建一些定時任務,其中就有一個任務heartbeatExecutor就是就是處理心跳的線程池,部分源碼(源碼位置:com.netflix.discovery.DiscoveryClient)如下:
heartbeatExecutor = new ThreadPoolExecutor(
1, clientConfig.getHeartbeatExecutorThreadPoolSize(), 0, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
new ThreadFactoryBuilder()
.setNameFormat("DiscoveryClient-HeartbeatExecutor-%d")
.setDaemon(true)
.build()
); // use direct handoff
...此處省略其他代碼
//finally, init the schedule tasks (e.g. cluster resolvers, heartbeat, instanceInfo replicator, fetch
initScheduledTasks();
查看方法initScheduledTasks以及註釋,可知該方法是初始化所有的任務(schedule tasks)。
/**
* Initializes all scheduled tasks.
*/
private void initScheduledTasks() {
...
// Heartbeat timer
scheduler.schedule(
new TimedSupervisorTask(
"heartbeat",
scheduler,
heartbeatExecutor,
renewalIntervalInSecs,
TimeUnit.SECONDS,
expBackOffBound,
new HeartbeatThread()
),
renewalIntervalInSecs, TimeUnit.SECONDS);
...
}
在上述方法中,第15行創建了一個線程HeartbeatThread,該線程就是處理心跳任務:
/**
* The heartbeat task that renews the lease in the given intervals.
*/
private class HeartbeatThread implements Runnable {
public void run() {
if (renew()) {
lastSuccessfulHeartbeatTimestamp = System.currentTimeMillis();
}
}
}
/**
* Renew with the eureka service by making the appropriate REST call
*/
boolean renew() {
EurekaHttpResponse<InstanceInfo> httpResponse;
try {
httpResponse = eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(), instanceInfo.getId(), instanceInfo, null);
logger.debug(PREFIX + "{} - Heartbeat status: {}", appPathIdentifier, httpResponse.getStatusCode());
if (httpResponse.getStatusCode() == Status.NOT_FOUND.getStatusCode()) {
REREGISTER_COUNTER.increment();
logger.info(PREFIX + "{} - Re-registering apps/{}", appPathIdentifier, instanceInfo.getAppName());
long timestamp = instanceInfo.setIsDirtyWithTime();
boolean success = register();
if (success) {
instanceInfo.unsetIsDirty(timestamp);
}
return success;
}
return httpResponse.getStatusCode() == Status.OK.getStatusCode();
} catch (Throwable e) {
logger.error(PREFIX + "{} - was unable to send heartbeat!", appPathIdentifier, e);
return false;
}
}
在renew方法中,首先會發送一個心跳數據到服務端,服務端返回一個狀態碼,如果是NOT_FOUND(即404),表示Eureka服務端不存在該客戶端的服務信息,那麼就會向服務端發起註冊請求(上面代碼25行調用register方法):
boolean register() throws Throwable {
logger.info(PREFIX + "{}: registering service...", appPathIdentifier);
EurekaHttpResponse<Void> httpResponse;
try {
httpResponse = eurekaTransport.registrationClient.register(instanceInfo);
} catch (Exception e) {
logger.warn(PREFIX + "{} - registration failed {}", appPathIdentifier, e.getMessage(), e);
throw e;
}
if (logger.isInfoEnabled()) {
logger.info(PREFIX + "{} - registration status: {}", appPathIdentifier, httpResponse.getStatusCode());
}
return httpResponse.getStatusCode() == Status.NO_CONTENT.getStatusCode();
}
在register方法中,向服務端的註冊信息instanceInfo,它是com.netflix.appinfo.InstanceInfo,包括服務名、ip、端口、唯一實例ID等信息。
1.2、總結
Eureka客戶端在啓動時,首先會創建一個心跳的定時任務,定時向服務端發送心跳信息,服務端會對客戶端心跳做出響應,如果響應狀態碼爲404時,表示服務端沒有該客戶端的服務信息,那麼客戶端則會向服務端發送註冊請求,註冊信息包括服務名、ip、端口、唯一實例ID等信息。
2、服務端如何保存客戶端服務信息
2.1、源碼分析
服務端註冊源碼(com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl.class的方法register)如下:
@Override
public void register(final InstanceInfo info, final boolean isReplication) {
int leaseDuration = Lease.DEFAULT_DURATION_IN_SECS;
if (info.getLeaseInfo() != null && info.getLeaseInfo().getDurationInSecs() > 0) {
leaseDuration = info.getLeaseInfo().getDurationInSecs();
}
super.register(info, leaseDuration, isReplication);
replicateToPeers(Action.Register, info.getAppName(), info.getId(), info, null, isReplication);
}
第7行調用了父類(com.netflix.eureka.registry.AbstractInstanceRegistry)register方法,源碼如下:
public abstract class AbstractInstanceRegistry implements InstanceRegistry {
...
private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry
= new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();
...
public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
try {
read.lock();
Map<String, Lease<InstanceInfo>> gMap = registry.get(registrant.getAppName());
REGISTER.increment(isReplication);
if (gMap == null) {
final ConcurrentHashMap<String, Lease<InstanceInfo>> gNewMap = new ConcurrentHashMap<String, Lease<InstanceInfo>>();
gMap = registry.putIfAbsent(registrant.getAppName(), gNewMap);
if (gMap == null) {
gMap = gNewMap;
}
}
...
}
}
在register方法中,我們可以看到將服務實例信息InstanceInfo註冊到了register變量中,它其實就是一個ConcurrentHashMap。
2.2、總結
客戶端通過Jersey框架(亞馬遜的一個http框架)將服務實例信息發送到服務端,服務端將客戶端信息放在一個ConcurrentHashMap對象中。
3、客戶端如何拉取服務端已保存的服務信息
在知道客戶端是如何拉取服務端信息的同時,也需要清楚以下問題:
是需要時纔去服務端拉取,還是先拉取到本地,需要用的時候直接從本地獲取?
3.1、源碼分析
我們回到問題1的1.1節的initScheduledTasks方法中:
private void initScheduledTasks() {
if (clientConfig.shouldFetchRegistry()) {
// registry cache refresh timer
int registryFetchIntervalSeconds = clientConfig.getRegistryFetchIntervalSeconds();
int expBackOffBound = clientConfig.getCacheRefreshExecutorExponentialBackOffBound();
scheduler.schedule(
new TimedSupervisorTask(
"cacheRefresh",
scheduler,
cacheRefreshExecutor,
registryFetchIntervalSeconds,
TimeUnit.SECONDS,
expBackOffBound,
new CacheRefreshThread()
),
registryFetchIntervalSeconds, TimeUnit.SECONDS);
}
...
}
上述代碼中初始化了一個刷新緩存的定時任務,我們看到第14行的新建了一個線程CacheRefreshThread(源碼不再列出),既是用來定時刷新服務端已保存的服務信息。
3.2、總結
通過3.1節源碼總結:客戶端拉取服務端服務信息是通過一個定時任務定時拉取的,每次拉取後刷新本地已保存的信息,需要使用時直接從本地直接獲取。
4、如何構建高可用的Eureka集羣
首先,搭建一個高可用的Eureka集羣,只需要在每個註冊中心(服務端)通過配置:
eureka.client.service-url.defaultZone
指定其他服務端的地址,多個使用逗號隔開,如:
eureka.client.service-url.defaultZone=http://localhost:10000/eureka/,http://localhost:10001/eureka/,http://localhost:10002/eureka/
在eureka的高可用狀態下,這些註冊中心是對等的,他們會互相將註冊在自己的實例同步給其他的註冊中心,同樣是通過問題1的方式將註冊在自己上的實例註冊到其他註冊中心去。
那麼問題來了,一旦 其中一個eureka收到一個客戶端註冊實例時,既然eureka註冊中心將註冊在自己的實例同步到其他註冊中心中的方式和客戶端註冊的方式相同,那麼在接收的eureka註冊中心一端,會不會再同步回給註冊中心(或者其他註冊中心),從而導致死循環。
4.1、源碼解析
我們回到2.1節的PeerAwareInstanceRegistryImpl類的register方法,在該方法中的最後一行:
replicateToPeers(Action.Register, info.getAppName(), info.getId(), info, null, isReplication);
replicateToPeers方法字面意思是同步或者複製到同事(即其他對等的註冊中心),最後一個參數爲isReplication,是一個boolean值,表示是否同步(複製),如果是客戶端註冊的,那麼爲false,如果是其他註冊中心同步的則爲true,replicateToPeers方法中,如果isReplication=false時,將會發起同步(第19行):
private void replicateToPeers(Action action, String appName, String id,
InstanceInfo info /* optional */,
InstanceStatus newStatus /* optional */, boolean isReplication) {
Stopwatch tracer = action.getTimer().start();
try {
if (isReplication) {
numberOfReplicationsLastMin.increment();
}
// If it is a replication already, do not replicate again as this will create a poison replication
if (peerEurekaNodes == Collections.EMPTY_LIST || isReplication) {
return;
}
for (final PeerEurekaNode node : peerEurekaNodes.getPeerEurekaNodes()) {
// If the url represents this host, do not replicate to yourself.
if (peerEurekaNodes.isThisMyUrl(node.getServiceUrl())) {
continue;
}
replicateInstanceActionsToPeers(action, appName, id, info, newStatus, node);
}
} finally {
tracer.stop();
}
}
4.2、總結
- 搭建高可用的Eureka集羣,只需要在註冊中心的配置文件中配置其他註冊中心的地址,配置屬性如下:
eureka.client.service-url.defaultZone
- 註冊中心收到註冊信息後會判斷是否是其他註冊中心同步的信息還是客戶端註冊的信息,如果是客戶端註冊的信息,那麼他將會將該客戶端信息同步到其他註冊中心去;否則收到信息後不作任何操作。通過此機制避免集羣中信息同步的死循環。
5、心跳和服務剔除機制是什麼
5.1、源碼分析
在eureka源碼中,有個evict(剔除,驅逐,源碼位置:com.netflix.eureka.registry.AbstractInstanceRegistry,代碼清單5.1)的方法:
public void evict(long additionalLeaseMs) {
logger.debug("Running the evict task");
if (!isLeaseExpirationEnabled()) {
logger.debug("DS: lease expiration is currently disabled.");
return;
}
// We collect first all expired items, to evict them in random order. For large eviction sets,
// if we do not that, we might wipe out whole apps before self preservation kicks in. By randomizing it,
// the impact should be evenly distributed across all applications.
List<Lease<InstanceInfo>> expiredLeases = new ArrayList<>();
for (Entry<String, Map<String, Lease<InstanceInfo>>> groupEntry : registry.entrySet()) {
Map<String, Lease<InstanceInfo>> leaseMap = groupEntry.getValue();
if (leaseMap != null) {
for (Entry<String, Lease<InstanceInfo>> leaseEntry : leaseMap.entrySet()) {
Lease<InstanceInfo> lease = leaseEntry.getValue();
if (lease.isExpired(additionalLeaseMs) && lease.getHolder() != null) {
expiredLeases.add(lease);
}
}
}
}
// To compensate for GC pauses or drifting local time, we need to use current registry size as a base for
// triggering self-preservation. Without that we would wipe out full registry.
int registrySize = (int) getLocalRegistrySize();
int registrySizeThreshold = (int) (registrySize * serverConfig.getRenewalPercentThreshold());
int evictionLimit = registrySize - registrySizeThreshold;
int toEvict = Math.min(expiredLeases.size(), evictionLimit);
if (toEvict > 0) {
logger.info("Evicting {} items (expired={}, evictionLimit={})", toEvict, expiredLeases.size(), evictionLimit);
Random random = new Random(System.currentTimeMillis());
for (int i = 0; i < toEvict; i++) {
// Pick a random item (Knuth shuffle algorithm)
int next = i + random.nextInt(expiredLeases.size() - i);
Collections.swap(expiredLeases, i, next);
Lease<InstanceInfo> lease = expiredLeases.get(i);
String appName = lease.getHolder().getAppName();
String id = lease.getHolder().getId();
EXPIRED.increment();
logger.warn("DS: Registry: expired lease for {}/{}", appName, id);
internalCancel(appName, id, false);
}
}
}
在上述代碼第4行,做了isLeaseExpirationEnabled(字面意思:是否啓用租約到期,即是否開啓了服務過期超時機制,開啓之後就會將過期的服務進行剔除)的if判斷,源碼(com.netflix.eureka.registry
.PeerAwareInstanceRegistryImpl實現類中,代碼清單5.2)如下:
@Override
public boolean isLeaseExpirationEnabled() {
if (!isSelfPreservationModeEnabled()) {
// The self preservation mode is disabled, hence allowing the instances to expire.
return true;
}
return numberOfRenewsPerMinThreshold > 0 && getNumOfRenewsInLastMin() > numberOfRenewsPerMinThreshold;
}
同樣在上述方法開始的第3行也做了isSelfPreservationModeEnabled方法的判斷,該方法是判斷是否開啓了自我保護機制(有關自我保護機制有關說明在第6節),接下來看到第4行的註釋翻譯如下:
自保存模式被禁用,因此允許實例過期
也就是說如果關閉了自我保護機制,那麼直接就允許實例過期,也就是說可以將過期的服務實例剔除。那如果開啓了自我保護機制,會做如下判斷(代碼清單5.3):
numberOfRenewsPerMinThreshold > 0 && getNumOfRenewsInLastMin() > numberOfRenewsPerMinThreshold
getNumOfRenewsInLastMin即最後一分鐘接收到的心跳總數,numberOfRenewsPerMinThreshold 表示收到一分鐘內收到服務心跳數臨界值(後簡稱臨界值),也就是說當臨界值大於0,且最後一分鐘接收到的心跳總數大於臨界值時,允許實例過期,他的計算方式源碼如下(代碼清單5.4):
protected void updateRenewsPerMinThreshold() {
this.numberOfRenewsPerMinThreshold = (int) (this.expectedNumberOfClientsSendingRenews
* (60.0 / serverConfig.getExpectedClientRenewalIntervalSeconds())
* serverConfig.getRenewalPercentThreshold());
}
其中:
- this.expectedNumberOfClientsSendingRenews:接收到的客戶端數量
- serverConfig.getExpectedClientRenewalIntervalSeconds():客戶端發送心跳時間的間隔,默認是30秒
- serverConfig.getRenewalPercentThreshold():一個百分比率閾值,默認是0.85,可以通過配置修改
從上述代碼的計算方法可以看出:
一分鐘內收到服務心跳數臨界值 = 客戶端數量 * (60/心跳時間間隔) * 比率
帶入默認值:
一分鐘內收到服務心跳數臨界值 = 客戶端數量 * (60/30) * 0.85 = 客戶端數量 * 1.7
所以假如有總共有10個客戶端,那麼表示一分鐘至少需要收到17次心跳。
所以代碼清單5.3的解析就是,如果開啓只我保護機制,那麼一分鐘內收到的心跳數大於一分鐘內收到服務心跳數臨界值時,則啓用租約到期機制,即服務剔除機制。
那麼最終回到代碼清單5.1的第4行的if判斷,即如果沒有啓用服務剔除機制(即開啓了自我保護機制或者一分鐘收到的心跳數小於臨界值),那麼直接return結束,不做任何操作。否則代碼繼續運行,從代碼的第9行註釋到最後,可以看出先跳出已過期的服務實例,然後通過隨機數的方式將這些已過期的實例進行剔除。
5.2、總結
心跳機制:
- 客戶端啓動後,就會啓動一個定時任務,定時向服務端發送心跳數據,告知服務端自己還活着,默認的心跳時間間隔是30秒。
服務剔除機制:
- 如果開啓了自我保護機制,那麼所有的服務,包括長時間沒有收到心跳的服務(即已過期的服務)都不會被剔除;
- 如果未開啓自我保護機制,那麼將判斷最後一分鐘收到的心跳數與一分鐘收到心跳數臨界值(計算方法參考5.1節)比較,如果前者大於後者,且後者大於0的話,則啓用服務剔除機制;
- 一旦服務剔除機制開啓,則Eureka服務端並不會直接剔除所有已過期的服務,而是通過隨機數的方式進行剔除,避免自我保護開啓之前將所有的服務(包括正常的服務)給剔除。
6、Eureka自我保護機制是什麼
由於在第5節中已經提到了有關Eureka自我保護機制的用途以及它在服務剔除機制中起到的作用,這裏不再結合源碼分析,這裏分析Eureka爲什麼要採用自我保護機制。
在分佈式系統的CAP理論中,Eureka採用的AP,也就是Eureak保證了服務的可用性(A),而捨棄了數據的一致性(C)。當網絡發生分區時,客戶端和服務端的通訊將會終止,那麼服務端在一定的時間內將收不到大部分的客戶端的一個心跳,如果這個時候將這些收不到心跳的服務剔除,那可能會將可用的客戶端剔除了,這就不符合AP理論。