技术选型
公司的 RPC 框架是 dubbo,配合使用的服务发现组件一直是 zookeeper,长久以来运行稳定。那么,为什么还要考虑替换掉 ZooKeeper 呢?其实并非因为它的性能瓶颈,而是为了向 云原生 方向演进。
云原生计算基金会(CNCF)对云原生的定义是:
云原生技术有利于各组织在公有云、私有云和混合云等新型动态环境中,构建和运行可弹性扩展的应用。云原生的代表技术包括容器、服务网格、微服务、不可变基础设施和声明式 API。这些技术能够构建容错性好、易于管理和便于观察的松耦合系统。结合可靠的自动化手段,云原生技术使工程师能够轻松地对系统作出频繁和可预测的重大变更。
要落地云原生,mesh化 是关键一步,当前主要指 service mesh(服务网格)。可以形象地将其理解为“微服务时代的 TCP 协议”。服务网格最大的优势在于将基础设施能力下沉,实现服务治理与业务开发的解耦。
举个例子,在传统的微服务架构中,若要做好服务治理,往往需要引入限流、熔断、监控、服务发现、负载均衡等一系列第三方组件。这些组件多以 jar 包形式被业务代码依赖,甚至不同的开发语言还需要维护不同的实现,导致业务与基础设施高度耦合,治理变得异常困难。
Service Mesh 则通过一系列网格代理来处理服务间的通信,这些代理对应用透明。之前提到的各种服务治理能力,都可以下沉到网格代理中统一实现。
那么,这和替换 ZooKeeper 有什么关系呢?这就要从 Dubbo 的设计说起了。Dubbo 架构中有三大核心角色:服务提供者(Provider)、服务消费者(Consumer)和注册中心(Registry)。
Provider 启动后,会向 Registry 注册自己的 IP、端口、服务名等信息。Consumer 发起调用时,则根据服务名从 Registry 中查找对应的 Provider 地址,进而完成远程调用。

而在云原生体系下,服务注册与发现的机制与 Dubbo 有很大不同。主流容器编排平台 k8s 的服务发现是基于 DNS 的,即通过域名解析到对应的 IP 地址。这使得将现有 Dubbo 服务迁移到云原生体系变得非常困难。有没有一款能兼容两种服务发现模式的组件呢?经过调研,我们发现 Nacos 正是答案。
首先,Nacos 和 ZooKeeper 一样,支持传统微服务的注册与发现模式。其次,Nacos 还提供了一个基于 CoreDNS 的插件 DNS-F(DNS Filter)。DNS-F 作为代理拦截主机上的 DNS 请求,如果查询的服务在 Nacos 中已注册,则直接返回注册的 IP;否则,请求将继续转发给下游 DNS 服务器进行解析。
完成规划后,我们的 Service Mesh 架构大致如下:

