最近接了一个服务拆分选型的验证任务,需要快速确认 Dubbo 3.x 和 Nacos 2.x 能不能平稳地组合在一起跑通整个注册发现链路。我没有直接去翻官方文档,而是先搭了一个最简单的 Dubbo+Nacos 案例:一个 provider 把服务注册到 Nacos,一个 consumer 从 Nacos 拉地址列表,然后像调本地方法一样完成一次远程调用。这个案例看起来很小,但服务治理里最核心的几件事——服务注册、服务发现、健康检查、负载均衡——全都涉及了。这篇文章就把整个搭建过程原原本本记录下来,包括工程结构、关键配置、启动验证,还有三个我实际踩进去又爬出来的坑。如果你正在做服务化改造,或者刚接触 Dubbo 和 Nacos,想找一条最短的路径把环境跑起来,这篇可以作为参考。
1. 先弄明白这个小案例到底在干什么
1.1 服务提供者与服务消费者是什么关系
在聊代码之前,先建立一些基础概念。Dubbo 是一个 RPC 框架,核心解决“远程调用像本地调用一样简单”这件事。在分布式系统里,两个服务之间的调用不再是通过一个固定的 IP 加端口写死在代码里,而是通过一个注册中心来完成动态寻址。
我把这个模型拆成三个角色:服务提供者(Provider)、服务消费者(Consumer)、注册中心(Registry)。Provider 启动后把自己暴露的接口和地址注册到 Registry;Consumer 启动后订阅自己需要的服务列表,拿到 Provider 的地址,然后建立连接发起调用。
用一个生活场景类比:服务调用就像你找一家餐馆。传统写死地址的方式就像背诵每家餐馆的门牌号,门店一换地址你就找不到。注册中心相当于一个城市的美食 App,所有餐馆把店名、地址、电话登记上去,顾客打开 App 搜索就能找到,餐馆装修换地址也只需要在 App 上更新信息,顾客下次搜还是能搜到。
对应到技术细节:Provider 注册的是服务名、IP、Dubbo 协议端口;Consumer 订阅的是服务名的变化。当 Provider 新增或下线,注册中心会推送变更给订阅者,Consumer 动态更新自己的可用地址列表。这个过程在 Dubbo 里叫作服务注册与服务发现。
1.2 为什么推荐用 Nacos 作注册中心
Dubbo 官方支持的注册中心有好几个,常见的是 ZooKeeper、Nacos、Consul 和 Redis。我这个案例选择 Nacos,不是因为它是最强的,而是它在中小团队里综合成本最低。
对比一下主流选项:
| 注册中心 | 部署难度 | 自带控制台 | 配置中心能力 | 临时实例机制 | 社区活跃度 |
|---|---|---|---|---|---|
| ZooKeeper | 中等,集群管理复杂 | 弱,需要额外工具 | 无 | 基于会话过期,不稳定 | 高 |
| Nacos | 低,单机一条命令 | 有,服务列表直观可见 | 自带,无需额外组件 | 支持临时实例,心跳超时自动移除 | 高 |
| Consul | 中等 | 有 | 有,但使用习惯偏海外 | 支持 | 中等 |
ZooKeeper 本身是一个分布式协调系统,做注册中心属于“借力打力”。虽然也能用,但它的临时节点依赖会话保活,网络抖动容易导致服务被误删;而且它没有可视化控制台,排查问题不够直观。Eureka 早期用得多,但已经进入维护状态,新项目基本不再推荐。Consul 功能全面,不过部署和概念模型比 Nacos 重一些。
Nacos 最吸引我的两点,一是它把注册中心和配置中心合在了一起,一个小团队少维护一套系统;二是控制台做得清楚,服务注册没注册、实例健康不健康,打开浏览器一眼就能确认。对第一次跑通 Dubbo 环境的人来说,这种可视化反馈非常关键。
1.3 案例的工程结构设计
我设计了三个 Maven 模块:
- api 模块:只放接口定义和公共模型
- provider 模块:实现接口,把服务暴露到注册中心
- consumer 模块:引用接口,通过注册中心完成远程调用
接口单独抽一个模块,是很多 Dubbo 实践经验里推荐的做法。因为 Provider 和 Consumer 都要依赖同一个接口定义,如果各自复制一份接口代码,只要包名或者方法签名稍微不一样,调用时就会出现“方法找不到”之类的诡异问题。接口作为独立模块,相当于双方共同遵守的一份契约,在代码层面就避免了这个坑。
2. 版本选型与环境准备
2.1 一套求稳的版本组合
Dubbo 和 Nacos 的版本兼容性在官方文档有说明,但真实开发里我建议直接抄一套被验证过的组合,而不是自己从零排列组合。
我的选择:
| 组件 | 版本 |
|---|---|
| JDK | 8 |
| Maven | 3.6+ |
| Spring Boot | 2.7.18 |
| Dubbo | 3.2.x |
| Nacos Server | 2.3.x |
| nacos-client | 2.3.x |
为什么用 Spring Boot 2.7 而不是 3.x?因为 Spring Boot 3 要求 JDK 17,而大部分存量系统的中间件兼容性都还是基于 JDK 8 验证的。虽然 Dubbo 3.x 已经支持 Spring Boot 3,但这次案例的目的是验证核心链路,不是追新,所以选择最稳定的组合。如果你是新项目且依赖很少,用 JDK 17 和 Spring Boot 3 当然也可以,只是遇到问题时可参考的资料会少一些。
2.2 Nacos 2.x 的一个隐藏端口坑
提前说一个 Nacos 2.x 的坑:它默认启用了 gRPC 通信,除了 8848 之外,客户端还会尝试连接 9848 和 9849 端口。9848 是客户端 gRPC 端口,规则是主端口加 1000。如果你部署在带防火墙的服务器上,或者用 Docker 映射端口,千万别只映射 8848,否则服务一直注册不上去,控制台里永远看不到实例。我在本地开发环境第一次遇到这个问题时,排查了半天,最后发现是端口没放通。
2.3 本地环境和依赖引入
先把基础环境确认好:JDK 8、Maven 3.6 以上、本地可以访问网络下载依赖。然后从 Nacos 官方渠道下载 server 压缩包,解压后先不急着启动,晚点在验证环节再启动它。
父工程的 pom 我用 dependencyManagement 统一管理版本,三个子模块都继承父工程。关键依赖写在 provider 和 consumer 里:
<dependencies> <dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo-spring-boot-starter</artifactId> <version>3.2.11</version> </dependency> <dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>2.3.2</version> </dependency> </dependencies>注意 nacos-client 需要显式引入。dubbo-spring-boot-starter 并不会自动把所有注册中心客户端都带进来,不写这个依赖,启动时就会报找不到 Nacos 相关类的错误。
consumer 额外加一个 spring-boot-starter-web,因为我要对外暴露一个 HTTP 接口来手动触发远程调用,方便在浏览器里反复验证。provider 其实不需要 web 容器,但加上也无妨,不影响核心逻辑。
3. 核心代码实现:从接口定义到远程调用
3.1 接口模块:把契约先定下来
在 api 模块定义一个最简单的服务接口:
package com.demo.api; public interface GreetingService { String sayHello(String name); }这个接口没有复杂逻辑,只负责定义方法签名。有一点要特别强调:Provider 和 Consumer 中接口的包名、类名、方法签名必须完全一致。实际开发中,有人把接口类复制到两个工程,然后稍微改了一下包名,结果 Consumer 启动不报错,一调用就报No provider available,排查半天才发现包名不对。使用独立 api 模块就能从源头规避这个问题。
如果将来接口要传自定义对象,比如用户信息,那么这个对象也放在 api 模块,并且必须实现java.io.Serializable。因为 Dubbo 默认的协议在网络上传输数据时用的是二进制序列化,对象不实现序列化接口,运行时会抛出NotSerializableException。
3.2 Provider 模块:把实现类暴露为远程服务
provider 模块先引入 api 模块的依赖,然后实现接口:
package com.demo.provider.impl; import com.demo.api.GreetingService; import org.apache.dubbo.config.annotation.DubboService; import org.springframework.stereotype.Component; @Component @DubboService public class GreetingServiceImpl implements GreetingService { @Override public String sayHello(String name) { return "Hello, " + name + "! This is provider at " + System.currentTimeMillis(); } }注意注解用的是@DubboService,这是 Dubbo 3.x 的注解。如果是 Dubbo 2.7,对应的是@Service。这两个注解容易混,尤其网上旧文章很多,直接复制旧代码到新项目里,注解扫描不到,服务就不会暴露。
再看 provider 的核心配置:
server: port: 8081 dubbo: application: name: demo-provider registry: address: nacos://127.0.0.1:8848 protocol: name: dubbo port: 20880 scan: base-packages: com.demo.provider.impl逐行解释一下:
dubbo.application.name:当前应用名,也是注册到 Nacos 上的服务标识之一,要保证在注册中心里唯一。dubbo.registry.address:注册中心地址,格式是nacos://ip:port。这个前缀很关键,换注册中心时只需要改这里,业务代码完全不用动。dubbo.protocol.name和dubbo.protocol.port:Dubbo RPC 通信的协议和端口。协议默认就是 dubbo,端口默认 20880。dubbo.scan.base-packages:告诉 Dubbo 扫描哪个包下的@DubboService注解。漏掉这个配置,服务就无法暴露。
这里有一个新手最容易懵的点:server.port: 8081是 Spring Boot 内置的 Web 容器端口,而dubbo.protocol.port: 20880是 RPC 通信端口。它们是两个独立的东西。Provider 注册到 Nacos 的地址是 IP + 20880,Consumer 也是通过这个端口来建立 RPC 连接。我一开始学 Dubbo 时,以为调用的端口就是 Web 端口,结果惊险地发现 Provider 注册的是 20880,跟 Web 端口一点关系没有。
3.3 Consumer 模块:远程调用像本地调用
consumer 模块同样引入 api 模块,然后在需要调用的地方使用@DubboReference注入接口:
package com.demo.consumer.controller; import com.demo.api.GreetingService; import org.apache.dubbo.config.annotation.DubboReference; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class GreetingController { @DubboReference private GreetingService greetingService; @GetMapping("/greet") public String greet(String name) { return greetingService.sayHello(name); } }consumer 的配置:
server: port: 8082 dubbo: application: name: demo-consumer registry: address: nacos://127.0.0.1:8848可以看到 consumer 端完全没有配置 Dubbo 协议端口,因为它不需要对外提供 RPC 服务,只需要作为客户端发起调用。@DubboReference会从注册中心拉取服务地址,创建一个远程服务的代理对象,调用时把方法参数序列化后通过网络发送给 Provider,之后再把结果反序列化返回给调用方。对调用方来说,整个过程确实就像调用一个本地方法。
Consumer 不依赖 provider 模块,只依赖 api 模块。这是一个很重要的设计——服务双方只通过接口契约耦合,具体实现完全隔离。这就是接口下沉带来的依赖倒置。
4. 启动与验证全流程
4.1 先把 Nacos 服务器跑起来
本地开发用单机模式启动 Nacos 就够。在解压目录的 bin 目录下执行:
Windows:
startup.cmd -m standaloneLinux / macOS:
sh startup.sh -m standaloneNacos 2.x 启动比 1.x 慢一些,不要心急。看到日志输出Nacos started successfully才表示启动完成。然后打开控制台:http://127.0.0.1:8848/nacos,默认账号密码都是nacos。
控制台的“服务管理 -> 服务列表”是后续判断注册是否成功的主要依据。第一次打开时列表是空的,等 Provider 启动后再刷新。
4.2 依次启动 Provider 和 Consumer
启动顺序上建议先 Provider 后 Consumer。严格来说,Dubbo 的 Consumer 可以在 Provider 没有启动时先启动,因为@DubboReference默认不会在启动阶段强制检查 Provider 是否存在,但为了第一次演示时减少干扰,我习惯把 Provider 先拉起来。
Provider 启动成功后,在 Nacos 控制台上刷新服务列表,能看到demo-provider这个服务名,点击详情可以看到一个健康实例,IP 是本地地址,端口是 20880。
再启动 Consumer。启动完成后,在浏览器访问:
http://127.0.0.1:8082/greet?name=zhangsan正常情况下浏览器会返回类似这样的内容:
Hello, zhangsan! This is provider at 1690012345678返回内容中的时间戳每次应该不一样,因为每次请求都会真的发送到 Provider 执行一次。到这里,最小闭环已经跑通了:注册中心、服务发现、RPC 调用三个环节全部正常。
4.3 用多实例验证负载均衡
单实例跑通说明不了什么问题,我建议多做一步:启动第二个 Provider 实例,让程序自由选择一个可用的提供者。
第二个实例只需要改两个参数:Dubbo 协议端口改成 20881,Web 端口改成 8083。application.name 保持demo-provider不变。启动后回到 Nacos 控制台,刷新服务列表,demo-provider的健康实例会变成两个,分别对应 20880 和 20881。
然后连续刷新多次http://127.0.0.1:8082/greet?name=zhangsan,观察返回结果。Dubbo 默认的负载均衡策略是加权随机,理论上两次调用的结果会随机落在不同的 Provider 上。如果我在返回内容里加入机器标识字段,就能直观看到请求被分发到了不同的实例。
这一步验证的价值在于:Consumer 并没有重启,它就自动感知到了 Provider 实例数量从一到二的变化。这就是注册中心动态服务发现的实际意义,也是服务扩容时不需要修改调用端的核心原因。
4.4 顺带理解 Nacos 的配置中心能力
Nacos 除了做注册中心,还能做配置中心。在这个案例里我没有把配置动态刷新完整写进去,因为那需要额外引入配置中心的相关依赖,会让最小案例变复杂。但我建议你打开 Nacos 控制台的“配置管理”看一眼,新建一条配置,然后想想如果 Provider 或 Consumer 运行时读取的是 Nacos 上的配置,意味着改动配置后不需要重新部署服务。
这一点对生产环境非常重要。传统方式改配置需要重新打包、重启服务,引入配置中心后,改配置和发配置通知几乎是实时的。Dubbo 的注册中心地址本身也可以放在 Nacos 配置里,进一步减少配置文件里的环境差异。
5. 常见问题与排查心得
5.1 服务一直注册不上:优先查端口和网络
我这个案例里最常碰到的问题,就是 Provider 启动时没有任何报错,但 Nacos 控制台服务列表是空的。这种问题通常不是代码逻辑问题,而网络和端口的问题。
排查顺序我固定为以下几步:
- 确认 Provider 配置的
nacos://127.0.0.1:8848能通:telnet 127.0.0.1 8848 - 确认 9848 端口也通。Nacos 2.x 的 gRPC 客户端一定会连这个端口,只检查 8848 是不全面的。
- 看 Provider 日志里有没有
nacos相关报错,比如连接超时、注册失败。 - 检查同一个 Dubbo 协议端口是不是被其他进程占用。本地起多个 Provider 时,如果都用了 20880,后面的进程会直接报端口绑定失败。
还有一个小经验:本地调试时如果 Nacos 地址写了机器主机名,而 hosts 里没配好映射,也会注册不上。统一用127.0.0.1最省事。
5.2 调用时报 No provider available:先从包名和注解查起
No provider available是 Dubbo 调用失败时最典型、也最让人头疼的报错。它表示 Consumer 在注册中心没有找到可用的服务提供者。
可能的原因我按出现频率排序:
- 接口包名不一致。Consumer 引用的 api 模块和 Provider 实现的接口包名不同,注册到 Nacos 的服务名虽然一样,但 dubbo 内部做服务匹配时要求接口全限定名一致。
@DubboService没被扫描到。dubbo.scan.base-packages配置的包路径写错,或者实现类上用的是 Spring 的@Service而不是 Dubbo 的注解。- Provider 启动后很快就退出。比如启动时发生异常,或者进程被本地 IDE 中断,Nacos 上的实例会在一段时间后被移除。
- Consumer 启动时 Provider 还没完全注册成功。如果你的验证方式是用 ApplicationRunner 在启动阶段就发起远程调用,很容易踩到这个时序问题。所以我建议用 Controller 加手动浏览器访问的方式验证,避开这个坑。
5.3 本地多实例启动的端口冲突与序列化问题
本地验证负载均衡时会启动多个 Provider,这里有两个容易忽略的冲突。
第一个是 Dubbo QoS 端口冲突。Dubbo 3.x 默认会在 22222 端口启动 QoS 服务,同一台机器启动多个 Dubbo 进程时,第二个会报端口被占用。解决办法是在配置里显式关闭或改端口:
dubbo: application: qos-enable: false或者给每个应用配置不同的 QoS 端口。
第二个是 Dubbo 协议端口冲突。之前提到过,需要保证每个 Provider 的dubbo.protocol.port不相同,比如第一个用 20880,第二个用 20881。
序列化问题同样值得提醒。当接口方法参数是自定义对象时,对象必须实现Serializable。我看到过一个很典型的报错:
java.io.NotSerializableException: com.demo.api.UserInfo原因就是接口方法接收一个UserInfo对象,但这个对象没有实现序列化接口。Dubbo 默认 dubbo 协议走的是 TCP 二进制流,任何跨进程传输的对象都逃不过序列化这一步。这种情况下,就算本地测试不出问题,一旦真正远程调用就会立刻暴露。
6. 实际体会与可扩展的方向
整套 Dubbo+Nacos 最小案例跑下来,我最大的感受是:Dubbo 对新手最友好的地方,在于它把复杂的远程通信细节全部封装掉了。从代码层面看,你写的就是一个接口、一个实现、一个注解注入,但背后涉及的服务注册、心跳续约、地址推送、连接管理、负载均衡,每一个环节都是可以拿出来单独深入研究的。
如果你想把这套案例往生产方向推进,我建议按这个顺序继续扩展:
- 给 Provider 做优雅上下线,让服务在下线前先把实例标记为不健康,等请求排空后再退出。
- 引入 Nacos 配置中心,把线程池参数、开关类配置放到 Nacos 上动态刷新,而不是每次改代码重启。
- 在 Consumer 侧接入限流和熔断组件,避免单个 Provider 故障导致全链路雪崩。
- 给服务调用加上链路追踪,这样多级调用时能一眼看清每个环节的耗时。
最后分享一个我自己坚持的习惯:像这种框架集成案例,第一遍一定要亲手从空工程开始搭建,不要直接复制现成代码。只有自己一个个依赖引进去、一行行配置写上去,遇到那几个典型的坑并亲自解决,你才会真正理解注册中心和 RPC 之间的关系。跑通之后再考虑抽象和封装,就会顺手很多。