文章目录
前言
- 在微服务架构中,服务注册与发现 是基础组件。Spring Cloud Netflix 提供了强大的服务治理工具——Eureka,它通过注册中心的概念,帮助我们实现服务注册、发现与健康监控。本文将详细讲解 Eureka 的基本原理、服务注册与高可用流程、以及常见配置和用法。
Eureka 简介
Eureka
是Spring Netflix OSS
中服务发现与注册的核心组件,在Spring Cloud 2022.0.x
版本开始,Spring Netflix OSS
组件(例如Hystrix、Zuul
)被正式移除。Spring 团队逐渐停止了对这些组件的支持,转而支持基于 Spring Cloud 的其他解决方案,比如Spring Cloud Gateway、Resilience4j
等。但Eureka
仍然支持,说明在设计上Eureka
作为服务注册与发现仍占有一席之地。- Spring Cloud Release Notes 参考
架构设计
- Eureka 是 Netflix 开源的服务发现组件,遵循 AP 模式设计,采用 Client/Server 模式。它分为 Eureka Server 和 Eureka Client 两部分:
Eureka Server:作为注册中心,维护所有服务的注册信息;
Eureka Client:服务提供者或消费者,通过 Eureka Server 注册或获取服务信息。
工作流程
- 服务注册:服务启动后,会将自身信息(如 IP、端口、服务名称等)注册到 Eureka Server 上。
- 服务续约:Eureka Client 会定期向 Eureka Server 发送心跳,续约租约。
- 服务发现:服务消费者从 Eureka Server 获取注册表,并根据服务名称访问目标服务。
- 服务剔除:当某服务不再发送心跳时,Eureka Server 在租约过期后将其移出注册列表。
项目 demo 构建
Eureka Server 的搭建
-
创建一个 Spring Boot 项目,并引入 Spring Cloud Netflix Eureka Server 依赖
-
pom.xml
文件
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>eureka-server</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
</parent>
<properties>
<spring-cloud.version>2021.0.1</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
</project>
- 配置文件
application.yml
server:
port: 8761
spring:
application:
name: eureka-server
eureka:
instance:
hostname: localhost # Eureka Server 的主机名,其他服务会通过这个地址注册
instance-id: ${spring.application.name}:${server.port} # 实例 ID,唯一标识一个服务实例
client:
register-with-eureka: true # 是否将 Eureka Server 本身作为客户端注册到注册中心
fetch-registry: false # Eureka Server 不拉取服务注册表(只提供服务注册功能)
service-url:
defaultZone: http://localhost:8761/eureka/ # Eureka Server 地址,客户端注册所用
server:
enable-self-preservation: false # 启用自我保护模式,防止服务实例因心跳丢失被剔除
eviction-interval-timer-in-ms: 60000 # 清理失效服务的时间间隔,单位为毫秒
- 启动类
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
Eureka Client 的配置
pom.xml
文件
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>service-demo1</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
</parent>
<properties>
<spring-cloud.version>2021.0.1</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
- 配置文件
application.yml
server:
port: 8081
spring:
application:
name: service-demo1
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/ # Eureka Server 地址,客户端注册所用
fetch-registry: true # 是否从 Eureka Server 拉取服务注册表,服务提供者通常设置为 true
register-with-eureka: true # 是否将自己注册到 Eureka Server,服务提供者需要设置为 true
registry-fetch-interval-seconds: 30 # 注册表拉取间隔
instance:
hostname: localhost
instance-id: ${spring.application.name}:${server.port} # 实例 ID,唯一标识一个服务实例
prefer-ip-address: true # 使用 IP 地址注册服务,通常设置为 true
lease-renewal-interval-in-seconds: 30 # 心跳间隔
lease-expiration-duration-in-seconds: 90 # 过期时间
- 启动类
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
补充说明
- Spring Cloud & Spring Boot 版本依赖参考
报错 com.netflix.discovery.AbstractDiscoveryClientOptionalArgs that could not be found. 解决方法:需要引入 spring-boot-starter-web
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
运行效果
深入使用
Eureka
注册中心添加认证
添加 Spring Security 模块
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 修改配置文件
application.yml
spring:
application:
name: eureka-security-server
security: # 配置 Spring Security 登录用户名和密码
user:
name: admin
password: 123456
添加 Java 配置 WebSecurityConfig
默认情况下添加SpringSecurity依赖的应用每个请求都需要添加CSRF token才能访问。
Eureka客户端注册时并不会添加,所以需要配置/eureka/**路径不需要CSRF token。
package org.example.config;
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().ignoringAntMatchers("/eureka/**");
super.configure(http);
}
}
- 运行效果
搭建 Eureka 集群实现高可用
- 服务的注册和发现都是通过注册中心实现,一旦注册中心宕机所有服务的注册和发现都会不可用,因此我们可以
搭建 Eureka 集群实现高可用
,集群一方面可以实现高可用,另一方面也可以分担服务注册和发现的压力。
双节点集群搭建
- 下面我们搭建一个双节点的注册中心集群:
- 复制一份启动配置
- 修改配置文件
application.yml
实现相互注册
# 配置 host 文件
127.0.0.1 localhost1
127.0.0.1 localhost2
# eureka-server1
server:
port: 8761
eureka:
instance:
hostname: localhost1 # Eureka Server 的主机名,其他服务会通过这个地址注册
prefer-ip-address: false # 使用 IP 地址注册服务,通常设置为 true
instance-id: ${spring.application.name}:${server.port} # 实例 ID,唯一标识一个服务实例
client:
register-with-eureka: true # 将 Eureka Server 本身作为客户端注册到注册中心
fetch-registry: false # Eureka Server 不拉取服务注册表(只提供服务注册功能)
service-url:
defaultZone: http://admin:123456@localhost2:8762/eureka/
# eureka-server2
server:
port: 8762
eureka:
instance:
hostname: localhost2 # Eureka Server 的主机名,其他服务会通过这个地址注册
prefer-ip-address: false # 使用 IP 地址注册服务,通常设置为 true
instance-id: ${spring.application.name}:${server.port} # 实例 ID,唯一标识一个服务实例
client:
register-with-eureka: true # 将 Eureka Server 本身作为客户端注册到注册中心
fetch-registry: false # Eureka Server 不拉取服务注册表(只提供服务注册功能)
service-url:
defaultZone: http://admin:123456@localhost1:8761/eureka/
# 上面两个注册中心实现相互注册,并修改 eureka-client 配置
eureka:
client:
service-url:
defaultZone: http://admin:123456@localhost:8761/eureka/,http://admin:123456@localhost:8762/eureka/
运行效果
补充说明
为什么要配置 不同host
eureka
底层使用isThisMyUrl
方法去重,如果获取到相同的host
会被当做一个主机被去重,无法实现集群同步。
/**
* Checks if the given service url contains the current host which is trying
* to replicate. Only after the EIP binding is done the host has a chance to
* identify itself in the list of replica nodes and needs to take itself out
* of replication traffic.
*
* @param url the service url of the replica node that the check is made.
* @return true, if the url represents the current node which is trying to
* replicate, false otherwise.
*/
public boolean isThisMyUrl(String url) {
final String myUrlConfigured = serverConfig.getMyUrl();
if (myUrlConfigured != null) {
return myUrlConfigured.equals(url);
}
return isInstanceURL(url, applicationInfoManager.getInfo());
}
原理解析
服务注册、心跳续期详细流程
Eureka Client
在启动完成后实例状态为变更UP
状态,并尝试进行客户端注册,注册成功后定时进行心跳请求保持客户端状态;若第一次注册失败,后续定时心跳续期请求会返回 204 ,并重新尝试注册。Eureka Client
在发送注册、心跳等请求时,会向Eureka Server
集群节点serviceUrlList
顺序逐个去尝试,如果有一个请求成功了,就不再去向其他节点请求,最多只重试3次,超过3次直接抛出异常。
# com.netflix.discovery.shared.transport.decorator.RetryableEurekaHttpClient#execute
@Override
protected <R> EurekaHttpResponse<R> execute(RequestExecutor<R> requestExecutor) {
List<EurekaEndpoint> candidateHosts = null;
int endpointIdx = 0;
// 顺序尝试前 numberOfRetries 可以注册中心实例
for (int retry = 0; retry < numberOfRetries; retry++) {
EurekaHttpClient currentHttpClient = delegate.get();
EurekaEndpoint currentEndpoint = null;
if (currentHttpClient == null) {
if (candidateHosts == null) {
candidateHosts = getHostCandidates();
if (candidateHosts.isEmpty()) {
throw new TransportException("There is no known eureka server; cluster server list is empty");
}
}
if (endpointIdx >= candidateHosts.size()) {
throw new TransportException("Cannot execute request on any known server");
}
currentEndpoint = candidateHosts.get(endpointIdx++);
currentHttpClient = clientFactory.newClient(currentEndpoint);
}
try {
EurekaHttpResponse<R> response = requestExecutor.execute(currentHttpClient);
if (serverStatusEvaluator.accept(response.getStatusCode(), requestExecutor.getRequestType())) {
delegate.set(currentHttpClient);
if (retry > 0) {
logger.info("Request execution succeeded on retry #{}", retry);
}
return response;
}
logger.warn("Request execution failure with status code {}; retrying on another server if available", response.getStatusCode());
} catch (Exception e) {
logger.warn("Request execution failed with message: {}", e.getMessage()); // just log message as the underlying client should log the stacktrace
}
// Connection error or 5xx from the server that must be retried on another server
delegate.compareAndSet(currentHttpClient, null);
if (currentEndpoint != null) {
quarantineSet.add(currentEndpoint);
}
}
throw new TransportException("Retry limit reached; giving up on completing the request");
}
# org.springframework.cloud.netflix.eureka.http.RestTemplateEurekaHttpClient#register
@Override
public EurekaHttpResponse<Void> register(InstanceInfo info) {
String urlPath = serviceUrl + "apps/" + info.getAppName();
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.ACCEPT_ENCODING, "gzip");
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
ResponseEntity<Void> response = restTemplate.exchange(urlPath, HttpMethod.POST, new HttpEntity<>(info, headers),
Void.class);
return anEurekaHttpResponse(response.getStatusCodeValue()).headers(headersOf(response)).build();
}
- 从上面的设计中我们可以看出,为了避免注册中心实例单点压力过大,我们在配置
service-url
应该尽可能把所有地址都配置上,且顺序应该保持随机。
Eureka Client
失效驱逐
Eureka Service
会定时遍历遍历注册表中的实例,找出超过租约期的实例并将其从注册表中移除。若已启用自我保护模式,则停止驱逐,直到恢复心跳,自我保护模式关闭。
# com.netflix.eureka.registry.AbstractInstanceRegistry#evict(long)
public void evict(long additionalLeaseMs) {
logger.debug("Running the evict task");
// 判断自我保护模式是否启动:防止由于网络分区或临时的网络中断等非正常情况导致 Eureka 大规模地将实例误认为已失效并驱逐,避免影响系统的整体可用性。
if (!isLeaseExpirationEnabled()) {
logger.debug("DS: lease expiration is currently disabled.");
return;
}
// We collect first all expired items, to evict them in random order. For large eviction sets,
// if we do not that, we might wipe out whole apps before self preservation kicks in. By randomizing it,
// the impact should be evenly distributed across all applications.
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);
}
}
}
}
// To compensate for GC pauses or drifting local time, we need to use current registry size as a base for
// triggering self-preservation. Without that we would wipe out full registry.
// 即使关闭自我保护模式,若不将 renewalPercentThreshold 设置为 0 ,实例也会分批过期,避免网络原因造成服务难以恢复
int registrySize = (int) getLocalRegistrySize();
int registrySizeThreshold = (int) (registrySize * serverConfig.getRenewalPercentThreshold());
int evictionLimit = registrySize - registrySizeThreshold;
int toEvict = Math.min(expiredLeases.size(), evictionLimit);
if (toEvict > 0) {
logger.info("Evicting {} items (expired={}, evictionLimit={})", toEvict, expiredLeases.size(), evictionLimit);
// 随机驱逐的方式将过期实例的驱逐影响分布在不同应用之间,避免某一应用实例被全部驱逐。
Random random = new Random(System.currentTimeMillis());
for (int i = 0; i < toEvict; i++) {
// Pick a random item (Knuth shuffle algorithm)
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();
logger.warn("DS: Registry: expired lease for {}/{}", appName, id);
internalCancel(appName, id, false);
}
}
}
- 除了自我保护模式以外,失效驱逐过程中还以为了分批和随机驱逐的方式来提供系统的可用性,分批驱逐举例:
假设 20 个租约,其中有 10 个租约过期。
第一轮执行开始
int registrySize = 20;
int registrySizeThreshold = (int) (20 * 0.85) = 17;
int evictionLimit = 20 - 17 = 3;
int toEvict = Math.min(10, 3) = 3;
第一轮执行结束,剩余 17 个租约,其中有 7 个租约过期。
第二轮执行开始
int registrySize = 17;
int registrySizeThreshold = (int) (17 * 0.85) = 14;
int evictionLimit = 17 - 14 = 3;
int toEvict = Math.min(7, 3) = 3;
第二轮执行结束,剩余 14 个租约,其中有 4 个租约过期。
...以此类推,或者将 renewalPercentThreshold 设置为0 ,但不建议
集群模式下如何注册 & 集群同步
Eureka
属于 AP 设计,注册中心是完全平等和独立,且状态并不完全一致,当Eureka Client
请求注册、续期、下线到某一个注册中心实例时,该实例会将这些信息同步到集群中其它注册中心,以注册的代码为例:
# com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl#register
/**
* Registers the information about the {@link InstanceInfo} and replicates
* this information to all peer eureka nodes. If this is replication event
* from other replica nodes then it is not replicated.
*
* @param info
* the {@link InstanceInfo} to be registered and replicated.
* @param isReplication
* true if this is a replication event from other replica nodes,
* false otherwise.
*/
@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);
}
# com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl#replicateToPeers
/**
* Replicates all eureka actions to peer eureka nodes except for replication
* traffic to this node.
*
*/
private void replicateToPeers(Action action, String appName, String id,
InstanceInfo info /* optional */,
InstanceStatus newStatus /* optional */, boolean isReplication) {
Stopwatch tracer = action.getTimer().start();
try {
if (isReplication) {
numberOfReplicationsLastMin.increment();
}
// If it is a replication already, do not replicate again as this will create a poison replication
if (peerEurekaNodes == Collections.EMPTY_LIST || isReplication) {
return;
}
for (final PeerEurekaNode node : peerEurekaNodes.getPeerEurekaNodes()) {
// If the url represents this host, do not replicate to yourself.
if (peerEurekaNodes.isThisMyUrl(node.getServiceUrl())) {
continue;
}
replicateInstanceActionsToPeers(action, appName, id, info, newStatus, node);
}
} finally {
tracer.stop();
}
}
自我保护模式
作用
- Eureka 通过定期接收注册实例发送的心跳信号(续约请求)来判断服务是否存活。当一个实例未能按时发送心跳信号时,Eureka 会将其标记为“不可用”,并从注册列表中移除。但是,当遇到网络分区、延迟或临时故障时,可能导致一些正常运行的实例无法发送心跳信号,导致 Eureka 错误地将这些实例下线,进而造成服务的大量下线,影响系统的稳定性。
- 为了防止这种情况,Eureka 的自我保护机制的目的是:
避免大量下线:当心跳数量突然下降时,停止过快地移除实例。
提高系统可用性:在网络抖动或短暂的连接问题下,保证系统中的服务实例尽可能保持在线。
- 缺点:当服务实例确实不可用时,可能导致 Eureka 注册表无法及时更新,影响服务发现的准确性。
工作原理
- 计算预期心跳续约率:Eureka 根据注册表中当前服务实例的数量计算出预期的心跳续约总数。
protected void updateRenewsPerMinThreshold() {
this.numberOfRenewsPerMinThreshold = (int) (this.expectedNumberOfClientsSendingRenews
* (60.0 / serverConfig.getExpectedClientRenewalIntervalSeconds())
* serverConfig.getRenewalPercentThreshold());
}
# 以上面的实例为例
int(3 (实例数) * (60.0 / 30(续期时间))* 0.85) = 5
- 实际续约率低于预期阈值:如果实际收到的心跳续约数低于预期数的 85%(默认阈值),Eureka 会自动启动自我保护模式,认为可能发生了网络问题或节点不可达的情况。
- 暂停实例下线:在自我保护模式下,Eureka 暂停对未续约实例的剔除操作,直到心跳率恢复到正常水平。
参考文档
个人简介
👋 你好,我是 Lorin 洛林,一位 Java 后端技术开发者!座右铭:Technology has the power to make the world a better place.
🚀 我对技术的热情是我不断学习和分享的动力。我的博客是一个关于Java生态系统、后端开发和最新技术趋势的地方。
🧠 作为一个 Java 后端技术爱好者,我不仅热衷于探索语言的新特性和技术的深度,还热衷于分享我的见解和最佳实践。我相信知识的分享和社区合作可以帮助我们共同成长。
💡 在我的博客上,你将找到关于Java核心概念、JVM 底层技术、常用框架如Spring和Mybatis 、MySQL等数据库管理、RabbitMQ、Rocketmq等消息中间件、性能优化等内容的深入文章。我也将分享一些编程技巧和解决问题的方法,以帮助你更好地掌握Java编程。
🌐 我鼓励互动和建立社区,因此请留下你的问题、建议或主题请求,让我知道你感兴趣的内容。此外,我将分享最新的互联网和技术资讯,以确保你与技术世界的最新发展保持联系。我期待与你一起在技术之路上前进,一起探讨技术世界的无限可能性。
📖 保持关注我的博客,让我们共同追求技术卓越。
个人简介
👋 你好,我是 Lorin 洛林,一位 Java 后端技术开发者!座右铭:Technology has the power to make the world a better place.
🚀 我对技术的热情是我不断学习和分享的动力。我的博客是一个关于Java生态系统、后端开发和最新技术趋势的地方。
🧠 作为一个 Java 后端技术爱好者,我不仅热衷于探索语言的新特性和技术的深度,还热衷于分享我的见解和最佳实践。我相信知识的分享和社区合作可以帮助我们共同成长。
💡 在我的博客上,你将找到关于Java核心概念、JVM 底层技术、常用框架如Spring和Mybatis 、MySQL等数据库管理、RabbitMQ、Rocketmq等消息中间件、性能优化等内容的深入文章。我也将分享一些编程技巧和解决问题的方法,以帮助你更好地掌握Java编程。
🌐 我鼓励互动和建立社区,因此请留下你的问题、建议或主题请求,让我知道你感兴趣的内容。此外,我将分享最新的互联网和技术资讯,以确保你与技术世界的最新发展保持联系。我期待与你一起在技术之路上前进,一起探讨技术世界的无限可能性。
📖 保持关注我的博客,让我们共同追求技术卓越。