网格内外的访问策略可以这样归纳:
- 网格外 Dubbo -> 网格内 Dubbo:通过注册中心
- 网格外 Dubbo -> 网格外 Dubbo:通过注册中心
- 网格内 Dubbo -> 网格外 Dubbo:域名 -> DNS-F -> 注册中心
- 网格内 Dubbo -> 网格内 Dubbo:域名 -> DNS-F -> DNS
- 异构语言服务(如 PHP、Node.js)可通过服务名直接发起调用,由 DNS-F 拦截并解析为正确的 IP 地址,且负载均衡策略可调。
除此之外,Nacos 的 AP 模式提供了高可用与写的可扩展性,以及可对接 CMDB 等特性,也是我们选择它的重要原因。如果你对微服务架构中的其他组件也感兴趣,可以到 云栈社区 的后端与架构板块看看更多深度讨论。
迁移方案
要从 ZooKeeper 平滑迁移到 Nacos,主要有两种方案可选:
- 改造所有 Dubbo 应用,使其支持双注册(同时注册到 ZooKeeper 和 Nacos)。待所有应用改造完成后,再统一将消费端切换至 Nacos。
- 使用迁移工具,先将 ZooKeeper 上注册的所有服务一次性同步到 Nacos。之后再逐步修改应用配置,迁移过程中即可提前使用 Nacos 的新特性。
方案一实现简单,但改造成本巨大。一些陈旧的、无人维护的服务迁移困难,且公司内还有 PHP、Node.js 等服务同样依赖 ZooKeeper,无法一次性完成迁移。
方案二则需要一个强大的迁移工具,增加了技术复杂度。幸运的是,Nacos 官方提供了 nacosSync 工具。我们最终选择了方案二,并在此基础上做了一些优化。
为了降低迁移风险,我们基于 Dubbo 优秀的扩展性,定制了一套 动态注册中心。服务启动时,动态注册中心会从配置中心读取配置,决定是向 ZooKeeper、Nacos 还是两者同时注册。服务消费时,则只能指定其中一个注册中心进行查询。
默认情况下,服务会同时向两个注册中心注册,但消费端默认查询 ZooKeeper。这样一来,业务方只需引入一个 jar 包即可,无需感知底层变化。切换时,只需在配置中心修改相应配置,下次服务发布就会生效。该方案支持针对单个应用进行配置,便于灰度发布。
迁移工具优化
NacosSync 的原理很直观:当从 ZooKeeper 同步数据到 Nacos 时,它会作为一个 ZooKeeper 客户端,拉取并解析 ZooKeeper 上的所有服务信息,然后以 Nacos 的格式注册上去,同时监听各服务节点的变化,并实时更新 Nacos 中的数据。
单向同步策略
NacosSync 支持 ZooKeeper 与 Nacos 之间的双向同步。但我们认为双向同步存在风险,毕竟 Nacos 对我们而言是个新组件,其稳定性有待验证。如果 Nacos 中的数据有误,再同步回 ZooKeeper,可能会引发生产故障。因此,我们采取了更保守的 ZooKeeper 到 Nacos 的单向同步 策略。
高可用
作为需要长期在线的迁移工具,必须保证其自身的稳定性和高可用性。试想,如果迁移工具宕机,导致 Nacos 上的所有服务掉线,那将是毁灭性的。NacosSync 将数据存储下沉到了数据库,组件本身是无状态的,因此可以部署多个实例来防止单点故障。
但这又带来了新问题:部署 N 个实例,对 Nacos 服务端的压力就是 N 倍,因为同一个服务会被注册 N 次,更新也会执行 N 次。这部分优化我们会在后面讨论。
全量同步支持
原生的 NacosSync 不支持全量同步,只能逐个服务地进行配置。对于拥有 3000+ 服务的我们来说,手动配置是不可行的。因此,我们开发了全量同步配置功能,虽然实现不难,但非常实用。

ZooKeeper 事件乱序处理
NacosSync 监听 ZooKeeper 节点,当节点变更时同步数据到 Nacos。但在测试中,我们发现了一个问题:当 Dubbo 服务非优雅下线(例如进程被 kill -9)时,它会在几秒到几分钟内(取决于配置)被 ZooKeeper 剔除。如果此时服务立刻重新注册,可能会出现旧的“节点删除”事件比新的“节点新增”事件晚到达的情况。这会导致一个严重问题:新注册的服务,被旧的删除事件给下线了。
解决方案比较简单。Dubbo 注册的节点信息中包含毫秒级的 timestamp。我们在处理每个事件时,会比较事件中的 timestamp 与当前记录的 timestamp。如果新事件的 timestamp 大于或等于当前值,则认为事件有效并处理;否则,将其视为旧事件直接丢弃。通过这个逻辑判断,此类问题再未出现。
主动心跳检测
由于我们部署了两台 NacosSync,如果某个服务下线时,其中一台 NacosSync 发生了未知异常,可能导致该服务在 ZooKeeper 上已消失,但在 Nacos 上仍被标记为可用。这时消费者发起调用就会报错。
为了避免这种情况,我们在 NacosSync 中新增了对服务实例端口的主动检测逻辑。定期尝试与所有实例建立 TCP 连接,如果连接失败,再进一步检查该实例在 ZooKeeper 中是否真的不存在,确认后才从 Nacos 中剔除。
为什么不直接在心跳检测失败后就剔除呢?因为有时服务器可能因网络波动、瞬时负载过高而拒绝连接或超时,但服务本身仍在正常运行。所以,我们最终以 ZooKeeper 中的数据为准。为什么不直接轮询扫描 ZooKeeper?主要是出于对 ZooKeeper 性能的担忧,万一扫描操作压垮了 ZooKeeper,那可是大故障。
Nacos 优化
迁移工具优化得差不多后,我们开始将线上所有服务同步到 Nacos。起初,我们搭建了一个 3 节点的 Nacos 集群,结果由于服务数量太多,导致 Nacos 服务器的 CPU 长期处于高位,超过 50%。参考数据如下:
- 服务数:3000+
- 服务实例数:30000+
- NacosSync 节点数:2
- Nacos 节点数:3 (CPU 利用率 50%~80%)
监控完善
如何优化?首先得找出性能瓶颈。Nacos 原生基于 Spring Boot 提供了一些监控,但指标不够细致。于是,我们从客户端和服务端两个维度完善了监控体系。以下是一些我认为比较关键的指标:
- Nacos 服务端:CPU 占比、服务总数、实例总数、各 API 的请求数量、各 API 的请求响应时间、心跳处理速度、推送耗时、推送总量。

