Mahoo Space

专栏:前端转 Java 微服务实战笔记 · 第 08 篇 · 发布日期:2026-08-02 目录:专栏目录 | 上一篇:07 数据访问:MyBatis-Plus、XML、分页与插件链 | 下一篇:09 多租户与数据权限进阶:SQL 是怎么被改写的

第 07 篇讲完了“单服务内部数据怎么查”;这一篇我们要攻克“数据在别的服务里,怎么拿”。

核心结论先亮在这里:在企业级微服务体系中,内部服务间通信绝大多数不走 HTTP,而是走高性能的 Dubbo 二进制 RPC;接口契约集中沉淀在 api-* 模块中,由服务提供方(Provider)和服务消费方(Consumer)共同依赖。

习惯了使用 Axios 发 HTTP 请求的前端同学,第一眼看到 Dubbo 往往会觉得新奇甚至有些反直觉: “既然都是网络通信,为什么不直接发个 GET/POST 请求拿 JSON,非要折腾一个专门的 api-* 模块和注解代理?” “为什么本地明明没有接口的实现类,写个 @DubboReference 就能像调本地函数一样直接调?” “为什么服务显示启动成功,但一调登录却突然崩溃抛出 No provider available?”

今天我们就顺着生产环境的 Dubbo 架构,把跨服务通信的契约设计、底层网络流转与排障思路彻底讲透。


1. 概念对照:前端直觉与后端 RPC

前端开发经验示例微服务架构对应架构意图与差异
内部共享 npm 包仅导出 TypeScript 类型声明(@company/api-types)api-* 契约模块,只放接口与 DTO 实体前后端共享类型;后端 Provider 与 Consumer 共享编译期契约
tRPC / gRPC Client,调用远程像调本地函数@DubboReference 注入的远程网络代理对象抹平底层网络通信细节,提供强类型面向接口编程体验
服务端 Node.js 之间用 HTTP/REST 互相请求生产环境内部通信采用 Dubbo 二进制 RPC避免 HTTP 文本头冗余与频繁 JSON 序列化,长连接多路复用,吞吐量更高
调用外部第三方开放平台(如微信支付、高德地图)依旧采用标准 HTTP 客户端(HttpClient / RestTemplate)面向异构系统和跨公网调用时,HTTP 依然是通用标准
fetch / Axios 的 timeout 与重试拦截器Dubbo 的 timeout 与 retries 参数配置RPC 框架在底层接管了超时熔断与网络重试

核心差异在于:前端调后端是“跨进程 + 跨网络”;Dubbo 也是跨进程与跨网络,只是框架底层把“寻址、打包、序列化、TCP 发送、反序列化”全部封装到了一个普通的 Java 接口背后。


2. 为什么必须要有独立的 api-* 契约模块?

2.1 解决什么痛点?

如果允许各个微服务自己随手定义一份“用户服务接口”,很快会演变成灾难:

  • 消费方和提供方的方法参数或返回值类型稍有不一致,编译期无法察觉,运行期直接报序列化或反序列化失败;
  • 提供方新增或修改了一个字段,消费方完全不知情导致数据丢失;
  • 改动一处契约,需要人肉在 N 个服务的代码里同步修改。

因此,架构上必须把跨服务通信的契约单独抽离出来,作为独立的 Maven 子模块:

Text
Provider 实现接口 ─┐
                    ├─ 共同依赖同一个 api-* 契约模块
Consumer 引用接口 ─┘

契约模块的发布节奏直接约束着两端:接口若删除或修改方法签名,属于二进制不兼容变更,未同步更新的消费方在调用时会直接报错 NoSuchMethodError。

前端类比:正如在前端 monorepo 中将所有的 API 请求与响应类型抽离到 packages/types,所有 app 统一依赖它以实现端到端的强类型约束。

2.2 api 模块的精简设计原则

工程目录下的 api-module/ 按业务域拆分为:api-bom、api-system、api-equipment、api-ota、api-resource 等。

打开 api-system 的 pom.xml,可以看到其依赖极其克制:

XML
<dependencies>
    <dependency>
        <groupId>com.example.demo</groupId>
        <artifactId>common-core</artifactId>
    </dependency>
    <dependency>
        <groupId>com.example.demo</groupId>
        <artifactId>common-excel</artifactId>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-json</artifactId>
    </dependency>
</dependencies>

铁律:api-* 模块绝对不依赖 MyBatis、不依赖 Spring Web,更不依赖任何业务微服务的实现。它必须保持极致的轻量纯粹,任何微服务引入它都不会带来多余的依赖污染。

2.3 接口与 DTO 的标准形态

用户服务暴露的远程契约接口:

