最近帮团队搭一个内部小项目,要把用户信息和订单信息拆成两个独立服务,互相之间还得频繁查数据。一开始我图省事,用 RestTemplate 写 HTTP 接口,结果没过多久就后悔了——服务地址写死在配置里、请求超时没人管、想加一台实例还得改代码。后来我把这套调用方式换成了 Dubbo + Nacos 的注册调用模式,接口和实现彻底分离,消费端只按服务名调,提供端加实例不需要动任何消费端配置。整个过程最痛苦的不是写代码,而是排查各种“服务明明在,却调不通”的问题。这篇文章就把我验证过的最小闭环案例完整拆开,适合刚接触 Dubbo 和 Nacos 的后端开发,照着写一遍就能跑通,顺带把容易踩的坑也都讲清楚。
1. 为什么我选 Dubbo 搭档 Nacos,而不是继续写 HTTP 接口
1.1 微服务拆分之后,真正缺的不是通信,而是治理
服务拆开以后,最简单的通信方案就是 HTTP + JSON,两个服务互相请求接口就完了。但一旦服务数量超过三个,问题立刻冒出来:每个调用方都要知道被调方的地址列表,新加一台机器还得手动改或者走运维流程;被调方挂了、超时了,调用方没有统一处理策略;多个同类型服务之间怎么分摊流量,完全靠人肉。这些需求恰好是 RPC 框架加注册中心的看家本领。
Dubbo 把远程调用包装成“像调用本地方法”一样,对业务代码侵入很小。开发的时候,Consumer 拿到的是一份接口,背后是哪台机器在提供、用了什么策略做负载均衡、超时重试怎么处理,都可以交给框架。Nacos 在这套体系里承担服务发现和配置管理的角色,Provider 启动后自动注册,Consumer 启动后自动订阅,两边都不用硬编码地址。这样的组合对于中小团队做内部微服务非常合适,不需要维护太多基础设施,又能把服务治理的底子打好。
1.2 注册中心选了 Nacos,对比之后发现它最顺手
服务注册中心可选的不少,光常见就有 ZooKeeper、Consul、Eureka、Nacos 等。我把几类实现放在一起对比过:
| 注册中心 | 一致性模型 | 健康检查 | 自带控制台 | 配置中心 | 与 Dubbo 集成度 |
|---|---|---|---|---|---|
| ZooKeeper | CP | Session 心跳 | 需要额外装客户端 | 弱 | 老牌支持,配置繁琐 |
| Consul | CP | 自定义检查 | 有 | 弱 | 需额外适配 |
| Eureka | AP | 心跳续约 | 有 | 无 | Dubbo 旧版兼容一般 |
| Nacos | AP(临时实例)/ CP(持久实例) | 心跳 + 健康检查 | 有 | 有 | 官方原生支持 |
Nacos 让我比较满意的地方有三个:第一,服务发现场景下绝大多数业务其实需要的是“最终一致,但要尽快可用”,Nacos 的 AP 模式更贴近服务发现的实际情况;第二,Nacos 控制台开箱即用,服务列表长什么样、实例是否健康、配置内容是什么,浏览器里就能看;第三,Dubbo 3.x 直接原生支持nacos://协议,不用写额外适配代码,配置一行地址就完事。还有一点很实际,Nacos 本身就可以当配置中心,一个组件解决两个需求,后面扩展配置管理也不用重新引入一套系统。
1.3 Dubbo 3.x 的新特点,让这个组合更简单
如果翻老教程,看到的很可能是 Dubbo 2.x + ZooKeeper 的组合,xml 配置一大片。Dubbo 3.x 变化很大,比如默认注册方式从接口级服务发现改成应用级服务发现,每个服务的元数据通过专用连接维护,大幅度减少注册中心压力,也让注册信息更稳定。协议上现在默认支持 Triple 协议,基于 HTTP/2,既能做 RPC 又能兼容更开放的生态,兼容性比老版 Dubbo 协议更好。
用 Dubbo 3.x 配合 Nacos 还有一个好处:聚合层只关心应用名,不关心具体接口数量。以前如果服务有几十个接口,注册中心会塞进去几十条接口级数据,规模一大就很拥挤。现在应用级注册之后,服务名和实例信息一条就够了,这对生产环境的稳定性提升非常明显。我这次做的小案例就是基于 Dubbo 3.2 和 Nacos 2.2,配置上只用了最简单的几项,没有历史包袱。
2. 最小闭环落地:Provider 和 Consumer 这么写才能不跑偏
2.1 工程结构先拆清楚,API 模块单独放
看起来只是一个 Demo,但工程结构如果上来就乱,后面集成业务必返工。我建议用 Maven 聚合工程,至少拆三个模块:
api:存放接口定义和入参/出参对象,Provider 和 Consumer 都依赖这个模块provider:接口的具体实现,启动后注册到 Nacosconsumer:通过 Dubbo 引用接口,对外暴露 HTTP 或者直接做业务编排
API 模块单独放的核心原因是避免“两端各自维护一份接口”的问题。如果 Provider 和 Consumer 各自复制一份接口类,哪怕类名一样,只要包名不同,Dubbo 就无法路由,这是非常隐蔽的坑。接口和 DTO 集中管理之后,任何一边改动类型,编译期就能发现,而不是上线后才爆雷。
依赖版本我这次用的是相对稳定的一套组合:
<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>Spring Boot 用的是 2.7.x。这里提醒一句,版本匹配不是越新越好,Spring Boot 3.x 需要配合更新的 Dubbo 版本,如果项目里还有大量旧组件,盲目升到 3.x 成本会翻倍。稳妥做法是参照 Dubbo 官方给出的版本兼容矩阵,选一套稳定组合长期用。
2.2 API 层和 Provider 实现代码
先定义接口,包名比如com.example.api:
public interface UserService { User getUserById(Long id); }User 对象放同一个模块里,实现 Serializable 接口,提供无参构造器和 getter / setter,这样序列化问题最少。
Provider 模块的 pom 引入api模块和 Dubbo 依赖后,写实现类:
@DubboService(group = "demo", version = "1.0.0") public class UserServiceImpl implements UserService { @Override public User getUserById(Long id) { User user = new User(); user.setId(id); user.setName("user-" + id); return user; } }启动类加上@EnableDubbo,然后用application.yml做最基础的配置:
spring: application: name: user-provider server: port: 8081 dubbo: scan: base-packages: com.example.provider.service protocol: name: dubbo port: 20880 registry: address: nacos://127.0.0.1:8848scan.base-packages很重要,它告诉 Dubbo 去哪里扫描带@DubboService的类,漏配的话服务不会注册。protocol.port是 Dubbo 自己的通信端口,跟 Spring Boot 的server.port不是一个概念,很多新手把这个搞混,导致两个端口互相挤占。
2.3 Consumer 的引用方式和配置要点
Consumer 模块同样引入api模块,启动类加@EnableDubbo,然后直接注入接口:
@RestController public class UserController { @DubboReference(group = "demo", version = "1.0.0", timeout = 3000) private UserService userService; @GetMapping("/user/{id}") public User getUser(@PathVariable Long id) { return userService.getUserById(id); } }Consumer 配置:
spring: application: name: user-consumer server: port: 8080 dubbo: registry: address: nacos://127.0.0.1:8848这里有个平时容易被忽略的点:@DubboReference里的group和version必须和 Provider 暴露的完全一致,否则控制台里看到服务在,实际调用却报找不到提供者。生产环境不建议给 Consumer 单独配dubbo.consumer.check: false,虽然本地调试很爽,但上线后启动时不会立刻暴露问题,反而埋雷。
2.4 启动顺序和验证方式
启动顺序很简单:先 Nacos,再 Provider,最后 Consumer。原理想一想就明白,Provider 启动要注册,Consumer 启动要订阅,注册中心不在一切都是空谈。
验证步骤我习惯这么来:
- 打开 Nacos 控制台,进入服务列表,确认
user-provider出现在列表里,实例数 > 0 且状态为健康 - 看 Provider 启动日志,出现类似
Export dubbo service ...和Register instance ...字样 - 看 Consumer 启动日志,订阅到服务列表,没有
No provider available异常 - 浏览器或者 curl 请求 Consumer 的 HTTP 接口:
curl http://127.0.0.1:8080/user/1正常会返回:
{"id":1,"name":"user-1"}到了这一步,Dubbo + Nacos 的最小闭环等于已经通了,框架已经把远程调用、注册发现、负载均衡的底层逻辑接管了,业务代码里几乎感知不到远程请求的存在。
3. 运行中常见的“坑”,按排错链路逐段拆解
3.1 Provider 启动就报注册中心连接失败,问题大多出在端口上
现象是 Provider 启动日志刷出一大段[DUBBO] Connect to registry ... failed,偶尔还会附带nacos client connect server failed。第一次遇见时,我会下意识以为是 Nacos 没启动,但打开控制台又好好的,这就很迷惑。
排错链路要按照“网络 → 端口 → 应用配置”的顺序来。先用命令确认基础连通性:
curl http://127.0.0.1:8848/nacos/v1/console/health/readiness如果返回 OK,说明 8848 端口没问题。再检查 9848 端口:
nc -vz 127.0.0.1 9848Nacos 2.x 的客户端和服务端通信不只是走 8848,还会使用 gRPC 端口 9848,这个端口是对外映射的 8848 端口加 1000 得到的。很多本地案例调不通,就是因为只放行了 8848,忘了 9848。如果 Nacos 跑在 Docker 里,docker run 的-p参数必须把 9848 也映射出来,否则服务端看起来一切正常,客户端却一直连接不上。这一步排查完,十有八九的问题都能解决。
3.2 服务列表能看到 Provider,Consumer 却报 No provider available
这个问题是最容易让人崩溃的,因为现象和预期完全不符:Nacos 控制台里user-provider明显存在,实例也是健康的,但 Consumer 一调用就报No provider available from registry ...。我排查了不止一次,最后把原因归结到几类常见情况。
首先是接口的全限定名不一致,这是最隐蔽的。Provider 里实现的接口是com.example.api.UserService,Consumer 里引用的却是另一个包路径下的同名接口,哪怕类名一模一样,Dubbo 执行路由时会把两者当成完全不同的服务。用聚合工程统一管理 API 模块,能从根源规避这个问题。
其次是group或version不匹配。@DubboService上写了group="demo", version="1.0.0",Consumer 的@DubboReference就也得一字不差,任何一边缺了或写错,服务就变成“可见但不可用”的状态。Nacos 控制台显示的是服务名级别的信息,不会告诉你 group 和 version 对不上。
最后还要检查 namespace。如果 Provider 的 registry 地址带了namespace=dev_001,而 Consumer 没有带,两边就在不同的命名空间里,互相看不见。这三个点,我用一张表总结过排查顺序:
| 检查项 | 检查方法 | 常见问题 |
|---|---|---|
| 接口包名 | 对比两端的 import | 同名但包名不同 |
| group / version | 对比 yml 和注解 | 漏配或不一致 |
| namespace | 对比 registry 参数 | 两端不在同一命名空间 |
| 服务是否成功暴露 | Provider 日志 | 扫描路径错误 |
| 消费端是否订阅成功 | Consumer 日志 | 启动顺序颠倒 |
3.3 调用成功但返回值全是 null,多半是序列化兼容问题
调用通了,接口返回了,但对象字段全是 null,这种情况比报错更诡异。常见原因有两个方向。
第一,DTO 类没有无参构造器。很多序列化框架反序列化时需要通过无参构造创建对象再逐个字段赋值,要是只写了带参构造,就会出现对象存在但属性丢光或直接抛异常的现象。解决办法很简单:所有 RPC 的入参和返回对象,保留无参构造器,同时最好实现Serializable。
第二,序列化策略不一致。不同版本的 Dubbo 默认序列化方案会有差异,如果 Provider 侧手动指定了某种序列化器,Consumer 侧也最好保持一致。好一点的做法是接口 DTO 尽量用基本类型和通用结构,避免使用某个框架专有的类型。这个坑在服务端和消费端依赖版本不一致时特别容易出现,信息也都写进日志里,多看异常 Cause 链,别只看第一行。
3.4 端口冲突和版本兼容,两个容易被忽视的隐形坑
本地调试时,Provider 和 Consumer 往往跑在同一台机器,如果两个服务都用server.port=8080,后者启动时直接报端口占用。我习惯的做法是统一规划:Consumer 用 8080 对外提供 HTTP,Provider 用 8081 作为 Spring Boot 管理端口,Dubbo 协议的端口选 20880。如果同一台机器有多个 Provider,协议端口不能相同,改成 20881、20882 这种固定递增方式更便于排查。
版本兼容的问题更隐蔽。Dubbo 3.x 不同小版本对 Spring Boot 版本有要求,比如 Spring Boot 2.4 配 Dubbo 3.0 会有一堆奇怪的兼容报错,Spring Boot 3.x 需要 Dubbo 3.2 以上版本。我踩过最莫名的一次是 Provider 注册正常、Consumer 引用正常,但调用时一直超时,最后发现是两边引入的 nacos-client 版本差太多,gRPC 握手协议不兼容。后来我给自己定了个规矩:升级任何基础组件前,先看官方兼容矩阵,不拍脑袋追新版本。
4. 从能跑到好用:Nacos 多环境隔离与调用策略优化
4.1 namespace 和 group 到底怎么用
本地单环境可以不用 namespace,但一旦要区分开发、测试、生产环境,namespace 就是最基本的环境隔离手段。Nacos 的 namespace 可以理解为独立隔离域,不同命名空间之间服务不互通,数据也不互相影响。
配置方式很简单,注册中心地址后面直接带参数:
dubbo: registry: address: nacos://127.0.0.1:8848?namespace=dev_001&group=DEMO_GROUP关键注意事项是,Provider 和 Consumer 两端的 namespace 必须一致。很多人会在这里犯迷糊:开发环境配成dev_001,测试环境配成test_001,结果服务跨环境互相不可见,还以为自己代码写错了。group 则是在同一个环境里做逻辑分组的,比如同一个 Nacos 集群里跑多个业务线,可以用 group 区分订单业务和用户业务,避免同名服务互相干扰。
4.2 超时、重试和负载均衡策略怎么设置
Dubbo 的调用策略在 Consumer 侧可以细粒度配置。以我之前线上遇到过的问题为例:某个服务偶尔出现慢请求,默认 1000ms 超时明显不够,只调大超时又容易让占用资源多的节点被持续命中。正确的做法是具体业务具体配置。
负载均衡策略默认是随机加权,这个对大多数情况够用。但如果集群里机器性能差异很大,或者某些接口参与计算量不稳定,我会改用最少活跃调用优先策略,让框架尽量把请求分给当前负担最小的节点。要对同一个服务策略做调整,注解里可以直接指定:
@DubboReference(loadbalance = "leastactive", timeout = 5000, retries = 0)重试次数要格外小心。Dubbo 默认在调用失败后会重试,但如果下游接口不是幂等的,重试会导致重复下单、重复扣款之类的严重后果。我平时做的原则是:写操作一律retries=0,读操作可以保留一次重试。这属于生产环境的基础安全意识,哪怕写 Demo 也要养成这个习惯。
4.3 Nacos 的另一个价值:把配置也统一收口
很多人知道 Nacos 能做服务注册,却忽略了它同时还是配置中心。引入配置管理之后,各个微服务的配置文件不再散落在各自的 jar 包里,而是统一在 Nacos 控制台维护,环境切换时不必重新打包发布。
Spring Cloud Alibaba 生态里,接入 Nacos 配置中心只需额外引入配置模块依赖,然后在bootstrap.yml里声明配置中心地址和 dataId。我实际体验最明显的变化是:改一个开关、一个阈值,控制台发布后服务动态刷新,不用重启进程。对 Dubbo 场景来说,注册中心和配置中心共用一套基础设施,运维成本能省下一大块。
4.4 用 Dubbo Filter 给调用链加上日志
调通了只是第一步,真正乱起来的时候,最需要的是知道“每个请求打到哪台 Provider、耗时多少”。Dubbo 提供 Filter 扩展机制,用@Activate注解就能自定义一个全局过滤器,在调用前后记录链路信息。
我在项目里做过一个很简单的版本:Filter 里生成 traceId,放进 RpcContext,打印 Consumer 调用serviceName / methodName / 耗时,Provider 侧再配合输出同样的 traceId,两端日志就能串起来。这一步看起来只是日志增强,但排查问题时价值极大。没有链路信息的时候,一条调用失败了,得翻三四个服务日志猜;有 traceId 之后,一张图就能定位故障点。
5. 从 Demo 走向生产:组合的边界和后续扩展方向
5.1 单机 Nacos 能用,但生产环境必须换集群
本地 Demo 启动一个单机 Nacos 完全没问题,但生产环境必须要考虑高可用。Nacos 官方推荐集群模式至少三个节点,数据默认存储到内嵌数据库,如果想要更可靠的持久化,可以配置外部 MySQL。这里有个容易误会的点:Nacos 作为注册中心在 AP 模式下,集群节点之间通过 Distro 协议做数据同步,极端情况下可能出现短暂的服务列表不一致,但最终会收敛。这个设计对服务发现场景是合理的,注册信息短暂不一致,总比整个注册中心不可用要好。如果业务对服务发现的一致性要求极度苛刻,那就要多做一层客户端缓存或容错设计。
5.2 可观测性不是可选项,是最低门槛
服务拆出去之后,故障定位难度是成倍增加的。Dubbo 本身提供了一些监控指标,但真正好用的还是接入开源的链路追踪方案。我建议这个阶段不要自己造轮子,直接用成熟方案,埋点、上报、看拓扑一起解决。Dubbo 的 provider 和 consumer 两端会自动生成 span,配合接入层就能把跨服务调用链完整串起来。再加上前面提到的自定义 Filter 打 traceId,排查问题基本可以做到秒级定位。
5.3 服务网格和未来演进,先别急,跑通才有资格谈
Dubbo 3.x 对外强调支持服务网格形态,包括 Sidecar 模式和 Proxyless 模式,后面也可以注册到服务网格体系里统一治理。但我的观点很明确:如果内部服务规模还没到几十个,先把 Dubbo + Nacos 这套经典玩法用扎实,比盲目上网格架构更实际。服务网格引入的组件和运维复杂度是另一座山,不是简单改几个配置就能享受红利。先把注册发现、调用治理、可观测性这套地基打牢,遇到规模问题时再考虑演进,会让整个团队少走很多弯路。
最后分享一点自己的体会:Dubbo + Nacos 这套组合真正的价值,不在于它能把远程调用包装得像本地方法,而在于它把微服务时代最容易被忽略的“服务治理”这件事,以较低的成本落地了。我搭完这个小案例之后最大的收获不是跑通了调用链,而是理解了注册中心、服务发现、负载均衡这些组件各自解决什么痛点。如果你刚接触这块,照着这个最小闭环先跑一遍,遇到问题用上面三章的思路去查,基本能覆盖大部分场景。跑通之后再慢慢往里面加超时策略、日志链路、多环境隔离,整个工程就会越来越扎实。