SpringCloud系列——EurekaServer注册表更新机制

SpringCloud 专栏收录该内容
7 篇文章 1 订阅

PS:本篇源码涉及到的类,都是在原生的netflix-eureka模块中,基于v1.9.25 。
本来是想把服务端和客户端的注册表更新机制放在一篇文章里,写的时候发现服务端讲解已占篇幅比较大,故而拆开来讲。

Eureka注册表简单介绍

eureka中注册表是一个非常重要的概念,其实可以这么理解,不管是EurekaClient端还是EurekaServer端,都有一个map这样的数据结构,来存储应用的实例信息。比如,应用order-api要通过eureka方式访问user-api时,是从order-api本地的EurekaClient注册表里根据user-api(即clientName)来选择应用实例的地址,并请求数据,具体流程如图所示:
在这里插入图片描述

Eureka注册表更新机制概览

还是画图根据客户端和服务端来简单介绍下注册表机制:
在这里插入图片描述

EurekaServer注册表机制

获取注册表的入口

入口是在ApplicationResource当中:

@GET
public Response getApplication(@PathParam("version") String version,
                               @HeaderParam("Accept") final String acceptHeader,
                               @HeaderParam(EurekaAccept.HTTP_X_EUREKA_ACCEPT) String eurekaAccept) {
    ......
    String payLoad = responseCache.get(cacheKey);
    ...
    if (payLoad != null) {
        return Response.ok(payLoad).build();
    } else {
        return Response.status(Status.NOT_FOUND).build();
    }
}

获取的方式,既支持application/xml数据格式,也支持application/json格式。
至于注册表,重点就是要关注ResponseCache的实现类ResponseCacheImpl

ResponseCacheImpl类解析

重要属性介绍

在此类中,我们能看到几个非常重要的属性:

//读缓存
private final ConcurrentMap<Key, Value> readOnlyCacheMap = new ConcurrentHashMap<Key, Value>();
//读写缓存
private final LoadingCache<Key, Value> readWriteCacheMap;
//是否应该使用读缓存,默认是true
private final boolean shouldUseReadOnlyResponseCache;
//实际操作读写缓存的类,含有读写锁
private final AbstractInstanceRegistry registry;

readOnlyCacheMap不用多说,是一个线程安全的HashMap,而readWriteCacheMap是一个本地缓存类LoadingCache,这里的一篇文章 java缓存架构剖析 讲得挺好的。

几种刷新注册表的方式

EurekaServer端数据既然是存放在Map数据结构里,那它是怎么去刷新里面的数据呢?我将定义成三种,主动刷新,被动刷新,定时刷新,下面一一介绍:

主动刷新

主动刷新,指的是应用实例(即EurekaClient端)在进行注册,续约租期,更新状态,删除时,会主动来刷新readWriteCacheMap中的数据,入口在AbstractInstanceRegistry类,关键代码如下:

// AbstractInstanceRegistry类关键代码
// 这里只截取了注册相关代码,续约租期,更新状态,删除是类似的
public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
	try{
		......
		invalidateCache(registrant.getAppName(), registrant.getVIPAddress(), registrant.getSecureVipAddress());
		......
	}finally {
       read.unlock();
    }
}
private void invalidateCache(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress) {
    responseCache.invalidate(appName, vipAddress, secureVipAddress);
}

// ResponseCacheImpl类关键代码
public void invalidate(Key... keys) {
    for (Key key : keys) {
        readWriteCacheMap.invalidate(key);
        ......
        for (Key keysWithRegion : keysWithRegions) {
            readWriteCacheMap.invalidate(keysWithRegion);
        }
        ......
    }
}

被动刷新

这里的被动,其实是说将readWriteCacheMap的数据定时刷入到readOnlyCacheMap中,默认是每隔30s进行刷新,但前提是shouldUseReadOnlyResponseCachetrue才行,关键代码如下:

// 只有shouldUseReadOnlyResponseCache为true
// 才会执行定时任务将`readWriteCacheMap`的数据刷入到`readOnlyCacheMap`中
if (shouldUseReadOnlyResponseCache) {
    timer.schedule(getCacheUpdateTask(),
            new Date(((System.currentTimeMillis() / responseCacheUpdateIntervalMs) * responseCacheUpdateIntervalMs)
                    + responseCacheUpdateIntervalMs), responseCacheUpdateIntervalMs);
}
private TimerTask getCacheUpdateTask() {
    return new TimerTask() {
        public void run() {
            for (Key key : readOnlyCacheMap.keySet()) {
                ......
                Value cacheValue = readWriteCacheMap.get(key);
                Value currentCacheValue = readOnlyCacheMap.get(key);
                if (cacheValue != currentCacheValue) {
                    //刷新读缓存的关键代码
                    readOnlyCacheMap.put(key, cacheValue);
                }
                ......
            }
        }
    };
}

其实从这里也可以看出,EurekaServer默认是二级缓存,但也可以设置成一级缓存,在配置文件中如此设置即可:

eureka.server.use-read-only-response-cache=false

定时刷新

定时刷新指的是,如果隔了一段时间,readWriteCacheMap读写缓存中的数据还没刷新的话,就会将此数据给过期删除掉,关键代码如下:

 this.readWriteCacheMap =
                CacheBuilder.newBuilder().initialCapacity(serverConfig.getInitialCapacityOfResponseCache())