Java
package com.example.demo.system.api;

public interface UserRpcService {
    LoginUser getUserInfo(String username, String tenantId) throws UserException;
    LoginUser getUserInfo(Long userId, String tenantId) throws UserException;
    LoginUser getUserInfoByPhoneNumber(String phoneNumber, String tenantId);
}

字典服务的远程契约接口:

Java
public interface DictRpcService {
    DictTypeView selectDictTypeByType(String dictType);
    List<DictDataView> selectDictDataByType(String dictType);
}

RPC 传输的 DTO 实体必须是可序列化的 POJO:

Java
@Data
@NoArgsConstructor
public class UserRpcView implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;
    private Long userId;
    private String tenantId;
}

为什么强调 Serializable? Dubbo 需要将对象转换成二进制字节流通过 TCP 网络发送。如果传输类漏掉了 implements Serializable,或者未提供无参构造函数,升级改动字段后极易触发 NotSerializableException 导致调用崩溃。


3. Provider:通过 @DubboService 暴露服务

3.1 服务实现类

在系统服务(system-service)中,提供方的实现类标准写法:

Java
@RequiredArgsConstructor
@Service
@DubboService
public class UserRpcServiceImpl implements UserRpcService {
    // 具体的查库与业务组装实现
}

三层含义:

  • @Service:向本地 Spring 容器注册为普通 Bean;
  • @DubboService:由 Dubbo 框架接管,将其作为 RPC 服务发布到注册中心;
  • implements UserRpcService:实现定义在 api-system 契约包里的公共接口。

全站 Provider 分布(典型示例):

归属微服务核心暴露的 RPC Provider
system-serviceUserRpcServiceImpl、PermissionRpcServiceImpl、DictRpcServiceImpl
equipment-serviceDeviceRpcServiceImpl、DeviceQueryRpcServiceImpl
resource-serviceFileRpcServiceImpl、SmsRpcServiceImpl
ota-serviceOtaRpcServiceImpl

3.2 开启 Dubbo 自动装配

在各个微服务的主启动类上,必须标注 @EnableDubbo(如 DeviceApplication、AuthApplication)。缺少该注解,Spring 不会扫描处理 @DubboService 与 @DubboReference。

3.3 注册到 Nacos 注册中心

在公共配置 common-rpc.yaml 中:

YAML
dubbo:
  registry:
    address: nacos://${REGISTRY_ADDR}
    group: RPC_GROUP
    parameters:
      namespace: ${RPC_NAMESPACE}
  metadata-report:
    address: redis://${REDIS_ADDR}
  register-mode: instance
  migration: FORCE_APPLICATION

核心细节:

  • 注册中心同样复用 Nacos,但分组名是 RPC_GROUP,与 Spring Cloud Gateway 的 DEFAULT_GROUP 物理隔开;
  • namespace 严格对应当前环境的命名空间;
  • register-mode: instance + migration: FORCE_APPLICATION:采用现代应用级服务发现(Application-Level Service Discovery),以应用(Application)为单位向 Nacos 注册,大大降低了注册中心在大规模集群下的寻址压力。这也意味着:在 Nacos 服务列表中,你看到的是服务名(如 system-service),而不是单独的接口名。

3.4 传输协议与端口自增机制

YAML
dubbo:
  protocol:
    name: dubbo
    port: -1          # -1 表示从 20880 开始寻找首个未被占用的端口自增绑定
    payload: 104857600

port: -1 是微服务本地多服务调试的利器:

  • 本机同时启动多个 Spring Boot 进程,Dubbo 会自动为每个进程分配不同的 RPC 通信端口(如 20880、20881),杜绝端口冲突;
  • 但在容器或内网部署时,需确保防火墙和安全组对 20880 起的端口段开放通行。

4. Consumer:@DubboReference 注入的是远程代理

4.1 业务代码中的引用示范

在设备服务或认证服务中,需要调用外部能力时,直接声明接口:

Java
@DubboReference private DictRpcService dictRpcService;
@DubboReference private FileRpcService fileRpcService;
@DubboReference private OtaRpcService otaRpcService;
@DubboReference private UserRpcService userRpcService;
@DubboReference private DepartmentRpcService departmentRpcService;
@DubboReference private RoleRpcService roleRpcService;

在认证服务的密码登录策略实现中:

Java
public class PasswordAuthStrategy implements AuthStrategy {
    @DubboReference private ClientRpcService clientRpcService;
    @DubboReference private UserRpcService userRpcService;

    // 登录校验逻辑...
}

4.2 底层流转本质

@DubboReference 注入的绝非本地实例,而是由 Javassist 或 ByteBuddy 动态生成的网络代理对象:

