新建配置:
Data-id:
配置中心用来区分和精准定位配置文件的属性
在Nacos-Server中新建配置,其中Data ID它的定义规则是:${prefix}-${spring.profiles.active}.${file-extension}
注意:当 spring.profile.active 为空时,对应的连接符 - 也将不存在,dataId 的拼接格式变成 {prefix}.{file-extension}
这里我创建Data Id 为pxsemic-demo.properties的配置文件,其中Group为指定应用pxsemic-demo,配置文件的格式也相应的选择properties,如图所示
Nacos的默认Group为DEFAULT_GROUP,可以在新建时根据需要进行切换
个人开发使用nacos,建议直接变更group为自定义参数进行指定加载即可
命名规则为:${prefix}-${spring.profiles.active}.${file-extension}
通过其中的spring.profile.active属性即可进行多环境下配置文件的读取
新建配置
1、启动Nacos-Server后,创建配置文件Data ID为:pxsemic-demo.properties, 其配置如下:
server.port: 10001
这里是dev环境下默认加载
2、继续创建配置文件Data ID为:pxsemic-demo-test.properties, 其配置如下:
server.port: 8899
这里是test环境
多环境测试
通过Idea启动demo项目,并指定spring.profiles.active,通过不同的环境进行启动
可以通过指定group进行配置文件的指定切换
只通过Group来进行多环境的区分的方式不推荐使用,因为涉及到了多环境自然就会改变spring.profile.active,而profile一旦生效,配置文件就会依据DataID的规则进行查找。所以Group的方式仅作参考。
Group的合理用法应该是配合namespace进行服务列表和配置列表的隔离和管理,如开发环境多开发人员各自持有一份配置文件。
Namespace命名空间进行环境隔离也是官方推荐的一种方式。Namespace的常用场景之一是不同环境的配置的区分隔离,例如:开发测试环境和生产环境的资源(如配置、服务)隔离等。
创建命名空间
创建命名空间dev和prod,不同的命名空间会生成相应的UUID,如下图
可以通过指定spring.cloud.nacos.config.namespace来进行namespace的切换
日常开发中,多个模块可能会有很多共用的配置,比如数据库连接信息,Redis 连接信息,MQ 连接信息,监控配置等等。那么此时,我们就希望可以加载多个配置,多个项目共享同一个配置之类等功能,Nacos Config 也确实支持。
上述两类配置都⽀持三个属性:data-id、group(默认为字符串DEFAULT_GROUP)、refresh(默认为true)。
1、配置实例
spring.cloud.nacos.config.file-extension=properties spring.cloud.nacos.config.extension-configs[0].data-id=api-conf.properties spring.cloud.nacos.config.extension-configs[0].group=pxsemic-demo spring.cloud.nacos.config.extension-configs[0].refresh=true spring.cloud.nacos.config.extension-configs[1].data-id=comm-plugins.properties spring.cloud.nacos.config.extension-configs[1].group=pxsemic-demo spring.cloud.nacos.config.extension-configs[1].refresh=true spring.profiles.active=test #spring cloud中可以复用的配置 e.g.数据库连接 #spring.cloud.nacos.config.shared-configs[0].data-id= ##共享配置一般不指定group也可以根据应用区分 ##spring.cloud.nacos.config.shared-configs[0].group= #spring.cloud.nacos.config.shared-configs[0].file-extension=properties
参数解析:
注意:这里的Data ID后面是加.properties后缀的,且不需要指定file-extension。
三、共享配置和扩展配置的区
实际上,Nacos中并未对extension-configs和shared-configs的差别进⾏详细阐述。我们从他们的结构,看不出本质差别;除了优先级不同以外,也没有其他差别。那么,Nacos项⽬组为什么要引⼊两个类似的配置呢?我们可以从当初该功能的需求(issue)上找到其原始⽬的。
3.1 Nacos对配置的默认理念
3.2 主配置是应⽤专有的配置
因此,主配置应当在dataId上要区分,同时最好还要有group的区分,因为group区分应⽤(虽然dataId上区分了,不⽤设置group也能按应⽤单独加载)。
3.3 要在各应⽤之间共享⼀个配置,请使⽤上⾯的 shared-configs
因此按该理念,shared-configs指定的配置,本来应该是不指定group的,也就是应当归⼊DEFAULT_GROUP这个公共分组。
3.4 如果要在特定范围内(⽐如某个应⽤上)覆盖某个共享dataId上的特定属性,请使⽤ extension-config
⽐如,其他应⽤的数据库url,都是⼀个固定的url,使⽤shared-configs.dataId = mysql的共享配置。但其中有⼀个应⽤demo是特例,需要为该应⽤配置扩展属性来覆盖。
3.5 关于优先级
1、上述两类配置都是数组,对同种配置,数组元素对应的下标越⼤,优先级越⾼。也就是排在后⾯的相同配置,将覆盖排在前⾯的同名配置。
2、不同种类配置之间,优先级按顺序如下:主配置 > 扩展配置(extension-configs) > 共享配置(shared-configs)
服务注册中心,是一个给服务提供者注册服务、给服务消费者获取服务信息的地方,一般还提供服务列表查询、心跳检测等功能。注册中心为保证可用性一般集群部署。
注册中心组件我们可选的组件有Eureka、ZK、Nacos,ZK支持CP,Eureka支持AP,Nacos可支持AP也可支持CP。下面分别阐述下。
一、Eureka
Eureka分服务端与客户端,服务端为注册中心,而客户端完成向服务端注册与服务发现。服务端的主要工作有:
客户端的功能这里只是简单罗列一下:服务注册、自动刷新缓存获取最新服务列表、服务续约上报心跳、远程调用、服务下线。
注册与发现的工作流程:
Eureka是CAP里的AP
从CAP理论看,Eureka是一个AP系统,其优先保证可用性(A)和分区容错性,不保证强一致性,但能做到最终一致性。
由于Eureka并不强调一致性而侧重可用性,在设计上为提升性能采用了多级缓存的方案。这种设计和mysql的读写分离及JDK里的CopyOnWriteArrayList有点类似,目的是为了使操作不阻塞读操作。
Eureka数据存储机制
Eureka没有采用数据库这类存储介质,它的数据层分数据存储层和缓存层。数据存储层记录注册到 Eureka Server 上的服务信息,缓存层是经过包装后的数据,可以直接在 Eureka Client 调用时返回。
存储层
我们先来看看数据存储层的数据结构,它底层是一个双层HashMap:
private final ConcurrentHashMap>> registry= new ConcurrentHashMap >>();
缓存层
接下来我们再来看看缓存层。
Eureka Server 为了提供响应效率,提供了两层的缓存结构,将 Eureka Client 所需要的注册信息,直接存储在缓存结构中。
readOnlyCacheMap : 是一个 CurrentHashMap 只读缓存,这个主要是为了供客户端获取注册信息时使用,其缓存更新,依赖于定时器的更新,通过和 readWriteCacheMap 的值做对比,如果数据不一致,则以 readWriteCacheMap 的数据为准。
readWriteCacheMap:readWriteCacheMap 的数据主要同步于存储层。当获取缓存时判断缓存中是否没有数据,如果不存在此数据,则通过 CacheLoader 的 load 方法去加载,加载成功之后将数据放入缓存,同时返回数据。
readWriteCacheMap 缓存过期时间,默认为 180 秒,当服务下线、过期、注册、状态变更,都会来清除此缓存中的数据。
存储类型 |
数据结构 |
概述 |
readOnlyCacheMap 一级缓存 |
ConcurrentHashMap |
周期更新,默认每30s从二级缓存readWriteCacheMap中同步数据更新;Eureka Client默认从这里获取服务注册信息,可配为直接从readWriteCacheMap获取 |
readWriteCacheMap 二级缓存 |
Guava Cache |
服务有变动时实时更新,缓存时间180秒,如果不存在此数据,则通过 CacheLoader 的 load 方法去registry层加载 |
registry 存储层 |
双层HashMap |
服务有变动时实时更新,又名注册表,UI界面从这里获取服务注册信息 |
客户端查询流程
客户端查询服务端数据的流程是怎样的呢,这里暂时不考虑客户端自身的缓存。
Eureka Client 获取全量或者增量的数据时,会先从一级缓存中获取;如果一级缓存中不存在,再从二级缓存中获取;如果二级缓存也不存在,这时候先将存储层的数据同步到缓存中,再从缓存中获取。
通过 Eureka Server 的二层缓存机制,可以非常有效地提升 Eureka Server 的响应时间,通过数据存储层和缓存层的数据切割,根据使用场景来提供不同的数据支持。
客户端缓存
客户端缓存只是简单提一下:
Eureka Client 也同样存在着缓存机制,Eureka Client 启动时会全量拉取服务列表,启动后每隔 30 秒从 Eureka Server 量获取服务列表信息,并保持在本地缓存中。
二、ZK
ZK作为注册中心原理主发依赖于其自身的文件,ZK 的文件结构类似于 Linux 系统的树状结构,注册服务时,即在 ZK 中创建一个唯一的 znode 节点来保存服务的 IP、端口、服务名等信息;发现服务时,遍历树状结构文件找到具体的 znode 节点或者服务相关信息进行远程调用。
注册与发现的工作流程
ZK与Eureka的区别
这里简单提一下京东自研的RPC框架JSF,其注册中心为NameServer,与Eureka各节点一样,它们之间也是平等的、各节点存着全量数据,数据的同步采用的是消息总线的方式,显而易见,NameServer属于AP原则,通过消息总线来保证最终一致性。
三、Nacos
Nacos既能作为注册中心也可以作为配置中心,下面是作为注册中心的流程:
Nacos里的CP与AP
先看一下Nacos的架构图:
我们可以看到Nacos是集成了Raft与Distro这两种一致性协议的,我们先从Distro入手,看下Nacos的设计机制:
Nacos哪些地方用到了AP与CP呢?
Nacos、ZK、Eureka区别
Eureka不能支撑大量服务实例,因为它的每个节点之间会产生大量心跳检查导致并发性能降低;ZK如果出现频繁上下线通知也会导致性能下降;Nacos可以支持大量服务实例而又不丢失性能,服务数量可达到10万级别。
使用nacos注册中心需要添加依赖如下:
com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery 2.2.9.RELEASE org.springframework.boot spring-boot-starter-web
正常添加上方依赖,nacos控制中心前端才能刷新注册客户端数据
在SpringBoot启动类上开启@EnableDiscoveryClient注解启用注册到注册中心
配置文件:
spring.cloud.nacos.discovery.server-addr=192.168.25.136:8848 spring.cloud.nacos.discovery.namespace=83b829ce-6ebd-46d9-bcdc-6f9d95f364a3 #group不指定默认为 DEFAULT_GROUP spring.cloud.nacos.discovery.group=pxsemic-demo #根据需求设定实例是否为持久化实例,默认为true spring.cloud.nacos.discovery.ephemeral=false
nacos设置是否持久化实例发生变更且变更应用曾注册至nacos,需要将 \data\protocol\raft目录下注册元数据清除后重启nacos来进行应用的正常启动
根据此配置可以决定nacos使用CP/AP模式
服务注册源码:
Nacos心跳检测(客户端):
心跳检测(服务端):
/** * Create a beat for instance. * * @param request http request * @return detail information of instance * @throws Exception any error during handle */ @CanDistro @PutMapping("/beat") @Secured(action = ActionTypes.WRITE) public ObjectNode beat(HttpServletRequest request) throws Exception { ObjectNode result = JacksonUtils.createEmptyJsonNode(); result.put(SwitchEntry.CLIENT_BEAT_INTERVAL, switchDomain.getClientBeatInterval()); String beat = WebUtils.optional(request, "beat", StringUtils.EMPTY); RsInfo clientBeat = null; if (StringUtils.isNotBlank(beat)) { clientBeat = JacksonUtils.toObj(beat, RsInfo.class); } String clusterName = WebUtils .optional(request, CommonParams.CLUSTER_NAME, UtilsAndCommons.DEFAULT_CLUSTER_NAME); String ip = WebUtils.optional(request, "ip", StringUtils.EMPTY); int port = Integer.parseInt(WebUtils.optional(request, "port", "0")); if (clientBeat != null) { if (StringUtils.isNotBlank(clientBeat.getCluster())) { clusterName = clientBeat.getCluster(); } else { // fix #2533 clientBeat.setCluster(clusterName); } ip = clientBeat.getIp(); port = clientBeat.getPort(); } String namespaceId = WebUtils.optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID); String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME); NamingUtils.checkServiceNameFormat(serviceName); Loggers.SRV_LOG.debug("[CLIENT-BEAT] full arguments: beat: {}, serviceName: {}, namespaceId: {}", clientBeat, serviceName, namespaceId); BeatInfoInstanceBuilder builder = BeatInfoInstanceBuilder.newBuilder(); builder.setRequest(request); int resultCode = getInstanceOperator() .handleBeat(namespaceId, serviceName, ip, port, clusterName, clientBeat, builder); result.put(CommonParams.CODE, resultCode); result.put(SwitchEntry.CLIENT_BEAT_INTERVAL, getInstanceOperator().getHeartBeatInterval(namespaceId, serviceName, ip, port, clusterName)); result.put(SwitchEntry.LIGHT_BEAT_ENABLED, switchDomain.isLightBeatEnabled()); return result;
@Override public int handleBeat(String namespaceId, String serviceName, String ip, int port, String cluster, RsInfo clientBeat, BeatInfoInstanceBuilder builder) throws NacosException { // 获取服务 Service{namespace='public', group='DEFAULT_GROUP', // name='service-consumer', ephemeral=true, revision=0} Service service = getService(namespaceId, serviceName, true); // 获取客户端ID信息 192.168.60.1:1020#true String clientId = IpPortBasedClient.getClientId(ip + InternetAddressUtil.IP_PORT_SPLITER + port, true); // 根据客户端ID信息从 clientManager 中获取注册的实例 IpPortBasedClient client = (IpPortBasedClient) clientManager.getClient(clientId); // 如果 client 为空或者发布者 publishers 信息集合里面没有该客户端实例那么就从新注册一个 if (null == client || !client.getAllPublishedService().contains(service)) { // 如果心跳实体信息为空返回 20404,请求未找到 if (null == clientBeat) { return NamingResponseCode.RESOURCE_NOT_FOUND; } // 构建实例信息 Instance instance = new Instance(); // 设置端口信息 instance.setPort(clientBeat.getPort()); // 设置IP信息 instance.setIp(clientBeat.getIp()); //设置权重 instance.setWeight(clientBeat.getWeight()); // 设置元数据 instance.setMetadata(clientBeat.getMetadata()); // 设置集群名称 instance.setClusterName(clientBeat.getCluster()); // 设置服务名称 instance.setServiceName(serviceName); // 设置实例ID信息 instance.setInstanceId(instance.getInstanceId()); // 设置虚拟节点 instance.setEphemeral(clientBeat.isEphemeral()); // 发起注册 registerInstance(namespaceId, serviceName, instance); // 注册完后再一次获取 client 从 clientManager 中, // 因为注册的时候就是把这个客户端放入到 clientManager 中去的 client = (IpPortBasedClient) clientManager.getClient(clientId); } // 注册时候已经将当前的 service 放入到了 ServiceManager.getInstance()的 // ConcurrentHashMap singletonRepository 里面 // 所以在这里面获取了一次所以会有的 if (!ServiceManager.getInstance().containSingleton(service)) { throw new NacosException(NacosException.SERVER_ERROR, "service not found: " + serviceName + "@" + namespaceId); } // 如果心跳信息为空那么就构建一个心跳信息 if (null == clientBeat) { clientBeat = new RsInfo(); clientBeat.setIp(ip); clientBeat.setPort(port); clientBeat.setCluster(cluster); clientBeat.setServiceName(serviceName); } // 创建心跳处理器任务 ClientBeatProcessorV2 beatProcessor = new ClientBeatProcessorV2(namespaceId, clientBeat, client); HealthCheckReactor.scheduleNow(beatProcessor); client.setLastUpdatedTime(); return NamingResponseCode.OK; }
Nacos与eureka区别(事件处理):
@Override public void run() { if (Loggers.EVT_LOG.isDebugEnabled()) { Loggers.EVT_LOG.debug("[CLIENT-BEAT] processing beat: {}", rsInfo.toString()); } // 获取ID String ip = rsInfo.getIp(); // 端口号 int port = rsInfo.getPort(); // 服务名称 String serviceName = NamingUtils.getServiceName(rsInfo.getServiceName()); // 组名称 String groupName = NamingUtils.getGroupName(rsInfo.getServiceName()); // 获取服务 Service service = Service.newService(namespace, groupName, serviceName, rsInfo.isEphemeral()); // 获取健康检测实例发布信息里面多了几个属性, // 比如 lastHeartBeatTime 和 healthCheckStatus HealthCheckInstancePublishInfo instance = (HealthCheckInstancePublishInfo) client.getInstancePublishInfo(service); System.out.println("健康检测 ====== start =====+" + ip + "_" + port + "+ HealthCheckInstancePublishInfo instance" + instance.toString() + "date: " + new Date() + "====end===="); if (instance.getIp().equals(ip) && instance.getPort() == port) { if (Loggers.EVT_LOG.isDebugEnabled()) { Loggers.EVT_LOG.debug("[CLIENT-BEAT] refresh beat: {}", rsInfo.toString()); } // 更新一下实例的心跳时间 instance.setLastHeartBeatTime(System.currentTimeMillis()); System.out.println("健康检测 ====== start =====+" + ip + "_" + port + "+ HealthCheckInstancePublishInfo instance" + instance.toString() + "date: " + new Date() + "====end===="); // 如果实例是健康的那么就直接执行完任务, // 如果不是健康的那么就设置成健康的,之后发布服务改变事件和客户端改变事件 if (!instance.isHealthy()) { instance.setHealthy(true); Loggers.EVT_LOG.info("service: {} {POS} {IP-ENABLED} valid: {}:{}@{}, "region: {}, msg: client beat ok", rsInfo.getServiceName(), ip, port, rsInfo.getCluster(), UtilsAndCommons.LOCALHOST_SITE); NotifyCenter.publishEvent(new ServiceEvent.ServiceChangedEvent(service)); NotifyCenter.publishEvent(new ClientEvent.ClientChangedEvent(client)); } }