Nacos服务治理

461 阅读8分钟

Nacos概念

在学习Dubbo过程中,发现了以Nacos最为注册中心的扩展,就打算学习一下。

Nacos 致力于帮助您发现、配置和管理微服务。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理

官网地址 nacos.io/zh-cn/docs/…

目前主要的用途还是注册的注册发现和服务配置

主要的架构图如下:

从上图中,可以看到几个信息:
1、nacos对外的通信,主要采用http接口来通信。同时也提供了SDK的方式,打开SDK的源码会发现,其实内部还是通过http来通信。
2、Config Service 配置中心服务 和 Naming Service 注册/发现服务,从这个命名来看,应该与RocketMq中的nameserver相似。这里可以推测到Naming Service也许也是类似mq中一样,对于服务的下线并不是非常的实时,需要在客户端做好容错和重试的功能。

模型

数据模型

Nacos 数据模型 Key 由三元组唯一确定, Namespace默认是空串,公共命名空间(public),分组默认是 DEFAULT_GROUP。 例如下图

服务领域模型

来自官网的图

Nacos 集群

nacos采用leader-follower集群的模式。leader节点承担事物请求,follower节点承担非事物请求。当事物请求到follower节点时,转发到leader节点。在nacos中节点有3种状态:

  • leader 领导
  • follower 跟随
  • candidate 候选人 nacos中分为2种实例,临时实例和持久实例,分别支持AP和CP 2种模式,本文主要讲由raft算法完成的CP。 CAP原则又称CAP定理,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
    一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)

可用性(A):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)

分区容忍性(P):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

CAP原则的精髓就是要么AP,要么CP,要么AC,但是不存在CAP。如果在某个分布式系统中数据无副本, 那么系统必然满足强一致性条件, 因为只有独一数据,不会出现数据不一致的情况,此时C和P两要素具备,但是如果系统发生了网络分区状况或者宕机,必然导致某些数据不可以访问,此时可用性条件就不能被满足,即在此情况下获得了CP系统,但是CAP不可同时满足。

数据提交投票

在raft 算法中,数据同步需要遵循 遵循过半2pc原则,2阶提交。
主 -> 从 写入其他follower的日志里面
主 <- 从 收到来自follower的确认可以写入(过半同意包括自己) 同时主节点回复写入成功
主 -> 从 commit结果

虽然,在nacos的源码中并没有采用2阶过半提交,而是直接发送给follower数据同步,接结束了。但是过半的2pc在分布式中是非常常见的,被广泛接受这种数据同步,如zk同步数据,redis sentinel 客观下线。

这里也许会有疑问,那支持的follower提交了数据,那些不支持的follower怎么保持数据保持一致。一般在follower和leader有一个心跳检测,检测存活和数据的一致性,当发现数据不一致时,leader会向该follower同步数据。

选举投票 & raft算法

类似这种分布式协调的框架,都存在一个2个投票。一个是数据同步时的过半投票,一个就是选举leader时的投票。nacos采用raft算法来完成,这里有2个网站,讲的非常的详细
thesecretlivesofdata.com/raft/ 动画演示网站
niceaz.com/2018/11/03/… raft和zab的区别

尤其是第二个文章,我觉得讲的非常的清晰和有自己的理解。这里就不做多于的描述了,感觉怎么写也不如上面的写的好。

Naming Service

nacos采用定时任务和HTTP请求,来维持消费端和服务端的心跳检测。服务的管理中,无论是在哪个中间件,都非常的关注动态感知服务的上下线。

服务端

服务端通过openapi,向nacos进行服务的注册,同时在在本地开启一个延迟任务BeatTask,来完成后续的心跳检测。当BeatTask 触发时,会向nacos进行心跳的检测,同时若发现在nacos并没有这个服务,则会注册一个服务,然后再最后,会生成一个新的延迟任务BeatTask 用来继续下一次的心跳检测。这个操作很像在zk中的watch机制,watch机制只能监听触发一次,为了能够一直监听,会在一次监听的最后,重新注册上watch。

这样的原因为了防止,因为网络的延迟,导致在上一次心跳检测未完成时,就触发了下一次心跳检测。

消费端