Text
业务代码调用 userRpcService.getUserInfo(...)
  → 触发本地代理对象的拦截机制
  → 向 Nacos 注册表索取系统服务的健康可用实例地址
  → 将方法名、参数类型、入参对象进行二进制序列化打包
  → 通过 Netty 维系的 TCP 长连接发送给提供方
  → 提供方执行本地 Service 查库,打包返回结果
  → 客户端反序列化对象,还原为 UserDto
  → 业务代码拿到对象,继续执行

因此:

  • 本地不需要也不应该存在实现类即可通过编译;
  • 接口方法签名在两端必须严格一致(保证引入相同版本的 api-*);
  • 提供方若抛出 UserException 业务异常,异常对象也会被序列化后跨网络原样抛给调用方捕获。

4.3 核心注解参数一览

参数声明生产作用与场景
@DubboReference(timeout = 300000)针对特定复杂接口单独放大超时时间(如大批量同步任务)
@DubboReference(retries = 0)显式禁止失败重试(对于写操作和非幂等接口极其关键)
@DubboReference(check = false)启动时不校验 Provider 是否存活
@DubboReference(stub = "true")启用本地存根,在客户端发起远程调用前先执行一段本地预检查
@DubboReference(mock = "true")Provider 全部宕机时自动走降级逻辑,返回兜底空数据

5. 核心配置与参数基线

公共配置 common-rpc.yaml 的关键参数基线:

配置项推荐值生产影响与深度考量
dubbo.consumer.retries0失败不重试。避免因网络超时重试导致数据库发生重复插入或重复扣款。
dubbo.consumer.checkfalse启动时不校验 Provider 是否存在,把校验推迟到首次实际调用,防止服务间循环启动死锁。
dubbo.consumer.cachefalse不在 Consumer 端做本地结果缓存,确保跨服务读取数据的强一致性。
dubbo.consumer.validationjvalidationNew在发起 RPC 调用前,自动在客户端执行 Hibernate Validator 参数校验。
dubbo.consumer.payload / provider.payload524288000 (500MB)内存大对象传输阈值上限。
dubbo.protocol.payload104857600 (100MB)底层 Netty 协议层帧大小限制(实际生效以该层限制为准)。

超时控制策略

Dubbo 默认超时时间通常较短(约 1000ms)。对于涉及跨库计算或长事务的批处理任务,示例代码中通常在引用点显式放大:

  • 普通高频查询接口:保持默认或 3000ms,超时快速失败防雪崩;
  • 批量数据同步任务(如 DeviceSyncTask):通过 @DubboReference(timeout = 300_000) 显式放宽至数分钟。

序列化协议

项目采用 Dubbo 默认的 Hessian2 二进制序列化协议。速度快、报文体积小,这也是为什么所有参与 RPC 交互的 DTO 必须严格实现 Serializable 接口。


6. consumer.check=false 的启动陷阱与排查全解

很多转后端的同学刚接手项目时,最常遇到的诡异现象就是:

Text
服务启动日志:风平浪静,打印 "Started AuthServiceApplication in 4.5 seconds"
页面点击登录:控制台瞬间爆炸,抛出 No provider available for the service com.example...UserRpcService

这是因为 check=false 为了保证微服务群能顺利无序拉起,把“Provider 到底存不存在”的硬性校验推迟到了第一次真实调用的瞬间。

排查六步法

遇到 No provider available 时,按顺序核对:

  1. Provider 服务究竟启动了没有? 打开 Nacos 控制台服务列表,查看提供方应用(如 system-service)是否在线。
  2. Nacos 命名空间(namespace)是否完全一致? Consumer 和 Provider 必须在同一个 RPC_NAMESPACE 下才能感知彼此。
  3. Dubbo 分组(group)是否一致? 系统 Dubbo 配置的注册分组是 RPC_GROUP,如果一方漏配退化为默认的 DEFAULT_GROUP,寻址必定失败。
  4. 接口版本号(version)是否匹配? @DubboService 与 @DubboReference 上的 version 必须对齐。
  5. 网络端口连通性:Provider 启动时打印的动态端口(从 20880 起)是否在局域网内可达。
  6. 本地旧实例干扰:排查 Nacos 实例列表中是否残留着其他同事本地已异常退出的脏 IP 实例。

常见 RPC 异常关键词速查:

异常关键词根本原因定位
No provider available提供方未启动、服务名不匹配、Nacos 命名空间或分组未对齐
RpcTimeoutException下游服务执行慢(慢 SQL、大事务锁等待、Full GC 卡顿)超过设定的超时时间
SerializationExceptionDTO 类漏掉了 Serializable,或者新旧版本的字段类型发生了不兼容变更
NoSuchMethodError契约包版本不一致(调用方使用了升级后的新接口方法,但部署的提供方依然是旧版本 JAR 包)

