1. 先搞清楚:为什么微服务必须有“动态”服务发现
老规矩,先说结论。Nacos动态服务发现,你拆成三个词来看:Nacos、动态、服务发现。服务发现是它要做的事,动态是它做得最漂亮的地方,Nacos是这一切的载体。很多人一上来就翻安装教程、配Spring Cloud,结果连“服务发现”到底在解决什么问题都没搞明白,遇到问题全靠猜,这是学习路径上最大的坑。
1.1 从单体到微服务,IP这层“硬编码”最先崩
想象一下你维护一个老式单体应用:一个War包扔到Tomcat里,端口8080永远不变,别的系统要调用你,直接配置一个http://192.168.1.100:8080/api就完事。这种模式本身没什么问题,问题出在系统开始膨胀之后。
微服务把一个大系统拆成了几十上百个独立进程。订单服务要调用户服务,商品服务要调库存服务,服务之间互相调用是家常便饭。如果每个调用方都在配置文件里写死被调方的IP和端口,会出现什么情况?
- 用户服务从1个实例扩容到5个实例,调用方不知道,只能轮着试或者干等超时。
- 某个实例挂了,负载均衡器还继续往它发流量,错误率直接飙升。
- 每次发布上线,IP只要一变,所有依赖方的配置文件都要跟着改一遍。
- 新加的实例永远不会被调用方感知,扩容等于白扩。
所以微服务架构里,服务之间的调用关系必须由“硬编码”升级成“动态感知”。服务提供方启动时主动上报自己的地址,服务消费方每次调用前先去一个中央节点查询“现在谁在提供服务”,然后从名单里挑一个来调。这个中央节点,就是注册中心。Nacos就是目前国内用得最多的注册中心之一,另外两个常被拿来对比的是Eureka和Consul。
1.2 没有服务发现的时代,我们是怎么扛过来的
我知道肯定有人说:不搞注册中心行不行?用Nginx做反向代理不也能转发吗?
能,但那是“半自动”方案。Nginx是一个静态配置的流量入口,你新增一个服务实例,必须手动改Nginx配置然后reload,实例下线也得手动摘除。服务多起来之后,运维光维护Nginx的upstream配置就能崩溃,更别说每次发布都要盯着改配置这种事了。
还有一种方案是用Kubernetes自带的Service。K8s确实内置了服务发现能力,Pod的IP变了没关系,Service的虚拟IP不变,流量照常转发。但问题是,不是所有公司都上了K8s,也不是所有遗留系统都能轻易容器化。在没有K8s的环境里,或者正在向K8s迁移的过渡阶段,注册中心仍然是最通用、最轻量的选择。
Nacos、Eureka这类注册中心本质上做了这么几件事:服务注册、服务发现、健康检查、负载均衡的配合。服务端维护一张“当前有哪些服务、每个服务有哪些实例、每个实例是否健康”的实时名单,客户端通过API注册自己、查询别人,并且能收到名单变化的通知。这就是“动态服务发现”的全貌。
1.3 “动态”到底动态在哪:服务实例的生命周期
理解了服务发现解决的问题,我们再细看“动态”这两个字。一个服务实例从启动到下线,要经历这么几个状态:
- 注册:实例启动时,拿着自己的服务名、IP、端口去Nacos注册,告诉Nacos“我上线了,可以接收流量”。
- 心跳:注册之后,实例需要不停跟Nacos报平安,默认每5秒一次,相当于举手说“我还活着”。
- 摘除:如果一段时间收不到心跳,Nacos会把这个实例标记为不健康,再等一段时间直接把它从列表里踢掉。
- 下线:实例正常关闭时,主动通知Nacos“我要走了,把我的名字划掉”。
这一整套流程,消费方是完全无感知的。它每次来查服务列表,拿到的都是最新的可用实例名单。实例多了,它自然能调用到新实例;实例挂了,它在很短时间内就感知到并自动避开。用一句话总结:动态服务发现的核心价值,是让上游服务永远拿到一份“活”的下游实例列表,不需要人工干预。
我见过不少团队用了Nacos之后,因为对这套生命周期理解不到位,出了问题就一头雾水。比如服务明明启动了,Nacos控制台却看不到;比如实例已经杀掉,列表里还残留旧IP。这些问题的根源几乎都在心跳机制和健康检查的参数上,后面我会专门写一节排查实录。
2. Nacos是怎么把“动态发现”做出来的
2.1 整体架构与核心角色
Nacos的架构其实不复杂,核心角色只有三个:Nacos Server、服务提供方(Provider)、服务消费方(Consumer)。
Nacos Server是独立部署的服务端进程,默认端口是8848,它负责维护服务注册数据、处理心跳、做健康检查、给客户端推送变更。Provider启动时向Server注册自己的元信息,运行期间持续发送心跳。Consumer启动时向Server订阅它关心的服务,把服务列表拉到本地缓存,之后每次发起远程调用前先从本地缓存里选一个实例。
这里有一个非常关键的设计:Consumer并不是每次调用都去请求Nacos Server拿最新列表,那样性能太差了。它的工作模式是“拉取 + 订阅”:启动时拉一次全量列表,之后依赖Nacos的推送机制来更新本地缓存。这个推送机制的实现方式经历了几个版本的演进,2.x之后用的是gRPC双向流,1.x时期用的则是UDP + 长轮询的组合方案。
了解这个机制之后再看Nacos的功能定位就清楚了:它不只是一个“通讯录”,还是一个带健康监测的通讯录。普通通讯录只记录联系人的联系方式,Nacos还会定期确认这些联系方式还能不能用,不能用的自动删除。
2.2 服务注册与心跳保活机制
服务注册的流程看起来很简单,Provider调用Nacos的注册接口,把自己以“服务名 + 分组 + 集群 + IP + 端口”的组织形式提交上去。但有几个细节值得注意。
第一,实例的类型。Nacos把实例分为临时实例和持久化实例,默认是临时实例。临时实例的心跳由客户端主动上报,服务端被动接收,一旦客户端挂了、心跳停了,服务端会在超时后主动把这个实例剔除,这是AP模式的思路。持久化实例则是由服务端主动发心跳检查,也就是健康检查,如果客户端没响应,服务端会把它标记为不健康但不会直接删除,这是CP模式的思路。
第二,心跳相关的三个时间参数。客户端每5秒发一次心跳,服务端如果15秒没收到心跳就标记该实例不健康,30秒没收到就剔除实例。这三个数值在Nacos中是可以配置的,但在大多数场景下用默认值就够了,除非你的客户端和服务端网络状况特殊,需要放宽超时阈值。
第三,临时实例为什么更适合微服务?因为微服务实例的存活周期本身就短,发布、扩容、缩容是常态。临时实例随心跳自动注册自动剔除,不需要人工干预。持久化实例更适用于不需要频繁变化的场景,比如数据库、缓存这种基础组件。
2.3 订阅、推送与本地容灾:客户端如何感知变化
Consumer启动时向Nacos订阅服务,拿到全量列表之后,剩下的问题就是“服务列表变了,客户端怎么知道”。
Nacos 2.x版本的推送是走gRPC长连接的。客户端与Server建立一条长连接,Server感知到服务列表变化后,直接把变更数据推给客户端。这个方案的优点很明显:实时性强,没有轮询延迟,链路简单。缺点是需要维护长连接,对网络环境有一定要求,所以Nacos在启动时会额外监听一个偏移量为1000的gRPC端口,比如8848对应9848。
1.x时代没有gRPC,用的是UDP + 长轮询的组合。Server通过UDP给客户端推送变更通知,客户端收到通知后再通过HTTP接口拉取最新数据做全量更新。如果UDP包丢了,客户端还有兜底方案:它有一个定时任务会定期检查本地缓存与服务端数据的版本号是否一致,不一致就主动拉取。这个兜底逻辑后来在2.x版本依然保留,相当于多了一重保险。
另外还有一个容易忽略的容灾机制:客户端会把拉取到的服务列表快照保存在本地,通常是~/nacos/naming/目录下。当客户端与服务端彻底失联时,它不会丢弃本地已有的服务列表,而是继续用这份“过期但不空白”的列表对外提供服务。这比直接报错强得多,至少在故障期间还能维持部分请求的正常转发。
2.4 临时实例与持久化实例,AP和CP的区别
Nacos的命名用到了CAP理论。Nacos同时支持AP和CP两种模式,通过实例类型来区分:临时实例走AP,持久化实例走CP。
AP模式强调的是可用性和分区容错性,在网络分区发生时,服务端仍然可以对外提供注册查询服务,但数据可能不是最新的。微服务场景下,服务列表短暂不一致完全可以接受,因为调用方有重试机制,选到一个正在启动的实例顶多报一次错。这也是为什么Nacos默认使用临时实例的原因。
CP模式强调一致性和分区容错性,核心是Raft协议。Nacos用Raft来保证持久化实例的注册数据在各节点之间一致,写请求必须经过Leader确认才返回成功。这个模式适合配置类数据、元数据这类不能丢的场景,但不适合服务实例这种高频变动的数据——既要强一致,又要高并发写,性能压力会很大。
所以Nacos做了一个很有意思的设计:同一个服务名下,可以同时存在临时实例和持久化实例,互不干扰。注册中心层面用Distro协议处理临时实例的数据同步,用Raft协议处理持久化实例的数据同步,两套机制共存。
3. 在Spring Cloud里跑通动态服务发现
3.1 先让Nacos Server跑起来:各个平台的安装要点
Nacos Server的安装本身不难,真正让人崩溃的是各种环境差异。把搜索热度高的几个安装场景汇总一下,基本能覆盖80%的人。
最省事的方式是直接拉官方镜像跑Docker容器:
docker run --name nacos -e MODE=standalone \ -p 8848:8848 -p 9848:9848 \ -d nacos/nacos-server:v2.2.1注意两个参数:MODE=standalone表示单机模式,不传默认也是standalone,但建议显式声明;端口必须同时映射8848和9848,前者是HTTP接口,后者是gRPC通信端口,2.x版本如果只开了8848,客户端和服务端之间的长连接建立不起来,服务注册会一直超时。这个坑我见人踩过无数次。
Windows本机部署的话,从GitHub Release下载对应版本的压缩包,Windows选nacos-server-2.2.1.zip,解压后进到bin目录,双击startup.cmd启动。Windows默认启动模式是cluster,也就是集群模式,单机开发必须加参数,命令行执行:
startup.cmd -m standalone如果双击启动发现控制台一闪而过,一般是JAVA_HOME环境变量没配好,Nacos要求JDK 8以上。可以用命令行方式启动,报错信息能直接看到。
Mac是同样的套路,startup.sh -m standalone就可以启动。Linux服务器上生产部署可以先调JVM参数,默认的堆内存设置比较大,在bin/startup.sh里有一行JAVA_OPT变量,单机资源有限的话把它改成-Xms512m -Xmx512m -Xmn256m,不然2G内存的云主机会很吃力。
启动成功之后访问http://localhost:8848/nacos,默认账号密码都是nacos。
3.2 服务端配置:从默认配置到生产建议
Nacos Server的配置文件在conf/application.properties。本地跑默认配置就够了,但生产环境最好改几个地方。
首先是开启鉴权。Nacos 2.2.1之前的版本默认不鉴权,谁都能访问控制台和管理接口,非常危险。在application.properties里加一行:
nacos.core.auth.enabled=true从2.2.1版本开始,服务端默认开启鉴权,这也是很多人升级之后发现客户端注册报401的原因,后面我专门讲。鉴权开启之后,客户端接入时必须在配置里带上账号密码,不能偷懒只配server-addr。
其次是数据源。Nacos默认自带内嵌的Derby数据库,单机玩没问题,一旦部署集群就必须切到MySQL,否则多个节点各写各的Derby,数据根本对不上。老版本还要手动建库建表,2.x版本提供了MySQL初始化脚本,在conf/mysql-schema.sql里,导入之后配置数据源:
spring.datasource.platform=mysql db.num=1 db.url.0=jdbc:mysql://127.0.0.1:3306/nacos_config?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC db.user.0=nacos db.password.0=yourpassword这一节标题的热搜词里还有“Nacos适配达梦数据库”“Nacos支持DB2吗”,其实Nacos的数据源是SPI机制扩展的,社区有人在搞达梦适配,但官方主流程还是以MySQL为主。真要是信创项目必须用达梦,得自己改扩展,不建议在生产环境直接依赖第三方魔改包。
3.3 客户端接入:Spring Cloud Alibaba整合示例
服务端起来了,接下来就是让服务互相发现。我用Spring Cloud Alibaba的Nacos Discovery组件来演示。
假设有两个服务:order-service和user-service,order-service需要调用user-service。先给user-service加上注册能力。
第一步,在pom.xml引入依赖:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2021.0.5.0</version> </dependency>注意版本要跟Spring Cloud对应,2021.0.x对应Spring Boot 2.6.x,2023.0.1.x对应Spring Boot 3.2.x,版本对不上启动就报错。
第二步,在application.yml里配置Nacos地址:
spring: application: name: user-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 username: nacos password: nacos服务启动后,控制台的服务列表里就能看到user-service,点进去能看到具体实例的IP和端口。
接下来让order-service去调用它。最原始的方式是拿到服务名后用RestTemplate,但需要给RestTemplate加上负载均衡能力:
@Bean @LoadBalanced public RestTemplate restTemplate() { return new RestTemplate(); }然后直接用服务名发HTTP请求:
@RestController public class OrderController { @Autowired private RestTemplate restTemplate; @GetMapping("/order/user") public String getUser() { String url = "http://user-service/user/info"; return restTemplate.getForObject(url, String.class); } }http://user-service/user/info这个地址在正常情况下是解析不了的,但通过@LoadBalanced标签,RestTemplate会拦截请求,从Nacos里查出user-service对应的真实地址,再发起调用。这就是动态服务发现在代码层的直接体现。
实际项目中大家几乎都用OpenFeign来做声明式调用:
@FeignClient(name = "user-service") public interface UserClient { @GetMapping("/user/info") String getUserInfo(); }Feign底层同样先从Nacos拿到服务列表,再通过负载均衡策略选一个实例发起调用。Nacos本身不负责负载均衡,它只管把可用的实例清单交给你,选谁是你自己的事。Ribbon/Spring Cloud LoadBalancer负责选,Nacos负责告诉它有哪些可选的。
3.4 配置中心动态刷新:动态不只服务发现,还有配置
很多人把Nacos当纯粹的注册中心用,忽略它的另一个核心能力:配置中心。配置中心跟动态服务发现的底层逻辑其实是相通的——都是围绕“动态”在做文章,一个是动态发现服务实例,一个是动态更新配置内容。
接入步骤不复杂。先加依赖:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2021.0.5.0</version> </dependency>然后要把application.yml改为bootstrap.yml(Spring Boot 2.4之前),或者在Spring Boot 2.4之后使用spring.config.import=nacos:的方式导入配置。下面用兼容性更好的bootstrap方式:
spring: application: name: user-service cloud: nacos: config: server-addr: 127.0.0.1:8848 file-extension: yaml username: nacos password: nacosNacos配置中心默认读取Data ID为{spring.application.name}.{file-extension}的配置,也就是user-service.yaml。在控制台的配置管理里新建一个配置,Data ID填user-service.yaml,内容随便写一个自定义参数,比如:
app: notice: "这是一条来自Nacos配置中心的动态通知"再在代码里使用@RefreshScope标记组件,就能实现不重启服务动态拿到最新配置:
@Component @RefreshScope public class AppConfig { @Value("${app.notice:}") private String notice; }修改Nacos里的配置并发布,AppConfig里的notice会在几秒钟内刷新为新值。动态服务发现和配置动态刷新是两个独立能力,但共享同一套服务端,这也是Nacos能在一众注册中心里脱颖而出的原因。
4. 生产环境常见问题与排查实录
4.1 账号密码能登录,服务注册却报401
这是搜索量极高的问题。控制台能进去,账号密码是对的,但服务一启动就报错,提示401 Unauthorized,服务注册失败。
这个问题的根源在于鉴权上下文配置不完整。控制台登录和客户端注册走的是两条链路,控制台登录成功说明账号密码确实有效,但客户端注册时如果没有显式把认证信息传进去,服务端直接拒绝。
解决方式就是前面提到的,在客户端的application.yml里补上username和password两个字段。或者更彻底一点,用Nacos的AccessToken机制,启动时先调用登录接口获取Token,然后把它配置到客户端上。但在绝大多数场景下,直接在discovery和config配置里写账号密码就够了。
另外一个产生401的原因是你改了服务端的鉴权密钥,而客户端和服务端版本之间密钥不一致。Nacos 2.2.1版本默认要求客户端版本匹配,如果客户端还是1.x的老版本,携带的Token格式不对,也会报401。解决方法是把客户端依赖升到和服务端一致的版本。
4.2 服务列表有服务,但调用总是失败
控制台的服务列表里能看到provider在列,看起来一切正常,但consumer远程调用就是失败。这种情况需要先判断是哪一层出了问题。
第一步看网络连通性。如果provider和consumer不在同一台机器,先确认防火墙是否放行了Nacos的8848和9848端口,以及服务自身的业务端口。9848是gRPC长连接的端口,很多人在安全组里只放行8848,服务注册能成功(走HTTP),但长连接建立失败,导致推送收不到,列表更新自然不及时。
第二步看健康状态。控制台的服务列表里,如果实例显示为“不健康”,先检查provider的应用日志,看心跳线程是否还活着。心跳线程被GC停顿卡死的情况在下游应用频繁Full GC时会出现,但这属于极端情况。
第三步看负载均衡策略。Nacos返回的实例列表里不仅有IP端口,还有权重值。如果某个实例的权重被调成0,按权重负载均衡策略就不会把流量分配给它,但实例本身是健康的。遇到“有服务却调不通”的情况,顺手看一眼实例的权重和健康状态,有时候问题不在代码。
4.3 配置改了不生效,客户端永远拿到旧值
配置中心动态刷新失效,是最容易让人困惑的问题。排查方向有四个。
检查@RefreshScope是否加上了。只有被这个注解标记的Bean才能在配置变更时重新绑定属性,没加注解的类,即使Nacos那边发了更新事件,它也不会刷新。
检查配置内容是否真的发布到了正确的Data ID。常见错误是把配置写到了application.yaml,但客户端拉取的是{spring.application.name}.yaml。两者不一致,客户端永远拿不到你改的那份配置。
检查file-extension配置。Nacos Config默认按properties格式解析,如果你的配置中心里存的是YAML格式,但客户端没配置file-extension: yaml,服务端返回的内容会被当properties解析,就会报解析异常或者属性绑定不上。
检查Spring Boot版本。Spring Boot 2.4之后默认不加载bootstrap上下文,如果你还在用bootstrap.yml,需要额外引入spring-cloud-starter-bootstrap依赖,并确认没有禁用bootstrap。
4.4 常用排查指令和速查表
排查Nacos问题,我习惯用几条命令快速定位:
# 查看服务端健康状态 curl http://127.0.0.1:8848/nacos/v1/console/health/readiness # 查看集群节点状态 curl http://127.0.0.1:8848/nacos/v1/core/cluster/nodes # 查看某个服务注册的所有实例 curl "http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=user-service" # 手动触发服务的全量推送检查 curl -X GET "http://127.0.0.1:8848/nacos/v1/ns/service/list?pageNo=1&pageSize=10"我把高频问题整理成一张速查表,方便直接对照处理:
| 现象 | 可能原因 | 处理动作 |
|---|---|---|
| 服务启动后Nacos界面看不到 | 服务名配置不对、MODE是cluster但只起了一个节点 | 检查spring.application.name和启动模式 |
| 服务注册报401 | 鉴权未配置或客户端版本太旧 | 配置username/password,升级客户端版本 |
| 实例一直显示“不健康” | 心跳端口被防火墙拦截、客户端GC停顿 | 检查9848端口连通,查看GC日志 |
| 调用时报Connection Refused | 服务实例的端口没放行或实例已挂 | 检查业务端口连通性和实例存活状态 |
| 日志出现SocketTimeoutException | 服务端地址不通或8848/9848未放行 | 用telnet分别测试两个端口 |
| 配置变更后未刷新 | 缺@RefreshScope、Data ID不对 | 检查注解和Data ID命名 |
| 控制台无法登录 | 密码被改过或者数据库被重置 | 检查用户表数据或用初始化账号恢复 |
5. 关于“动态”的一些个人理解与学习中必问的几个问题
最后聊点我对动态服务发现的理解,顺便把面试中常问的几个问题串一遍。这些东西搞懂了,你对Nacos的理解会跟只看教程的人完全不一样。
Nacos的动态服务发现,本质上是在两个层面解决“动态”问题。第一层是Provider层面的动态注册与注销,解决的是“服务实例上线下线,如何被外界感知”;第二层是Consumer层面的动态订阅与刷新,解决的是“服务列表变化,如何快速同步到调用方”。一写一读,两边的动态能力拼起来,才构成了完整的动态服务发现闭环。
有人在学习了之后会问:Nacos和Eureka哪个好?这个问题放在今天其实没有太大讨论价值。Eureka 2.0已经停止开发,1.x版本在大量团队里已经退居二线。Nacos能赢,靠的是三件事:注册中心和配置中心二合一,省掉一套运维成本;默认临时实例的AP模式适合微服务动态扩缩容,同时保留CP能力处理持久化数据;控制台功能完善,数据可视化和操作便利性比Eureka好太多。
还有几个高频面试题,我简单过一遍答案,加深一下理解。
问:Nacos是如何实现客户端感知服务列表变化的?答:2.x版本基于gRPC建立长连接,服务端数据变化通过长连接直接推送。1.x版本是UDP推送变更通知加长轮询兜底,客户端收到推送后主动拉取全量数据,同时客户端也有定时检查机制确保数据最终一致。
问:临时实例和持久化实例有什么区别?答:临时实例由客户端心跳保活,服务端心跳超时后主动删除实例,走AP模式;持久化实例由服务端主动健康检查,实例异常时标记不健康但不删除,走CP模式。微服务默认用临时实例。
问:Nacos支持哪些协议?答:核心包括HTTP(注册查询接口)、gRPC(2.x的推送通道)、以及用于数据副本同步的Distro协议和用于CP数据一致性的Raft协议。
问:为什么Nacos需要额外开9848端口?答:2.x版本用gRPC替代了1.x的UDP通信,gRPC服务端默认监听8848+1000=9848端口。如果只放行8848,客户端能走HTTP注册但建立不了gRPC长连接,推送功能会失效。
我在实际项目中踩过的最深的坑,是所有人都在关注服务注册是否成功,却很少有人关心服务发现的数据一致性。Nacos的AP模式注定了它在极端情况下返回的数据可能不是最新的,这时候客户端需要有容错能力——重试机制、超时控制、熔断降级一个都不能少。服务注册中心只是把可用实例告诉你,但一个请求能不能成功,还要看你的调用链路是否足够健壮。
最后分享一个小技巧:给Nacos客户端加上日志级别调整,排查问题时非常好用。在application.yml里加入:
logging: level: com.alibaba.nacos.client: info com.alibaba.nacos.client.naming: debug这样在服务注册、心跳发送、列表更新时,都能从客户端日志里看到详细过程,很多“为什么列表没有更新”“为什么注册失败”的问题,看日志比看控制台更直观。Nacos不是黑盒,它的客户端日志写得非常详细,用好日志等于多了一双眼睛。