对于消费端的操作,就比较的奢华,采用了pull和push2种机制来维护服务信息。在服务端通过api拉取服务信息时,开启延迟任务UpdateTask定时来更新和拉取订阅的服务信息默认10秒一次,拉取保存到本地内存中。在拉取的同时,本地开启UDP端口,并把端口号发送给nacos,通过PushReceiver中的PushReceiver线程池来完成,其中使用while循环来等待UDP的推送信息。当远程服务变化时,nacos通过UDP的端口来通知消费者。

但是由于UDP的不可靠性,故需要pull和push2种机制来搭配完成。

服务注册

下面为源码中实现部分

API

这些部分是在服务者方发生的,在nacos使用中会有一些中间的包,来提供nacos和spring框架等桥接,这里就不阐述了,就是一些自动装配类
这里直接从Nacos的SDK的注册入口看NamingService#registerInstance

 public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
        // 获取完成服务名称
        String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
        // 判断是否是临时节点,对应的nacos的CAP模型不同,临时节点为AP,持久化节点为CP
        if (instance.isEphemeral()) {
            BeatInfo beatInfo = beatReactor.buildBeatInfo(groupedServiceName, instance);
            // 开启心跳检测
            beatReactor.addBeatInfo(groupedServiceName, beatInfo);
        }
        // 通过api注册,内部就是一个封装好的http请求
        serverProxy.registerService(groupedServiceName, groupName, instance);
    }
Nacos端

InstanceController#register 收到请求信息,然后ServiceManager#registerInstance处理

public void registerInstance(String namespaceId, String serviceName, Instance instance) throws NacosException {
        // 创建一个空服务
           // 初始化集合
            // 初始化服务提供信息
        createEmptyService(namespaceId, serviceName, instance.isEphemeral());
        
        Service service = getService(namespaceId, serviceName);
        
        if (service == null) {
            throw new NacosException(NacosException.INVALID_PARAM,
                    "service not found, namespace: " + namespaceId + ", service: " + serviceName);
        }
        // 把客户端传递过来的实例信息,
        addInstance(namespaceId, serviceName, instance.isEphemeral(), instance);
    }
    
    
    public void addInstance(String namespaceId, String serviceName, boolean ephemeral, Instance... ips)
            throws NacosException {
        
        String key = KeyBuilder.buildInstanceListKey(namespaceId, serviceName, ephemeral);
        
        Service service = getService(namespaceId, serviceName);
        
        synchronized (service) {
            List<Instance> instanceList = addIpAddresses(service, ephemeral, ips);
            
            Instances instances = new Instances();
            instances.setInstanceList(instanceList);
            // 在CP模式下这里是RaftConsistencyServiceImpl,去把本次注册的服务,发布给其他的follower节点
            consistencyService.put(key, instances);
        }
    }

通过RaftCore#signalPublish向其他节点同步数据,

....
// 这里为遍历nacos的节点,通过api同步数据,但是看到这里并没有遵守2pc的原则,但是保留了term 在RaftPeerSet中,故在心跳检测中,会向低term的节点同步数据
for (final String server : peers.allServersIncludeMyself()) {
                if (isLeader(server)) {
                    latch.countDown();
                    continue;
                }
                final String url = buildUrl(server, API_ON_PUB);
                HttpClient.asyncHttpPostLarge(url, Arrays.asList("key=" + key), content,
                        new AsyncCompletionHandler<Integer>() {
                            @Override
                            public Integer onCompleted(Response response) throws Exception {
                                if (response.getStatusCode() != HttpURLConnection.HTTP_OK) {
                                    Loggers.RAFT
                                            .warn("[RAFT] failed to publish data to peer, datumId={}, peer={}, http code={}",
                                                    datum.key, server, response.getStatusCode());
                                    return 1;
                                }
                                latch.countDown();
                                return 0;
                            }
                            
                            @Override
                            public STATE onContentWriteCompleted() {
                                return STATE.CONTINUE;
                            }
                        });
                
            }
            ...

服务发现

API

入口为NacosNamingService#getAllInstances

