Spring Cloud 核心组件:Eureka

对于微服务系统而言,服务注册、服务发现功能必不可少。

Spring Cloud 作为帮助快速构建分布式系统的框架,内置了 Eureka 作为注册中心组件。


为什么需要 Eureka

在微服务架构中,多个微服务通常部署在不同的机器上。而有时候一个完整的业务流程,通常需要多个微服务相互协同。以订单为例,一个完整的订单业务流程包括创建订单(订单系统)、修改库存(商品库存系统)、订单支付(支付系统)和增加用户积分(用户系统)等等。多个微服务之间通常使用 RPC 或者 HTTP 进行通信。

当业务变得越发复杂,这样固化的微服务地址会越来越多。或者当微服务集群需要大范围调整(扩容缩容)会有大批的微服务地址需要更新 …

自然而然,我们希望存在一个这样的“服务注册中心”:能够自动的帮我们管理所有的微服务,当微服务上线之后,会自动注册到这个“服务注册中心”,而其他使用到微服务的节点能够根据固定的微服务名称(如 USER_SERVICE)从“服务注册中心”获取注册表,进行服务调用。当微服务出现节点故障,能够自动从“服务注册中心”进行下线,更新调用方获取的注册表,从而确保调用方访问到的都是能够提供正常服务的健康节点。

这其实和发布-订阅(Publish/Subscribe)模式中的思想如出一辙,对于服务提供者而言,只管往“服务注册中心”注册/续期/下线服务,而无须关心具体的服务消费者是谁;而服务消费者只需使用固定的微服务名称定期从“服务注册中心”拉取最新的注册表,进行服务调用,而无须关心服务提供者具体都有谁。


服务注册 & 服务发现

在 Eureka 实现的服务注册 & 服务发现功能中,存在两个角色:Eureka Server & Eureka Client:

Eureka Servce

  1. 概述

Eureka Server :管理所有注册的微服务。提供 服务注册/服务续约(心跳机制)/服务下线 功能给服务提供者,同时提供 获取服务注册表 功能给服务消费者。

属性 含义 默认值
eureka.instance.lease-renewal-interval-in-seconds 服务续约时间间隔
代表每隔多少秒,服务提供者需要向 Eureka Server 发送服务续约心跳
30 s
eureka.instance.lease-expiration-duration-in-seconds 服务失效时间间隔
代表当服务提供者间隔了多少秒没有向 Eureka Server 发送服务续约心跳之后会被下线
90 s
  1. 基本使用

    2.1 实现一个 Eureka Server,首先导入相关依赖:

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
    </dependency>

    2.2 修改 application.yaml 配置文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    server:
    port: 7001

    eureka:
    instance:
    hostname: eureka-server.com
    client:
    # 设置为 false 代表该微服务本身不用注册到 Eureka Server(不用往自身注册),只有 Client 需要
    register-with-eureka: false
    # 设置为 false 代表不从 Eureka Server 拉取服务配置,只有 Client 需要
    fetch-registry: false
    service-url:
    # 这里必须使用 defaultZone ,而不能使用 default-zone,因为上面的 service-url 是对应属性 serviceUrl(Map 类型),这里的 defaultZone 是指 Map 里面的 key
    defaultZone: eureka-server.com:7001/eureka

    2.3 在启动类声明该微服务为 Eureka Server:

    1
    2
    3
    4
    5
    6
    7
    @SpringBootApplication
    @EnableEurekaServer
    public class Main {
    public static void main(String[] args) {
    SpringApplication.run(Main.class, args);
    }
    }

Eureka Client

  1. 概述

Eureka Client :包括服务的提供者和服务消费者。对于服务提供者角色的 Eureka Client 来说,启动时会向 Eureka Server 进行 服务注册(Register),之后每间隔一段时间会向 Eureka Server 发送心跳进行 服务续约(Renew),当一段时间间隔没有向 Eureka Server 发送心跳后,会被 Eureka Server 进行 服务剔除(Eviction),也可以由自身发起 服务下线(Cancel);对于服务消费者角色的 Eureka Client 来说,通过向 Eureka Server 获取注册表(GetRegisty),并将其缓存在本地,一定时间间隔会从 Eureka Server 更新注册表,在获得了注册表之后即可进行 远程调用(Remote Call)

