Nacos注册中心12-Server端(Server和Client间的UDP通信)

2,723 阅读11分钟

欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

0. 环境

  • nacos版本:1.4.1
  • Spring Cloud : 2020.0.2
  • Spring Boot :2.4.4
  • Spring Cloud alibaba: 2.2.5.RELEASE

测试代码:github.com/hsfxuebao/s…

一文中我们介绍了nacos两种服务发现的方式,一种是直接去nacos服务器上拉取实例信息,另一种是订阅拉取,就是拉取的时候,订阅这个服务,一旦你订阅的服务实例发生变化,nacos服务端就会通知你。在上篇中关于服务实例变化,nacos服务端通知客户端这块内容我们还没有介绍,本文将介绍下服务变化通知这块的代码实现。

1. 关于客户端服务订阅的补充

在服务发现上篇中,我们只是使用了NamingServicegetAllInstances 方法作为了服务发现获取服务实例列表的方式,其实nacos还支持订阅方式,通过提供订阅的服务名称与监听器(listener )就可以了,然后订阅的服务实例信息一旦发生变化,nacos服务端就会通知你这个client,client收到通知后就会调用你这监听器,执行对应的逻辑。

NamingService naming = NamingFactory.createNamingService(properties);

// 订阅orderService 服务
naming.subscribe("orderService", new EventListener() {
    @Override
    public void onEvent(Event event) {
        System.out.println(((NamingEvent) event).getServiceName());
        System.out.println(((NamingEvent) event).getInstances());
    }
});

就是上面这个样子的,直接调用subscribe 的方法就对该服务订阅了,一旦“orderService”服务实例信息发生变化,nacos服务端就会使用udp协议通知你这个client,client收到通知后,判断与本地缓存的服务实例信息有没有变化,如果有变化的话,就会调用你这个事件监听器。 我们看下这个subscribe 方法背后的实现源码:

public void subscribe(String serviceName, String clusters, EventListener eventListener) {
    notifier.registerListener(serviceName, clusters, eventListener);
    getServiceInfo(serviceName, clusters);
}

首先 就是nacos服务端通知过来之后,client先会跟本地缓存的服务实例信息做一下比较,如果发生了变化,就会整一个服务实例变化的通知给EventDispatcher 组件,这个组件就会根据服务信息,然后找到对应的listener ,进行执行,它其实就是管着 listener 注册与调用listener 执行调用的。知道EventDispatcher组件作用之后,它的addListener方法就很简单了,就是将订阅服务信息与listener 放到一个map中。

public void registerListener(String serviceName, String clusters, EventListener listener) {
    String key = ServiceInfo.getKey(serviceName, clusters);
    ConcurrentHashSet<EventListener> eventListeners = listenerMap.get(key);
    if (eventListeners == null) {
        synchronized (lock) {
            eventListeners = listenerMap.get(key);
            if (eventListeners == null) {
                eventListeners = new ConcurrentHashSet<EventListener>();
                // 加入观察者map中
                listenerMap.put(key, eventListeners);
            }
        }
    }
    eventListeners.add(listener);
}

然后 找到hostReactor 组件获取一下这个serviceInfo信息,这块代码是不是很熟悉,我们在上篇专门解析过,就是在getAllInstances 方法中需要订阅那个代码块中

image.png

就是这里,这个getServiceInfo方法主要就是看看本地有没有这个serviceInfo ,如果没有的话,就创建一个,并且立马向服务端拉取这个服务实例信息,另外带着订阅。然后就是将这个订阅搞个任务出来,定时的去服务端拉拉看看,这个任务就是防止某个nacos服务端实例挂了导致不能及时通知了,它时不时的去服务端刷新下订阅信息,就算是某个nacos 服务挂掉了,它也可以请求其他的nacos服务继续拉取实例,同时也告诉下nacos 服务端我这个客户端还活着,维护者一个连接那种感觉。这一块详细代码我们就不看了,主要是在上篇中已经详细介绍过了。

2. 服务端异步通知

在服务注册与服务下线时候,都是先找到ConsistencyService 一致性服务将实例信息存储到对应的存储器中,我们这里主要介绍临时节点的一致性服务,也就是找到DistroConsistencyServiceImpl 这个组件,将某个service下的所有实例信息存储到了DataStore(其实就是个map)里面去了,并且会创建一个任务放到一个队列中,整完就响应给客户端了。然后DistroConsistencyServiceImpl 有个Notifier 组件会不停的从队列中获取任务,执行任务,其实就是找到对应服务的listener进行通知,说你这个服务的实例信息发生了变化,就是调用service的onChange方法进行通知,好让这个service修改下自己里面维护的信息。在修改完的时候会有这么一行代码getPushService().serviceChanged(this);,这行代码就是找到Push服务,然后向那些订阅了这个服务的客户端推送最新的服务实例信息。