//定时刷新数据的时间,就是在这里设置进去了
.expireAfterWrite(serverConfig.getResponseCacheAutoExpirationInSeconds(), TimeUnit.SECONDS)
                        .removalListener(new RemovalListener<Key, Value>() {}).build();

这个时间默认是180s,也可在配置文件中更改:

eureka.server.response-cache-auto-expiration-in-seconds=180

AbstractInstanceRegistry类解析

重要属性介绍

private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock read = readWriteLock.readLock();
private final Lock write = readWriteLock.writeLock();
//注册表的全量信息,就是在此map数据结构里了!
private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry
            = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();

AbstractInstanceRegistry类其实就是具体执行应用实例的注册,租期续约,删除实例,更新实例状态,获取全量应用这些,这里就不讲了。然后始终贯穿这些操作的一个概念,就是读写锁了,也就是上面的几个属性。下面就来看看读写锁的奇技淫巧。

读锁/写锁是在哪些条件下用的,以及为啥这样用?

读锁使用场景

读锁在4个地方使用了:

  • 注册register()
  • 更新应用实例状态statusUpdate()
  • 删除应用实例状态deleteStatusOverride()
  • 取消注册cancel()

其中取消注册的场景是在EurekaClient端在停掉应用前会调用。至于使用读锁的关键代码,这里只取注册这一个模块,其他几个场景都类似:

public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
	try{
		read.lock();
		......
		Map<String, Lease<InstanceInfo>> gMap = registry.get(registrant.getAppName());
		recentlyChangedQueue.add(new RecentlyChangedItem(lease));
		invalidateCache(registrant.getAppName(), registrant.getVIPAddress(), registrant.getSecureVipAddress());
	}finally{
		read.unlock();
	}
}

写锁使用场景

写锁在2个场景下使用了:

  • 获取应用增量getApplicationDeltas()
  • 从多区域获取应用增量getApplicationsFromMultipleRegions()

关键代码展示:

public Applications getApplicationDeltasFromMultipleRegions(String[] remoteRegions) {
		......
        try {
            write.lock();
            Iterator<RecentlyChangedItem> iter = this.recentlyChangedQueue.iterator();
            while (iter.hasNext()) {
                Lease<InstanceInfo> lease = iter.next().getLeaseInfo();
                InstanceInfo instanceInfo = lease.getHolder();
                ......
                Application app = applicationInstancesMap.get(instanceInfo.getAppName());
                if (app == null) {
                    app = new Application(instanceInfo.getAppName());
                    applicationInstancesMap.put(instanceInfo.getAppName(), app);
                    apps.addApplication(app);
                }
                app.addInstance(new InstanceInfo(decorateInstanceInfo(lease)));
                ......
            }
            Applications allApps = getApplicationsFromMultipleRegions(remoteRegions);
            // 设置全量应用的hashcode值,便于客户端校对hashcode
            apps.setAppsHashCode(allApps.getReconcileHashCode());
            return apps;
        } finally {
            write.unlock();
        }
    }

从关键代码可以得知,读写锁,锁的就是recentlyChangedQueue这个线程安全的队列。每次有注册/状态更新/取消注册这些操作时,就会新增一个RecentlyChangedItem进去。netflix版本默认会每隔180s,而springCloud中配置的默认是30s,会将未更新的RecentlyChangedItem进行剔除,关键代码在AbstractInstanceRegistry.getDeltaRetentionTask中,参数可配置:

eureka.server.delta-retention-timer-interval-in-ms=30

而获取全量应用时,并没有加锁,获取的是registry这个变量中的数据。

为啥写的场景用读锁,读的场景用写锁?

还是性能问题,因为写的场景多(注册,下线,更新状态),而读的场景只有获取增量应用。而读写锁的特性是,读读不互斥,读写互斥,写写互斥。写场景主要是新增数据:

recentlyChangedQueue.add(new RecentlyChangedItem(lease));

多个场景多个线程新增数据,对recentlyChangedQueue并无影响。但是Eureka获取增量应用数据时,为了保证数据的一致性,就要保证recentlyChangedQueue无改变。

为啥应用实例租期续约的时候没用锁?

读写锁锁的是recentlyChangedQueue,而应用实例的租期续约,操作的是registry这个map类型结构的数据。registry里面保存的是全量应用的数据,Eureka客户端获取的注册表,也就是registry。所以,只有在应用实例注册/取消注册/状态更新时才会操作registry中的数据。Eureka客户端首次注册完了之后获取全量注册表,就是获取的registry中的数据。

那么问题来了,为什么没有把registry给锁起来?各种更新操作都有它。答案是,还是性能问题,如果registry加上读写锁,性能会大大下降。但是没加锁,其实并不能保证Eureka服务端和客户端注册表的数据一致性。Eureka做了一个补偿机制,就是在客户端获取增量数据时,服务端会把全量注册表的hashcode值也给到客户端,客户端将本地的全量数据和新获取的增量数据也做一个hash,并将俩hashcode值进行比对,如果不一致的话,说明数据过期了,这时候就会去Eureka服务端获取全量数据。

  • 0
    点赞
  • 4
    评论
  • 1
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

©️2022 CSDN 皮肤主题:Age of Ai 设计师:meimeiellie 返回首页

打赏作者

罗小辉

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值