Dubbo(一):RPC 基础原理与 Dubbo 整体架构
Dubbo(一):RPC 基础原理与 Dubbo 整体架构
导语:Dubbo 面试的第一层不是「Dubbo 怎么用」,而是「RPC 到底是什么、一次远程调用中间经过了什么」——这是能把"背配置的人"和"懂原理的人"区分开的分水岭。本篇先用 5 题打透 RPC 基础原理(定义与边界、完整调用链路、框架分层、动态代理、序列化选型),再落地到 Dubbo 的架构五角色、十层分层与服务暴露/引用流程,共 7 题。
一、RPC 基础与原理
1. 什么是 RPC?它和本地调用、HTTP 调用有什么区别?
答:
定义:RPC(Remote Procedure Call,远程过程调用)是一种进程间通信方式——它让调用方可以像调用本地方法一样调用另一台机器上的服务,而不用自己处理 socket 连接、报文拼装、序列化这些网络细节。
它需要同时解决三个问题:
| 要解决的问题 | 说明 |
|---|---|
| ① 寻址(Call ID / 服务发现) | 本地调用靠"方法指针",远程调用必须有一种方式告诉服务端"我要调的是哪个方法" |
| ② 序列化与反序列化 | 内存对象跨进程无法直接传递,必须编码成字节流,且要能被还原 |
| ③ 网络传输 | 字节流要在两端可靠/高效地传输(TCP 长连接、IO 模型、连接池等) |
⚠️ 常见误解纠正:RPC 不是一种协议,而是一种"调用范式"。它可以基于自定义 TCP 协议(Dubbo 协议、Thrift),也可以基于 HTTP(gRPC 就跑在 HTTP/2 上)。面试里说"RPC 就是比 HTTP 快的那个协议"是不准确的。
与本地调用的区别(面试第一问的真正考点):
| 维度 | 本地调用 | RPC 远程调用 |
|---|---|---|
| 速度 | 纳秒级,压栈出栈 | 毫秒级,网络 RTT 是主要开销 |
| 可靠性 | 要么成功要么抛异常 | 存在超时、失败、重试、乱序,结果可能"三态"(成功 / 失败 / 未知) |
| 参数传递 | 支持引用、指针、共享内存 | 只能传值,参数必须可序列化 |
| 异常 | 直接抛出同进程异常 | 远程异常要包装传输,调用方拿到的是反序列化后的异常对象 |
| 幂等性 | 天然无需考虑 | 重试必须考虑幂等(这是 RPC 最大的坑) |
与 HTTP/REST 的区别(第二高频追问):
| 维度 | RPC(Dubbo / gRPC) | HTTP + JSON(RESTful) |
|---|---|---|
| 抽象层次 | 面向方法 / 服务(OrderService.getById()) | 面向资源(GET /orders/{id}) |
| 协议 | 可自定义二进制 TCP 协议,或走 HTTP/2 | HTTP/1.1 文本协议 |
| 序列化 | 二进制(Hessian2 / Protobuf / Kryo) | 文本(JSON 为主) |
| 性能 | 报文小、长连接、多路复用,吞吐高 | 报文大、解析慢,但对人和浏览器友好 |
| 契约 | 强契约:依赖 API jar / .proto IDL,跨语言靠代码生成 | 弱契约:靠文档,浏览器可直接调试 |
| 耦合度 | 强(消费方要有接口定义) | 弱 |
| 服务治理 | 内置:注册发现、负载均衡、容错、路由、限流、降级 | 需要网关 / SDK 额外补齐 |
| 典型场景 | 内部微服务之间的高频调用 | 对外开放 API、前后端交互、跨系统对接 |
【一句话记忆】
内部高频调用 → 要性能 + 要治理 → RPC
对外暴露能力 → 要通用 + 要可读 → HTTP/REST面试加分句:RPC 的本质是「屏蔽网络细节的透明化远程调用」,它的代价是引入了分布式系统的全部复杂度(超时、重试、幂等、雪崩),所以"用 RPC"从来不只是换个调用方式那么简单。
2. 一次完整的 RPC 调用流程是怎样的?(必考)
答: 标准答案是「8 步 + Stub 对称结构」,核心思想是:调用方出 1 个 Stub(代理),服务方出 1 个 Stub(骨架),两端各做一次序列化/反序列化。
拆开说,每一步在做什么:
| 步骤 | 位置 | 关键动作 | 对应的 Dubbo 组件 |
|---|---|---|---|
| ① | 调用方 | 调的是代理对象,不是实现类 | ProxyFactory 生成的代理 |
| ② | Client Stub | 把「接口名 + 方法名 + 参数类型 + 参数值 + 附件」封装成 Invocation | InvokerInvocationHandler |
| ③ | Client Stub | 服务发现 + 负载均衡 + 路由 选出目标地址 | Directory / Router / LoadBalance |
| ④ | Client Stub | 按协议编码成二进制报文 | ExchangeCodec / DubboCodec |
| ⑤ | 网络层 | 异步 NIO 发送(默认单一长连接) | NettyClient / NettyChannel |
| ⑥ | Server Stub | 解码 → 根据 <serviceKey, method> 反射调用 | DubboProtocol 的 ExchangeHandler |
| ⑦ | Server Stub | 结果 / 异常兜底处理 | Result 包装 |
| ⑧ | Client Stub | 同步等待(或回调)并反序列化 | DefaultFuture(future/promise 机制) |
一个高频追问:「第 ⑤ 步发出去后,线程在干什么?」
答案是:取决于同步/异步调用方式。同步调用时业务线程会阻塞在
DefaultFuture.get()(内部是LockSupport.park),收到响应后被唤醒;异步调用直接返回CompletableFuture;Dubbo 底层始终通过 requestId 关联请求与响应(Map<Long, DefaultFuture>),所以连接上并发的请求可以乱序返回也不会串包。
3. 一个 RPC 框架由哪些核心模块组成?(分层画出来)
答: 主流 RPC 框架的分层基本一致,从上到下是「代理 → 集群 → 协议 → 传输 → 序列化」,右侧旁挂注册中心与监控:
各层职责与面试要点:
| 层 | 职责 | 典型追问 |
|---|---|---|
| 代理层 | 生成接口的代理对象,屏蔽网络通信 | 为什么必须用动态代理?(见第 4 题) |
| 集群层 | 从一堆 Provider 里挑一个、失败了怎么办 | 负载均衡策略、容错策略(详见下一篇) |
| 协议层 | 定义报文格式、请求/响应语义、Invoker 抽象 | Dubbo 协议的 header 长什么样? |
| 传输层 | 连接建立/复用、IO 模型、线程模型、心跳 | 为什么 Dubbo 用单一长连接? |
| 序列化层 | 对象 ↔ 字节 | 各种序列化怎么选?(见第 5 题) |
| 注册中心 | 只是存地址、推变更,不转发业务流量 | 注册中心挂了还能不能调用? |
⚠️ 必须说清的一点:RPC 是「点对点直连」调用,注册中心不参与实际请求转发。流量是 Consumer 直连 Provider 的——这和 HTTP 走 Nginx/API 网关是完全不同的模型。这条搞反了,整个 Dubbo 的理解都是错的。
4. 为什么 RPC 要用动态代理?「透明化调用」是怎么做到的?
答:
原因:RPC 的卖点是「像调用本地方法一样调用远程服务」。但接口没有实现类(实现类在另一台机器上),所以必须有一个对象来"顶替"接口的位置,把方法调用劫持下来,转成网络请求。动态代理就是干这件事的。
没有动态代理会怎样(对比表的说法):
| 方案 | 问题 |
|---|---|
| ❌ 手写 HTTP 请求 | 业务代码里全是 URL、拼参数、解析 JSON,接口一改全线返工 |
| ❌ 静态代理(给每个接口手写代理类) | 有多少接口就要写多少代理类,维护灾难 |
| ✅ 动态代理 | 运行时自动生成代理类,所有接口复用同一套逻辑,接口变更无需改框架代码 |
Dubbo 里代理的两种实现:
| 实现 | 原理 | 特点 |
|---|---|---|
| JDK 动态代理 | Proxy#newProxyInstance + InvocationHandler | JDK 原生、只能代理接口 |
| Javassist / ByteBuddy(字节码生成) | 直接生成代理类的字节码并加载 | 无需 interface 也能用、性能更好;Dubbo 2.x 默认 Javassist,Dubbo 3 起默认 ByteBuddy |
顺带记住 Dubbo 的两个"代理扩展"(也是高频题):
| 特性 | 说明 |
|---|---|
本地存根 Stub(<dubbo:service stub="true"/>) | 在消费方先执行一段本地逻辑(参数校验、缓存、兜底)后再发起远程调用 |
本地伪装 Mock(<dubbo:reference mock="return null"/>) | 服务降级:调用失败时返回兜底值,而不是把异常抛给上层 |
5. 常见序列化协议怎么选?(一张表 + 三个坑)
答: 序列化是 RPC 里唯一直接影响报文体积和 CPU 开销的环节,几乎是必考题。
| 序列化方式 | 可读性 | 跨语言 | 性能 | 体积 | 兼容性 | 典型使用 |
|---|---|---|---|---|---|---|
| JDK Serializable | ❌ 无 | ❌ Java only | ❌ 最慢 | ❌ 最大 | ⚠️ 需 serialVersionUID | 生产禁用 |
| Hessian2 | ❌ 二进制 | ✅ | 中 | 小 | ✅ 较好 | Dubbo 2.x/3.x 默认 |
| JSON(Jackson / Fastjson) | ✅ 可读、好调试 | ✅ | 中(文本解析慢) | ❌ 偏大 | ✅ 好 | 对外 API、配置下发、跨团队 |
| Kryo / FST | ❌ | ❌ Java only | ✅ 极快 | ✅ 极小 | ⚠️ 一般 | Dubbo 中替代 Hessian 的性能选项 |
| Protobuf | ❌ | ✅ 强 | ✅ 快 | ✅ 小 | ✅ 好(字段号演进) | gRPC、Dubbo(跨语言场景) |
| MsgPack | ❌ | ✅ | 快 | 小 | 一般 | Redis、MessagePack |
【选型结论(可直接背)】
1)Dubbo 默认 Hessian2 —— 平衡了性能与兼容性,够用
2)追求极致性能且纯 Java 技术栈 → Kryo / FST
3)跨语言 + 长期演进 → Protobuf(字段号天然支持前后兼容)
4)对外 / 要人工排错 → JSON
5)永远不要在生产用 JDK 原生序列化 —— 性能差 + 反序列化漏洞风险三个必须知道的坑:
| 坑 | 说明 | 规避 |
|---|---|---|
| ① Kryo 非线程安全 | Kryo 实例不是线程安全的,多线程共用会出错 | 用 ThreadLocal 或对象池持有实例 |
| ② 序列化安全性 | JDK 原生序列化、部分 JSON 库的 AutoType 存在反序列化 RCE 漏洞 | 禁 JDK 序列化;Fastjson 关闭 AutoType 或用白名单 |
| ③ 兼容性 | 字段增删/改类型会破坏老版本反序列化 | 用支持「字段演进」的协议(Protobuf/Hessian2);DTO 增删字段保持「加字段不加序号」 |
面试加分句:序列化选型不是越快越好,而是「性能、可读性、跨语言、长期演进」四个维度的权衡。很多公司最后选 Hessian2 或 Protobuf,恰恰是因为兼容性比那点 CPU 收益重要得多。
二、Dubbo 架构与核心流程
6. Dubbo 的整体架构是怎样的?有哪些核心角色?
答: Dubbo 官方架构有 5 个角色,其中注册中心只做地址存储和变更推送,不转发流量:
角色职责:
| 角色 | 职责 | 关键点 |
|---|---|---|
| Provider | 暴露服务的提供方 | 启动时向注册中心注册自己的地址(接口 → 地址映射) |
| Consumer | 调用远程服务的消费方 | 启动时订阅所需服务,缓存地址列表到本地 |
| Registry | 注册中心(Nacos / Zookeeper / Redis 等) | 存地址、推变更;挂了不影响已有调用(本地缓存生效) |
| Monitor | 监控中心 | 接收 Consumer/Provider 上报的调用次数与耗时(默认每分钟一次) |
| Container | 服务运行容器 | 负责启动、加载、运行 Provider(通常就是 Spring 容器) |
执行时序(官方标准表述):
0. 服务容器负责启动、加载、运行服务提供者
1. Provider 启动时,向注册中心注册自己提供的服务(写入 URL)
2. Consumer 启动时,向注册中心订阅自己所需的服务
3. 注册中心返回 Provider 地址列表给消费者;如有变更,基于长连接推送变更数据
4. Consumer 从地址列表中,基于软负载均衡算法选一台 Provider 调用;失败则换一台
5. Consumer 和 Provider 在内存中累计调用次数与耗时,定时(默认每分钟)发送统计数据到监控中心Dubbo 的十层分层(从上到下,面试画出来很有说服力):
记忆锚点:最容易被追问的三层是 proxy(为什么能像本地调用)、cluster(一堆变一个)、protocol(Invoker 抽象)——Dubbo 里有一句经典概括:「Invoker 是 Dubbo 领域模型中绝大多数操作的实体对象」。
7. Dubbo 的服务暴露(Export)与服务引用(Refer)流程是怎样的?
答: 这是 Dubbo 原理里最高频的"深挖题",核心是记住两个对称的过程,以及贯穿全程的抽象 Invoker。
核心概念:Dubbo 里
Invoker是实体域,几乎所有操作都围绕它展开——
- Provider 侧:
<实现类的实例>→Invoker(把"某个对象的方法"抽象成"可 invoke 的东西")- Consumer 侧:
<一堆远程 Provider>→Cluster Invoker(把"一堆远程地址"伪装成"一个可 invoke 的东西")
① 服务暴露流程
【简化版(面试口述)】
1. ServiceBean 在 Spring 容器刷新完成后调用 export()
2. ServiceConfig 拼出 URL(协议 / host / port / 参数全集)
3. ProxyFactory#getInvoker 把「实现类实例」包成 Invoker
4. Protocol#export 依次经过 ProtocolListenerWrapper、ProtocolFilterWrapper 织入 Filter 链
5. RegistryProtocol#export → DubboProtocol#export → openServer 启动 Netty(默认 20880,同端口复用)
6. 最后把 Provider URL 注册到注册中心② 服务引用流程
【简化版(面试口述)】
1. ReferenceBean#getObject(或 Spring 注入时)触发 ReferenceConfig#init
2. 注册 Consumer URL,并向注册中心订阅服务
3. 拿到 Provider URL 列表 → RegistryDirectory 转成 Invoker 列表(并建立 Netty 长连接)
4. Cluster#join(directory) 把「多个 Invoker」包装成「1 个 ClusterInvoker」(挂载负载均衡与容错策略)
5. ProxyFactory#getProxy 生成代理对象,注入到业务 Bean 的字段
—— 至此,业务代码调用的其实是代理对象。两侧对比(把对称性说清楚):
| 阶段 | Provider 侧 | Consumer 侧 |
|---|---|---|
| 入口 | ServiceConfig#export | ReferenceConfig#init/get |
| 代理的作用 | 实现类 → Invoker(getInvoker) | Invoker → 接口代理(getProxy) |
| Protocol 的作用 | 启动 Netty Server 并导出服务 | 建立 Netty Client 连接并引用服务 |
| 注册中心动作 | register(写地址) | subscribe(拉地址 + 监听变更) |
| 最终产物 | Exporter(可 unexport 优雅下线) | 接口的动态代理对象 |
加分追问:「消费者到底长连接到哪里?」
Consumer 是与每个 Provider 各建立一条 TCP 长连接(Dubbo 协议默认单一长连接、全双工复用),默认的 Dubbo 协议端口是 20880。所谓"单一长连接"是指一个 Consumer 到一个 Provider 之间只有一条连接,而不是全链路只有一条。
本篇小结
| 题 | 一句话 |
|---|---|
| 1 | RPC 是调用范式不是协议;区别于本地调用的关键是「结果三态 + 幂等」 |
| 2 | 一次调用 = 8 步、Stub 对称,靠 requestId 关联请求与响应 |
| 3 | 框架分层:代理 → 集群 → 协议 → 传输 → 序列化,注册中心不转发流量 |
| 4 | 动态代理是"透明化"的实现方式,Dubbo 默认 Javassist(2.x)/ ByteBuddy(3.x) |
| 5 | 序列化四维权衡:性能 / 可读性 / 跨语言 / 演进;生产禁用 JDK 原生序列化 |
| 6 | 五角色 + 十层分层,核心价值是 Invoker 抽象与集群层伪装 |
| 7 | Export / Refer 完全对称,都围绕 Invoker 展开 |
下一篇:《Dubbo(二)》进入 Dubbo 真正的必考主战场——注册中心选型与「注册中心挂了还能不能调」、5 种负载均衡策略(含一致性哈希虚拟节点)、6 种集群容错策略(默认 Failover 与重试放大的坑)、SPI 自适应扩展机制(与 Java SPI 的对比)、协议选型(dubbo 协议报文头与 Triple)以及超时重试的服务降级与配置优先级。