属性 含义 默认值
eureka.client.registry-fetch-interval-seconds 服务消费者定期更新服务注册表的时间间隔 30 s
  1. 基本使用

    2.1 导入相关依赖:

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

    2.2 修改 application.yaml 配置文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    server:
    port: 8001

    eureka:
    client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
    # 指明 Eureka Server 的地址,如果是 Eureka Server 集群,则用逗号分隔
    defaultZone: eureka-server.com:7001/eureka
    instance:
    # 在 Eureka Server 显示的名称
    instance-id: user-service-8001
    # 显示 ip 地址
    prefer-ip-address: true

    2.3 在启动类声明该微服务为 Eureka Client:

    1
    2
    3
    4
    5
    6
    7
    @SpringBootApplication
    @EnableEurekaClient
    public class Main {
    public static void main(String[] args) {
    SpringApplication.run(Main.class, args);
    }
    }

至此,我们完成了 Eureka Server 和 Eureka Client 的相关配置。


服务监听

到现在为止,我们几乎没在 Euerka Server 写过什么代码,仅仅是在启动类声明了一下该微服务是作为 Eureka Server 运行。

但在某些特定的需求下,我们需要对服务的上下线进行监控(例如,当服务意外下线或者自动恢复上线都应该邮件通知运维人员),Eureka 中提供了事件监听的方式来扩展。

  • EurekaInstanceCanceledEvent : 服务下线事件
  • EurekaInstanceRegisteredEvent : 服务注册事件
  • EurekaInstanceRenewedEvent : 服务续约事件
  • EurekaRegistryAvailableEvent : 注册中心 启动事件
  • EurekaServerStartedEvent : Eureka Server 启动事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Component
public class EurekaStateChangeListener {
/**
* EurekaInstanceCanceledEvent 服务下线事件
* @param event
*/
@EventListener
public void listen(EurekaInstanceCanceledEvent event) {
System.out.println(event.getServerId() + "\t" + event.getAppName() + " 服务下线");
}

/**
* EurekaInstanceRegisteredEvent 服务注册事件
* @param event
*/
@EventListener
public void listen(EurekaInstanceRegisteredEvent event) {
InstanceInfo instanceInfo = event.getInstanceInfo();
System.out.println(instanceInfo.getAppName() + "进行注册");
}

/**
* EurekaInstanceRenewedEvent 服务续约事件
* @param event
*/
@EventListener
public void listen(EurekaInstanceRenewedEvent event) {
System.out.println(event.getServerId() + "\t" + event.getAppName() + " 服务进行续约");
}

/**
* EurekaRegistryAvailableEvent Eureka 注册中心启动事件
* @param event
*/
@EventListener
public void listen(EurekaRegistryAvailableEvent event) {
System.out.println("注册中心 启动");
}

/**
* EurekaServerStartedEvent Eureka Server 启动事件
* @param event
*/
@EventListener
public void listen(EurekaServerStartedEvent event) {
System.out.println("Eureka Server 启动");
}
}

在每个监听事件里,可以随意添加日志记录,邮件/短信通知等功能。


REST API

Eureka 作为注册中心,其本质是存储了每个客户端的注册信息。

使用 Eureka 作为注册中心,我们可以通过 Eureka 获取调用方信息,而无须关心服务方的 IP 和 端口,从而实现了微服务的随意扩容和随机端口启动。

那么网关微服务也可以这样做吗?网关微服务作为 API 入口,通常会用前面做一层 Nginx 作为负载。那么 Nginx 就必须知道网关微服务有哪几个节点,这样网关微服务就不能随便修改端口或者扩容了。虽然网关微服务一般不会经常变动,但我们仍然希望能够实现自动扩容。

其实利用 Eureka 提供的 REST API 我们可以获取某个服务的实例信息,也就是说我们可以根据 Eureka REST API 中返回的数据来动态配置 Nginx 的 upstream(也就是在 Lua 脚本里面编写动态获取服务 IP 和端口的代码,作为插件的形式加载,实现动态修改 Nginx 的 upstream 配置)


安全问题

