【Spring Cloud】Eureka-Client 源码解读|周末学习

1,777 阅读7分钟

本文已参与周末学习计划,点击链接查看详情 链接

一、前言

看源码:抓大放小,先主流程,再细枝末节。

用技巧连蒙带猜:

  1. 看方法名(英文名)
  2. 看注释
  3. 单元测试入手

搭建环境:

// 定位:eureka-examples 模块下
// 修改 ExampleEurekaClient.java

// 1. 增加一个方法,用于初始化环境变量,方便调试
private static void injectEurekaConfiguration() throws UnknownHostException {
    String myHostName = InetAddress.getLocalHost().getHostName();
    String myServiceUrl = "http://" + myHostName + ":8080/v2/";

    System.setProperty("eureka.region", "default");
    System.setProperty("eureka.name", "eureka");
    System.setProperty("eureka.vipAddress", "eureka.mydomain.net");
    System.setProperty("eureka.port", "8080");
    System.setProperty("eureka.preferSameZone", "false");
    System.setProperty("eureka.shouldUseDns", "false");
    System.setProperty("eureka.shouldFetchRegistry", "false");
    System.setProperty("eureka.serviceUrl.defaultZone", myServiceUrl);
    System.setProperty("eureka.serviceUrl.default.defaultZone", myServiceUrl);
    System.setProperty("eureka.awsAccessId", "fake_aws_access_id");
    System.setProperty("eureka.awsSecretKey", "fake_aws_secret_key");
    System.setProperty("eureka.numberRegistrySyncRetries", "0");
}
    
    
// 2. 在 main 方法添加
public static void main(String[] args) throws UnknownHostException {

    // 添加如下这行
    injectEurekaConfiguration();
    ... ... 
}



二、从源码中学到了什么

凡凡觉得 “学到了” 的东西。

Tips:方法名、变量名日常值得学习。

(1)锁的使用

锁的使用有:

  1. 读写锁 ReentrantReadWriteLock
  2. synchronized 使用
  3. ReentrantLock 使用

详细使用,如下:

  1. 读写锁 ReentrantReadWriteLock
// 定位:com.netflix.eureka.registry.AbstractInstanceRegistry.java
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock read = readWriteLock.readLock();
private final Lock write = readWriteLock.writeLock();

public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
    try {
        // 读锁
        read.lock();
    } finally {
        // 释放
        read.unlock();
    }
}
  1. synchronized 使用
// 定位:com.netflix.eureka.registry.AbstractInstanceRegistry.java
protected final Object lock = new Object();

public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
    synchronized (lock) {
        ... ...
    }
}
  1. ReentrantLock 使用
// 定位:com.netflix.discovery.DiscoveryClient.java
private final Lock fetchRegistryUpdateLock = new ReentrantLock();

// 尝试加锁
if (fetchRegistryUpdateLock.tryLock()) {
    try {
        ... ...
    } finally {
        // 释放锁
        fetchRegistryUpdateLock.unlock();
    }
} else {
    // 获取锁失败,打日志
    logger.warn("Cannot acquire update lock, aborting getAndUpdateDelta");
}

(2)队列使用

队列有:

  1. 最近修改队列:ConcurrentLinkedQueue

    private ConcurrentLinkedQueue<RecentlyChangedItem> recentlyChangedQueue
        = new ConcurrentLinkedQueue<RecentlyChangedItem>();
    
  2. 最近取消队列:CircularQueue

    private final CircularQueue<Pair<Long, String>> recentCanceledQueue
        = new CircularQueue<Pair<Long, String>>(1000);
    
    // 要并发使用,要加锁,如下:
    synchronized (recentCanceledQueue) {
        // 在最近取消队列中,把这个事件加入
        recentCanceledQueue.add(
            new Pair<Long, String>(System.currentTimeMillis(), appName + "(" + id + ")"));
    }
    



三、直接怼源码

(1)eureka-client 如何启动(初始化)?

代码如下:

// 定位:com.netflix.eureka.ExampleEurekaClient.java
public static void main(String[] args) throws UnknownHostException {
    // 1. 初始化 eureka 环境变量
    injectEurekaConfiguration();
    // 2. 创建 eureka 服务
    ExampleEurekaClient sampleClient = new ExampleEurekaClient();
    // 3. 创建服务实例管理器
    ApplicationInfoManager applicationInfoManager = 
        initializeApplicationInfoManager(new MyDataCenterInstanceConfig());
    // 4. 创建 eureka-client
    EurekaClient client = 
        initializeEurekaClient(applicationInfoManager, new DefaultEurekaClientConfig());

    ... ...
}