先来介绍下PushService 组件

2.1 PushService

这个组件有个静态代码块,在这个代码块中初始化了一些东西我们看下

static {
    try {
        // udp
        udpSocket = new DatagramSocket();

        Receiver receiver = new Receiver();

        Thread inThread = new Thread(receiver);
        inThread.setDaemon(true);
        inThread.setName("com.alibaba.nacos.naming.push.receiver");
        inThread.start();

        // 20s执行一次
        GlobalExecutor.scheduleRetransmitter(() -> {
            try {
                // 移除 僵尸客户端
                removeClientIfZombie();
            } catch (Throwable e) {
                Loggers.PUSH.warn("[NACOS-PUSH] failed to remove client zombie");
            }
        }, 0, 20, TimeUnit.SECONDS);

    } catch (SocketException e) {
        Loggers.SRV_LOG.error("[NACOS-PUSH] failed to init push service");
    }
}

先是创建了一个udp的socket,启动一个线程,执行Receiver 任务,这个任务其实就是接收客户端ack的,你给客户端推送过去了最新的实例信息,然后客户端收到之后也得给你个ack。接着就是启动一个定时任务20s一次,清理下僵尸客户端,就是处理掉几秒没刷新订阅的客户端,默认是10s。 我们看下这个serviceChange 服务改变方法:

public void serviceChanged(Service service) {
    // merge some change events to reduce the push frequency:
    // 合并一些变更事件减少推送频率
    if (futureMap
            .containsKey(UtilsAndCommons.assembleFullServiceName(service.getNamespaceId(), service.getName()))) {
        return;
    }
    // 发布服务变更事件
    this.applicationContext.publishEvent(new ServiceChangeEvent(this, service));
}

使用spring的事件通知发布了一个事件,PushService 这个服务其实也是一个ServiceChangeEvent 事件的观察者,实现了onApplicationEvent 方法。

我们看下它的具体实现,其实就是先生成一个future,然后将future放到一个任务调度线程池中执行,延迟1s,再把future放到这个futureMap中,也就是上面serviceChange方法里面那个判断future。看下这个future。

@Override
public void onApplicationEvent(ServiceChangeEvent event) {
    Service service = event.getService();
    String serviceName = service.getName();
    String namespaceId = service.getNamespaceId();
    // 启动一个定时操作,异步执行相关内容
    Future future = GlobalExecutor.scheduleUdpSender(() -> {
        try {
            Loggers.PUSH.info(serviceName + " is changed, add it to push queue.");
            // 从缓存map中获取当前服务的内层map,内层map中存放着当前服务的所有Nacos Client的
            // UDP客户端PushClient
            ConcurrentMap<String, PushClient> clients = clientMap
                    .get(UtilsAndCommons.assembleFullServiceName(namespaceId, serviceName));
            if (MapUtils.isEmpty(clients)) {
                return;
            }

            Map<String, Object> cache = new HashMap<>(16);
            // 更新最后引用时间
            long lastRefTime = System.nanoTime();
            // 遍历所有PushClient,向所有该服务的订阅者Nacos Client进行UDP推送
            for (PushClient client : clients.values()) {
                // 若当前PushClient为僵尸客户端
                if (client.zombie()) {
                    Loggers.PUSH.debug("client is zombie: " + client.toString());
                    // 将该PushClient干掉
                    clients.remove(client.toString());
                    Loggers.PUSH.debug("client is zombie: " + client.toString());
                    continue;
                }

                Receiver.AckEntry ackEntry;
                Loggers.PUSH.debug("push serviceName: {} to client: {}", serviceName, client.toString());
                String key = getPushCacheKey(serviceName, client.getIp(), client.getAgent());
                byte[] compressData = null;
                Map<String, Object> data = null;
                if (switchDomain.getDefaultPushCacheMillis() >= 20000 && cache.containsKey(key)) {
                    org.javatuples.Pair pair = (org.javatuples.Pair) cache.get(key);
                    compressData = (byte[]) (pair.getValue0());
                    data = (Map<String, Object>) pair.getValue1();

                    Loggers.PUSH.debug("[PUSH-CACHE] cache hit: {}:{}", serviceName, client.getAddrStr());
                }

                if (compressData != null) {
                    ackEntry = prepareAckEntry(client, compressData, data, lastRefTime);
                } else {
                    ackEntry = prepareAckEntry(client, prepareHostsData(client), lastRefTime);
                    if (ackEntry != null) {
                        cache.put(key, new org.javatuples.Pair<>(ackEntry.origin.getData(), ackEntry.data));
                    }
                }

                Loggers.PUSH.info("serviceName: {} changed, schedule push for: {}, agent: {}, key: {}",
                        client.getServiceName(), client.getAddrStr(), client.getAgent(),
                        (ackEntry == null ? null : ackEntry.key));
                // todo UDP通信
                udpPush(ackEntry);
            }
        } catch (Exception e) {
            Loggers.PUSH.error("[NACOS-PUSH] failed to push serviceName: {} to client, error: {}", serviceName, e);

        } finally {
            futureMap.remove(UtilsAndCommons.assembleFullServiceName(namespaceId, serviceName));
        }

    }, 1000, TimeUnit.MILLISECONDS);

    futureMap.put(UtilsAndCommons.assembleFullServiceName(namespaceId, serviceName), future);

}