通过上面的 Eureka Server 和 Eureka Client 搭建,我们实现了 Eureka 注册中心。每个注册到 Eureka Server 的 Eureka Client 都可以通过 Eureka 自带的管理页面查到。

但如果我们泄露了 Eureka 的运行端口(也就是 Eureka 自带的管理页面地址,事实上这也很容易被查到,写个脚本轮询访问所有端口即可),我们将会把所有 Eureka Client 注册实例的信息暴露在外,这显然是巨大的安全隐患。

针对此,Eureka 提供了密码认证功能。

  1. 导入依赖:

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

  2. 修改配置文件:

    1
    2
    3
    4
    5
    spring:
    security:
    user:
    name: eurekaname
    password: eurekapassword

  3. 增加配置类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable();
    http.authorizeRequests().anyRequest().authenticated().and().httpBasic();
    }
    }

完成上述配置后,再访问 Eureka 的管理页面,将会要求输入账号密码。

同时,原本直接使用 Eureka Server 地址就能注册的 Eureka Client 也要做出相应修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8001

eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
# 需要提供账号密码才能往 Eureka Server 注册
defaultZone: http://eurekaname:eurekapassword@eureka-server.com:7001/eureka
instance:
instance-id: user-service-8001
prefer-ip-address: true


注册流程

那么 Eureka Client 到底是如何向 Eureka Server 注册的呢?注册又包括哪些内容?

查看源码发现,在 Eureka Client 启动时,EurekaAutoServiceRegistration 会被添加到 Spring 中的 lifecycleBeans 当中。

随后当 Spring 的 DefaultLifecycleProcessor 执行 doStart() 方法时, EurekaAutoServiceRegistration 的 start() 方法会被调用,并在该方法中进行 Eureka Client 的注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

// org.springframework.cloud.entflix.eureka.serviceregistry.EurekaAutoServiceRegistration.java
public void start() {
if (this.port.get() != 0) {
if (this.registration.getNonSecurePort() == 0) {
this.registration.setNonSecurePort(this.port.get());
}

if (this.registration.getSecurePort() == 0 && this.registration.isSecure()) {
this.registration.setSecurePort(this.port.get());
}
}

if (!this.running.get() && this.registration.getNonSecurePort() > 0) {
// 调用 rergister 方法向 Euerka Server 进行注册
this.serviceRegistry.register(this.registration);
// 向 Spring 上下文增加一个事件
this.context.publishEvent(new InstanceRegisteredEvent(this, this.registration.getInstanceConfig()));
// 设置 running 标识位为 true
this.running.set(true);
}
}

而 this.serviceRegistry.register(this.registration) 的调用会执行 DiscoveryClient 类中的 register() 方法进行注册,并返回注册结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

// com.netflix.discovery.DiscoveryClient.java
boolean register() throws Throwable {
logger.info(PREFIX + "{}: registering service...", appPathIdentifier);
EurekaHttpResponse<Void> httpResponse;
try {
// 调用注册
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();
}

eurekaTransport.registrationClient.register(instanceInfo) 经过 EurekaHttpClientDecorator 类最终调用 AbstractJerseyEurekaHttpClient 类的 register() 方法,入参是 InstanceInfo :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

// com.netflix.discovery.shared.transport.jersey.AbstractJerseyEurekaHttpClient.java
public EurekaHttpResponse<Void> register(InstanceInfo info) {
String urlPath = "apps/" + info.getAppName();
ClientResponse response = null;
try {
Builder resourceBuilder = jerseyClient.resource(serviceUrl).path(urlPath).getRequestBuilder();
addExtraHeaders(resourceBuilder);
// 设置压缩方式为 gzip,传输格式为 JSON
response = resourceBuilder
.header("Accept-Encoding", "gzip")
.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON)
.post(ClientResponse.class, info);
return anEurekaHttpResponse(response.getStatus()).headers(headersOf(response)).build();
} finally {
if (logger.isDebugEnabled()) {
logger.debug("Jersey HTTP POST {}/{} with instance {}; statusCode={}", serviceUrl, urlPath, info.getId(),
response == null ? "N/A" : response.getStatus());
}
if (response != null) {
response.close();
}
}
}

