深入浅出 Spring Cloud 之 Eureka

17,232 阅读24分钟

什么是 Eureka

Eureka is a REST (Representational State Transfer) based service that is primarily used in the AWS cloud for locating services for the purpose of load balancing and failover of middle-tier servers. We call this service, the Eureka Server. Eureka also comes with a Java-based client component,the Eureka Client, which makes interactions with the service much easier. The client also has a built-in load balancer that does basic round-robin load balancing. At Netflix, a much more sophisticated load balancer wraps Eureka to provide weighted load balancing based on several factors like traffic, resource usage, error conditions etc to provide superior resiliency.

这是 Netflix 官方的说明,如果英文不是很熟练的可以对照着下面的翻译看。

译:Eureka是基于REST(代表性状态转移)的服务,主要在AWS云中用于定位服务,以实现负载均衡和中间层服务器的故障转移。我们称此服务为Eureka服务器。Eureka还带有一个基于Java的客户端组件Eureka Client,它使与服务的交互变得更加容易。客户端还具有一个内置的负载平衡器,可以执行基本的循环负载平衡。在Netflix,更复杂的负载均衡器将Eureka包装起来,以基于流量,资源使用,错误条件等多种因素提供加权负载均衡,以提供出色的弹性。

简而言之, Eureka 就是 Netflix服务发现框架

服务发现:其实就是一个“中介”,整个过程中有三个角色:服务提供者(卖房子出租房子的)、服务消费者(租客买主)、服务中介(房屋中介)。

服务提供者: 就是提供一些自己能够执行的一些服务给外界。

服务消费者: 就是需要使用一些服务的“用户”。

服务中介: 其实就是服务提供者和服务消费者之间的“桥梁”,服务提供者可以把自己注册到服务中介那里,而服务消费者如需要消费一些服务(使用一些功能)就可以在服务中介中寻找注册在服务中介的服务提供者。

可以充当服务发现的组件有很多:ZookeeperConsulEureka 等。

Eureka 某些基础概念

  • 服务注册 Register:当 Eureka 客户端向 Eureka Server 注册时,它提供自身的元数据,比如IP地址、端口,运行状况指示符URL,主页等。

  • 服务续约 RenewEureka 客户会每隔30秒(默认情况下)发送一次心跳来续约。 通过续约来告知 Eureka ServerEureka 客户仍然存在,没有出现问题。 正常情况下,如果 Eureka Server 在90秒没有收到 Eureka 客户的续约,它会将实例从其注册表中删除。

  • 获取注册列表信息 Fetch RegistriesEureka 客户端从服务器获取注册表信息,并将其缓存在本地。客户端会使用该信息查找其他服务,从而进行远程调用。该注册列表信息定期(每30秒钟)更新一次。每次返回注册列表信息可能与 Eureka 客户端的缓存信息不同, Eureka 客户端自动处理。如果由于某种原因导致注册列表信息不能及时匹配,Eureka 客户端则会重新获取整个注册表信息。 Eureka 服务器缓存注册列表信息,整个注册表以及每个应用程序的信息进行了压缩,压缩内容和没有压缩的内容完全相同。Eureka 客户端和 Eureka 服务器可以使用JSON / XML格式进行通讯。在默认的情况下 Eureka 客户端使用压缩 JSON 格式来获取注册列表的信息。

  • 服务下线 Cancel:Eureka客户端在程序关闭时向Eureka服务器发送取消请求。 发送请求后,该客户端实例信息将从服务器的实例注册表中删除。该下线请求不会自动完成,它需要调用以下内容:DiscoveryManager.getInstance().shutdownComponent();

  • 服务剔除 Eviction: 在默认的情况下,当Eureka客户端连续90秒(3个续约周期)没有向Eureka服务器发送服务续约,即心跳,Eureka服务器会将该服务实例从服务注册列表删除,即服务剔除。

参考自 深入理解Eureka

