一、引入
上篇文章借着服务注册的功能, 我们得出了EurekaClient与EurekaServer是通过HTTP来进行交互的, 并且了解
到了服务实例在EurekaServer中存储的数据结构(双层的Map), 再之后又了解到了EurekaServer中与注册表相关
的操作都定义在了AbstractInstanceRegistry中, 通过责任链模式来对该类进行扩展, 增加了集群同步和事件
机制, 于是我们得出要分析的各个功能其实就是对AbstractInstanceRegistry中的不同方法的源码进行分析而
已, 在得出了上面的结构后, 我们接下来就可以专门针对于这些功能对应的方法进行分析了
二、服务剔除
1、第一部分代码
public void evict(long additionalLeaseMs) {
if (!isLeaseExpirationEnabled()) {
return;
}
........
}
服务剔除的源码一共会分为三个部分来进行分析, 首先来看看第一部分:
if (!isLeaseExpirationEnabled()) {
return;
}
如果节点的是不允许过期, 那么就return了, isLeaseExpirationEnabled是判断是否允许节点过期, 然后被剔
除的, 如果返回true, 则允许过期, 返回false, 则不允许过期, 允许过期有两种情况, 第一种是自我保护机制
压根就没启动, 即eureka.server.enable-self-preservation这个配置为false, 第二种是没有触发自我保护
机制, 关于什么时候自我保护机制会被触发可以看看第一篇文章, 其实第一篇文章对自我保护机制源码的分析基
本就是对isLeaseExpirationEnabled这个方法的分析而已
2、第二部分代码
public void evict(long additionalLeaseMs) {
..........................
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);
}
}
}
}
..........................
}
其实很简单, 扫描注册表中的所有实例, 利用实例的isExpired方法来判断是否过期, 如果过期就添加到
expiredLeases这个集合中, isExpired源码如下:
public boolean isExpired(long additionalLeaseMs) {
return (evictionTimestamp > 0 ||
System.currentTimeMillis() > (lastUpdateTimestamp + duration + additionalLeaseMs));
}
lastUpdateTimestamp表示上一次更新时间, duration其实就是客户端的
eureka.instance.lease-expiration-duration-in-seconds配置, 这个配置的意思是告诉EurekaServer超
过了该配置时间还没收到我的心跳包就可以将我剔除了, 换句话说, 从上一次更新时间lastUpdateTimestamp开
始, 如果超过了duration还没收到心跳, 那么这个服务就可以被认为是过期的了
总结就是, 如果evictionTimestamp大于0, 这个字段在节点触发了剔除功能后就会被更新, 如果已经是大于0了
则说明之前就已经被剔除过了, 或者(当前时间大于上一次更新的时间与duration之和)也被认为是过期了, 而
additionalLeaseMs这个字段是EurekaServer通过一定的算法计算出来的集群同步会造成的时间差
这个时候肯定就会有人问了, 怎么一个服务被剔除了后还能再一次被剔除呢? 或者evictionTimestamp什么时候
才会大于0呢? 这种情况发生在集群同步的时候, 其实服务下线和服务剔除都是会将evictionTimestamp字段设置
为当前时间的, 当服务下线的时候, 就会触发集群同步, 从而更新其他EurekaServer中该节点的信息, 然而在
其他EurekaServer中触发了服务剔除功能的时候, 就可能会扫描到evictionTimestamp大于0的节点了
3、第三部分代码
public void evict(long additionalLeaseMs) {
int registrySize = (int) getLocalRegistrySize();
int registrySizeThreshold = (int) (registrySize * serverConfig.getRenewalPercentThreshold());
int evictionLimit = registrySize - registrySizeThreshold;
int toEvict = Math.min(expiredLeases.size(), evictionLimit);
if (toEvict > 0) {
Random random = new Random(System.currentTimeMillis());
for (int i = 0; i < toEvict; i++) {
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();
internalCancel(appName, id, false);
}
}
}
registrySize获取的是整个注册表中节点的个数, registrySizeThreshold其实就是利用整个注册表中节点的
个数 乘以 续约阈值(默认是0.85, 即eureka.server.renewal-percent-threshold配置对应的值), 利用这
两个值计算本次剔除节点的限额evictionLimit, 其实也很好理解, 由于自我保护机制的存在, 代码执行到这个
时候, 已经有可能触发了自我保护机制了, 所以EurekaServer一次剔除操作不能将所有过期的节点剔除掉, 这样
在下一次剔除任务被执行的时候就会触发自我保护机制了, 假设有100个服务, 默认情况下, 一次剔除操作最多能
剔除15个, 即保留85个
之后开始利用一个for循环一个个的剔除过期的节点, 核心方法就是internalCancel方法, 在剔除之前, 可以看
到会计算一个随机的索引, 然后剔除这个索引下的节点, EurekaServer不是一股脑的对保存了过期节点的集合进
行遍历, 只取前evictionLimit个节点剔除的, 而是利用一个随机值来剔除这个集合中的任意一个, 这样的好处
是, 当有100个节点过期, 但是只能剔除15个的时候, 被剔除的节点会分散到集合中的各个位置
4、剔除的核心方法internalCancel
protected boolean internalCancel(String appName, String id, boolean isReplication) {
Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
Lease<InstanceInfo> leaseToCancel = null;
if (gMap != null) {
leaseToCancel = gMap.remove(id);
}
if (leaseToCancel == null) {
return false;
} else {
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);
return true;
}
}
internalCancel方法原理其实很简单, 从Map中将这个需要被剔除的节点移除掉就好了, 如果Map中没有这个节
点, 那么就返回false, 否则返回true, 该方法之的返回值是一个布尔值, 原因我们在服务下线的时候进行分析,
因为服务下线那里复用了这个方法进行节点的删除
如果Map中确实存在这个节点, 那么就调用leaseToCancel.cancel方法, 该方法其实就是将evictionTimestamp
设置为当前时间而已, 随后, 将该节点放入到recentlyChangedQueue中(这个队列我们在上一篇文章也有提到
过, 用于客户端服务发现的时候的增量拉取用的, 之后的文章我们再进行分析), 再往后设置上一次更新时间为
当前时间, 最后, 使得缓存失效
5、小小的总结
服务剔除的源码我们分成了四个小节进行分析, 经过上面的分析, 我们得出以下几点:
<1> 服务剔除一开始就会判断自我保护机制, 如果自我保护机制开启并且被触发的情况下, 就不会走后面的
逻辑进行服务剔除了
<2> 使用一个集合expiredLeases来保存通过isExpired方法判断后确定为过期的节点, 然而在剔除的时候
不一定会将这个集合中所有的节点剔除, 而是会根据续约阈值来计算最多能剔除的节点个数
<3> 在剔除节点的时候, 通过随机索引来选择expiredLeases这个集合中的节点进行剔除
<4> 核心的剔除方法是internalCancel, 这个方法会返回一个布尔值, 该方法会在服务下线的时候被重用
于节点的删除
<5> internalCancel方法里面就是从双层Map中找到节点并删除, 随后更新剔除时间evictionTimestamp
为当前时间, 之后将缓存失效以及往recentlyChangedQueue队列中放入一条记录
由于服务剔除是服务端的主动行为, 这个方法在什么时候会被触发, 在之后的EurekaServer的自动配置原理中
我们才会看到该方法会在一个定时器中被执行的, 而这个定时器也是在那个时候被创建的
三、服务续约
public boolean renew(String appName, String id, boolean isReplication) {
Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
Lease<InstanceInfo> leaseToRenew = null;
if (gMap != null) {
leaseToRenew = gMap.get(id);
}
if (leaseToRenew == null) {
return false;
} else {
InstanceInfo instanceInfo = leaseToRenew.getHolder();
if (instanceInfo != null) {
InstanceStatus overriddenInstanceStatus = this.getOverriddenInstanceStatus(
instanceInfo, leaseToRenew, isReplication);
if (overriddenInstanceStatus == InstanceStatus.UNKNOWN) {
RENEW_NOT_FOUND.increment(isReplication);
return false;
}
if (!instanceInfo.getStatus().equals(overriddenInstanceStatus)) {
instanceInfo.setStatusWithoutDirty(overriddenInstanceStatus);
}
}
renewsLastMin.increment();
leaseToRenew.renew();
return true;
}
}
分析:
非常简单, 从Map中获取实例, 如果实例不存在, 就返回false, 如果实例存在, 就主要执行两行代码:
renewsLastMin.increment();
leaseToRenew.renew();
renewsLastMin.increment, 根据我们在第一篇对自我保护机制源码的分析, 这个参数的作用已经在那篇文章中
进行了详细分析, 每次续约都会导致这个计数器中的currentBucket值加1
leaseToRenew.renew的源码就相当简单了, 就是将Lease对象中的最近一次更新时间戳进行了更新而已, 如下:
public void renew() {
lastUpdateTimestamp = System.currentTimeMillis() + duration;
}
然后我们可以思考一下, lastUpdateTimestamp表示的是最近一次的更新时间, 回想起上一小节分析的服务剔除
的源码, 我们知道, 判断一个节点是否过期是其实就是判断上一次更新时间是否大于当前时间加上duration字段
的值(撇开additionalLeaseMs字段), 即如下:
public boolean isExpired(long additionalLeaseMs) {
return (evictionTimestamp > 0 ||
System.currentTimeMillis() > (lastUpdateTimestamp + duration + additionalLeaseMs));
}
duration字段的值是客户端告诉服务, 如果超过了duration字段规则的值还没收到心跳包则认为过期了, 所以
上一次更新时间 + duration字段的值就是一个节点的最大存活时间了, 这个应该很好理解
我们理想状态下, 每一次的续约都应该将lastUpdateTimestamp改为当前时间, 然而在renew方法中竟然是改为
了在当前时间上加了一个duration的值, 其实这应该算是Eureka的一个bug了, 而在javadoc中其也承认了是一
个bug......
所以到此我们可以知道, 一个节点其实存活时间是两倍的duration字段的值, 即两倍的
eureka.instance.lease-expiration-duration-in-seconds配置的值!!!
四、服务下线
public boolean cancel(String appName, String id, boolean isReplication) {
return internalCancel(appName, id, isReplication);
}
是不是觉得很熟悉, 服务下线就是调用了internalCancel方法, 而这个方法在服务剔除中被用于删除节点了!!!
五、集群同步
1、集群同步结构分析
在对服务注册、服务续约、服务下线的源码进行分析完毕后, 我们再统一来看看集群同步的源码吧!!
public class PeerAwareInstanceRegistryImpl extends AbstractInstanceRegistry {
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);
}
public boolean renew(final String appName, final String id, final boolean isReplication) {
if (super.renew(appName, id, isReplication)) {
replicateToPeers(Action.Heartbeat, appName, id, null, null, isReplication);
return true;
}
return false;
}
public boolean cancel(final String appName, final String id,
final boolean isReplication) {
if (super.cancel(appName, id, isReplication)) {
replicateToPeers(Action.Cancel, appName, id, null, null, isReplication);
synchronized (lock) {
if (this.expectedNumberOfClientsSendingRenews > 0) {
this.expectedNumberOfClientsSendingRenews = this.expectedNumberOfClientsSendingRenews - 1;
updateRenewsPerMinThreshold();
}
}
return true;
}
return false;
}
}
集群同步的源码其实相当的简单, 因为服务注册、续约、下线的功能都是调用父类对应的方法来完成的, 于是通
过上面三个方法, 我们就清晰的看到了, 先调用父类的方法完成功能, 然后调用replicateToPeers方法进行集
群同步, 在此我们就看到了, 当父类的renew和cancel方法返回了true的时候, 才会进行集群同步
再来看看cancel方法的调用吧, 通过上面的分析, 我们知道该方法里面其实就是调用了internalCancel方法将
节点从双层Map中剔除的, 如果剔除成功了, 那么就要更新expectedNumberOfClientsSendingRenews值以及续
约阈值了, 即同步块里面的内容(这个更新操作在第一篇分析自我保护机制源码的时候已经详细分析过了)
所以核心方法就是replicateToPeers方法, 可以看到, 第一个参数就是表明这个操作是注册还是续约还是下线,
不同操作传不同的值, 于是可以联想到, 在该方法中可能会存在一个switch-case语句来判断是哪种操作, 进而
完成不同的功能
2、集群同步原理分析
在看代码之前, 我们先通过文字的形式来分析下集群同步的原理, 其中就涉及到了isReplication字段的作用了,
在Eureka中, 多个EurekaServer构成集群, 集群之间会进行信息的同步, 同步采用HTTP协议, 假设有A、B、C
三个EurekaServer构成一个集群, A的serviceUrl指向B, B的serviceUrl指向C, C的serviceUrl指向A, 当一
个节点注册到A中的时候, 回调用register方法完成注册操作, 而A往B中同步信息其实就是调用了B中的register
方法, 那么问题来了, A通过调用B的register方法将新注册到自身的节点同步到B后, B是否也会将节点信息同
步到C呢, 如果会的话, 那么C收到节点的注册信息岂不是又要将节点信息同步到A, 这样不就变成了一个死循环
了吗, 而Eureka就是通过isReplication字段来避免这种情况发生的
在replicateToPeers方法的开始, 就有一个判断, 如果isReplication为true, 则直接返回了, 那么什么时候
为true呢, 客户端注册到A的时候, 调用A的register方法, 此时isReplication为false, 当A的register方法
执行完毕后, 会执行replicateToPeers方法, 此时由于isReplication为false, 所以会执行集群同步, 即调用
B的register方法对应的接口, 不过, 这个时候isReplication就会传为true了, 从而在B的register方法处理
完后, 调用replicateToPeers方法时, 由于isReplication为true, 此时就不会进行集群同步了
以上就是集群同步原理的文字说明, 从中我们可以得到, 集群同步只能同步一层, 即注册到A的节点只能同步到B
而不会发生同步到B后再由B同步到C的情况
3、集群同步源码分析(replicateToPeers方法)
private void replicateToPeers(Action action, String appName, String id,
InstanceInfo info ,
InstanceStatus newStatus , boolean isReplication) {
if (peerEurekaNodes == Collections.EMPTY_LIST || isReplication) {
return;
}
for (final PeerEurekaNode node : peerEurekaNodes.getPeerEurekaNodes()) {
if (peerEurekaNodes.isThisMyUrl(node.getServiceUrl())) {
continue;
}
replicateInstanceActionsToPeers(action, appName, id, info, newStatus, node);
}
}
分析:
可以看到, 如果集群中的节点为空或者isReplication为true就不会进行集群同步了, 在此之后会遍历集群
中的所有节点, 调用replicateInstanceActionsToPeers方法完成集群同步
private void replicateInstanceActionsToPeers(Action action, String appName,
String id, InstanceInfo info,
InstanceStatus newStatus,
PeerEurekaNode node) {
InstanceInfo infoFromRegistry = null;
CurrentRequestVersion.set(Version.V2);
switch (action) {
case Cancel:
node.cancel(appName, id);
break;
case Heartbeat:
InstanceStatus overriddenStatus = overriddenInstanceStatusMap.get(id);
infoFromRegistry = getInstanceByAppAndId(appName, id, false);
node.heartbeat(appName, id, infoFromRegistry, overriddenStatus, false);
break;
case Register:
node.register(info);
break;
}
}
很简单, 一个switch-case语法来判断是哪一种操作(简略了一部分操作), 在之后就是利用调用各个集群对应操
作的各个方法了, 这时候当前EurekaServer其实就是被当成了一个客户端而已.....
里面的代码其实就是利用EurekaClient来发送不同的请求, 这个我们就不进行分析了, 在此扩展一点,
isReplication字段是通过请求头来进行传递的, 在上面三个case语句种, 以node.register方法为例, 大家
跟进源码会发现, 其实就是调用了replicationClient.register方法, 在这个方法中, 有一段代码是调用了
addExtraHeaders方法, 在这个方法中就添加了一个请求头, key为isReplication, 值为true, 如下:
webResource.header(PeerEurekaNode.HEADER_REPLICATION, "true");