也就是在 InstanceInfo 中我们可以得知 Eureka Client 到底向 Eureka Server 注册了些什么内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76

// com.netflix.appinfo.INstanceInfo.java
public class InstanceInfo {
...
@JsonCreator
public InstanceInfo(
@JsonProperty("instanceId") String instanceId,
@JsonProperty("app") String appName,
@JsonProperty("appGroupName") String appGroupName,
@JsonProperty("ipAddr") String ipAddr,
@JsonProperty("sid") String sid,
@JsonProperty("port") PortWrapper port,
@JsonProperty("securePort") PortWrapper securePort,
@JsonProperty("homePageUrl") String homePageUrl,
@JsonProperty("statusPageUrl") String statusPageUrl,
@JsonProperty("healthCheckUrl") String healthCheckUrl,
@JsonProperty("secureHealthCheckUrl") String secureHealthCheckUrl,
@JsonProperty("vipAddress") String vipAddress,
@JsonProperty("secureVipAddress") String secureVipAddress,
@JsonProperty("countryId") int countryId,
@JsonProperty("dataCenterInfo") DataCenterInfo dataCenterInfo,
@JsonProperty("hostName") String hostName,
@JsonProperty("status") InstanceStatus status,
@JsonProperty("overriddenstatus") InstanceStatus overriddenStatus,
@JsonProperty("overriddenStatus") InstanceStatus overriddenStatusAlt,
@JsonProperty("leaseInfo") LeaseInfo leaseInfo,
@JsonProperty("isCoordinatingDiscoveryServer") Boolean isCoordinatingDiscoveryServer,
@JsonProperty("metadata") HashMap<String, String> metadata,
@JsonProperty("lastUpdatedTimestamp") Long lastUpdatedTimestamp,
@JsonProperty("lastDirtyTimestamp") Long lastDirtyTimestamp,
@JsonProperty("actionType") ActionType actionType,
@JsonProperty("asgName") String asgName) {
this.instanceId = instanceId;
this.sid = sid;
this.appName = StringCache.intern(appName);
this.appGroupName = StringCache.intern(appGroupName);
this.ipAddr = ipAddr;
this.port = port == null ? 0 : port.getPort();
this.isUnsecurePortEnabled = port != null && port.isEnabled();
this.securePort = securePort == null ? 0 : securePort.getPort();
this.isSecurePortEnabled = securePort != null && securePort.isEnabled();
this.homePageUrl = homePageUrl;
this.statusPageUrl = statusPageUrl;
this.healthCheckUrl = healthCheckUrl;
this.secureHealthCheckUrl = secureHealthCheckUrl;
this.vipAddress = StringCache.intern(vipAddress);
this.secureVipAddress = StringCache.intern(secureVipAddress);
this.countryId = countryId;
this.dataCenterInfo = dataCenterInfo;
this.hostName = hostName;
this.status = status;
this.overriddenStatus = overriddenStatus == null ? overriddenStatusAlt : overriddenStatus;
this.leaseInfo = leaseInfo;
this.isCoordinatingDiscoveryServer = isCoordinatingDiscoveryServer;
this.lastUpdatedTimestamp = lastUpdatedTimestamp;
this.lastDirtyTimestamp = lastDirtyTimestamp;
this.actionType = actionType;
this.asgName = StringCache.intern(asgName);

// ---------------------------------------------------------------
// for compatibility

if (metadata == null) {
this.metadata = Collections.emptyMap();
} else if (metadata.size() == 1) {
this.metadata = removeMetadataMapLegacyValues(metadata);
} else {
this.metadata = metadata;
}

if (sid == null) {
this.sid = SID_DEFAULT;
}
}
...
}

InstanceInfo 的构造方法中,可以看到所有 Eureka Client 向 Eureka Server 注册的信息,并且由构造方法的注解可以看出,Eureka Client 的注册信息最终会被处理成 JSON,可见 Eureka Client 和 Eureka Server 之间通过使用 JSON 进行通讯。另外,InstanceInfo 中的几乎所有的属性都被 volatile 所修饰,目的是为了确保线程可见性。