我们可以这么理解,转换为现实中的问题就是 房屋中介问题

服务注册: 房东或者房屋的主人 (提供者 Eureka Client Provider)在中介 (服务器 Eureka Server) 那里登记房屋的信息,比如面积,价格,地段等等(元数据 metaData)。

服务续约: 房东或者房屋的主人 (提供者 Eureka Client Provider) 定期告诉中介 (服务器 Eureka Server) 我的房子还租或者还卖 (续约) ,中介 (服务器Eureka Server) 收到之后继续保留房屋的信息。

获取注册列表信息:租客或者买主(消费者 Eureka Client Consumer) 去中介 (服务器 Eureka Server) 那里获取所有的房屋信息列表 (客户端列表 Eureka Client List) ,而且租客或者买主为了获取最新的信息会定期向中介 (服务器 Eureka Server) 那里获取并更新本地列表。

服务下线:房东或者房屋的主人 (提供者 Eureka Client Provider) 告诉中介 (服务器 Eureka Server) 我的房子不卖了不租了,中介之后就将注册的房屋信息从列表中剔除。

服务剔除:房东或者房屋的主人 (提供者 Eureka Client Provider) 会定期联系 中介 (服务器 Eureka Server) 告诉他我的房子还租还卖(续约),如果中介 (服务器 Eureka Server) 长时间没收到提供者的信息,那么中介会将他的房屋信息给下架(服务剔除)。

Eureka架构

Eureka架构图
Eureka架构图

蓝色的 Eureka ServerEureka 服务器,这三个代表的是集群,而且他们是去中心化的。

绿色的 Application ClientEureka 客户端,其中可以是消费者提供者,最左边的就是典型的提供者,它需要向 Eureka 服务器注册自己和发送心跳包进行续约,而其他消费者则通过 Eureka 服务器来获取提供者的信息以调用他们

Eureka 与 Zookeeper 对比

  • Eureka: 符合AP原则 为了保证了可用性,Eureka 不会等待集群所有节点都已同步信息完成,它会无时无刻提供服务。
  • Zookeeper: 符合CP原则 为了保证一致性,在所有节点同步完成之前是阻塞状态的。

Eureka常用配置

服务端配置

eureka:
  instance:
    hostname: xxxxx    # 主机名称
    prefer-ip-address: true/false   # 注册时显示ip
  server:
    enableSelfPreservation: true   # 启动自我保护
    renewalPercentThreshold: 0.85  # 续约配置百分比

还需要在spring boot启动类中设置 @EnableEurekaServer 注解开启 Eureka 服务

客户端配置

eureka:
  client:
    register-with-eureka: true/false  # 是否向注册中心注册自己
    fetch-registry: # 指定此客户端是否能获取eureka注册信息
    service-url:    # 暴露服务中心地址
      defaultZone: http://xxxxxx   # 默认配置
  instance:
    instance-id: xxxxx # 指定当前客户端在注册中心的名称

用服务发现来查找服务(实战)

1.使用Spring DiscoveryClient

这种方法使用的比较少,因为它其中不会使用 Ribbon 来做 负载均衡 并且开发人员编写了过多的代码,不利于开发和维护。其实本质就是通过 DiscoveryClient 去获取所有实例的列表,然后从中获取一个的 service url 再通过 RestTemplate 进行远程调用。如果感兴趣可以去阅读 《微服务实战》的第四章。

下面两种方式,如果你做 Eureka Server 集群的话你会体验到 Ribbon 带来的 负载均衡 功能,因为这里只是简单的入门,如果读者感兴趣可以自己尝试一下。

2.使用启用了 RestTemplate 的 Spring DiscoveryClient

首先我们创建 Eureka Server

1. 创建 `spring boot` 项目并且勾选 `Eureka Server`
创建项目
创建项目
2. 编写配置文件
server:
  port: 8000