先是去clientMap这个订阅客户端map中获取这个服务的一堆订阅客户端。然后遍历这堆订阅者,先从缓存中获取,默认是不启用这个缓存的,没有的话,就自己准备这个数据,放入缓存一份, 最后是调用udpPush进行推送。

private static Receiver.AckEntry udpPush(Receiver.AckEntry ackEntry) {
    if (ackEntry == null) {
        Loggers.PUSH.error("[NACOS-PUSH] ackEntry is null.");
        return null;
    }
    // 若UDP通信重试次数超出了最大阈值,则将该UDP通信从两个缓存map中干掉
    if (ackEntry.getRetryTimes() > MAX_RETRY_TIMES) {
        Loggers.PUSH.warn("max re-push times reached, retry times {}, key: {}", ackEntry.retryTimes, ackEntry.key);
        ackMap.remove(ackEntry.key);
        udpSendTimeMap.remove(ackEntry.key);
        // 失败计数器加一
        failedPush += 1;
        return ackEntry;
    }

    try {
        // 计数器加一
        if (!ackMap.containsKey(ackEntry.key)) {
            totalPush++;
        }
        ackMap.put(ackEntry.key, ackEntry);
        udpSendTimeMap.put(ackEntry.key, System.currentTimeMillis());

        Loggers.PUSH.info("send udp packet: " + ackEntry.key);
        // todo 发送UDP
        udpSocket.send(ackEntry.origin);

        ackEntry.increaseRetryTime();
        // 开启定时任务,进行UPD通信失败后的重新推送
        GlobalExecutor.scheduleRetransmitter(new Retransmitter(ackEntry),
                TimeUnit.NANOSECONDS.toMillis(ACK_TIMEOUT_NANOS), TimeUnit.MILLISECONDS);

        return ackEntry;
    } catch (Exception e) {
        Loggers.PUSH.error("[NACOS-PUSH] failed to push data: {} to client: {}, error: {}", ackEntry.data,
                ackEntry.origin.getAddress().getHostAddress(), e);
        ackMap.remove(ackEntry.key);
        udpSendTimeMap.remove(ackEntry.key);
        failedPush += 1;

        return null;
    }
}

使用udpSocket进行推送,失败就重试,默认是重试1次的。 我们再来看下Receiver 的实现,它就是接收client ack的一个任务。

public static class Receiver implements Runnable {

    @Override
    public void run() {
        while (true) {
            byte[] buffer = new byte[1024 * 64];
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

            try {
                // 接收请求
                udpSocket.receive(packet);

                // 接收到请求,然后拆包 反序列化
                String json = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.UTF_8).trim();
                AckPacket ackPacket = JacksonUtils.toObj(json, AckPacket.class);

                InetSocketAddress socketAddress = (InetSocketAddress) packet.getSocketAddress();
                String ip = socketAddress.getAddress().getHostAddress();
                int port = socketAddress.getPort();

                // 超过10s ack超时
                if (System.nanoTime() - ackPacket.lastRefTime > ACK_TIMEOUT_NANOS) {
                    Loggers.PUSH.warn("ack takes too long from {} ack json: {}", packet.getSocketAddress(), json);
                }

                // ackKey
                String ackKey = getAckKey(ip, port, ackPacket.lastRefTime);
                // 移除
                AckEntry ackEntry = ackMap.remove(ackKey);
                if (ackEntry == null) {
                    throw new IllegalStateException(
                            "unable to find ackEntry for key: " + ackKey + ", ack json: " + json);
                }

                // 获取推送耗时
                long pushCost = System.currentTimeMillis() - udpSendTimeMap.get(ackKey);

                Loggers.PUSH
                        .info("received ack: {} from: {}:{}, cost: {} ms, unacked: {}, total push: {}", json, ip,
                                port, pushCost, ackMap.size(), totalPush);
                // 推送耗时
                pushCostMap.put(ackKey, pushCost);
                // 移除推送事件
                udpSendTimeMap.remove(ackKey);

            } catch (Throwable e) {
                Loggers.PUSH.error("[NACOS-PUSH] error while receiving ack data", e);
            }
        }
    }

