前言
前情回顧
上一講我們講了 client端向server端發送心跳檢查,也是默認每30鍾發送一次,server端接收后會更新注冊表的一個時間戳屬性,然后一次心跳(續約)也就完成了。
本講目錄
這一篇有兩個知識點及一個疑問,這個疑問是在工作中真真實實遇到過的。
例如我有服務A、服務B,A、B都注冊在同一個注冊中心,當B下線后,A多久能感知到B已經下線了呢?
不知道大家有沒有這個困惑,這篇文章最后會對此問題答疑,如果能夠看到文章的結尾,或許你就知道答案了,當然答案也會在結尾揭曉。
目錄如下:
- Client端服務實例下線通知Server端
- Server端定時任務 服務摘除
技術亮點:定時任務錯誤觸發時間補償機制
在Server端定時任務進行服務故障自動感知摘除的時候有一個設計很巧妙的點,時間補償機制。
我們知道,在做定時任務的時候,基於某個固定點觸發的操作都可能由於一些其他原因導致固定的點沒有執行對應的操作,這時再次執行定時操作后,計算的每次任務相隔時間就會出現問題。而Eureka 這里采用了一種補償機制,再計算時間差值的時候完美解決此問題。
說明
原創不易,如若轉載 請標明來源:一枝花算不算浪漫
源碼分析
Client端服務實例下線通知Server端
Client下線 我們還是依照之前的原則,從DiscoveryClient
看起,可以看到有一個shutdown()
方法,然后接着跟一下這個方法:
@PUT
public Response renewLease(
@HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication,
@QueryParam("overriddenstatus") String overriddenStatus,
@QueryParam("status") String status,
@QueryParam("lastDirtyTimestamp") String lastDirtyTimestamp) {
boolean isFromReplicaNode = "true".equals(isReplication);
boolean isSuccess = registry.renew(app.getName(), id, isFromReplicaNode);
// 省略部分代碼
logger.debug("Found (Renew): {} - {}; reply status={}" + app.getName(), id, response.getStatus());
return response;
}
public boolean renew(String appName, String id, boolean isReplication) {
RENEW.increment(isReplication);
Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
Lease<InstanceInfo> leaseToRenew = null;
if (gMap != null) {
leaseToRenew = gMap.get(id);
}
if (leaseToRenew == null) {
RENEW_NOT_FOUND.increment(isReplication);
logger.warn("DS: Registry: lease doesn't exist, registering resource: {} - {}", appName, id);
return false;
} else {
InstanceInfo instanceInfo = leaseToRenew.getHolder();
if (instanceInfo != null) {
// touchASGCache(instanceInfo.getASGName());
InstanceStatus overriddenInstanceStatus = this.getOverriddenInstanceStatus(
instanceInfo, leaseToRenew, isReplication);
if (overriddenInstanceStatus == InstanceStatus.UNKNOWN) {
logger.info("Instance status UNKNOWN possibly due to deleted override for instance {}"
+ "; re-register required", instanceInfo.getId());
RENEW_NOT_FOUND.increment(isReplication);
return false;
}
if (!instanceInfo.getStatus().equals(overriddenInstanceStatus)) {
Object[] args = {
instanceInfo.getStatus().name(),
instanceInfo.getOverriddenStatus().name(),
instanceInfo.getId()
};
logger.info(
"The instance status {} is different from overridden instance status {} for instance {}. "
+ "Hence setting the status to overridden status", args);
instanceInfo.setStatusWithoutDirty(overriddenInstanceStatus);
}
}
renewsLastMin.increment();
leaseToRenew.renew();
return true;
}
}
代碼也很簡單,做一些資源釋放,取消調度任等操作,這里主要還是關注的是通知Server端的邏輯,及Server端是如何做實例下線的。這里請求Server端請求主要看下unregister
方法,這里是調用jersey中的cancel
方法,調用Server端ApplicationsResource
中的@DELETE
請求。(看到這里,前面看到各種client端調用server端,都是通過請求方式來做restful風格調用的,這里不僅要感嘆 妙啊)
我們到Server端看下接收請求的入口代碼:
InstanceResource.cancelLease()
:
@DELETE
public Response cancelLease(
@HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication) {
boolean isSuccess = registry.cancel(app.getName(), id,
"true".equals(isReplication));
if (isSuccess) {
logger.debug("Found (Cancel): " + app.getName() + " - " + id);
return Response.ok().build();
} else {
logger.info("Not Found (Cancel): " + app.getName() + " - " + id);
return Response.status(Status.NOT_FOUND).build();
}
}
然后接着往下跟,AbstractInstanceRegistry.internalCancel
方法:
protected boolean internalCancel(String appName, String id, boolean isReplication) {
try {
read.lock();
CANCEL.increment(isReplication);
// 通過appName獲取注冊表信息
Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
Lease<InstanceInfo> leaseToCancel = null;
if (gMap != null) {
// 通過實例id將注冊信息從注冊表中移除
leaseToCancel = gMap.remove(id);
}
// 最近取消的注冊表信息隊列添加該注冊表信息
synchronized (recentCanceledQueue) {
recentCanceledQueue.add(new Pair<Long, String>(System.currentTimeMillis(), appName + "(" + id + ")"));
}
InstanceStatus instanceStatus = overriddenInstanceStatusMap.remove(id);
if (instanceStatus != null) {
logger.debug("Removed instance id {} from the overridden map which has value {}", id, instanceStatus.name());
}
if (leaseToCancel == null) {
CANCEL_NOT_FOUND.increment(isReplication);
logger.warn("DS: Registry: cancel failed because Lease is not registered for: {}/{}", appName, id);
return false;
} else {
// 執行下線操作的cancel方法
leaseToCancel.cancel();
InstanceInfo instanceInfo = leaseToCancel.getHolder();
String vip = null;
String svip = null;
if (instanceInfo != null) {
instanceInfo.setActionType(ActionType.DELETED);
// 最近更新的隊列中加入此服務實例信息
recentlyChangedQueue.add(new RecentlyChangedItem(leaseToCancel));
instanceInfo.setLastUpdatedTimestamp();
vip = instanceInfo.getVIPAddress();
svip = instanceInfo.getSecureVipAddress();
}
// 使注冊表的讀寫緩存失效
invalidateCache(appName, vip, svip);
logger.info("Cancelled instance {}/{} (replication={})", appName, id, isReplication);
return true;
}
} finally {
read.unlock();
}
}
接着看 Lease.cancel
:
public void cancel() {
// 這里只是更新服務實例中下線的時間戳
if (evictionTimestamp <= 0) {
evictionTimestamp = System.currentTimeMillis();
}
}
這里已經加了注釋,再總結下:
1、加上讀鎖,支持多服務實例下線
2、通過appName獲取注冊表信息map
3、通過appId移除對應注冊表信息
4、recentCanceledQueue添加該服務實例
5、更新Lease中的服務實例下線時間
6、recentlyChangedQueue添加該服務實例
7、invalidateCache() 使注冊表的讀寫緩存失效
這里針對於6、7再解釋一下,我們在第八講:【一起學源碼-微服務】Nexflix Eureka 源碼八:EurekaClient服務發現之注冊表抓取 精妙設計分析! 中講過,當client端第一次進行增量注冊表抓取的時候,是會從recentlyChangedQueue中獲取數據的,然后放入到讀寫緩存,然后再同步到只讀緩存,下次再獲取的時候直接從只讀緩存獲取即可。
這里會存在一個問題,如果一個服務下線了,讀寫緩存更新了,但是只讀緩存並未更新,30s后由定時任務刷新 讀寫緩存的數據到了只讀緩存,這時其他客戶端才會感知到該下線的服務實例。
配合文字說明這里加一個EurekaClient下線流程圖,紅色線是下線邏輯,黑色線是抓取注冊表 感知服務下線邏輯:
記住一點,這里是正常的服務下線,走shutdown邏輯,如果一個服務突然自己宕機了,那么注冊中心怎么去自動感知這個服務下線呢?緊接着往下看吧。
Server端定時任務 服務摘除
舉例一個場景,上面也說過,一個Client服務端自己掛掉了,並沒有正常的去執行shutdown方法,那么注冊中心該如何感知這個服務實例下線了並從注冊表摘除這個實例呢?
我們知道,eureka靠心跳機制來感知服務實例是否還存活着,如果某個服務掛掉了是不會再發送心跳過來了,如果在一段時間內沒有接收到某個服務的心跳,那么就將這個服務實例給摘除掉,認為這個服務實例以及宕機了。
這里自動檢測服務實例是否宕機的入口在:EurekaBootStrap
,eureka server在啟動初始化的時候,有個方法registry.openForTraffic(applicationInfoManager, registryCount)
里面會有一個服務實例檢測的調度任務(這個入口真的很隱蔽,網上查了別人的分析才找到),接着直接看代碼吧。
EurekaBootStrap.initEurekaServerContext()
:
protected void initEurekaServerContext() throws Exception {
// 省略部分代碼...
int registryCount = registry.syncUp();
registry.openForTraffic(applicationInfoManager, registryCount);
}
這里的代碼前面看過很多次,syncUp
是獲取其他EurekaServer中注冊表數據,然后拿到注冊表中服務實例registryCount
,然后和自己本地注冊表服務實例數量進行對比等等。
接着是openForTraffic方法,這里會計算預期的1分鍾所有服務實例心跳次數expectedNumberOfRenewsPerMin
(插個眼,后面eureka server自我保護機制會用到這個屬性)后面會詳細講解,而且這里設置還是有bug的。
在方法的最后會有一個:super.postInit();
到了這里才是真正的服務實例自動感知的調度任務邏輯。兜兜轉轉 在這個不起眼的地方 隱藏了這么重要的邏輯。
PeerAwareInstanceRegistryImpl.java
:
public int syncUp() {
// Copy entire entry from neighboring DS node
int count = 0;
for (int i = 0; ((i < serverConfig.getRegistrySyncRetries()) && (count == 0)); i++) {
if (i > 0) {
try {
Thread.sleep(serverConfig.getRegistrySyncRetryWaitMs());
} catch (InterruptedException e) {
logger.warn("Interrupted during registry transfer..");
break;
}
}
Applications apps = eurekaClient.getApplications();
for (Application app : apps.getRegisteredApplications()) {
for (InstanceInfo instance : app.getInstances()) {
try {
// isRegisterable:是否可以在當前服務實例所在的注冊中心注冊。這個方法一定返回true,那么count就是相鄰注冊中心所有服務實例數量
if (isRegisterable(instance)) {
register(instance, instance.getLeaseInfo().getDurationInSecs(), true);
count++;
}
} catch (Throwable t) {
logger.error("During DS init copy", t);
}
}
}
}
return count;
}
@Override
public void openForTraffic(ApplicationInfoManager applicationInfoManager, int count) {
// Renewals happen every 30 seconds and for a minute it should be a factor of 2.
// 如果有20個服務實例,乘以2 代表需要40次心跳
// 這里有bug,count * 2 是硬編碼,作者是不是按照心跳時間30秒計算的?所以計算一分鍾得心跳就是 * 2,但是心跳時間是可以自己配置修改的
// 看了master源碼,這一塊已經改為:
/**
* this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews + 1;
* updateRenewsPerMinThreshold();
*
* 主要是看 updateRenewsPerMinThreshold 方法:
* this.numberOfRenewsPerMinThreshold = (int) (this.expectedNumberOfClientsSendingRenews * (60.0 / serverConfig.getExpectedClientRenewalIntervalSeconds() * serverConfig.getRenewalPercentThreshold());
* 這里完全是讀取用戶自己配置的心跳檢查時間,然后用60s / 配置時間
*/
this.expectedNumberOfRenewsPerMin = count * 2;
// numberOfRenewsPerMinThreshold = count * 2 * 0.85 = 34 期望一分鍾 20個服務實例,得有34個心跳
this.numberOfRenewsPerMinThreshold =
(int) (this.expectedNumberOfRenewsPerMin * serverConfig.getRenewalPercentThreshold());
logger.info("Got " + count + " instances from neighboring DS node");
logger.info("Renew threshold is: " + numberOfRenewsPerMinThreshold);
this.startupTime = System.currentTimeMillis();
if (count > 0) {
this.peerInstancesTransferEmptyOnStartup = false;
}
DataCenterInfo.Name selfName = applicationInfoManager.getInfo().getDataCenterInfo().getName();
boolean isAws = Name.Amazon == selfName;
if (isAws && serverConfig.shouldPrimeAwsReplicaConnections()) {
logger.info("Priming AWS connections for all replicas..");
primeAwsReplicas(applicationInfoManager);
}
logger.info("Changing status to UP");
applicationInfoManager.setInstanceStatus(InstanceStatus.UP);
// 此方法會做服務實例的自動摘除任務
super.postInit();
}
-
關於
syncUp
方法,這里知道它是獲取其他服務注冊表信息,然后獲取注冊實例數量就行了,后面還會有更詳細的講解。 -
接着
openForTraffic
方法,第一行代碼:this.expectedNumberOfRenewsPerMin = count * 2;
這個count是相鄰注冊表中所有服務實例數量,至於乘以2 是什么意思呢? 首先是這個字段的含義是:期待的一分鍾所有服務實例心跳次數,因為服務續約renew 默認是30s執行一次,所以這里就想當然一分鍾就乘以2了。 -
大家看出來了吧?這是個很明顯的bug。因為續約時間是可配置的,如果手動配置成10s,那么這里乘以6才對。看了下公司代碼 spring-cloud版本是
Finchley.RELEASE
, 其中以來的netflix eureka 是1.9.2
仍然存在這個問題。 -
我也翻看了master分支的代碼,此bug已經修復了,修改如下:
其實這一塊還有很多bug,包括服務注冊、下線 用的都是+2 -2操作,后面一篇文章會有更多講解。
繼續看服務實例自動感知的調度任務:
AbstractInstanceRegistry.java
:
protected void postInit() {
renewsLastMin.start();
if (evictionTaskRef.get() != null) {
evictionTaskRef.get().cancel();
}
evictionTaskRef.set(new EvictionTask());
evictionTimer.schedule(evictionTaskRef.get(),
serverConfig.getEvictionIntervalTimerInMs(),
serverConfig.getEvictionIntervalTimerInMs());
}
class EvictionTask extends TimerTask {
private final AtomicLong lastExecutionNanosRef = new AtomicLong(0l);
@Override
public void run() {
try {
// 獲取補償時間 可能大於0
long compensationTimeMs = getCompensationTimeMs();
logger.info("Running the evict task with compensationTime {}ms", compensationTimeMs);
evict(compensationTimeMs);
} catch (Throwable e) {
logger.error("Could not run the evict task", e);
}
}
/**
* compute a compensation time defined as the actual time this task was executed since the prev iteration,
* vs the configured amount of time for execution. This is useful for cases where changes in time (due to
* clock skew or gc for example) causes the actual eviction task to execute later than the desired time
* according to the configured cycle.
*/
long getCompensationTimeMs() {
// 第一次進來先獲取當前時間 currNanos=20:00:00
// 第二次過來,此時currNanos=20:01:00
// 第三次過來,currNanos=20:03:00才過來,本該60s調度一次的,由於fullGC或者其他原因,到了這個時間點沒執行
long currNanos = getCurrentTimeNano();
// 獲取上一次這個EvictionTask執行的時間 getAndSet :以原子方式設置為給定值,並返回以前的值
// 第一次 將20:00:00 設置到lastNanos,然后return 0
// 第二次過來后,拿到的lastNanos為20:00:00
// 第三次過來,拿到的lastNanos為20:01:00
long lastNanos = lastExecutionNanosRef.getAndSet(currNanos);
if (lastNanos == 0l) {
return 0l;
}
// 第二次進來,計算elapsedMs = 60s
// 第三次進來,計算elapsedMs = 120s
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(currNanos - lastNanos);
// 第二次進來,配置的服務驅逐間隔默認時間為60s,計算的補償時間compensationTime=0
// 第三次進來,配置的服務驅逐間隔默認時間為60s,計算的補償時間compensationTime=60s
long compensationTime = elapsedMs - serverConfig.getEvictionIntervalTimerInMs();
return compensationTime <= 0l ? 0l : compensationTime;
}
long getCurrentTimeNano() { // for testing
return System.nanoTime();
}
}
-
這里執行
postInit
方法,然后執行EvictionTask
任務,執行時間是serverConfig.getEvictionIntervalTimerInMs()
默認是60s執行一次。 -
接着調用
EvictionTask
,這里也加了一些注釋,我們再來分析一下。
2.1 首先是獲取補償時間,compenstationTimeMs,這個時間很關鍵
2.2 調用evict
方法,摘除過期沒有發送心跳的實例
查看getCompensationTimeMs
方法,這里我添加了很詳細的注釋,這個方法主要是 為了防止 定時任務觸發點,服務因為某些原因沒有執行該調度任務,此時elapsedMs
會超過60s的,最后返回的compensationTime
就是實際延誤且需要補償的時間。
接着再看下evict
邏輯:
public void evict(long additionalLeaseMs) {
// 是否允許主動刪除宕機節點數據,這里判斷是否進入自我保護機制,如果是自我保護了則不允許摘除服務
if (!isLeaseExpirationEnabled()) {
logger.debug("DS: lease expiration is currently disabled.");
return;
}
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);
}
}
}
}
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);
}
}
}
public boolean isLeaseExpirationEnabled() {
if (!isSelfPreservationModeEnabled()) {
// The self preservation mode is disabled, hence allowing the instances to expire.
return true;
}
// 這行代碼觸發自我保護機制,期望的一分鍾要有多少次心跳發送過來,所有服務實例一分鍾得發送多少次心跳
// getNumOfRenewsInLastMin 上一分鍾所有服務實例一共發送過來多少心跳,10次
// 如果上一分鍾 的心跳次數太少了(20次)< 我期望的100次,此時會返回false
return numberOfRenewsPerMinThreshold > 0 && getNumOfRenewsInLastMin() > numberOfRenewsPerMinThreshold;
}
- 首先看
isLeaseExpirationEnabled
方法,這個方法是判斷是否需要自我保護的,里面邏輯其實也很簡單,獲取山一分鍾所有實例心跳的次數和numberOfRenewsPerMinThreshold
(期望的每分鍾所有實例心跳次數x85%) 進行對比,如果大於numberOfRenewsPerMinThreshold
才允許摘除實例,否則進入自我保護模式。下一節會詳細講解這個方法。 - 如果服務實例可以被移除,接着往下看,這里是遍歷所有的服務注冊信息,然后一個個遍歷服務實例心跳時間是否超過了對應的時間,主要看
lease.isExpired(additionalLeaseMs)
方法:
Lease.isExpired()
:
/**
* Checks if the lease of a given {@link com.netflix.appinfo.InstanceInfo} has expired or not.
*
* Note that due to renew() doing the 'wrong" thing and setting lastUpdateTimestamp to +duration more than
* what it should be, the expiry will actually be 2 * duration. This is a minor bug and should only affect
* instances that ungracefully shutdown. Due to possible wide ranging impact to existing usage, this will
* not be fixed.
*
* @param additionalLeaseMs any additional lease time to add to the lease evaluation in ms.
*/
public boolean isExpired(long additionalLeaseMs) {
// lastUpdateTimestamp renew成功后就會刷新這個時間,可以理解為最近一次活躍時間
// 查看 Lease.renew方法:lastUpdateTimestamp = System.currentTimeMillis() + duration;
// duration可以查看為:LeaseInfo中的DEFAULT_LEASE_RENEWAL_INTERVAL=90s 默認為90s
// 這段邏輯為 當前時間 > 上一次心跳時間 + 90s + 補償時間
/**
* 這里先不看補償時間,假設補償時間為0,這段的含義是 如果當前時間大於上次續約的時間+90s,那么就認為該實例過期了
* 因為lastUpdateTimestamp=System.currentTimeMillis()+duration,所以這里可以理解為 超過180是還沒有續約,那么就認為該服務實例過期了
*
* additionalLeaseMs 時間是一個容錯的機制,也是服務保持最終一致性的一種手段,針對於定時任務 因為一些不可控原因在某些時間點沒有定時執行,那么這個就是很好的容錯機制
* 這段代碼 意思現在理解為:服務如果宕機了,那么最少180s 才會被注冊中心摘除掉
*/
return (evictionTimestamp > 0 || System.currentTimeMillis() > (lastUpdateTimestamp + duration + additionalLeaseMs));
}
這里注釋已經寫得很清楚了,System.currentTimeMillis() > lastUpdateTimestamp + duration + additionalLeaseMs
如果將補償時間記為0,那么這段代碼的含義是 如果服務如果宕機了,那么最少180s 才會被注冊中心摘除掉
上面這段代碼翻譯完了,接着看一個彩蛋
看這段代碼注釋,我先谷歌翻譯給大家看下:
翻譯的不是很好,我再來說下,這里說的是在renew()
方法中,我們寫了一個bug,那里不應該多加一個duration(默認90s)時間的,加上了會導致這里duration * 2了,所以也就是至少180s才會被摘除。但是又由於修改會產生其他的問題,所以我們不予修改。
順便看下renew()
做了什么錯事:
這里確實多給加了一個duration,哈哈 通過這個注釋 可以感受到作者就像一個嬌羞的小媳婦一樣,我做錯了事 我就不改 哼!~
言歸正傳,這里接着看evict()
后面的操作:
- 將所有需要摘除的服務實例放到
expiredLeases
集合中去 - 計算服務摘除的閾值,
registrySizeThreshold
為注冊實例總數量 * 85% - 計算最多可摘除的服務實例個數:總數量 - 總數量 * 85%
這里實則也是一種保護機制,即使我很多服務宕機了,但是最多只能摘除15%的服務實例。 - 隨機摘取指定的服務實例數量,然后遍歷調用
internalCancel
方法來remove宕機的服務實例, 這里就是上面講解的服務下線調用的方法
總結
分析完了上面所有的代碼 是不是有一種大跌眼鏡的感覺?我們現在查看的版本確實還存在bug的,有一些bug在master中已經被修復,但仍有些存在。后面一講會重點跟進這些問題。
接下來就回答開頭拋出來的一個問題了:
例如我有服務A、服務B,A、B都注冊在同一個注冊中心,當B下線后,A多久能感知到B已經下線了呢?
答案是:最快180s才會被感知。如果有補償時間,或者服務摘除的時候 計算隨機摘除服務的時候 沒有摘除此服務,那么又會等待180s 來摘除。所以這個只能說一個最塊180被感知到。
這一講還是寫了很多,其實這里面包含了很多下一講的內容,下一講會對本講做一個補充。敬請期待。
申明
本文章首發自本人博客:https://www.cnblogs.com/wang-meng 和公眾號:壹枝花算不算浪漫,如若轉載請標明來源!
感興趣的小伙伴可關注個人公眾號:壹枝花算不算浪漫