eureka:
  instance:
    hostname: localhost   # 指定Eureka主机
  client:
    register-with-eureka: false  # 是否向服务中心注册自己
    fetch-registry: false        # 是否能够获取Eureka注册信息
    service-url:    # 暴露自己的服务中心地址
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka
3. 开启 `Eureka Server` 并启动项目访问
服务端注解
服务端注解

Eureka服务端网站效果
Eureka服务端网站效果

然后创建一个 ``Provider` 的客户端

1. 新建项目并勾选 `web` 和 `Eureka Discovery Client`
创建客户端项目
创建客户端项目
2. 编写客户端配置文件
server:
  port: 9001
spring:
  application:
    name: provider-application     # 指定当前应用名 如果不配置 instance-id 那么此项为客户端在注册中心的名称默认值
eureka:
  instance:
    instance-id: provider-application  # 指定当前客户端在注册中心的名称
  client:
    service-url:
      defaultZone: http://localhost:8000/eureka  # 服务中心的地址 就是我们在 Eureka Server 中配置的暴露地址
3. 编写服务代码并配置启动类
// 直接在启动类中
@SpringBootApplication
@RestController
public class EurekaProviderApplication {

    public static void main(String[] args) {
        SpringApplication.run(EurekaProviderApplication.class, args);
    }
    // 随便编写一个服务代码 主要是给消费者调用的
    @RequestMapping(value = "/provider/{id}")
    public Map providerMethod(@PathVariable(value = "id")Integer id) {
        Map map = new HashMap<>();
        map.put(id.toString(), "Provider");
        return map;
    }

}

创建一个 `Consumer` 客户端

1. 新建项目,和前面提供者一样
2. 编写消费者客户端配置信息
server:
  port: 9002
spring:
  application:
    name: consumer-application
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8000/eureka
3. 编写调用提供者代码的逻辑并设置启动类
@SpringBootApplication
@RestController
public class EurekaConsumerApplication {
    // 这个注解告诉spring cloud 创建一个支持 Ribbon 的 RestTemplate
    @LoadBalanced
    @Bean
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }

    @Autowired
    RestTemplate restTemplate;

    @RequestMapping(value = "/consumer/{id}")
    public Map consumerTest(@PathVariable(value = "id")Integer id) {
        // 通过带有 Ribbon 功能的 RestTemplate 调用服务
        ResponseEntity<Map> responseEntity
                // 注意这里的url是提供者的名称
                = restTemplate.exchange("http://provider-application/provider/" + id, HttpMethod.GET,
                null, Map.class, id);
        return responseEntity.getBody();
    }

    public static void main(String[] args) {
        SpringApplication.run(EurekaConsumerApplication.class, args);
    }
}
4. 测试结果
测试结果
测试结果

3.使用`Open Feign`

NetflixOpen FeignSpring 弃用 RibbionRestTemplate 的替代方案。
你可以在消费者端定义与服务端映射的接口,然后你就可以通过调用消费者端的接口方法来调用提供者端的服务了(目标REST服务),除了编写接口的定义,开发人员不需要编写其他调用服务的代码,是现在常用的方案。

现在我们只需要对上面的消费者项目进行简单的修改就行了。

增加 `open Feign` 依赖并配置接口

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
@Service
// 使用@FeignClient表示服务 这里的值是 提供者的名称
@FeignClient("provider-application")
// 这里的值是提供者相应的路径
@RequestMapping("/provider")
public interface FeginService {
    // 这里的路径也要和提供者相同 参数也需要一样
    @GetMapping("/{id}")
    Map providerMethod(@PathVariable(value = "id") int id);
}

创建 `Controller` 实现类

@RestController
public class FeignController {
    // 调用服务
    @Autowired
    private FeginService feginService;

    @RequestMapping(value = "/consumer/{id}")
    public Map consumerTest(@PathVariable(value = "id")Integer id) {
        return feginService.providerMethod(id);
    }
}

增加 `Feign` 配置

# 当然你可以不进行配置 这里不影响主要功能
feign:
  client:
    config:
      default:
        connectTimeout: 5000  # 指定Feign客户端连接提供者的超时时限   取决于网络环境
        readTimeout: 5000   # 指定Feign客户端从请求到获取到提供者给出的响应的超时时限  取决于业务逻辑运算时间
  compression:
    request:
      enabled: true   # 开启对请求的压缩
      mime-types: text/xml, application/xml
      min-request-size: 2048   # 指定启用压缩的最小文件大小
    response:
      enabled: true   # 开启对响应的压缩

配置启动类

@SpringBootApplication
@EnableFeignClients   // 这里需要使用 @EnableFeignClients 来启用 Feign客户端
public class EurekaConsumerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaConsumerApplication.class, args);
    }
}

Eureka 的自我保护机制

Eureka Server 在某种特定情况下 Eureka Server 不会剔除其注册列表中的实例,那就是 Eureka 的自我保护时期。

何为自我保护? 假想一下,当一个 server 节点出现了网络分区等不可抗力原因,那么它会因此收不到 client 的续约心跳,如果网络波动比较大,也就可能导致 server 因为一次网络波动剔除了所有或者绝大部分 Client 。这种情况是我们不想看见的。

所以 Eureka 会有一种自我保护机制,默认是15分钟内收到的续约低于原来的85%(这是上面的续约配置比例)那么就会开启 自我保护 。这阶段 Eureka Server 不会剔除其列表中的实例,即使过了 90秒 也不会。

Eureka 部分源码浅析

这里做一部分的源码分析,主要涉及 DiscoveryClientInstanceInfoReplicator 两个类。

上面我们讲到 Spring 中的 DiscoveryClient 可以用来作为发现服务,只不过比较麻烦。

而在 Netflix 中也有一个 DiscoveryClient , 这个类的功能更加强大。我们来看一下官方文档对它的描述。

The class that is instrumental for interactions with Eureka Server.

Eureka Client is responsible for a) Registering the instance with Eureka Server b) Renewalof the lease with Eureka Server c) Cancellation of the lease from Eureka Server during shutdown

大概意思就是这个类负责了 Eureka Client 的注册,下线,剔除,更新,查询实例列表等操作。

现在我们来看一下这个类是如何构造的。

@Inject
DiscoveryClient(ApplicationInfoManager applicationInfoManager,
    EurekaClientConfig config, 
    AbstractDiscoveryClientOptionalArgs args,
    Provider<BackupRegistry> backupRegistryProvider, EndpointRandomizer endpointRandomizer){
        // 前面做一些校验和预注册处理
        .........省略代码
        // 很重要 初始化定时任务
        initScheduledTasks();
        // 后面做一些其他处理 比如时间日志的打印
        .........省略代码
}

可以看到里面最重要的是调用了 initScheduledTasks() 函数,并且主要的初始化还在这里。

private void initScheduledTasks() {
    // 如果定义可以获取实例列表信息
    if (clientConfig.shouldFetchRegistry()) {
        // registry cache refresh timer
        // 这里默认获取的30秒 也就是说这里是
        // 客户端每三十秒获取一次实例列表信息的代码实现
        int registryFetchIntervalSeconds = clientConfig.getRegistryFetchIntervalSeconds();
        int expBackOffBound = clientConfig.getCacheRefreshExecutorExponentialBackOffBound();
        scheduler.schedule(
                new TimedSupervisorTask(
                        "cacheRefresh",
                        scheduler,
                        cacheRefreshExecutor,
                        registryFetchIntervalSeconds,
                        TimeUnit.SECONDS,
                        expBackOffBound,
                        new CacheRefreshThread()
                ),
                registryFetchIntervalSeconds, TimeUnit.SECONDS);
    }
    // 如果是想服务中心注册自己的 则注册自己
    if (clientConfig.shouldRegisterWithEureka()) {
        // 这是续约
        int renewalIntervalInSecs = instanceInfo.getLeaseInfo().getRenewalIntervalInSecs();
        int expBackOffBound = clientConfig.getHeartbeatExecutorExponentialBackOffBound();
        logger.info("Starting heartbeat executor: " + "renew interval is: {}", renewalIntervalInSecs);
        // 发送心跳续约 每10秒一次
        // Heartbeat timer
        scheduler.schedule(
                new TimedSupervisorTask(
                        "heartbeat",
                        scheduler,
                        heartbeatExecutor,
                        renewalIntervalInSecs,
                        TimeUnit.SECONDS,
                        expBackOffBound,
                        new HeartbeatThread()
                ),
                renewalIntervalInSecs, TimeUnit.SECONDS);

        // 这里初始化了 实例信息复制器 很重要
        instanceInfoReplicator = new InstanceInfoReplicator(
                this,
                instanceInfo,
                clientConfig.getInstanceInfoReplicationIntervalSeconds(),
                2); // burstSize
        // 做一些状态上的监听和更新操作
        .......省略代码
        // 这里启动了实例信息复制器
        instanceInfoReplicator.start(clientConfig.getInitialInstanceInfoReplicationIntervalSeconds());
    } else {
        ... 打印注册失败日志
    }
}

我们可以看到 initScheduledTasks() 主要就是初始化所有的定时任务,比如 多长时间获取实例信息列表多长时间发送心跳包多长时间进行一次续约多长时间复制实例变化信息到eureka服务器 等等。

你可能有疑问,为什么是三十秒,干嘛要这么慢。喜欢阅读文档的同学会发现,官方给了一些说明,并且他推荐使用默认值。

1.10. Why Is It so Slow to Register a Service?

Being an instance also involves a periodic heartbeat to the registry (through the client’s serviceUrl) with a default duration of 30 seconds. A service is not available for discovery by clients until the instance, the server, and the client all have the same metadata in their local cache (so it could take 3 heartbeats). You can change the period by setting eureka.instance.leaseRenewalIntervalInSeconds. Setting it to a value of less than 30 speeds up the process of getting clients connected to other services. In production, it is probably better to stick with the default, because of internal computations in the server that make assumptions about the lease renewal period.

翻译:成为实例还涉及到注册表的定期心跳(通过客户端的serviceUrl),默认持续时间为30秒。
直到实例,服务器和客户端在其本地缓存中都具有相同的元数据后,客户端才能发现该服务(因此可能需要3个心跳)。
您可以通过设置eureka.instance.leaseRenewalIntervalInSeconds来更改周期。
将其设置为小于30的值可加快使客户端连接到其他服务的过程。
在生产中,最好使用默认值,因为服务器中的内部计算对租约续订期进行了假设。

在最后还调用了 InstanceInfoReplicator 这个类的启动方法,并且传入了一个 初始复制实例变化信息到eureka服务器的时间间隔(40s),我们继续查看 InstanceInfoReplicator 这个类中的方法。

public void start(int initialDelayMs) {
    // CAS无锁
    if (started.compareAndSet(falsetrue)) {
        // 初始化需要40秒
        instanceInfo.setIsDirty();  // for initial register
        Future next = scheduler.schedule(this, initialDelayMs, TimeUnit.SECONDS);
        scheduledPeriodicRef.set(next);
    }
}

public void run() {
    try {
        // 刷新实例信息
        discoveryClient.refreshInstanceInfo();
        Long dirtyTimestamp = instanceInfo.isDirtyWithTime();
        if (dirtyTimestamp != null) {
            // 最终还是会调用到 DiscoveryClient 的注册方法
            discoveryClient.register();
            instanceInfo.unsetIsDirty(dirtyTimestamp);
        }
    } catch (Throwable t) {
        logger.warn("There was a problem with the instance info replicator", t);
    } finally {
        Future next = scheduler.schedule(this, replicationIntervalSeconds, TimeUnit.SECONDS);
        scheduledPeriodicRef.set(next);
    }
}
boolean register() throws Throwable {
    logger.info(PREFIX + "{}: registering service...", appPathIdentifier);
    EurekaHttpResponse<Void> httpResponse;
    try {
        // 深入里面会调用到 AbstractJerseyEurekaHttpClient 的 注册方法
        // 其实里面就是调用到 服务端给客户端暴露出来的 注册接口
        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();
}

大致整理了一下上面的流程,如果不深入其实很简单。

注册流程
注册流程

当然,上面我说到注册方法其实就是通过 RestHttp 最终调用 Eureka Server 暴露出来的注册接口,那么这个注册接口在哪呢?

就在 ApplicationResource 中的 addInstance()

// 这里是暴露了接口
@POST
@Consumes({"application/json""application/xml"})
public Response addInstance(InstanceInfo info,
                            @HeaderParam(PeerEurekaNode.HEADER_REPLICATION)
 String isReplication) 
{
    // 做一些日志和校验
    .....省略代码
    // 到注册中心去注册
    registry.register(info, "true".equals(isReplication));
    // 返回 204 即无内容的成功
    return Response.status(204).build();  // 204 to be backwards compatible
}

主要还在这个 register 方法中,最终调用的是 PeerAwareInstanceImpl 类中的注册方法。

@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);
}

这里的 register 方法还是调用到 AbstractInstanceRegistry 类中的注册方法,核心主要都在这里。

首先我提一嘴,Lease 其中是租约对象,其中装配了实例信息这东西(源码里面是泛型 holder)。

你可以简单理解为在租房合同中里面记录着房屋的基本信息。
public void register(InstanceInfo registrant, int leaseDuration, boolean isReplication) {
    try {
        read.lock();
        // 这玩意不就有点像注册中心么
        // 跟spring的IOC有点相似呀
        // 这个registry 是一个 并发hashMap 里面存储了许多实例信息
        // 然后 EurekaServer 通过 注册的实例的应用名去获取这个map中获取
        // 如果获取到了 说明已经存在了 只需要把它取出来做更新就行
        // 否则则新建一个 并做更新
        Map<String, Lease<InstanceInfo>> gMap = registry.get(registrant.getAppName());
        REGISTER.increment(isReplication);
        if (gMap == null) {
           。。。省略代码 创建新的gmap
        }
        .......省略一大堆代码 简单理解也就是更新
        // 创建租约
        Lease<InstanceInfo> lease = new Lease<InstanceInfo>(registrant, leaseDuration);
        if (existingLease != null) {
            lease.setServiceUpTimestamp(existingLease.getServiceUpTimestamp());
        }
        // 这也是更新、、
        gMap.put(registrant.getId(), lease);
        // 添加到注册队列
        synchronized (recentRegisteredQueue) {
            recentRegisteredQueue.add(new Pair<Long, String>(
                    System.currentTimeMillis(),
                    registrant.getAppName() + "(" + registrant.getId() + ")"));
        }
        // 进行已覆盖状态的初始状态转移
        // 后面涉及到覆盖状态了 不用管先
        。。。省略一大堆代码
    } finally {
        read.unlock();
    }
}

嗯,我省略了很多代码,如果你感兴趣可以去阅读源码并深入了解原理,这里我只做简单分析。

感觉和 Spring 中的 IOC 注册很像,就是注册到容器中去,这里只不过换了个名而已,也就是将实例信息封装成合约然后注册到统一的 Map 中(注册中心)。

总结

计算机世界有些东西如果能把它联想到现实世界的例子那么就真的很好理解了,比如熔断,负载均衡等等,甚至可以这么说,计算机很多东西的解决方案都是现实世界给的灵感,不知道我举的房产中介的例子是否能让 Eureka 变得通俗易懂,不过也感谢大家阅读。

因为最近在忙其他的事情,写文章的时间比较少,后面会加紧ヾ(◍°∇°◍)ノ゙。