7. 架构决策:什么时候用 Dubbo,什么时候用 HTTP?

决策维度Dubbo RPCHTTP / REST(Feign)
报文协议紧凑型二进制协议包含冗余头部的纯文本协议
执行性能极高(长连接多路复用,零冗余握手)较低(存在连接池开销与文本解析损耗)
契约强约束编译期校验(共享 api-* 契约,签名改动立即飘红)弱约束(依赖 Swagger 或文档,运行期反序列化才报错)
跨语言与通用性局限于 RPC 生态(Java 主流)极高(全语言、全终端通用)
调试成本需借助专用测试工具或本地环境浏览器和 curl 即可直接发起调试
生产定位系统内部核心微服务之间的高频通信与第三方外部系统对接(CRM、ERP、地图服务、支付渠道)

8. 练习与自检

练习建议(耗时约 30 分钟)

  1. 在工程中找到 UserRpcService 的接口定义(在哪个 api-* 模块)以及它的实现类 UserRpcServiceImpl(在哪个业务服务模块),记录它们各自的包路径。
  2. 在设备服务的源码中搜索找到 3 个带有 @DubboReference 的字段,分别写出它们对应的底层提供方服务名。
  3. 保持网关与认证服务运行,手动停掉系统服务;在前端发起一次登录请求,观察控制台抛出的异常堆栈,找到 No provider available 关键字。
  4. 在 common-rpc.yaml 与配置中心中,对比 consumer payload 与 protocol payload 的参数设定,体会协议层对传输报文大小的硬性约束。

自检问题

  1. 为什么在微服务架构中,api-* 契约模块绝对不能包含具体的 Service 实现类或 Mapper 接口? 答:因为 api-* 属于纯粹的契约层,需要被全站所有消费方服务共同引入。如果契约包中掺杂了具体的业务实现或数据库持久层依赖,会导致依赖污染迅速扩散,不仅极易引发 Maven 循环依赖,还会彻底破坏微服务独立部署与数据隔离的架构边界。

  2. 在业务 Controller 或 Service 中通过 @DubboReference 注入的变量,其实质是什么? 答:其实质是一个由 Dubbo 动态生成的网络代理对象(Proxy),而非本地真实实现。当调用其方法时,代理对象在底层执行寻址、参数序列化、TCP 发送并接收结果,对外呈现出与调用本地方法完全一致的编程体验。

  3. 配置参数 dubbo.consumer.check = false 的主要作用是什么?存在什么调试陷阱? 答:作用是允许微服务在启动时不强制要求下游所有 Provider 必须在线,避免微服务集群启动时的依赖死锁;调试陷阱在于它将寻址失败的报错推迟到了第一次实际调用时刻,容易造成“服务启动显示一切正常,但调接口瞬间报错崩溃”的排查困惑。

  4. Dubbo 的服务注册中心和 Spring Cloud Gateway 的服务注册中心是同一个吗?排查服务注册时有何差异? 答:两者底层通常共用同一个 Nacos 物理集群,但在逻辑上严格隔离。Dubbo 使用独立的 RPC_GROUP 分组,且采用应用级服务发现(register-mode: instance)。在 Nacos 服务列表排查 RPC 问题时,必须切换到对应的 RPC_GROUP 分组下,观察具体的应用实例是否健康在线。

  5. 在日常微服务开发中,什么时候必须坚决使用 Dubbo,什么时候应当选用 HTTP/REST? 答:内部各微服务之间频繁、高并发的系统级调用,坚决选用 Dubbo 以获取极高吞吐量与强类型编译期契约保障;而对外暴露给外部合作方调用、与第三方异构系统(如银行支付、开放平台)对接、或跨语言调用时,应当选用通用性强、调试直观的标准 HTTP/REST 协议。


9. 下一篇预告

现在,单服务内部的数据增删改查我们精通了,跨服务之间的 Dubbo 远程调用也摸透了。

但只要深入日常业务,很快就会遇到两个让人抓狂的隐形机制:多租户与数据权限。 为什么同一句 SQL,给 A 客户演示时数据齐全,换成 B 客户登录却一条都查不出来?为什么同一个账号在不同部门只能看到特定范围的设备?最可怕的是:如果在代码里写了一个异步多线程任务去查库,为什么会导致租户上下文全部丢失,引发灾难性的跨租户数据越权外泄?

下一篇,我们将深入 SQL 动态改写深水区:09 多租户与数据权限进阶:SQL 是怎么被改写的。