public List<Instance> getAllInstances(String serviceName, String groupName, List<String> clusters,
            boolean subscribe) throws NacosException {
        
        ServiceInfo serviceInfo;
        if (subscribe) {
            serviceInfo = hostReactor.getServiceInfo(NamingUtils.getGroupedName(serviceName, groupName),
                    StringUtils.join(clusters, ","));
        } else {
            serviceInfo = hostReactor
                    .getServiceInfoDirectlyFromServer(NamingUtils.getGroupedName(serviceName, groupName),
                            StringUtils.join(clusters, ","));
        }
        List<Instance> list;
        if (serviceInfo == null || CollectionUtils.isEmpty(list = serviceInfo.getHosts())) {
            return new ArrayList<Instance>();
        }
        return list;
    }

这里主要看一下订阅环境下的情况, HostReactor#getServiceInfo

public ServiceInfo getServiceInfo(final String serviceName, final String clusters) {
        
        NAMING_LOGGER.debug("failover-mode: " + failoverReactor.isFailoverSwitch());
        String key = ServiceInfo.getKey(serviceName, clusters);
        if (failoverReactor.isFailoverSwitch()) {
            return failoverReactor.getService(key);
        }
        
        ServiceInfo serviceObj = getServiceInfo0(serviceName, clusters);
        // 拉取服务信息
        if (null == serviceObj) {
            serviceObj = new ServiceInfo(serviceName, clusters);
            
            serviceInfoMap.put(serviceObj.getKey(), serviceObj);
            
            updatingMap.put(serviceName, new Object());
            // 立即拉取服务信息,并向nacos发送udp端口
            updateServiceNow(serviceName, clusters);
            updatingMap.remove(serviceName);
            
        } else if (updatingMap.containsKey(serviceName)) {
            // 防止同一时间更新同一个服务,个人感觉这里可以用featuretask的缓存来写,可以更优雅一点
            if (UPDATE_HOLD_INTERVAL > 0) {
                // hold a moment waiting for update finish
                synchronized (serviceObj) {
                    try {
                        serviceObj.wait(UPDATE_HOLD_INTERVAL);
                    } catch (InterruptedException e) {
                        NAMING_LOGGER
                                .error("[getServiceInfo] serviceName:" + serviceName + ", clusters:" + clusters, e);
                    }
                }
            }
        }
        
        scheduleUpdateIfAbsent(serviceName, clusters);
        
        return serviceInfoMap.get(serviceObj.getKey());
    }

服务下线

PushReceiver UDP接受nacos的推送

while (!closed) {
            try {
                
                // byte[] is initialized with 0 full filled by default
                byte[] buffer = new byte[UDP_MSS];
                DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
//                当没有收到请求时,这里会阻塞
                udpSocket.receive(packet);
                
                String json = new String(IoUtils.tryDecompress(packet.getData()), UTF_8).trim();
                NAMING_LOGGER.info("received push data: " + json + " from " + packet.getAddress().toString());
                
                PushPacket pushPacket = JacksonUtils.toObj(json, PushPacket.class);
                String ack;
                if ("dom".equals(pushPacket.type) || "service".equals(pushPacket.type)) {
                    hostReactor.processServiceJson(pushPacket.data);
                    
                    // send ack to server
                    ack = "{\"type\": \"push-ack\"" + ", \"lastRefTime\":\"" + pushPacket.lastRefTime + "\", \"data\":"
                            + "\"\"}";
                } else if ("dump".equals(pushPacket.type)) {
                    // dump data to server
                    ack = "{\"type\": \"dump-ack\"" + ", \"lastRefTime\": \"" + pushPacket.lastRefTime + "\", \"data\":"
                            + "\"" + StringUtils.escapeJavaScript(JacksonUtils.toJson(hostReactor.getServiceInfoMap()))
                            + "\"}";
                } else {
                    // do nothing send ack only
                    ack = "{\"type\": \"unknown-ack\"" + ", \"lastRefTime\":\"" + pushPacket.lastRefTime
                            + "\", \"data\":" + "\"\"}";
                }
                
                udpSocket.send(new DatagramPacket(ack.getBytes(UTF_8), ack.getBytes(UTF_8).length,
                        packet.getSocketAddress()));
            } catch (Exception e) {
                NAMING_LOGGER.error("[NA] error while receiving push data", e);
            }
        }

比较简单,就不叙述了

Config Service

下期在写吧。。。。