- Nacos 客户端:各 API 的请求量、各 API 的请求耗时、心跳发送速率。

心跳优化
监控完善后,瓶颈一目了然:心跳请求实在太多了,占到总请求量的 99% 以上。这与 Nacos 和 Dubbo 的设计差异有关。Dubbo 注册是服务维度的,一个 IP 地址可能会注册多个服务实例。而 Nacos 的心跳是以实例为维度的,默认每 5 秒一次。
近 4 万个实例,每 5 秒就是 4 万次心跳请求,换算成 QPS 约为 8k/s。再加上我们用了两台 NacosSync,心跳请求翻倍到 16k/s,并且这些都是 HTTP 请求,节点内部还有数据同步任务,CPU 不高才怪。于是,我们想了一系列办法进行优化,这也是对 中间件 进行深度调优的一个典型案例。
调整心跳间隔
将心跳间隔从默认的 5 秒调整为 10 秒,同时将实例无心跳下线时间从 30 秒调整为 60 秒。此举以牺牲少许实例下线实时性为代价,换取了心跳请求量减半的效果。
扩容
将 Nacos 服务端从 3 台扩容到 5 台。效果有一些,但不明显,治标不治本。
减少心跳
由于我们是逐步迁移服务,迁移后的服务本身会向 Nacos 发送心跳,而两台 NacosSync 也会同步发送心跳,这导致迁移后的服务实际上承受了三倍的心跳压力,同时也增加了服务下线后因某一方未及时剔除而导致“幽灵服务”的风险。
于是,我们在前文提到的动态注册中心中做了优化:对于已迁移、直接向 Nacos 注册的服务,我们增加了一条元数据 withNacos=true。然后修改 NacosSync 的逻辑,使其忽略从 ZooKeeper 同步过来的、带有 withNacos=true 标签的服务。这样,迁移后的服务就只由服务自身发送心跳,避免了重复。
合并心跳
NacosSync 中注册了大量服务,之前计算可知其每秒约发送 8k 次心跳。如果这部分心跳能合并发送,将大幅减少网络消耗,服务端的批处理也能提升效率。
在实现合并心跳前,需要理解 Nacos AP 模式下的 Distro 协议。简单概括单条心跳的处理路径:
客户端随机选择一个 Nacos 节点,对某个服务实例发送心跳。由于每个 Nacos 服务端节点只负责一部分服务,当它收到请求后,会判断该服务是否由自己负责。如果是,则处理;如果不是,则将其转交给负责该服务的节点。
单条心跳的路由处理比较简单。但合并发送心跳时,就需要在服务端将收到的批量请求,按负责的节点进行分类。只处理属于自己的那部分,对于不属于自己的,则批量转交给其他节点。这里需要注意区分请求是来自客户端还是其他服务端节点。
客户端实现上,我们将需要发送心跳的实例缓冲到一个队列中,每秒从队列中取出一批进行批量发送。缓冲区大小的设置很关键:太小可能导致心跳丢失,太大则消耗过多内存。
合并心跳的效果立竿见影。服务端的 CPU 使用率从 50%+ 骤降到 10% 以内,NacosSync 自身的 CPU 消耗也下降了一半。