就是接收消息,进行序列化啥的。后面的一堆都是一些指标统计的东西。 接下来我们看下客户端收到通知是怎么处理的吧。

3. 客户端处理通知

HostReactor组件中,有一个PushReceiver 是在HostReactor 实例化的时候创建的。先来看下这个PushReceiver组件

3.1 PushReceiver

public PushReceiver(HostReactor hostReactor) {
    try {
        this.hostReactor = hostReactor;
        // 开启一个udpSocket
        this.udpSocket = new DatagramSocket();
        // 创建一个线程池
        this.executorService = new ScheduledThreadPoolExecutor(1, new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r);
                thread.setDaemon(true);
                thread.setName("com.alibaba.nacos.naming.push.receiver");
                return thread;
            }
        });

        // 异步执行当前PushReceiver任务,执行run方法
        this.executorService.execute(this);
    } catch (Exception e) {
        NAMING_LOGGER.error("[NA] init udp socket failed", e);
    }
}

在实例化的时候,开启一个udpSocket,然后整了一个调度线程池,将自己作为一个任务扔进去了。

@Override
public void run() {
    // 开启一个无线循环
    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);
            // 接收来自Nacos Server的UDP推送的数据,并封装到packet数据包中
            udpSocket.receive(packet);
            // 将数据包中的数据解码为JSON串
            String json = new String(IoUtils.tryDecompress(packet.getData()), UTF_8).trim();
            NAMING_LOGGER.info("received push data: " + json + " from " + packet.getAddress().toString());

            // 将JSON串封装为PushPacket
            PushPacket pushPacket = JacksonUtils.toObj(json, PushPacket.class);
            // 根据不同的数据类型,形成不现的ack
            String ack;
            if ("dom".equals(pushPacket.type) || "service".equals(pushPacket.type)) {
                // todo 将来自Nacos Server发生变更的Service 更新到当前Nacos Client的本地注册表
                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":" + """}";
            }
            // 向推送数据的Nacos Server进行响应(UDP推送)
            udpSocket.send(new DatagramPacket(ack.getBytes(UTF_8), ack.getBytes(UTF_8).length,
                    packet.getSocketAddress()));
        } catch (Exception e) {
            if (closed) {
                return;
            }
            NAMING_LOGGER.error("[NA] error while receiving push data", e);
        }
    }
}

就是等着接收请求,然后对接收过来的信息进行反序列化,如果type是dom或者是service的话就会交给hostReactor组件的processServiceJson 来处理数据。最后生成ack 返回给nacos服务端。 我们看下这个hostReactorprocessServiceJson 方法是怎样处理的

3.2 HostReactor#processServiceJson

关于这个方法我们不细看,它其实就是新的服务实例信息替换下本地缓存的那个服务实例信息,然后拿新通知过来的服务实例列表信息与 本地缓存的一份实例列表信息进行比较,看看有没有发生变化,如果有的话,就通知这个NotifyCenter

// todo 来自Server的数据是最新 数据
public ServiceInfo processServiceJson(String json) {
    ...
    // 当前注册表中存在该服务,想办法将来自server端的数据更新到本地注册表中
    if (oldService != null) {

        // 来自server的serviceInfo替换到注册表中的当前服务
        serviceInfoMap.put(serviceInfo.getKey(), serviceInfo);
        ...
        // 只要发生了变更,就将这个发生变更的serviceInfo记录到一个缓存队列
        if (newHosts.size() > 0 || remvHosts.size() > 0 || modHosts.size() > 0) {
            // todo
            NotifyCenter.publishEvent(new InstancesChangeEvent(serviceInfo.getName(), serviceInfo.getGroupName(),
                    serviceInfo.getClusters(), serviceInfo.getHosts()));
            DiskCache.write(serviceInfo, cacheDir);
        }

    // 本地注册表中没有这个服务,直接将来自Server的serviceInfo写入到本地注册表
    } else {
       ...
        // 将这个发生变更的serviceInfo记录到一个缓存队列
        NotifyCenter.publishEvent(new InstancesChangeEvent(serviceInfo.getName(), serviceInfo.getGroupName(),
                serviceInfo.getClusters(), serviceInfo.getHosts()));
        serviceInfo.setJsonFromServer(json);
        DiskCache.write(serviceInfo, cacheDir);
    }
    ...
    return serviceInfo;
}

3.3 NotifyCenter.publishEvent

public static boolean publishEvent(final Event event) {
    try {
        // todo 
        return publishEvent(event.getClass(), event);
    } catch (Throwable ex) {
        LOGGER.error("There was an exception to the message publishing : {}", ex);
        return false;
    }
}

private static boolean publishEvent(final Class<? extends Event> eventType, final Event event) {
    if (ClassUtils.isAssignableFrom(SlowEvent.class, eventType)) {
        return INSTANCE.sharePublisher.publish(event);
    }

    final String topic = ClassUtils.getCanonicalName(eventType);

    EventPublisher publisher = INSTANCE.publisherMap.get(topic);
    if (publisher != null) {
        // todo 
        return publisher.publish(event);
    }
    LOGGER.warn("There are no [{}] publishers for this event, please register", topic);
    return false;
}
// com.alibaba.nacos.common.notify.DefaultPublisher#publish
@Override
public boolean publish(Event event) {
    checkIsStart();
    // todo 加入到队列中
    boolean success = this.queue.offer(event);
    if (!success) {
        LOGGER.warn("Unable to plug in due to interruption, synchronize sending time, event : {}", event);
        receiveEvent(event);
        return true;
    }
    return true;
}

这个就是将serviceInfo信息添加到一个队列中,DefaultPublisher 组件中有个Notifier 的任务会不断从这个队列中获取serviceInfo ,通知那些订阅这个服务的listener。

@Override
public void run() {
    openEventHandler();
}

void openEventHandler() {
    try {

        // This variable is defined to resolve the problem which message overstock in the queue.
        int waitTimes = 60;
        // To ensure that messages are not lost, enable EventHandler when
        // waiting for the first Subscriber to register
        for (; ; ) {
            if (shutdown || hasSubscriber() || waitTimes <= 0) {
                break;
            }
            ThreadUtils.sleep(1000L);
            waitTimes--;
        }

        for (; ; ) {
            if (shutdown) {
                break;
            }
            // 从队列中获取
            final Event event = queue.take();
            // todo
            receiveEvent(event);
            UPDATER.compareAndSet(this, lastEventSequence, Math.max(lastEventSequence, event.sequence()));
        }
    } catch (Throwable ex) {
        LOGGER.error("Event listener exception : {}", ex);
    }
}

void receiveEvent(Event event) {
    final long currentEventSequence = event.sequence();

    // Notification single event listener
    // 遍历
    for (Subscriber subscriber : subscribers) {
        // Whether to ignore expiration events
        if (subscriber.ignoreExpireEvent() && lastEventSequence > currentEventSequence) {
            LOGGER.debug("[NotifyCenter] the {} is unacceptable to this subscriber, because had expire",
                    event.getClass());
            continue;
        }

        // Because unifying smartSubscriber and subscriber, so here need to think of compatibility.
        // Remove original judge part of codes.
        // 通知订阅者
        notifySubscriber(subscriber, event);
    }
}

可以看到就是从这个队列中取出serviceInfo,然后从观察者map中获取对应的lisenter集合,然后遍历这个集合,进行事件的通知。

4. nacos服务发现在dubbo中是怎样使用的

这里还要提一下nacos作为dubbo的注册中心是怎样服务发现的。

其实是使用的getAllInstancessubscribe 结合的方式进行服务发现的。我们可以稍微看下源码

image.png

先是使用的getAllInstances 获取这个服务的实例信息,然后通知一下订阅者,接着就是向nacos服务端订阅这个服务信息

image.png 生成一个eventListener ,然后调用subscribe进行订阅。

5. 总结

到这我们服务发现就介绍完了,本篇主要是介绍了下服务实例改变后nacos使用udp协议通知那些订阅了的客户端,客户端收到推送过来的实例信息,会改变本地的缓存信息,然后通知执行对应的listener ,它这里面大量使用了异步处理的方式,就是生成一个任务放到队列中,然后某个线程就专门从队列中获取任务,执行相应的任务处理逻辑,这是值得我们学习的地方。最后我们介绍了下nacos 服务发现在dubbo框架中的应用。

参考文章

nacos-1.4.1源码分析(注释)
springcloud-source-study学习github地址
深度解析nacos注册中心
mac系统如何安装nacos