开篇
微服务简单来说是系统架构上的一种设计风格,他的主旨是将一个原本独立且庞大的系统按照不同的摸块划分成多个小型的服务,这些小型的服务都在各自独立的进程中运行,服务之间通过基于HTTP的RESTful API进行通信协作。
由于单体系统部署在一个进程中,往往我们在修改一个很小的功能,上线的时候还需要重启整个服务,对其他功能的运行也会产生影响。同时,单体应用中的不同功能模块对并发量、消耗的资源类型也各不相同,使得单体系统很难对服务器资源的合理里用。
在微服务的架构中,可以将不同的功能模块拆分成多个不同的服务,这些服务都能够独立部署和扩展,由于每个服务都运行在自己的进程内,在部署上,每个服务的更新不会影响到其他服务的运行。同时,我们可以更准确的为每个服务的性能进行评估,通过配合协作可以更容易发现系统的瓶颈,将服务器压力大的系统动态添加新的服务部署,并不需要修改代码。
Spring Cloud简介
Spring Cloud是一个基于Spring Boot实现的微服务架构开发工具,它为微服务中设计的配置管理、服务治理、智能路由、微代理、控制总线、决策竞选等提供了一种简单的开发方式。
Spring Cloud Eureka
Spring Cloud Eureka是Spring Cloud Netflix微服务套件中的一部分,它基于Netflix Eureka做了二次封装,主要服务完成微服务架构中的服务治理功能,Spring Cloud通过为Eureka增加了Spring Boot风格的自动化配置,我们只需要简单的引入依赖和注解配置就能让Spring Boot构建微服务应用轻松地与Eureka服务治理体系进行整合。
Eureka服务端,也称服务注册中心。同其他的服务注册中心一样,支持高可用配置,它依托于强一致性提供良好的服务实例可用性,可以应对多种不同的故障场景。如果Eureka以集群模式部署,当集群中有分片出现故障时,那么Eureka就转入自我保护模式。它允许在分片出现故障起间继续提功服务的发现和注册,当故障分片恢复运行时,集群中的其他分片会把他们的状态再此同步回来。
Eureka客户端,主要处理服务的注册与发现,客户端服务通过注解和参数配置的方式,嵌入在客户端应用程序的代码中,在应用程序运行时,Eureka客户端向注册中心注册自身提供的服务,并周期性的发送心跳来更新它的服务续约。同时,它也能从服务端查询当前注册的服务信息并把他们缓存到本地并周期性得刷新服务状态。
代码实践
服务端
创建一个基础的Spring Boot并引入依赖:
org.springframework.boot
spring-boot-starter-parent
2.3.10.RELEASE
org.springframework.cloud
spring-cloud-starter-netflix-eureka-server
启动类,通过@EnableEurekaServer注解启动一个服务注册中心提供给其他应用进行注册。
/**
* @className: EurekaServerApplication
* @description: eureka服务注册中心启动类
* @author: charon
* @create: 2021-05-17 21:55
*/
@EnableEurekaServer
@SpringBootApplication
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
配置,服务注册中心也会将自己作为客户来尝试注册自己,所以要禁用他的客户端注册行为,配置内容如下:
server.port=9001
eureka.instance.hostname=localhost
# 代表不向注册中心注册自己
eureka.client.register-with-eureka=false
# 由于注册中心的职责就是维护服务实例,并不需要去检索服务,所以也设置为false
eureka.client.fetch-registry=false
eureka.client.serviceUrl.defaultZone=http://${eureka.instance.hostname}:${server.port}/eureka/
启动之后在浏览器访问如下图所示,在instances currently registered with eureka这一栏是空的,说明还没有注册任何服务。
客户端
引入eureka客户端的依赖:
org.springframework.cloud
spring-cloud-starter-netflix-eureka-client
org.springframework.boot
spring-boot-starter-web
客户端启动类,通过@EnableDiscoveryClient注解,激活Eureka中的EnableDiscoveryClient实现。
@EnableDiscoveryClient
@SpringBootApplication
public class EurekaClientApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaClientApplication.class, args);
}
}
controller类:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @className: HelloController
* @description: 测试controller类
* @author: charon
* @create: 2021-05-17 22:46
*/
@RestController
public class HelloController {
/**
* 日志记录类
*/
private final Logger logger = LoggerFactory.getLogger(getClass());
@Value("${server.port}")
private String host;
@Value("${spring.application.name}")
private String instanceName;
@RequestMapping("/sayHello")
public String sayHello(){
logger.info("你好,服务名:{},端口为:{}",instanceName,host);
return "你好,服务名:"+instanceName+",端口为:"+host;
}
}
配置:
server.port=9002
spring.application.name=hello-server
eureka.client.serviceUrl.defaultZone=http://localhost:9001/eureka/
启动之后重新访问eureka的信息面板,在instances currently registered with eureka这一栏可以看到我们hello-server的服务注册上了。
在服务注册中心的控制台中,可以看到这样的输出,表示名为hello-service的服务被注册成功了。
2021-05-17 23:10:48.673 INFO 3156 --- [nio-9001-exec-5] c.n.e.registry.AbstractInstanceRegistry : Registered instance HELLO-SERVER/DESKTOP-4LG5AMO:hello-server:9002 with status UP (replication=false)
通过浏览器访问
http://localhost:9002/sayHello,直接向该服务发起请求,在控制台中可以看到如下的输出信息:
2021-05-17 23:15:24.216 INFO 3820 --- [nio-9002-exec-1] c.c.e.controller.HelloController : 你好,服务名:hello-server,端口为:9002
配置高可用的注册中心
在微服务架构这样的分布式环境中,需要充分考虑发生故障的情况,所以在生产环境中,必须对各个组件进行高可用的部署,对于注册中心也是一样的。在Eureka的服务治理设计中,所有节点既是服务提供方,也是服务消费方。上面在单节点的配置中,配置过如下两个参数,让服务注册中心不注册自己:
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
Eureka服务的高可用其实就是将自己作为服务向其他服务注册中心注册自己,这样就可以形成一组互相注册的服务注册中心,以实现服务清单的相互同步,达到高可用的效果。
下面我们来构建一个双节点的服务注册中心集群:
1.创建
application-server1.properties作为EurekaServer1服务中心的配置,并将serviceUrl指向EurekaServer2:
server.port=9001
spring.application.name=eureka-server
eureka.instance.hostname=eureka-server1
eureka.client.register-with-eureka=true
eureka.client.fetch-registry=true
eureka.client.serviceUrl.defaultZone=http://eureka-server2:9002/eureka/
2.创建
application-server2.properties作为EurekaServer2服务中心的配置,并将serviceUrl指向EurekaServer1:
server.port=9002
spring.application.name=eureka-server
eureka.instance.hostname=eureka-server2
eureka.client.register-with-eureka=true
eureka.client.fetch-registry=true
eureka.client.serviceUrl.defaultZone=http://eureka-server1:9001/eureka/
3.在配置文件中配置路径的转换,由于我这是windows上,所有我这的路径为:C:\Windows\System32\drivers\etc\hosts.(linux上的路径为:/etc/hosts)
127.0.0.1 eureka-server1
127.0.0.1 eureka-server2
4.项目打包,通过spring.profiles.active属性来分别启动eureka-server1和eureka-server2.
java -jar eureka-server-0.0.1-SNAPSHOT.jar --spring.profiles.active=server1
java -jar eureka-server-0.0.1-SNAPSHOT.jar --spring.profiles.active=server2
如下图所示,在9001的server1上可以看到DS Replicas为eureka-server2的集群高可用服务,同理,在9002的server2上也可以看到eureka-server1的服务。
5.修改上面的eureka客户端的配置,将注册中心的地址指向上面启动的两个服务注册中心的地址
eureka.client.serviceUrl.defaultZone=http://eureka-server1:9001/eureka/,http://eureka-server2:9002/eureka/
启动eureka-client的服务,通过访问http://localhost:9001/和http://localhost:9002/;可以观察到HELLO-SERVER服务被同时注册到了eureka-server1和eureka-server2上,此时若断开其中一个注册中心的服务,那么在另一个注册中心的服务依然可以访问到HELLO-SERVER,从而实现了服务注册中心的高可用。
搭建服务消费者
上面我们已经搭建了服务注册中心(包括单节点和高可用两种模式)和服务提供者,下面来构建一个服务消费者,主要有两个任务:发现服务和消费服务。在这里使用OpenFeign来完成服务消费。OpenFeign是一种声明式,模板化的HTTP请求远程服务调用工具,具体的我们会在后面的专题中讲解。
导入依赖配置:
org.springframework.cloud
spring-cloud-starter-netflix-eureka-client
org.springframework.cloud
spring-cloud-starter-openfeign
org.springframework.boot
spring-boot-starter-web
properties中的配置:
server.port=9004
spring.application.name=eureka-customer
eureka.client.serviceUrl.defaultZone=http://eureka-server1:9001/eureka/,http://eureka-server2:9002/eureka/
在主类中,通过@EnableFeignClients开启feign。
@EnableFeignClients //feign在应用程序中默认是不开启的
@SpringBootApplication
public class EurekaCustomerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaCustomerApplication.class, args);
}
}
controller请求类:
@RestController
public class CustomerController {
@Autowired
private CustomerSerivce serivce;
@RequestMapping("/sayHello")
public String sayHello(){
return serivce.invokeSayHello();
}
}
service实现类:
/**
* 接口
*/
public interface CustomerSerivce {
String invokeSayHello();
}
/**
* 实现类
*/
@Service
public class CustomerServiceImpl implements CustomerSerivce {
@Autowired
private CustomerFeign feign;
@Override
public String invokeSayHello() {
return feign.sayHello();
}
}
feign接口类:
@FeignClient("HELLO-SERVER") //使用的value参数,表示从HELLO-SERVER这个服务中调用服务
public interface CustomerFeign {
/**
* 要求:
* 返回值要对应,方法名随意,参数值要对应
* 方法上添加SpringMVC的注解
* @return
*/
@RequestMapping("/sayHello")
String sayHello();
}
如上图,我们在浏览器上调用eureka-customer这个服务的接口,能直接访问到eureka-client服务的接口上去。
Eureka详解
前面我们已经通过一个简单的服务注册和发现的示例,构建了一个Eureka服务治理体系中的三个核心角色:服务注册中心,服务提供者以及服务消费者。下面我们来详细了解一下Eureka节点间的通信以及一些配置吧。
服务提供者
服务注册
服务提供者在启动的时候会通过发送Rest请求的方式将自己注册到Eureka Server上,同时带上了自身服务的一些元数据信息。Eureka Server在接受到这个Rest请求之后,将这些元数据信息存储在一个双层结构的Map中,其中第一层为Key是服务名,第二层是具体的服务实例名。
在服务注册时,需要确认一下,
eureka.client.register-with-eureka这个参数是否为true,若为false将不会启动注册操作。
服务同步
服务同步,顾名思义就是服务注册中心之间因相互注册为服务,当服务提供者发送注册请求到一个服务注册中心上时,他会将改请求转发给集群中其他的注册中心上,从而实现注册中心之间的服务同步。
服务续约
在注册完成后,服务提供者会维护一个心跳用来告诉Eureka Server 我还活着。以防止Eureka Server的“剔除任务”将该服务实例从服务列表中排除出去。
# 用于定义服务续约的调用间隔。默认为30S
eureka.instance.lease-renewal-interval-in-seconds=30
# 用于定义服务失效的时间,默认为90S
eureka.instance.lease-expiration-duration-in-seconds=90
服务消费者
获取服务
当我们启动服务消费者的时候,他也会发送一个Rest请求给服务注册中心,来获取上面注册的服务清单。同时为了性能考虑,Eureka Server会维护一份只读的服务清单来返回给客户端,缓存清单的时间默认为30S一次。
# 获取服务时服务消费者的基础,所以这个值默认为true
eureka.client.fetch-registry=true
# 缓存清单的更新时间。默认为30S
eureka.client.registry-fetch-interval-seconds=30
服务调用
服务消费者在获取服务清单后,通过服务名可以获得具体提供服务的实例名和该实例的元数据信息。
对于访问实例的选择,Eureka中有Region和Zone的概念。一个Region中可以包含多个Zone,每个服务客户端需要被注册到一个Zone中,所以每个客户端对应一个Region和Zone。在进行服务调用的时候,优先访问同处一个Zone中的服务提供方,若访问不到,就访问其他的Zone。
服务下线
在系统运行过程中必然会面临关闭或重启服务的情况,在服务关闭期间,我们自然不希望客户端会继续调用关闭了的实例。所以在客户端程序中,当服务实例进行正常的关闭操作时,他会触发一个服务下线的Rest请求给Eureka服务,告诉服务注册中心要下线了,服务端在接受到请求之后,将该服务状态置为下线,并把下线事件传播出去。
服务注册中心
失效剔除
有些时候,我们的服务并不时正常下线的,可能是由于网络故障等原因是的服务不能正常工作,而服务注册中心并未收到“服务下线”的请求。为了从服务列表中将这些无法提供服务的实例剔除,Eureka Server 在启动的时候会创建一个定时任务,每隔一点时间(默认为60S)将当前清单中超时(默认为90S)没有续约的服务剔除出去。
自我保护
很多时候我们在Eureka注册中心经常会看到上面图中的红色警告,这就是触发了Eureka Server的自我保护机制。服务注册到Eureka Server之后,会维护一个心跳连接,告诉Eureka服务自己还活着,Eureka Server在运行期间,会统计心跳失败的比例在15分钟之内是否低于85%,如果出现低于的情况,Eureka Server将会把当前的实例注册信息保护起来,让这些服务不会过期,尽可能地保护这些注册信息。但是如果在这期间获取到实际不存在地服务实例,会出现调用失败地情况,所以客户端必须要有容错机制,比如可以使用请求重试,断路器等机制。
可以使用下面地参数来关闭保护机制,以确保注册中心将不可用地实例正确剔除.
eureka.server.enableself-preservation=false
源码解析
首先我们来看看,为什么在eureka的服务端添加了@EnableEurekaServer这个注解就可以了呢?@EnableEurekaServer到底有什么魔力呢?
@EnableEurekaServer注解:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(EurekaServerMarkerConfiguration.class)
public @interface EnableEurekaServer {
}
@Configuration(proxyBeanMethods = false)
public class EurekaServerMarkerConfiguration {
@Bean
public Marker eurekaServerMarkerBean() {
return new Marker();
}
class Marker {
}
}
可以看到,@EnableEurekaServer通过@impprt注解导入
EurekaServerMarkerConfiguration这个类,EurekaServerMarkerConfiguration这个是主要就是把一个Marker类变成Spring的Bean。这个类并没有什么实际的功能,只是作为Bean存在,触发自动配置,以达到一个开关的效果。
我们都知道Spring Boot的组件都有一个自动配置类以完成自动装配工作。Eureka服务的自动配置类就是
EurekaServerAutoConfiguration类。关于Spring Boot如何实现自动装配的,可以查看我之前的博客《SpringBoot自动装配源码》。
下面来看看
EurekaServerAutoConfiguration这个类到底做了些什么呢?
@Configuration(proxyBeanMethods = false)
@Import(EurekaServerInitializerConfiguration.class)
@ConditionalOnBean(EurekaServerMarkerConfiguration.Marker.class)
@EnableConfigurationProperties({ EurekaDashboardProperties.class,
InstanceRegistryProperties.class })
@PropertySource("classpath:/eureka/server.properties")
public class EurekaServerAutoConfiguration implements WebMvcConfigurer {
}
首先需要注意的是@ConditionalOnBean这个注解,它的作用是判断
EurekaServerMarkerConfiguration.Marker这个Bean是否存在。如果存在才会解析这个自动配置类,从而呼应了@EnableEurekaServer这个注解。
我们都知道Eureka的注册,续约,集群同步都是通过一个restful风格的基于HTTP的rpc调用框架来实现的,Eureka使用它来为客户端提供远程服务,所以应该有一个类似于Spring mvc的controller类--ApplicationResource类。jerseyApplication()这个方法通过扫描@Path和@Provider标签,然后封装成beanDefinition封装到Application的set容器里,通过filter过滤器来过滤url进行映射到对象的Controller上。扫描对应包下的Resource并添加到ResourceConfig当中。
@Bean
public javax.ws.rs.core.Application jerseyApplication(Environment environment,
ResourceLoader resourceLoader) {
ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(
false, environment);
// Filter to include only classes that have a particular annotation.
provider.addIncludeFilter(new AnnotationTypeFilter(Path.class));
provider.addIncludeFilter(new AnnotationTypeFilter(Provider.class));
// Find classes in Eureka packages (or subpackages)
Set> classes = new HashSet<>();
for (String basePackage : EUREKA_PACKAGES) {
Set beans = provider.findCandidateComponents(basePackage);
for (BeanDefinition bd : beans) {
Class> cls = ClassUtils.resolveClassName(bd.getBeanClassName(),
resourceLoader.getClassLoader());
classes.add(cls);
}
}
// Construct the Jersey ResourceConfig
Map propsAndFeatures = new HashMap<>();
propsAndFeatures.put(
// Skip static content used by the webapp
ServletContainer.PROPERTY_WEB_PAGE_CONTENT_REGEX,
EurekaConstants.DEFAULT_PREFIX + "/(fonts|images|css|js)/.*");
DefaultResourceConfig rc = new DefaultResourceConfig(classes);
rc.setPropertiesAndFeatures(propsAndFeatures);
return rc;
}
再来看看
EurekaServerAutoConfiguration这个类通过Import注解引入的 EurekaServerInitializerConfiguration类。这个类实现了Lifecycle接口里的start()方法,在start()方法里进行初始化,初始化过程调用了EurekaServerBootstrap的contextInitialized方法,初始化了EurekaEnvironment(设置各种配置)和EurekaServerContext(Eureka的上下文),在initEurekaServerContext()方法中,主要做了两件事:
- 从相邻的集群节点当中同步注册信息
- 注册一个统计器
服务注册
前面说到Eureka是使用jersey来对外提供restful风格的rpc调用的,并通过一个类似于Spring mvc的controller的Resource类来实现的。
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));
return Response.status(204).build();
}
跟进register方法:
@Override
public void register(final InstanceInfo info, final boolean isReplication) {
// ...
// 1.注册实例信息,注册信息被保存在一个Map中,服务注册就是往map中放置节点信息,取消就是往map中删除节点信息
super.register(info, leaseDuration, isReplication);
// 2.复制到其他节点通过循环遍历向所有的Peers节点注册,最终执行类PeerEurekaNodes的register()方法,该方法通过执行一个任务向其他节点同步注册该注册信息
replicateToPeers(Action.Register, info.getAppName(), info.getId(), info, null, isReplication);
}
存放注册信息的Map(AbstractInstanceRegistry 的一个成员变量)结构:
private final ConcurrentHashMap>> registry
= new ConcurrentHashMap>>();
{
"商品服务": { // 服务名
"实例的唯一ID": { // 实例标识符
"lease": { // 持有实例信息
"instanceInfo": { // 实例信息
"appName": "商品服务",
"instanceId": "实例的唯一ID",
"ipAddr": "IP地址",
"port": "调用端口"
}
}
}
}
}
服务同步
上面提到了register方法做了两件事,注册服务及复制服务到其他节点。下面来看看复制服务实例信息到其他节点。
PeerAwareInstanceRegistryImpl#replicateToPeers()。
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();
}
// 如果本次register操作本身就是复制,就不再复制到其他节点了,避免循环注册,造成死循环
if (peerEurekaNodes == Collections.EMPTY_LIST || isReplication) {
return;
}
// 遍历所有节点
for (final PeerEurekaNode node : peerEurekaNodes.getPeerEurekaNodes()) {
// 如果是当前节点,则直接跳过
if (peerEurekaNodes.isThisMyUrl(node.getServiceUrl())) {
continue;
}
// 复制操作,在这个方法内部,除了注册以外还有心跳检测,取消等需要同步到其它节点的操作。跟进register方法,复制 // 实例信息被构造成了一个任务丢给了batchingDispatcher去异步执行,如果失败将会重试。
replicateInstanceActionsToPeers(action, appName, id, info, newStatus, node);
}
} finally {
tracer.stop();
}
}
Eureka Server会将register、cancel、heartbeat等操作从一个节点同步发送到其它节点,从而实现了复制的功能。Eureka和Zookeeper不一样,它是遵循ap的,所以采用了最终一致性,并没有像Zookeeper一样选择强一致。Eureka Server之间的维持最终一致性的细节点还是很多的,比如失败重试、超时、心跳、实例的版本号、同一个节点的锁控制等等。
获取服务
ApplicationsResource#getContainers()方法。获取服务信息列表其实就是从registry当中获取Applications,然后做一次序列化,最后通过HTTP响应回去。
服务续约
通过InstanceResource#renewLease()方法实现,比较简单,就是更新对应的Lease对象的lastUpdateTimestamp属性。
public void renew() {
lastUpdateTimestamp = System.currentTimeMillis() + duration;
}
服务剔除及失效剔除
通过InstanceResource#cancelLease()方法实现,最终在AbstractInstanceRegistry#internalCancel()中,通过leaseToCancel = gMap.remove(id);方法将对应的实例信息从注册信息中remove掉,然后将对应的Lease对象给个过期时间
public void cancel() {
if (evictionTimestamp <= 0) {
evictionTimestamp = System.currentTimeMillis();
}
}
以上客户端主动提请求服务剔除的操作,还有一个专门的定时任务进行服务的轮询对比过期时间的剔除操作。在
EurekaServerInitializerConfiguration这个类中初始化initEurekaServerContext()方法中,执行了registry.openForTraffic(applicationInfoManager, registryCount);最后一句调用了AbstractInstanceRegistry#postInit()方法,在此方法里开启了一个每60秒调用一次EvictionTask#evict()的定时器。最后都是调用的同样的逻辑AbstractInstanceRegistry#internalCancel()。
自我保护
客户端长时间不发送续约,服务端默认每隔一分钟会进行一次服务剔除,所以在上面的失效剔除定时器中,有一个isLeaseExpirationEnabled()方法。
/**
* 期望 最大 每分钟 续租 次数。 计算公式 当前注册的应用实例数 x 2
*/
protected volatile int expectedNumberOfRenewsPerMin ;
/**
* 期望 最小 每分钟 续租 次数。 计算公式 expectedNumberOfRenewsPerMin * 续租百分比( eureka.renewalPercentThreshold )
*/
protected volatile int numberOfRenewsPerMinThreshold ;
@Override
public boolean isLeaseExpirationEnabled() {
if (!isSelfPreservationModeEnabled()) {
// The self preservation mode is disabled, hence allowing the instances to expire.
return true;
}
return numberOfRenewsPerMinThreshold > 0 && getNumOfRenewsInLastMin() > numberOfRenewsPerMinThreshold;
}