(注:上图显示 CPU 还在 20% 左右,是因为当时还有一个 Bug 未解决,修复后即降至 10% 以内。)
长连接
至此,心跳问题只解决了一半。因为在 NacosSync 管理大量服务时,批量心跳效果显著。但对于已经迁移的服务,单机可能只有十几个实例,一秒内攒不够几个心跳请求,批量效果就会大打折扣。我们分析,可能是建立 HTTP 连接的开销以及每个请求都要经过冗长的 Web Filter 链比较消耗性能。于是,我们想试试改为长连接的效果。
为了快速验证,我们决定先改造心跳接口,因为它的量最大,搞定它就能解决 80% 的问题。长连接方案考虑了 Netty 和 gRPC。为了快速验证并保持技术栈统一,我们选择了 gRPC。巧合的是,上文提到的 DNS-F 也是一个 Nacos 客户端,它是用 Go 语言编写的,原生就支持 gRPC。因此,我们毫不犹豫地用 gRPC 实现了一版心跳,并通过配置来切换使用原生心跳、批量心跳还是 gRPC 心跳。
在实现长连接时,我们遇到了 Distro 协议带来的挑战:当节点收到不属于自己负责的服务的心跳时,需要转发。原生 HTTP 是服务端内部中转,如果长连接也这么实现,就需要维持复杂的集群内部长连接网络。我们决定将逻辑下放到客户端:当客户端收到“非本节点负责”的响应时,就像 Redis 的 MOVED 指令一样,服务端返回一个 redirect 响应,告知客户端应该请求哪个节点。客户端则缓存这个映射关系,下次直接向正确的节点发送请求。只有当再次收到 redirect 或出错时,才清空缓存并重新随机选择节点。这样只要服务端节点不变化,客户端就能一次命中,简单高效。这个逻辑在 DNS-F 中实现起来也较为简单。
最终的测试结果是:如果 NacosSync 全量使用 gRPC 心跳,其 CPU 消耗会比批量心跳略高一点,但相差不大。考虑到这是单条心跳发送,能达到这个效果已经非常不错。这意味着即使所有服务都迁移完毕,心跳效率也能接近批量发送的水平。
关键接口长连接
在尝到长连接优化心跳的甜头后,我们将服务注册、服务拉取等几个关键接口也改为了 gRPC 长连接,并且为 DNS-F 适配了这套长连接方案。优化效果显著,最终达到了我们对 Nacos 集群性能的预期。
优雅上下线
Nacos 提供了服务实例的下线接口,但它是针对单个实例的。这与公司内部的发布系统流程不太匹配,因为发布系统通常只知道要下线哪台机器(IP),而不清楚这台机器上具体运行了哪些服务实例。因此,我们提供了一个 IP 维度 的批量下线接口。实现原理与批量心跳类似,同样需要注意处理 Distro 协议的转发逻辑。
DNS-F 改进
长连接
这部分上文已提及,不再赘述。
Dubbo 服务域名不合法
Dubbo 注册到 Nacos 上的服务名格式类似:
providers:com.xx.yy.zz
通常我们会将这个服务名作为域名发起 Dubbo 调用。但冒号 : 在域名中是不合法的字符,直接使用会导致 DNS 解析错误。因此,我们修改了 DNS-F 的代码:调用方使用合法的域名格式,例如:
providers.com.xx.yy.zz
DNS-F 在内部拦截到这个域名后,将开头的 providers. 替换回 providers:,再进行查询。这样对调用方的改动最小。
高可用
DNS-F 作为一个 agent 进程运行在每台机器上,我们通过两个手段来保证其高可用:
- 监控 DNS-F 进程,挂掉后及时拉起。
- 搭建一个集中式的 DNS-F 集群。当本地 DNS-F 不可用时,DNS 请求会先经过这个集中式集群进行解析,如果仍无法解析,再走正常的 DNS 查询链路。
最后
Nacos 作为一个较新的开源组件,在实际生产中使用时必然会遇到各种挑战。本文重点分享了我们在将 zookeeper 迁移至 nacos 过程中遇到的核心问题与解决方案,涵盖了迁移策略、工具优化、性能调优等多个方面,希望对有类似规划的同学有所帮助。当然,实际迁移中还有更多细节,限于篇幅未能一一展开。整个迁移过程也让我们对 云原生 基础设施的演进有了更深的理解。如果你也在进行类似的技术架构升级,欢迎到 云栈社区 交流探讨。