最后总结一下,Eureka Client 通过成为 Spring 的 lifecycleBean ,在 lifecycleBeans 被调用 doStart() 方法的时候,向 Eureka Server 进行注册,其中 Eureka Client 向 Eureka Server 注册信息主要包括:

  1. instanceId:实例 id,通常是注册的微服务的 eureka.instance.instance-id
  2. appName:服务名称,将来服务消费者通过该名称来获取服务列表,由 spring.application.name 配置指定
  3. ipAddr:微服务 ip 地址
  4. port:微服务端口
  5. homePageUrl:主页地址

Eureka Client 和 Eureka Server 使用 JSON/XML 进行通信,默认使用 JSON。


服务续约

我们知道,光有服务注册还不够,我们还需要及时知道那些注册到 Eureka Server 有哪些是正常,有哪些是出现了故障。特别是作为服务提供者角色的 Eureka Client ,出现故障时需要及时从 Eureka Server 中移除,确保其他作为服务消费者角色的 Eureka Client 不会拉取到这些故障节点的信息,发送远程调用。

换句话说,我们需要一个机制,能够让 Eureka Server 及时知道 Eureka Client 的最新状态。通常有两种方式实现:

  1. Eureka Server 定期向 Eureka Client 发送类 ping 指令,确保 Eureka Client 在线。
  2. Eureka Client 定期向 Eureka Server 发送类 ping 指令,告知自身状态信息。

Eureka 采用的第二种,由 Eureka Client 作为发包方定期发送“心跳”告知 Eureka Server 自身状态。

猜测原因由 Eureka Client 作为发包方可以有效避免“无效心跳”,因为 Eureka Client 除了需要告知 Eureka Server 自身状态以外,还需要及时将最新的注册信息交给 Eureka Server。所以如果在一个心跳周期内,Eureka Client 发送了最新信息给 Eureka Server,同时 Eureka Server 又发送了心跳包给 Eureka Client 确认,其实这时候心跳确认是没有意义的,因为 Eureka Client 能发送最新注册信息,必然是处于健康节点状态。

所以一个较好的实现方案是:Eureka Client 发送心跳包,同时心跳包带的是最新注册信息。相当于将确认状态和同步注册信息两个操作放在一个心跳流程里完成,同时由于传输的是压缩的注册信息,既保持了心跳包的“轻巧”,有避免了心跳包内容的“无意义”。

既然要定期发送“心跳”,就需要一个定时任务,开启定时发送“心跳”的任务定义在 DiscoveryClient 类的 initScheduledTasks() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

// com.netflix.discovery.DiscoveryClient.java
private void initScheduledTasks() {
...
if (clientConfig.shouldRegisterWithEureka()) {
int renewalIntervalInSecs = instanceInfo.getLeaseInfo().getRenewalIntervalInSecs();
int expBackOffBound = clientConfig.getHeartbeatExecutorExponentialBackOffBound();
logger.info("Starting heartbeat executor: " + "renew interval is: {}", renewalIntervalInSecs);

// Heartbeat timer
scheduler.schedule(
new TimedSupervisorTask(
"heartbeat",
scheduler,
heartbeatExecutor,
renewalIntervalInSecs,
TimeUnit.SECONDS,
expBackOffBound,
new HeartbeatThread()
),
renewalIntervalInSecs, TimeUnit.SECONDS);
...
} else {
logger.info("Not registering with Eureka server per configuration");
}
}

HeartbeatThread 是一个实现了 Runnable 接口的私有类,在 run() 方法中,调用 renew() 方法来发送“心跳”:

1
2
3
4
5
6
7
8
9
 
// com.netflix.discovery.DiscoveryClient.java
private class HeartbeatThread implements Runnable {
public void run() {
if (renew()) {
lastSuccessfulHeartbeatTimestamp = System.currentTimeMillis();
}
}
}

renew() 方法中调用了 register() 方法,走的和服务注册一样的流程,都是将注册信息往 Eureka Server 发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

