1. 从一个真实的服务拆分场景说起
去年下半年,我接手了一个挺典型的改造项目。原本是一个单体架构的电商后台,订单、库存、用户、营销几个模块全塞在一个工程里,代码量到了后期,改一个订单状态流转的逻辑,得把整个项目重新编译部署一遍,本地启动一次要等将近两分钟。更麻烦的是,营销模块的同事和订单模块的同事经常因为同一个公共类打架,谁都不敢随便动。
后来团队决定做服务化拆分,把库存和营销两个模块独立出去。拆分方案定下来之后,摆在面前的第一道选择题就是:服务之间怎么调用?HTTP 接口当然可以,但内部服务调用走 HTTP,序列化开销大、性能一般,而且接口契约靠文档维护,容易对不齐。团队讨论了几轮,最终选了 Dubbo 来做 RPC 调用,SpringBoot 作为基础框架。
这篇文章就是把这套东西从零跑通的完整过程记录下来。我会讲清楚 Dubbo 在 SpringBoot 里到底怎么集成、注册中心怎么选、服务怎么暴露和引用、超时和重试这些参数为什么那么配,以及我在实际调试中踩过的几个坑。如果你也正在做服务拆分,或者想在自己的项目里引入 RPC 调用,这篇内容应该能帮你少走一些弯路。整篇内容基于我实际跑通的工程整理,代码可以直接参考复现。
2. 为什么内部服务调用我最终选了 Dubbo 而不是继续用 HTTP
2.1 先搞清楚 RPC 到底解决了什么问题
很多人一上来就纠结用哪个框架,其实应该先想明白 RPC 这个东西存在的意义。RPC 全称是远程过程调用,它的核心目标是让调用远程服务像调用本地方法一样自然。你写orderService.createOrder(dto),代码上看不出这是本地调用还是跨网络调用,框架帮你把网络通信、序列化、寻址这些脏活全干了。
这跟 HTTP 接口调用有本质区别。HTTP 调用你得自己拼 URL、处理请求体、解析响应,调用方必须清楚地知道"我在调一个远程接口"。而 RPC 的抽象层次更高,调用方感知不到网络细节。当然代价是,一旦出问题,排查链路会更隐蔽,这个后面会专门讲。
Dubbo 在这个基础上又加了一层:服务治理。它不只是帮你做远程调用,还管服务的注册发现、负载均衡、容错、限流。这才是它相比裸 HTTP 调用真正的价值所在。
2.2 Dubbo 和 HTTP 调用的实际差异对比
我把当时做的选型对比整理成了一张表,方便你直观感受:
| 对比维度 | Dubbo RPC | HTTP 接口调用 |
|---|---|---|
| 通信协议 | 默认 Dubbo 协议,基于 TCP 长连接 | HTTP/1.1 或 HTTP/2 |
| 序列化方式 | Hessian2、Kryo、Protobuf 等 | JSON 为主 |
| 性能表现 | 高,长连接复用,序列化紧凑 | 相对低,每次请求开销大 |
| 服务发现 | 内置注册中心机制 | 需额外引入服务发现组件 |
| 负载均衡 | 内置多种策略 | 需网关或客户端自行实现 |
| 接口契约 | 依赖 Jar 包共享接口 | 依赖文档或 OpenAPI |
| 调试便利性 | 相对麻烦,需专门工具 | 浏览器、Postman 直接调 |
从表里能看出来,Dubbo 在性能和治理能力上占优,但调试便利性不如 HTTP。这也是为什么很多团队会采用混合方案:对外暴露 HTTP 接口,内部服务间用 Dubbo 调用。
2.3 接口契约共享这件事,是优势也是坑
Dubbo 要求服务提供方和消费方共享同一个接口 Jar 包。这意味着接口定义是强约束的,不会出现文档和实现不一致的情况,这是好事。但反过来,这个 Jar 包的版本管理就成了一个必须重视的问题。
我踩过的坑是这样的:提供方给接口加了一个新方法,重新打包发布了。消费方还在用旧版本的 Jar,编译没问题,但运行起来调用新方法就会报找不到方法的错。所以后来我们定了个规矩,接口 Jar 单独抽一个 module 管理,任何接口变更都要走版本号,消费方升级要显式改依赖版本。这个经验后面还会展开讲。
3. 环境搭建:注册中心、依赖版本这些容易翻车的地方
3.1 注册中心选型:为什么我用 Nacos 而不是 ZooKeeper
Dubbo 支持的注册中心有好几种,ZooKeeper、Nacos、Redis 都能用。我选 Nacos 的原因很实际:它同时能做注册中心和配置中心,一个组件顶两个用,运维成本低。而且 Nacos 有控制台界面,服务列表、健康状态一目了然,排查问题的时候比 ZooKeeper 那种要靠命令行敲的方式友好太多。
ZooKeeper 作为注册中心本身没问题,它是 Dubbo 早期最经典的搭配。但 ZooKeeper 有个特性需要注意:它是 CP 模型,网络分区时可能短暂不可用。对于注册中心这种场景,其实 AP 模型更合适,Nacos 默认就是 AP。当然这不是说 ZooKeeper 不能用,只是从运维便利性和模型匹配度上,我倾向 Nacos。
注册中心我建议单独部署,不要跟业务服务混在一台机器上。本地开发的话,直接下载 Nacos 的压缩包解压启动就行,单机模式足够用。
3.2 依赖版本对齐,这是最容易出问题的一步
SpringBoot 和 Dubbo 的版本兼容性是个大坑。我用的组合是 SpringBoot 2.7.x 配 Dubbo 3.1.x,这个组合实测稳定。如果你用 SpringBoot 3.x,那得配 Dubbo 3.2 以上,因为 SpringBoot 3 把底层的 Java EE 换成了 Jakarta EE,很多老版本的 Dubbo 适配不了。
父工程的依赖管理大概是这样:
<properties> <spring-boot.version>2.7.18</spring-boot.version> <dubbo.version>3.1.11</dubbo.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo-bom</artifactId> <version>${dubbo.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>用 BOM 的方式管理 Dubbo 依赖,能避免各个子模块版本不一致的问题。这一点很重要,我见过因为 dubbo-common 和 dubbo-registry 版本对不上导致启动直接报错的案例。
3.3 三个模块的工程结构怎么划分
我建议至少拆成三个 module:
- api 模块:只放接口定义和传输对象(DTO),不依赖任何实现。提供方和消费方都依赖它。
- provider 模块:服务提供方,实现 api 模块里的接口,依赖 SpringBoot Web 和 Dubbo。
- consumer 模块:服务消费方,引用 api 模块,通过 Dubbo 调用远程服务。
api 模块的依赖要尽可能干净,只保留必要的序列化注解依赖就行。我见过有人在 api 模块里引入了 Spring 全家桶,结果消费方被迫拖了一堆用不上的依赖,打包体积暴涨。接口模块就该轻量。
4. 服务提供方:接口定义、实现暴露与配置细节
4.1 接口定义要遵守的几个原则
api 模块里的接口定义,我总结了几个原则。第一,接口方法参数和返回值必须是可序列化的,最好都实现Serializable接口并显式声明serialVersionUID。第二,不要在接口里用过于复杂的嵌套泛型,序列化容易出问题。第三,接口方法要设计得足够"粗粒度",一次调用能拿到需要的数据,避免为了拿一个字段反复远程调用。
一个典型的接口定义长这样:
public interface InventoryService { /** * 扣减库存 * @param skuId 商品ID * @param quantity 扣减数量 * @return 扣减结果 */ InventoryResult deductStock(Long skuId, Integer quantity); }传输对象InventoryResult放在 api 模块里,提供方和消费方共用。这里有个细节,DTO 里的字段类型尽量用包装类型而不是基本类型,因为基本类型没法表达 null,远程调用时如果某个字段没值,用包装类型更安全。
4.2 用注解方式暴露服务
Dubbo 3.x 推荐用注解方式暴露服务,比 XML 配置清爽很多。在提供方的实现类上加@DubboService注解:
@DubboService(version = "1.0.0", timeout = 3000, retries = 0) public class InventoryServiceImpl implements InventoryService { @Override public InventoryResult deductStock(Long skuId, Integer quantity) { // 实际业务逻辑 // ... return InventoryResult.success(); } }注意这里的version参数,我强烈建议每个服务都显式指定版本号。不指定的话默认是空字符串,后期要做灰度发布或者接口不兼容升级时,没有版本号会非常被动。timeout是超时时间,单位毫秒,retries是重试次数,这两个参数后面会专门讲怎么定。
4.3 提供方的 application.yml 关键配置
提供方的配置文件里,注册中心地址和应用名是必须的:
dubbo: application: name: inventory-provider registry: address: nacos://127.0.0.1:8848 protocol: name: dubbo port: 20880 scan: base-packages: com.example.inventory.service.implscan.base-packages这个配置很关键,它告诉 Dubbo 去哪个包下扫描@DubboService注解。如果服务没注册上,第一个要检查的就是这个包路径对不对。我踩过一次坑,包路径写成了父包但实际实现类在子包里,结果服务死活注册不上,排查了半天。
protocol.port是 Dubbo 协议暴露的端口,默认 20880。如果一台机器上要起多个提供方实例,端口得错开,或者配成-1让 Dubbo 自动分配。
5. 服务消费方:引用服务与调用时的参数控制
5.1 用 @DubboReference 引用远程服务
消费方引用服务用@DubboReference注解:
@Service public class OrderService { @DubboReference(version = "1.0.0", timeout = 3000, retries = 0, check = false) private InventoryService inventoryService; public void createOrder(OrderDTO order) { InventoryResult result = inventoryService.deductStock( order.getSkuId(), order.getQuantity()); if (!result.isSuccess()) { throw new BusinessException("库存扣减失败"); } // 后续下单逻辑 } }这里的check = false是个很实用的配置。默认情况下,消费方启动时会检查依赖的服务是否可用,如果提供方还没启动,消费方直接启动失败。开发阶段这很烦人,设成false后,启动时不检查,真正调用时才去连。生产环境建议保持默认的true,能尽早暴露依赖问题。
5.2 超时时间到底该设多少
超时时间这个参数,设太短容易误判超时,设太长故障时线程堆积。我的经验是分场景定:
- 纯查询类接口,比如查商品详情,设 1000 到 2000 毫秒。
- 涉及写操作的接口,比如扣库存,设 3000 毫秒左右。
- 复杂计算或批量处理接口,可以放宽到 5000 毫秒甚至更长。
关键是,消费方的超时时间要大于提供方的实际处理时间。如果提供方一个接口正常要跑 2 秒,消费方超时设 1 秒,那必然大量超时。我一般会先压测拿到接口的 P99 耗时,然后在这个基础上乘以 1.5 到 2 倍作为超时值。
5.3 重试次数为什么我建议设成 0
Dubbo 默认的重试次数是 2,也就是失败后会重试两次,总共调用三次。这个默认值在查询场景下没问题,但在写操作场景下是灾难。
想象一下扣库存:第一次调用其实成功了,但响应因为网络抖动没回来,消费方以为失败,重试了第二次,结果库存被扣了两次。这就是典型的幂等性问题。所以对于写操作,我建议retries = 0,失败就失败,由业务层决定怎么处理。如果确实需要重试,那接口必须做幂等设计,比如用唯一请求号去重。
6. 联调时踩过的几个坑和排查思路
6.1 服务注册不上,先查这三个地方
服务启动后注册不上注册中心,是最常见的问题。我的排查顺序是这样的:
第一,看注册中心地址对不对。Nacos 的地址格式是nacos://ip:port,别写成http://。第二,看scan.base-packages配置的包路径是否覆盖了实现类。第三,看提供方和消费方的application.name是否重复,两个应用同名会导致注册覆盖。
有一次我排查了半天,最后发现是 Nacos 的命名空间没对上。提供方注册到了 public 命名空间,消费方配置里指定了另一个命名空间,两边根本不在一个空间里,自然找不到。这个坑很隐蔽,因为服务列表里能看到服务,但就是引用不到。
6.2 序列化异常:类找不到的典型表现
消费方调用时报SerializationException或者ClassNotFoundException,八成是 api 模块的版本不一致。提供方用了新版本的 DTO,消费方还是旧版本,反序列化时找不到新加的字段对应的类。
解决办法就是前面说的,api 模块单独管理,版本号严格对齐。我后来在 CI 流程里加了一步,构建时检查提供方和消费方的 api 依赖版本是否一致,不一致直接构建失败。这个检查省了很多联调时间。
6.3 调用超时但提供方日志显示执行成功
这个现象特别迷惑人。消费方报超时,但去提供方看日志,业务逻辑明明执行完了。原因通常是提供方处理时间接近或超过了消费方设置的超时时间,消费方等不及先返回了超时,但提供方还在继续跑,跑完了才打日志。
这种情况要么调大消费方超时,要么优化提供方的处理速度。如果是写操作,还要考虑前面说的幂等问题,因为消费方超时后可能会重试。
6.4 本地开发时直连提供方绕过注册中心
联调阶段有时候注册中心不稳定,或者想指定调用某台机器,可以用直连方式。在@DubboReference上加url参数:
@DubboReference(version = "1.0.0", url = "dubbo://127.0.0.1:20880") private InventoryService inventoryService;这样就不走注册中心了,直接连指定地址。调试单个服务的时候特别方便,但记得上线前把这个配置去掉。
7. 几个让这套方案更稳的进阶配置
7.1 负载均衡策略怎么选
Dubbo 内置了几种负载均衡策略,默认是随机(Random)。我一般这么选:
- Random:默认策略,适合大多数场景,提供方性能差不多时随机分配。
- RoundRobin:轮询,适合提供方性能一致的场景,分配更均匀。
- LeastActive:最少活跃调用数,适合提供方性能差异大的场景,能者多劳。
- ConsistentHash:一致性哈希,适合有状态服务,相同参数总是打到同一台机器。
配置方式是在@DubboReference上加loadbalance参数,比如loadbalance = "leastactive"。
7.2 集群容错策略的实际选择
容错策略决定调用失败后怎么处理。常用的有:
- Failover:失败自动切换,默认策略,配合重试用。
- Failfast:快速失败,失败立即报错,适合写操作。
- Failsafe:失败安全,忽略异常,适合日志记录这类非关键操作。
- Failback:失败自动恢复,后台定时重试,适合消息通知类。
写操作我建议用 Failfast,查询用 Failover。配置参数是cluster。
7.3 用 Mock 做服务降级
当提供方不可用时,消费方可以走 Mock 逻辑返回兜底数据,避免整个链路挂掉。配置方式是:
@DubboReference(version = "1.0.0", mock = "return null") private InventoryService inventoryService;mock = "return null"表示调用失败时返回 null。也可以指定一个 Mock 类,实现自定义的降级逻辑。这个在核心链路保护上很有用,但要注意降级返回的数据要能让上层业务正确处理。
8. 关于这套方案我个人的几点体会
跑通这套 Dubbo 集成之后,我最大的感受是:RPC 框架把远程调用的复杂度藏起来了,但藏起来不等于消失。调用方代码看起来像本地调用,可实际上每一次调用都可能因为网络、序列化、版本不一致出问题。所以用 Dubbo 的时候,心里要始终清楚"这是一次远程调用",超时、重试、幂等这些事一个都不能省。
另外就是接口 Jar 的版本管理,这个真的值得单独花精力去规范。我们后来把 api 模块的版本号和 Git 分支挂钩,每次接口变更都走独立的发布流程,消费方升级要显式改版本。虽然麻烦一点,但比联调时互相甩锅强太多。
最后分享一个小技巧:Dubbo 有个 QOS 端口,默认 22222,可以通过 telnet 连上去执行一些运维命令,比如查看服务列表、动态调整超时时间。调试的时候挺有用,但生产环境记得把这个端口限制在内网访问,别暴露出去。