过程如下:

  1. 初始化 eureka 环境变量

  2. 创建 eureka 服务,会有一个 eureka-client

  3. 创建服务实例管理器

    1. 构建服务实例(InstanceInfo
    2. 构建服务实例管理器(ApplicationInfoManager
    ApplicationInfoManager applicationInfoManager = initializeApplicationInfoManager(new MyDataCenterInstanceConfig());
    
  4. 创建 eureka-client(通过构造 DiscoveryClient

    • 处理配置
    • 服务的注册和注册表的抓取(初始化网络通信组件)
    • 创建几个线程池,启动调度任务
    • 注册监控项


(2)eureka-client 如何服务注册的?

针对注册,提出问题:

  1. 什么时候进行服务注册? 初始化 eureka-client 时候

eureka-client 的服务注册,是在 InstanceInfoReplicator 中完成的。

  1. 服务注册做哪些操作? 主要发送 HTTP 请求

针对这个两个问题,来看下源码。虽然这部分的源码写的不好,但也可以学习了解下他人的思路。

这部分源码比较难找,实际是在创建 DiscoveryClientinitScheduledTasks()(初始化调度任务)

// 定位:com.netflix.discovery.DiscoveryClient.java
private void initScheduledTasks() {
    // InstanceRegisterManager:实例注册管理器,专门来管理实例注册
    // 传参:默认 40秒
    instanceInfoReplicator.start(...);
}
  1. 启动实例注册管理器(InstanceRegisterManager
// 定位:com.netflix.discovery.InstanceInfoReplicator.java
public void start(int initialDelayMs) {
    // 原子操作
    if (started.compareAndSet(false, true)) {
        // 1. 先设置 isDirty = true
        instanceInfo.setIsDirty();
        // 2. 调度器执行,将自身传入,并且调度时间默认为 40 秒
        // 所以会执行 InstanceInfoReplicator.run() 方法
        Future next = scheduler.schedule(this, initialDelayMs, TimeUnit.SECONDS);
        scheduledPeriodicRef.set(next);
    }
}
  1. 执行 InstanceInfoReplicator.run()
// 定位:com.netflix.discovery.InstanceInfoReplicator.java
// 会发现:class InstanceInfoReplicator implements Runnable,是可创建线程的。
public void run() {
    try {
        // 1. 刷新了服务实例的信息,拿到服务的状态
        discoveryClient.refreshInstanceInfo();
        Long dirtyTimestamp = instanceInfo.isDirtyWithTime();
        if (dirtyTimestamp != null) {
            // 2. 注册:因为之前已经设置 isDirty = true,所以下面直接注册
            discoveryClient.register();
            // 3. 设置 isDirty = false
            instanceInfo.unsetIsDirty(dirtyTimestamp);
        }
    } catch (Throwable t) {
        logger.warn("There was a problem with the instance info replicator", t);
    } finally {
        // 4. 再次把自己丢进调度线程中
        Future next = 
            scheduler.schedule(this, replicationIntervalSeconds, TimeUnit.SECONDS);
        scheduledPeriodicRef.set(next);
    }
}
  1. 重要:注册 discoveryClient.register();
// 定位:com.netflix.discovery.DiscoveryClient.java
boolean register() throws Throwable {
    EurekaHttpResponse<Void> httpResponse;
    try {
        // 发送 HTTP 请求
        httpResponse = eurekaTransport.registrationClient.register(instanceInfo);
        ... ...
    }
    ... ...
    return httpResponse.getStatusCode() == 204;
}



(3)eureka-client 第一次启动全量抓取注册表

// 定位:com.netflix.discovery.DiscoveryClient.java
if (clientConfig.shouldFetchRegistry() && !fetchRegistry(false)) {
     fetchRegistryFromBackup();
}

private boolean fetchRegistry(boolean forceFullRegistryFetch) {
    ... ...
    try {
        // 第一次获取,为 null
        Applications applications = getApplications();

        if (...) {
            // applications == null 时,
            // 抓取全量注册表,并存储
            getAndStoreFullRegistry();
        } else {
            // 抓取增量注册表,并更新
            getAndUpdateDelta(applications);
        }
        ... ...
    } catch (Throwable e) {
        ... ...
    }
    ... ...
}

在启动的时候抓取全量的注册表信息,步骤如下:

  1. eureka-client 初始化的时候,会自动全量抓取注册表
  2. 先获取本地的 Applications 缓存(即,所有服务信息,Application 对应一个 InstanceInfo
  3. 发送 HTTP 请求(GET http://localhost:8080/v2/apps),获取全量注册表信息并缓存到本地



(4)eureka-client 增量抓取注册表

在初始化 eureka-client 时候,会全量抓取注册表; 同时会起一个定时任务,增量抓取注册表。

增量抓取注册表,定时任务如下:

// 定位:com.netflix.discovery.DiscoveryClient.java
// 1. 调度器
private void initScheduledTasks() {

    ... ...
    // 调度间隔默认 30s,执行 CacheRefreshThread()
    scheduler.schedule(
        new TimedSupervisorTask(
            "cacheRefresh",
            scheduler,
            cacheRefreshExecutor,
            30,
            TimeUnit.SECONDS,
            expBackOffBound,
            new CacheRefreshThread()   // 重要:执行方法
        ),
        30, TimeUnit.SECONDS);
}

// 2. 发现 CacheRefreshThread() 直接调用 refreshRegistry()
class CacheRefreshThread implements Runnable {
    public void run() {
        refreshRegistry();
    }
}

// 3. 进入 refreshRegistry()
void refreshRegistry() {
    ... ...
    // 直接调用 fetchRegistry()
    boolean success = fetchRegistry(remoteRegionsModified);
    ... ...
}

// 4. 进入 fetchRegistry()
private boolean fetchRegistry(boolean forceFullRegistryFetch) {
    ... ...
    try {
        // 第一次获取,为 null
        Applications applications = getApplications();

        if (...) {
            // applications == null 时,
            // 抓取全量注册表,并存储
            getAndStoreFullRegistry();
        } else {
            // 重要:抓取增量注册表,并更新
            getAndUpdateDelta(applications);
        }
        ... ...
    } catch (Throwable e) {
        ... ...
    }
    ... ...
}

重点:抓取增量注册表 getAndUpdateDelta(applications);

// 定位:
private void getAndUpdateDelta(Applications applications) throws Throwable {
    ... ...
    Applications delta = null;

    // HTTP请求,拿到增量的注册表
    EurekaHttpResponse<Applications> httpResponse = 
        eurekaTransport.queryClient.getDelta(remoteRegionsRef.get());
   // 如果 HTTP 状态OK,说明请求成功
   if (httpResponse.getStatusCode() == Status.OK.getStatusCode()) {
       // 赋值
       delta = httpResponse.getEntity();
   }
   ... ...

    if (delta == null) {
        // 增量为 null,则去拿全量
        getAndStoreFullRegistry();
        
    } else if (比较版本并设置)) {
       
        String reconcileHashCode = "";
        if (fetchRegistryUpdateLock.tryLock()) {
            try {
                // 将抓取到的注册表和本地缓存的注册表进行合并,更新操作
                updateDelta(delta);
                // 并计算 Hash 值
                reconcileHashCode = getReconcileHashCode(applications);
            } finally {
                fetchRegistryUpdateLock.unlock();
            }
        } else {
            logger.warn("Cannot acquire update lock, aborting getAndUpdateDelta");
        }
        
        // 本地注册表的 Hash值 与 远端 Hash值比较,若不一致:
        if (!reconcileHashCode.equals(delta.getAppsHashCode()) 
            || clientConfig.shouldLogDeltaDiff()) {
            // 从 eureka-server 抓取全量的注册表到本地
            reconcileAndLogDifference(delta, reconcileHashCode);  
        }
    } else {
        // 打异常日志
        ... ...
    }
}

总结,如图:

增量抓取注册表.png



(5)服务实例与注册中心的心跳机制(服务续约)

eureka-client 每隔一定的时间,会给 eureka-server 发送心跳, 告诉服务端 “我健康着呢!!”

心跳(heartbeat),在代码中等同于 续约(renew lease

创建 eureka-client 初始化时,初始化调度器,代码分析如下:

// 定位:com.netflix.discovery.DiscoveryClient.java
// 1. 调度器
private void initScheduledTasks() {

    ... ...
    // 默认 30s:发送一次心跳
    scheduler.schedule(
        new TimedSupervisorTask(
            "heartbeat",
            scheduler,
            heartbeatExecutor,
            renewalIntervalInSecs,
            TimeUnit.SECONDS,
            expBackOffBound,
            new HeartbeatThread()  // 重要
        ),
        30, TimeUnit.SECONDS);
    ... ...
}

// 2. 执行 HeartbeatThread()
private class HeartbeatThread implements Runnable {
    public void run() {
        // 续约
        if (renew()) {
            lastSuccessfulHeartbeatTimestamp = System.currentTimeMillis();
        }
    }
}


// 3. renew()
boolean renew() {
    
    try {
        // 发送 HTTP 请求:PUT apps/{appName}/{id}
        EurekaHttpResponse<InstanceInfo> httpResponse = ... ...
        
        // 404 则表示没注册上,需要重新去注册
        if (httpResponse.getStatusCode() == 404) {
            ... ...
            // 去调用注册
            boolean success = register();
            ... ...
            return success;
        }
        return httpResponse.getStatusCode() == 200;
    } catch (Throwable e) {
        ... ...
    }
}

eureka-server 接收到 eureka-clientheartbeat,且找到这个实例,则会进行:

Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
  1. 执行续约 leaseToRenew.renew();
  2. 更性上次更新的时间戳(lastUpdateTimestamp



(6)停止服务实例

服务下线,就会调用 shutdown() 方法。

eureka-client 停止服务,代码如下:

// 定位:com.netflix.discovery.DiscoveryClient.java
public synchronized void shutdown() {
    // 
    if (isShutdown.compareAndSet(false, true)) {
        ... ...
        // 1. 取消各种调度任务
        cancelScheduledTasks();

        // If APPINFO was registered
        if (applicationInfoManager != null && clientConfig.shouldRegisterWithEureka()) {
            // 设置状态
            applicationInfoManager.setInstanceStatus(InstanceStatus.DOWN);
            
            // 2. HTTP 请求,DELETE apps/{appName}/{id}
            unregister();
        }
        // 3. 网络组件关闭
        if (eurekaTransport != null) {
            eurekaTransport.shutdown();
        }
        // 4. 监听器等关闭
        heartbeatStalenessMonitor.shutdown();
        registryStalenessMonitor.shutdown();
    }
}

eureka-server 端处理,步骤如下:

  1. 接收到 HTTP 下线请求,执行 InstanceResource.cancelLease() 方法
  2. 调用 AbstractInstanceRegistry.internalCancel() 方法
protected boolean internalCancel(String appName, String id, boolean isReplication) {
    try {
        read.lock();
        CANCEL.increment(isReplication);
        // 1. 拿到注册表中对应的 map
        Map<String, Lease<InstanceInfo>> gMap = registry.get(appName);
        Lease<InstanceInfo> leaseToCancel = null;
        if (gMap != null) {
            // 2. 从注册表中移除
            leaseToCancel = gMap.remove(id);
        }
        // 3. 同步,最近取消队列
        synchronized (recentCanceledQueue) {
            // 3.1. 在最近取消队列中,把这个事件加入
            recentCanceledQueue.add(
                new Pair<Long, String>(System.currentTimeMillis(), appName + "(" + id + ")"));
        }
       
        // 其他 map 中移除
        ... ...
        
        if (leaseToCancel == null) {
            ... ...
        } else {
            // 4. 续约取消
            leaseToCancel.cancel();
            InstanceInfo instanceInfo = leaseToCancel.getHolder();
            String vip = null;
            String svip = null;
            if (instanceInfo != null) {
                ... ...
                // 5. 最近修改队列:添加服务实例变更记录,保留3分钟
                recentlyChangedQueue.add(new RecentlyChangedItem(leaseToCancel));
                instanceInfo.setLastUpdatedTimestamp();
                vip = instanceInfo.getVIPAddress();
                svip = instanceInfo.getSecureVipAddress();
            }
            // 6. 让缓存失效,即 readWriteCacheMap 里全部清理掉
            // 有定时任务每隔30秒让 readOnlyCacheMap 和 readWriteCacheMap 进行同步操作
            invalidateCache(appName, vip, svip);
           
            return true;
        }
    } finally {
        read.unlock();
    }
}

之后 eureka-client 拉取增量注册表的时候:

  1. eureka-server就会返回 recentlyChangedQueue 里的数据
  2. eureka-client 接收数据,并进行本地注册表合并,再计算 Hash
  3. eureka-client 对比 server 端的 Hash 值,再决定是否重新拉取全量注册表

服务下线,总结如图:

服务下线.png