// com.netflix.discovery.DiscoveryClient.java
boolean renew() {
EurekaHttpResponse<InstanceInfo> httpResponse;
try {
httpResponse = eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(), instanceInfo.getId(), instanceInfo, null);
logger.debug(PREFIX + "{} - Heartbeat status: {}", appPathIdentifier, httpResponse.getStatusCode());
if (httpResponse.getStatusCode() == Status.NOT_FOUND.getStatusCode()) {
REREGISTER_COUNTER.increment();
logger.info(PREFIX + "{} - Re-registering apps/{}", appPathIdentifier, instanceInfo.getAppName());
long timestamp = instanceInfo.setIsDirtyWithTime();
// 调用 register(),重新将注册信息交给 Eureka Server
boolean success = register();
if (success) {
instanceInfo.unsetIsDirty(timestamp);
}
return success;
}
return httpResponse.getStatusCode() == Status.OK.getStatusCode();
} catch (Throwable e) {
logger.error(PREFIX + "{} - was unable to send heartbeat!", appPathIdentifier, e);
return false;
}
}


服务发现

至于服务消费者角色的 Eureka Client 是如何发现可用服务,并进行远程调用就比较简单。

直接通过 Spring 注入的方式获取 DiscoveryClient 实例,并调用 getServices() 方法就可以获取所有注册到 Eureka Server 的服务,再通过 getInstances() + service 名称的方式可以获取到具体的 ServiceInstance 实例,从而拿到该服务向 Eureka Server 注册的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringBootTest
class EsquelTvApplicationTests {

@Resource
private DiscoveryClient discoveryClient;

@Test
void contextLoads() throws Exception {
List<String> services = discoveryClient.getServices();
for (String service : services) {
List<ServiceInstance> instances = discoveryClient.getInstances(service);
for (ServiceInstance instance: instances) {
// access instance information ...
System.out.println(instance.getInstanceId());
System.out.println(instance.getHost());
System.out.println(instance.getPort());
System.out.println(instance.getUri());
}
}
}
}


自我保护

由于 Eureka Client 和 Eureka Server 之间存在“心跳机制”,当 Eureka Client 在一定时间内没有及时发送心跳包,将会被 Eureka Server 移除。

这在网络状况一切正常的情况下当然没有问题,但是如果 Eureka Server 没有收到 Eureka Client 的心跳包是因为网络故障,而不是 Eureka Client 本身节点故障,这时候“心跳机制”将会导致健康节点被错误下线。

基于此,Eureka 在保留“心跳机制”的前提下,增加了“自我保护”机制:

Eureka Server 在运行期间会去统计心跳失败比例在 15 分钟之内是否低于 85%,如果低于 85%,说明大多数 Eureka Client 的心跳都没有收到,极有可能是因为 Eureka Server 的网络接收问题,而不是 Eureka Client 的发送问题。这时候 Eureka Server 就会会进入自我保护机制。

在 Eureka Server 的自我保护期间,将不会从注册表中移除因为长时间没有发送心跳包的 Eureka Client。同时访问 Eureka 首页会出现以下字样:

EMERGENCY! EUREKA MAY BE INCORRECTLY CLAIMING INSTANCES ARE UP WHEN THEY’RE NOT. RENEWALS ARE LESSER THAN THRESHOLD AND HENCE THE INSTANCES ARE NOT BEING EXPIRED JUST TO BE SAFE.

但仍能接收接受新服务的注册和查询请求,当网络恢复稳定,新的注册信息会被同步到其它节点中。

自我保护机制是 Eureka 的“宁可保留故障节点,也不剔除健康节点”一个首要原则。也就是 Eureka 在 CAP 定理中选择了 AP ,而不是 CP。在出现分区容错 P 时候,尽可能保证可用性 A,而牺牲掉一致性 C (可能存在真正故障的节点没有被移除,集群内的节点状态数据不一致)


集群 Eureka

在微服务的世界中,高可用至关重要。任何微服务都要避免发生单点故障,作为分布式系统中的“关键角色”注册中心自然也要搭建集群。

搭建 Eureka Server 集群十分简单,假设我们是使用三台服务器来搭建 Eureka Server 集群,一台 eureka-master,两台 eureka-cluster。我们只需要让 Eureka Server “彼此守望”即可:

  • 将 eureka-master 注册到 eureka-cluster0 和 eureka-cluster1 上
  • 将 eureka-cluster0 注册到 eureka-master 和 eureka-cluster1 上
  • 将 eureka-cluster1 注册到 eureka-master 和 eureka-cluster0 上

eureka-master 对应 application.yaml 变为:

1
2
3
4
5
6
7
8
eureka:
instance:
hostname: eureka-master.com
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://eureka-cluster0.com/eureka,http://eureka-cluster1.com/eureka

eureka-cluster0 对应 application.yaml 变为:

1
2
3
4
5
6
7
8
eureka:
instance:
hostname: eureka-cluster0.com
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://eureka-master.com/eureka,http://eureka-cluster1.com/eureka

eureka-cluster1 对应 application.yaml 变为:

1
2
3
4
5
6
7
8
eureka:
instance:
hostname: eureka-cluster1.com
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://eureka-master.com/eureka,http://eureka-cluster0.com/eureka


缓存机制

Eureka 中有服务状态 enum 类( com.netflix.appinfo.InstanceInfo.InstanceStatus ),用于描述节点的几种状态:

状态 说明
UP 在线
DOWN 下线
STARTING 正在启动
OUT_OF_SERVICE 失联
UNKNOWN 未知

Eureka Server 使用三个变量 (registry、readWriteCacheMap、readOnlyCacheMap) 保存服务注册信息。

默认情况下定时任务每 30s 将 readWriteCacheMap 同步至 readOnlyCacheMap,每 60s 清理超过 90s 未续约的节点,Eureka Client 每 30s 从 readOnlyCacheMap 更新服务注册信息,而 UI 则从 registry 更新服务注册信息。

三级缓存:

缓存 类型 说明
registry ConcurrentHashMap 实时更新,类 AbstractInstanceRegistry 成员变量,UI 端请求的是这里的服务注册信息
readWriteCacheMap Guava Cache/LoadingCache 实时更新,类 ResponseCacheImpl 成员变量,缓存时间 180s
readOnlyCacheMap ConcurrentHashMap 周期更新,类 ResponseCacheImpl 成员变量,默认每 30s 从 readWriteCacheMap 更新,Eureka client 默认从这里更新服务注册信息,可配置直接从 readWriteCacheMap 更新

与缓存相关的配置:

配置 默认 说明
eureka.server.useReadOnlyResponseCache true Client 从 readOnlyCacheMap 更新数据,false 则跳过 readOnlyCacheMap 直接从 readWriteCacheMap 更新
eureka.server.responsecCacheUpdateIntervalMs 30000 readWriteCacheMap 更新至 readOnlyCacheMap 周期,默认 30s
eureka.server.evictionIntervalTimerInMs 60000 清理未续约节点 (evict) 周期,默认 60s
eureka.instance.leaseExpirationDurationInSeconds 90 清理未续约节点超时时间,默认 90s

Eureka Client 存在两种角色:服务提供者服务消费者,作为服务消费者一般配合 Ribbon 或 Feign(Feign 内部使用 Ribbon)使用。

Eureka Client 启动后,作为服务提供者立即向 Server 注册,默认情况下每 30s 续约 (renew);作为服务消费者立即向 Server 全量更新服务注册信息,默认情况下每 30s 增量更新服务注册信息;Ribbon 延时 1s 向 Client 获取使用的服务注册信息,默认每 30s 更新使用的服务注册信息,只保存状态为 UP 的服务。

二级缓存:

缓存 类型 说明
localRegionApps AtomicReference 周期更新,类 DiscoveryClient 成员变量,Eureka Client 保存服务注册信息,启动后立即向 Server 全量更新,默认每 30s 增量更新
upServerListZoneMap ConcurrentHashMap 周期更新,类 LoadBalancerStats 成员变量,Ribbon 保存使用且状态为 UP 的服务注册信息,启动后延时 1s 向 Client 更新,默认每 30s 更新

与缓存相关的配置:

配置 默认 说明
eureka.instance.leaseRenewalIntervalInSeconds 30 Eureka Client 续约周期,默认 30s
eureka.client.registryFetchIntervalSeconds 30 Eureka Client 增量更新周期,默认30s(正常情况下增量更新,超时或与 Server 端不一致等情况则全量更新)
ribbon.ServerListRefreshInterval 30000 Ribbon 更新周期,默认 30s
更值得其他语言开发者看的《阿里Java开发手册》 深入了解 MyBatis :动态 SQL

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×