RPC 框架进阶设计:从插件化架构到服务治理与无损上下线

RPC 的核心目标看似简单:把本地方法调用转换成网络请求,再在远端恢复方法语义。但真正进入生产环境后,问题很快会从“如何完成一次远程调用”扩展到“如何发现实例、判断节点是否健康、控制流量、处理失败、保护上下游以及实现无损发布”。本文从 RPC 框架整体架构出发,系统梳理插件化设计、服务发现、健康检测、路由、负载均衡、异常重试、优雅启停、熔断限流与业务分组,并把这些能力重新组织成一套完整的 RPC 服务治理体系。

从“能调用”到“能治理”

如果只讨论最基础的 RPC,一次调用可以被概括成:

拦截本地方法调用,将方法、参数等信息转换成可以在网络上传输的二进制数据,在服务提供方恢复调用语义并执行目标方法,再把结果返回给调用方。

也就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
本地方法调用

序列化

协议编码

网络传输

协议解码

反序列化

反射 / 方法分发

执行目标方法

做到这里,只解决了 Point to Point(点对点)远程调用

生产环境中的服务几乎不会只有一个实例。当一个接口背后出现几十、几百甚至更多服务节点后,RPC 框架立即会遇到另一批问题:

  • 服务提供方在哪里?
  • 新节点上线后,调用方如何发现?
  • 节点挂掉以后,怎样快速停止向它发送流量?
  • 节点虽然没挂,但已经“半死不活”怎么办?
  • 一批节点中到底选择哪个?
  • 灰度发布时怎样只让部分流量进入新节点?
  • 下游变慢以后,怎样避免把上游一起拖死?
  • 网络抖动失败以后能否自动重试?
  • 服务重启过程中怎样做到业务无损?
  • 不同业务之间怎样避免互相争抢资源?
  • 以后想替换序列化、注册中心或负载均衡算法,是否必须修改 RPC 核心代码?

到这一阶段,RPC 已经不再只是一个“网络调用工具”,而逐渐成为一套围绕远程调用构建的 分布式服务治理基础设施


RPC 框架的整体架构

从职责上看,一个较完整的 RPC 框架可以划分为四层:传输层、协议层、集群层和入口层。

flowchart TB
    subgraph Entry["入口层 / Bootstrap"]
        Proxy["动态代理"]
        Trace["链路追踪"]
        Filter["过滤链"]
    end

    subgraph Cluster["集群层"]
        Discovery["服务发现"]
        Connection["连接管理"]
        Route["路由"]
        LB["负载均衡"]
        Fault["容错 / 重试"]
        Config["配置管理"]
    end

    subgraph Protocol["协议层"]
        Codec["协议编解码"]
        Serialization["序列化 / 反序列化"]
        Compress["压缩 / 解压缩"]
    end

    subgraph Transport["传输层"]
        TCP["TCP Transport"]
        HTTP["其他 Transport"]
    end

    Entry --> Cluster
    Cluster --> Protocol
    Protocol --> Transport

传输层:只负责把数据送过去

RPC 最底层仍然需要网络通信。

在典型实现中,RPC 会采用 TCP 长连接,在调用端与服务提供方之间传输二进制数据。传输模块应该尽可能屏蔽 Socket、连接读写、网络事件等细节,上层只需要面对:

1
2
send(binary)
receive(binary)

而不必关心底层数据到底经过了多少个 TCP Segment。

传输层本身也应该是可替换的。一个成熟的框架不应该把整个体系永久锁死在某一种传输实现上。

协议层:保证数据能够被正确“断句”和恢复

Java 对象不能直接通过网络传输,因此需要序列化。

但仅仅把对象转换成 byte[] 还不够。连续 TCP 字节流还必须知道:

1
2
3
这一条 RPC 请求从哪里开始?
在哪里结束?
下一条请求又从哪里开始?

这就是协议 framing 的问题。

资料中用“断句”来描述这个过程。工程实现并不意味着一定需要真正放置特殊分隔符,也可以通过协议头中的长度字段等方式确定消息边界。

例如:

1
2
3
+------------+-------------+----------------+
| Header | Payload Len | Payload |
+------------+-------------+----------------+

无论使用长度字段、分隔符还是其他 framing 机制,目标都一样:

让接收方能够准确恢复每一个独立的 RPC 消息。

协议层通常还适合放置压缩能力。当请求参数或者返回结果较大时,可以根据阈值进行无损压缩,在另一端使用相同算法解压,以降低实际网络传输的数据量。

集群层:RPC 服务治理的核心

一旦接口背后存在多个服务提供方,RPC 就进入集群调用阶段。

集群层需要解决:

1
2
3
4
5
6
7
8
9
10
11
接口

有哪些实例?

哪些实例可用?

当前请求允许访问哪些实例?

其中应该选择哪个实例?

调用失败后怎么办?

因此这里聚集了大量核心能力:

  • 服务发现;
  • 连接管理;
  • 健康检测;
  • 路由;
  • 负载均衡;
  • 容错与重试;
  • 配置管理。

很多 RPC 框架真正拉开差距的地方,并不是“能不能发 TCP 请求”,而是这一层的治理能力。

入口层:让远程调用看起来像本地调用

对业务开发者而言,理想的调用方式应该是:

1
userService.getUser(id);

而不是:

1
2
3
4
5
6
7
8
序列化参数
→ 查询服务地址
→ 建立连接
→ 编码
→ 发送
→ 等待响应
→ 解码
→ 反序列化

因此 RPC 框架一般会通过动态代理隐藏这些细节。

在 Spring 场景中,RPC 接口还可以被包装成 Bean,统一交给 BeanFactory 管理,业务代码直接通过依赖注入获得代理对象。

这也是 RPC “像调用本地方法一样调用远程服务”的真正入口。


为什么 RPC 架构必须插件化

前面的四层架构解决了“应该有哪些组件”,但还没有解决另一个很重要的问题:

如果明天想换掉序列化算法、注册中心、路由策略或者负载均衡算法怎么办?

最直接但也最糟糕的做法,是在核心代码里不断增加:

1
2
3
if serializer == json
else if serializer == protobuf
else if serializer == xxx

功能越多,核心代码就越容易演变成巨大的条件分支集合。

更合适的做法,是把变化点抽象成扩展接口。

例如:

1
2
3
4
5
6
7
8
9
Serialization
├── DefaultSerializer
├── SerializerA
└── SerializerB

LoadBalancer
├── RandomLoadBalancer
├── RoundRobinLoadBalancer
└── AdaptiveLoadBalancer

核心框架只依赖接口:

1
核心框架 → 扩展点接口

具体实现由插件提供:

1
插件 → 实现扩展点接口

这就形成了典型的 Microkernel Architecture(微内核架构)

flowchart LR
    Core["RPC 微内核"]

    Core --> E1["Transport SPI"]
    Core --> E2["Serialization SPI"]
    Core --> E3["Discovery SPI"]
    Core --> E4["Router SPI"]
    Core --> E5["LoadBalance SPI"]
    Core --> E6["Filter SPI"]

    E1 --> P1["TCP / 其他实现"]
    E2 --> P2["不同序列化实现"]
    E3 --> P3["不同注册中心实现"]
    E4 --> P4["IP / 参数路由"]
    E5 --> P5["随机 / 轮询 / 自适应"]

这种结构有两个直接收益。

一方面满足开闭原则,新能力通过扩展插件加入,不需要频繁修改核心代码。

另一方面可以保持核心包精简,减少因为 RPC 框架引入大量第三方依赖而造成的版本冲突。

Java SPI 只是实现插件化的一种方式

JDK 自带 Service Provider Interface(SPI),可以通过:

1
META-INF/services/<接口全限定名>

声明某个接口的 Provider。

但一个真正面向 RPC 的扩展系统通常还要解决更多问题,例如:

  • 如何按名称选择扩展,而不是让调用者自己遍历;
  • 如何进行扩展生命周期管理;
  • 如何根据配置动态选择实现;
  • 如何让扩展依赖另一个扩展;
  • 如何完成依赖注入;
  • 如何和 Spring 等容器集成;
  • 如何支持条件加载;
  • 如何避免无意义的扩展实例创建。

因此很多 RPC 框架都会在 Java SPI 思想之上构建自己的 Extension 体系。

还需要区分一点:

插件化是架构思想,不等于 Java SPI。

如果 RPC 是跨语言的,各语言完全可以采用不同的插件加载方式。真正应该稳定的是协议与扩展契约,而不是某一个 Java 类加载机制。


把一次完整 RPC 调用串起来

将后面所有治理能力放到一次调用中,可以得到这样一条逻辑链:

flowchart LR
    A["动态代理"] --> B["熔断检查"]
    B --> C["容错 / 重试策略"]
    C --> D["获取服务实例"]
    D --> E["健康状态过滤"]
    E --> F["路由过滤"]
    F --> G["负载均衡"]
    G --> H["选择连接"]
    H --> I["协议编码 / 序列化"]
    I --> J["网络发送"]
    J --> K["服务端限流"]
    K --> L["关闭挡板"]
    L --> M["业务执行"]

这里有几个顺序尤其重要:

路由先过滤,负载均衡再选择。

路由回答的是:

“哪些机器符合这次请求的规则?”

负载均衡回答的是:

“在这些符合规则的机器里,最终选择哪一台?”

健康检测则应该尽可能在选择前,把明显不可用的连接从候选集合中排除。

熔断发生得更早,它甚至可能让请求根本不进入后续网络调用。


服务发现:RPC 如何找到服务提供方

为什么必须有服务发现

假设某个接口:

1
OrderService

现在部署了:

1
2
3
192.168.1.10:9000
192.168.1.11:9000
192.168.1.12:9000

调用方真正需要维护的是一种映射:

1
2
3
Service Key

Service Instance List

服务节点随时可能:

  • 扩容;
  • 缩容;
  • 重启;
  • 宕机;
  • 迁移;
  • 改变状态。

显然不可能把 IP 硬编码在调用端。

于是产生了注册中心。

flowchart TD
    Provider["服务提供方"] -->|注册服务实例| Registry["注册中心"]
    Consumer["服务调用方"] -->|订阅服务| Registry
    Registry -->|下发实例列表| Consumer
    Consumer -->|RPC 调用| Provider

核心过程可以概括为:

服务注册

服务提供方启动后,把接口与自己的地址注册到注册中心。

服务订阅

调用方启动后订阅对应服务,获得全部服务实例,并缓存到本地。

服务变更通知

节点新增、删除或状态变化后,注册中心将变化通知调用方。


为什么仅靠 DNS 很难满足传统 RPC 服务发现

从本质上看,DNS 也解决:

1
Name → Address

那么为什么不直接:

1
2
3
4
5
order-service.example

DNS

多个 Provider IP

问题不在于 DNS 完全不能做,而在于传统 RPC 对节点变化感知的实时性和治理粒度要求通常更高。

DNS 查询往往存在多级缓存:

1
2
3
4
5
6
7
应用 / JVM

本机缓存

本地 DNS

上游 DNS

因此节点摘除、扩容以后,变化传播会受到 TTL、客户端缓存以及运行时配置等因素影响。

需要说明的是,JVM DNS 缓存行为会受到 JDK 版本与安全配置影响,不应简单把某一种历史默认值理解成所有环境下都永久不变。真正需要关注的核心问题是:

DNS 的缓存体系并不是专门按照 RPC 实例快速上下线和细粒度治理设计的。


在 DNS 前增加 VIP 为什么仍然不是理想答案

另一种做法是:

1
2
3
4
5
6
7
Consumer

DNS

VIP / 四层负载均衡

Provider Cluster

它能隐藏后端实例变化,但也改变了 RPC 的数据路径。

请求不再是:

1
Consumer → Provider

而变成:

1
Consumer → LB → Provider

这会带来:

  • 增加中间网络跳转;
  • 需要部署额外代理或负载均衡设施;
  • 服务节点上下线需要和负载层协同;
  • 每个服务、分组需要不同治理策略时配置变复杂;
  • RPC 客户端无法直接利用完整实例信息做细粒度路由。

因此传统 RPC 更常见的思路是:

注册中心只负责告诉客户端“有哪些节点”,真正的节点选择由 RPC 客户端自己完成。


基于 ZooKeeper 的服务发现

一个典型的 ZooKeeper 服务目录可以设计成:

1
2
3
4
/service
/com.demo.UserService
/provider
/consumer

服务提供方注册时,在 provider 下创建临时节点。

调用方订阅时:

  1. consumer 下建立自己的节点;
  2. 获取 provider 下的全部实例;
  3. Watch Provider 目录;
  4. Provider 发生变化后接收通知。
sequenceDiagram
    participant M as 服务管理端
    participant Z as ZooKeeper
    participant P as Provider
    participant C as Consumer

    M->>Z: 创建服务目录
    P->>Z: 创建 Provider 临时节点
    C->>Z: 获取 Provider 列表
    C->>Z: Watch Provider 目录
    Z-->>C: 返回实例列表

    P--xZ: 会话结束 / 节点变化
    Z-->>C: Provider 变化通知

这种机制清晰、成熟,也确实可以完成 RPC 服务发现。

但随着系统规模增大,注册中心面对的压力并不只来自查询,而更可能来自:

  • 大批量实例同时注册;
  • 大批量节点同时下线;
  • Watch 事件集中产生;
  • 高频数据同步;
  • 注册目录不断增大;
  • 注册中心恢复后的集中重连和重新注册。

这也是注册中心架构设计必须面对的“惊群”和恢复风暴问题。


服务发现到底更需要 CP 还是 AP

这组资料把这个问题概括成:

RPC 服务发现通常没有必要为每一次实例变化追求瞬间强一致,更重要的是整个注册中心持续可用,并能够在较短时间内收敛到正确状态。

这里的 CP/AP 更适合作为工程取舍来理解,而不要简单理解成“某种注册中心所有副本每次写入都必须同时完全一致”。

RPC 服务发现存在一个非常关键的业务特征:

假设现在新增一个实例:

1
P4

即使调用方晚几秒钟才发现 P4,通常也只是:

1
P4 暂时少接一点流量

并不会直接造成业务错误。

这意味着服务发现能够容忍短时间的旧数据。

于是超大规模场景下可以优先考虑:

1
2
3
4
5
可用性
+
最终一致性
+
快速收敛

而不是让每次注册变更都成为整个注册中心集群的同步瓶颈。


基于消息总线的最终一致性注册中心

一种思路是让每个注册中心节点都维护完整服务数据,并通过消息总线传播变更。

flowchart LR
    P1["Provider"] --> R1["Registry A"]
    R1 -->|生成变更事件 v102| Bus["消息总线"]

    Bus --> R1
    Bus --> R2["Registry B"]
    Bus --> R3["Registry C"]

    R1 --> C1["Consumer A 本地缓存"]
    R2 --> C2["Consumer B 本地缓存"]
    R3 --> C3["Consumer C 本地缓存"]

它的几个关键点很有代表性。

注册中心内存保存完整服务视图

查询服务实例时尽量从内存获取,而不是把每次调用压力转移给持久化存储。

每次服务变化产生版本化事件

例如:

1
2
3
Service: com.demo.OrderService
Address: 192.168.1.9:9080
Version: 102

注册中心收到消息后进行版本比较:

1
2
3
4
5
message.version > local.version
→ apply

message.version <= local.version
→ discard

推拉结合

只依赖 Push 有丢通知的风险。

只依赖 Pull 又会增加延迟。

因此采用:

1
2
Push:快速获得变化
Pull:定期校准最终状态

可以概括成一个非常值得记住的设计原则:

推拉结合,以拉为准。

两级缓存

第一层:

1
Registry Memory Cache

第二层:

1
Consumer Local Cache

Consumer 平时直接基于本地实例列表完成路由和负载均衡,从而避免每次 RPC 调用都访问注册中心。

旧节点真的被选中了怎么办

最终一致性意味着:

1
Consumer 当前实例表

可能短时间落后于真实状态。

例如某个 Provider 已经停止提供接口,但 Consumer 尚未接收到最新通知。

此时仍然需要 RPC 数据面兜底:

1
2
3
4
5
6
7
请求 → 旧节点

节点拒绝

Consumer 更新状态

换节点重试

这说明:

服务发现不是 RPC 高可用的唯一防线。

最终一致性能够成立,是因为健康检测、节点拒绝、异常重试等其他机制一起兜底。


健康检测:连接存在不代表服务健康

服务发现解决的是:

1
有哪些 Provider?

健康检测解决的是:

1
这些 Provider 现在还能不能正常处理请求?

这是两个完全不同的问题。

TCP 没断,不代表应用没问题

最简单的判断是:

1
2
3
TCP Connection Alive

Provider Healthy

但现实情况可能是:

1
2
3
4
5
6
TCP 连接正常
CPU 已经打满
线程全部阻塞
应用出现长时间 GC
业务线程池耗尽
部分请求持续超时

也就是说:

网络层活着,不代表应用层活着。

因此 RPC 还需要主动探活。


心跳机制与三态健康模型

最经典的方法就是 Heartbeat。

调用方周期性询问:

1
你还好吗?

服务提供方回应自身状态。

一个简单健康状态模型可以划分为:

  • Healthy:健康;
  • SubHealthy:亚健康;
  • Dead:死亡。
stateDiagram-v2
    [*] --> Healthy: 建连成功
    [*] --> Dead: 建连失败

    Healthy --> SubHealthy: 连续心跳失败
    SubHealthy --> Healthy: 连续探活恢复
    SubHealthy --> Dead: 持续失败
    Dead --> Healthy: 重连成功

初始化阶段一般没有必要先进入亚健康状态:

1
2
连接建立成功 → Healthy
连接建立失败 → Dead

之后再根据实际探活结果动态变化。

资料给出的经验示例是:

1
连续失败 3 次 → 亚健康

以及大约:

1
30 秒进行一次常规心跳

这些数字应该理解成示例和经验参数,而不是所有 RPC 框架的统一默认值。

心跳间隔太短:

1
2
3
探测实时性 ↑
Provider 额外压力 ↑
网络波动误判概率 ↑

心跳间隔太长:

1
2
成本 ↓
故障摘除速度 ↓

最终需要结合业务流量和网络环境确定。


“半死不活”的节点为什么最难发现

真正麻烦的往往不是完全宕机,而是这种节点:

1
2
3
4
5
请求 1 → 成功
请求 2 → 超时
请求 3 → 成功
请求 4 → 超时
心跳 → 偶尔成功

如果判断规则只是:

1
连续 3 次心跳失败

那么这种节点很可能永远达不到阈值。

RPC 会继续认为它是健康节点,然后负载均衡继续把业务请求发送过去。

这就是单一心跳指标的局限。


将业务可用率加入健康检测

健康检测最终服务的不是 TCP,而是业务。

因此可以在一个时间窗口内统计:

1
2
Availability =
Successful Requests / Total Requests

当某节点可用率持续低于阈值时,将它降级到亚健康状态。

这样就把:

1
网络探活

和:

1
真实业务调用

结合起来。

为什么不能只看失败次数?

因为不同接口调用频率差异巨大:

1
2
接口 A:1 秒 100 次
接口 B:30 分钟 1 次

固定失败 5 次对两个接口表达的意义完全不同。

为什么也不能简单只看 TPS?

因为接口本身耗时差异同样巨大:

1
2
接口 A:平均 1ms
接口 B:平均 10s

因此成功率通常比绝对失败次数更具有普适性。

但可用率也需要足够的样本量。例如窗口内只有:

1
2
3
2 次请求
1 成功
1 失败

立即判定:

1
2
可用率 = 50%
→ 节点异常

很容易误判。

实际实现时应同时考虑最小样本数量、统计窗口以及业务特征。


健康检测最重要的是减少误判

还有一种场景:

1
探测器 → Provider 网络异常

Provider 自己其实完全正常。

如果只有一个探测点,就可能错误地下线健康实例。

对于独立监控系统,一种降低误判的方法是:

1
2
3
多个机架
多个网络位置
甚至多个机房

分别进行检测。

这样可以区分:

1
Provider 真故障

和:

1
单条检测链路故障

在 RPC 客户端视角下,调用方自己观察到的请求质量依然非常重要,因为不同调用方到同一个 Provider 的网络路径也可能并不相同。

所以健康状态并不是一个简单的“全局真值”,而是:

1
2
3
4
5
Provider 自身状态
+
Consumer 到 Provider 链路状态
+
实际业务请求结果

共同决定的。


路由:先决定“哪些节点可以用”

假设:

1
OrderService

目前有:

1
2
3
P1
P2
P3

如果三台节点完全等价,那么直接做负载均衡即可。

但生产环境中经常需要:

1
某些请求只能进入特定节点

例如:

  • 灰度发布;
  • 定点调用;
  • 黑白名单;
  • 机房路由;
  • 测试流量隔离;
  • 新老版本切换。

这就是 Router 的职责。


为什么路由逻辑不应该塞进注册中心

理论上,注册中心可以根据调用方身份直接返回不同 Provider 列表。

例如:

1
2
Consumer A → P1、P2
Consumer B → P1、P3

但这会让注册中心逐渐承担复杂计算逻辑。

而注册中心的核心职责更适合保持为:

1
2
3
存储服务元数据
+
传播服务状态变化

如果所有灰度、业务条件、参数判断全部在注册中心执行,随着节点与规则增加:

  • 注册中心计算压力增加;
  • 需要对开源注册中心二次开发;
  • 控制面逻辑越来越复杂;
  • 注册中心故障域扩大。

更合理的方式是:

1
2
3
注册中心下发节点和规则

Consumer 本地执行路由

让控制面负责“配置规则”,数据面负责“执行规则”。


IP 路由:让特定调用方访问灰度实例

假设新上线:

1
P2-new

希望只有:

1
Consumer A

先验证新版本。

可以配置:

1
2
Consumer A → 允许 P2-new
Consumer B → 过滤 P2-new

于是:

flowchart LR
    CA["Consumer A"] --> P1["P1"]
    CA --> P2["P2 New"]
    CA --> P3["P3"]

    CB["Consumer B"] --> P1
    CB -. "路由过滤" .-> P2
    CB --> P3

这样灰度影响范围就从:

1
所有调用方

缩小到了:

1
指定调用方

上线出现问题时,爆炸半径明显更小。


参数路由:把灰度进一步细化到业务对象

有些灰度不能按调用方划分。

例如商品系统新老版本并行运行时,我们可能希望:

1
2
商品 10001 的所有请求 → 新版本
商品 10002 的所有请求 → 老版本

而不能出现:

1
2
查询商品 → 新版本
修改商品 → 老版本

否则同一个业务对象的操作可能穿梭在不同版本逻辑之间。

此时可以:

  1. 给 Provider 打上新旧版本标签;
  2. 调用时读取请求中的业务参数;
  3. 根据统一路由规则筛选对应 Provider 集合;
  4. 再执行负载均衡。
flowchart LR
    Req["RPC Request<br/>productId"] --> Router["Parameter Router"]
    Router -->|命中新版本规则| New["New Provider Pool"]
    Router -->|命中旧版本规则| Old["Old Provider Pool"]
    New --> LB1["Load Balance"]
    Old --> LB2["Load Balance"]

具体如何根据参数划分对象属于路由规则本身,关键要求是:

相同业务对象在整个灰度期间必须得到稳定、一致的路由结果。


路由与负载均衡到底有什么区别

这是 RPC 服务治理中很容易混淆的一组概念。

假设注册中心给出:

1
P1 P2 P3 P4 P5

路由执行后:

1
P1 P3 P5

负载均衡最终:

1
P3

所以:

能力 回答的问题
服务发现 有哪些节点?
健康检测 哪些节点还正常?
路由 当前请求允许访问哪些节点?
负载均衡 候选节点中最终选哪一个?

一个非常实用的记忆方式是:

Route 是 Filter,Load Balance 是 Select。

典型顺序就是:

1
2
3
4
5
6
7
8
9
服务发现

健康过滤

路由过滤

负载均衡

发送请求

RPC 负载均衡为什么通常放在客户端

传统 Web 架构常见:

1
2
3
4
5
Client

Nginx / LVS / F5

Backend

RPC 更常见:

1
2
3
4
RPC Consumer
├── P1 Connection
├── P2 Connection
└── P3 Connection

客户端已经知道完整 Provider 列表,因此可以直接:

1
2
3
4
5
Consumer

本地 LoadBalancer

选择 Provider

这样避免额外中间网络跳转,并让不同接口、不同服务甚至不同调用方采用不同负载均衡策略。


常见负载均衡策略

随机

从实例中随机选择节点。

实现简单,节点足够多、请求足够多以后,整体分布会趋于均匀。

加权随机

为不同节点配置不同权重。

例如:

1
2
3
P1 weight = 100
P2 weight = 100
P3 weight = 50

在长期统计意义上,P3 获得的流量大约是 P1/P2 的一半。

这种策略特别适合:

  • 机器配置不同;
  • 灰度节点;
  • 手动降权;
  • 启动预热。

轮询与加权轮询

轮流选择服务节点。

普通 Round Robin 更适合节点处理能力接近的场景。

机器配置存在明显差异时,可以增加权重。

最少活跃调用

统计每个节点正在处理的调用数量。

某节点活跃调用越少,意味着当前压力相对较低,更有机会接收下一次请求。

Hash / 一致性 Hash

当业务希望相同 Key 更稳定地进入特定节点时,可以使用 Hash 类策略。

它更适合存在请求亲和性需求的场景。


固定权重仍然解决不了节点实时负载差异

假设:

1
2
3
P1:8C16G
P2:8C16G
P3:4C8G

可以人工:

1
P3 weight = 50

但机器能力并不是唯一变量。

运行过程中还可能出现:

1
2
3
P1 CPU 20%
P2 CPU 90%
P3 CPU 40%

即使三台机器静态配置相同,实时处理能力也已经完全不同。

这就产生了自适应负载均衡。


自适应负载均衡:根据节点状态动态调权

基本思想并不复杂:

状态好的节点多接流量,状态差的节点少接流量。

Consumer 可以收集 Provider 指标:

  • CPU 核数;
  • CPU Load;
  • 内存;
  • 平均请求耗时;
  • TP99;
  • TP999;
  • 健康状态;
  • 其他运行时指标。

然后给每个指标配置权重。

抽象来看:

1
2
3
4
Score =
Metric1 × Weight1
+ Metric2 × Weight2
+ ...

假设:

1
2
最终评分 = 8 / 10
基础权重 = 100

则可以调整为:

1
2
3
Effective Weight
= 100 × 80%
= 80

再交给加权随机算法选择。

flowchart TD
    Metrics["指标采集器"] --> Score["节点打分"]
    Latency["请求耗时统计"] --> Score
    Health["健康状态"] --> Score

    Score --> Weight["计算动态权重"]
    Base["静态基础权重"] --> Weight

    Weight --> LB["加权负载均衡"]
    LB --> Node["选择 Provider"]

这里其实形成了一个反馈控制回路:

1
2
3
4
5
6
7
8
9
节点状态

指标采集

评分

调整流量

节点新状态

自适应策略也存在控制滞后

自适应并不意味着“自动就一定正确”。

假设一个空闲节点当前评分很高:

1
Score = 10

于是大量 Consumer 同时增加它的流量。

几秒后它可能突然被打满,但 Consumer 使用的仍然是上一个统计周期的状态。

这体现了任何基于指标反馈的负载算法都需要面对:

1
2
3
4
5
采样延迟
+
指标传播延迟
+
控制策略滞后

采集太慢,状态不新鲜。

采集太快,又会增加额外负担,并可能产生频繁权重震荡。

因此自适应负载均衡的重点并不是堆指标,而是保证整个反馈回路稳定。


异常重试:失败以后不能简单再发一次

RPC 最常见的问题之一就是瞬时网络故障。

例如:

1
Consumer → Provider

发送过程中刚好出现一次网络抖动。

如果这是读取用户资料之类的请求,业务通常希望尽可能成功,而不是立刻暴露一次偶发网络错误。

于是 RPC 可以提供框架级重试。

1
2
3
4
5
6
7
8
9
调用失败

判断异常

满足重试条件?

重新选择 Provider

重新发送

但“自动重试”也是 RPC 中最危险的能力之一。


不是什么异常都应该重试

例如服务端明确抛出了:

1
InsufficientBalanceException

再次发送相同请求通常没有任何意义。

而这些异常更可能适合重试:

1
2
3
连接异常
网络异常
部分超时异常

所以 RPC 应该先判断异常类型。

flowchart TD
    E["RPC Exception"] --> Q{"是否允许重试?"}
    Q -->|网络 / 框架允许重试异常| R["Retry"]
    Q -->|业务异常| F["直接返回"]
    Q -->|用户配置白名单| R

某些业务异常虽然来自服务端,但业务明确知道它可以重试,这时可以提供:

1
Retryable Exception Whitelist

由业务声明哪些异常允许进入框架重试流程。


重试最大的安全前提:幂等

考虑这样一次请求:

1
INSERT order

调用方发送成功:

1
Consumer → Provider

Provider 已经完成数据库写入。

但是返回响应时网络断了:

1
Provider -X-> Consumer

从 Consumer 视角:

1
请求超时

它无法知道:

1
2
服务端没有执行?
还是执行成功但响应丢了?

如果 RPC 立即重试:

1
INSERT order

就可能重复创建数据。

所以只要请求存在:

1
已经到达 Provider 的可能性

自动重试就必须建立在业务幂等基础上。

如果业务不是天然幂等,也可以在业务协议中增加唯一 Request ID:

1
requestId = 8f...

服务端记录:

1
requestId → execution result

再次收到同一个 ID 时直接返回第一次结果。

但这种方案已经跨越 RPC 与业务边界,需要团队统一约定,而不能由 RPC 框架凭空保证。


重试不能让整体超时时间失效

假设:

1
RPC timeout = 10s

第一次调用耗费:

1
5s

失败后如果重新给第二次请求完整:

1
10s

那么这次 RPC 最终可能耗费:

1
15s

已经违背调用者设置的 10 秒超时约束。

因此可靠的设计应该维护:

1
整体 Deadline

而不是每次 Retry 都重新获得完整超时时间。

例如:

1
2
3
4
5
6
总预算:10s

第一次消耗:5s
剩余预算:5s

第二次 timeout:最多 5s

也就是:

1
2
Remaining Timeout
= Deadline - CurrentTime

如果:

1
Remaining Timeout <= 0

立即返回 Timeout。

这一点非常重要:

所谓“重置重试超时时间”,应该理解为根据剩余总预算重新计算下一次调用 timeout,而不是把它重新恢复成原始完整值。


重试时还应该避开刚刚失败的节点

如果第一次:

1
P2 timeout

重试后负载均衡又得到:

1
P2

那这次重试的意义明显下降。

因此一次调用可以维护:

1
Failed Providers

重试选择节点时把已经失败的 Provider 排除。

1
2
3
4
5
第一次:
P1 P2 P3 → P2 → fail

第二次:
P1 P3 → P1

如果所有节点都尝试失败,再根据整体重试策略决定是否继续。

对于实时 RPC,也不宜机械套用非常长的指数退避。重试次数、间隔以及总时间预算,都需要服从这次调用的 Deadline。


一个更完整的 RPC 重试模型

flowchart TD
    Start["第一次调用"] --> Call["选择 Provider 并发送"]
    Call --> OK{"成功?"}
    OK -->|是| End["返回结果"]
    OK -->|否| Retryable{"异常允许重试?"}

    Retryable -->|否| Fail["返回异常"]
    Retryable -->|是| Idempotent{"满足幂等要求?"}

    Idempotent -->|否| Fail
    Idempotent -->|是| Deadline{"还有剩余时间?"}

    Deadline -->|否| Timeout["返回超时"]
    Deadline -->|是| Remove["排除失败节点"]
    Remove --> Count{"达到重试次数?"}

    Count -->|是| Fail
    Count -->|否| Call

这说明可靠重试至少涉及四个维度:

1
2
3
4
异常类型
幂等性
总时间预算
节点排除

它远比:

1
2
3
catch(Exception) {
retry();
}

复杂。


优雅关闭:服务停止前先把流量切走

应用发布往往意味着实例重启。

最危险的时间窗口是:

1
2
Provider 已经开始关闭
但是 Consumer 还不知道

此时:

1
2
TCP 连接可能还存在
Provider 也仍然在健康列表里

负载均衡就有可能继续选择它。


仅依赖注册中心下线通知并不能完全无损

一种很自然的流程是:

1
2
3
4
5
Provider
↓ 注销
Registry
↓ 通知
Consumer

问题在于服务发现通常采用异步传播,并且在大规模场景强调最终一致性。

因此可能出现:

1
2
3
4
5
Provider 已经进入 Shutdown

部分 Consumer 还保留旧实例缓存

继续发送新请求

这也是为什么:

服务发现不能独自承担无损停机的全部责任。


Provider 可以直接通知 Consumer

RPC 通常存在长连接,因此 Provider 可以记录连接自己的 Consumer。

关闭时主动发送:

1
I am shutting down.

Consumer 接收到后:

1
从健康节点列表移除 Provider

这比:

1
Provider → Registry → Consumer

链路更短。

但它依然存在并发窗口:

1
2
Provider 开始关闭
Consumer 请求已经在网络途中

通知和业务请求甚至可能只相差不到 1ms。

因此还需要最后一道防线。


关闭挡板:已经关门,就不要再接新业务

一旦 Provider 进入关闭状态,应该立刻设置:

1
shutdown = true

随后所有新请求经过调用链时先检查:

1
2
if shuttingDown
reject

可以返回一个专门的:

1
ShutdownException

它向 Consumer 表达一个非常重要的语义:

请求已经到达我这里,但是我处于关闭阶段,因此没有执行业务逻辑。

既然业务没有执行,这个请求就可以安全切换到其他节点重试。

这就是“挡板”的价值。


已经开始执行的请求不能直接杀掉

关闭新请求以后,还可能存在:

1
100 个正在执行的请求

如果进程立即退出,还是会产生业务损失。

因此 Provider 可以维护:

1
inflight requests

每开始一个请求:

1
counter++

请求结束:

1
counter--

Shutdown 时:

1
2
3
4
5
6
7
拒绝新请求

等待 counter = 0

释放资源

退出进程

但也不能无限等待。

如果某个请求永远阻塞:

1
counter != 0 forever

服务将永远无法关闭。

因此还需要:

1
Graceful Shutdown Timeout

资料给出了约 10 秒作为经验示例,这同样应该根据业务最大处理时间调整,而不是理解成固定标准。


Java 中如何捕获关闭事件

在 Java 中,可以通过 JVM Shutdown Hook 捕获进程正常关闭过程:

1
Runtime.addShutdownHook(...)

RPC 框架可以在启动阶段注册 ShutdownHook,并组织整个优雅停机流程。

sequenceDiagram
    participant OS as OS / JVM
    participant P as Provider
    participant C as Consumer
    participant R as Registry

    OS->>P: Shutdown Signal
    P->>P: 设置 Closing 状态
    P->>C: 主动通知节点下线
    P->>R: 注销服务

    Note over P: 开启请求挡板

    C-->>P: 并发到达的新请求
    P-->>C: ShutdownException
    C->>C: 摘除节点并选择其他 Provider

    P->>P: 等待已有请求完成
    P->>P: 达到 0 或超时
    P->>P: 释放资源并退出

优雅关闭可以概括成:

先停止接收新工作,再完成已有工作,最后释放资源。

也就是:

1
由外向内关闭

优雅启动:启动成功不等于已经适合接流量

关闭需要讲究,启动同样如此。

刚启动的 Java 应用通常还处于冷状态:

  • 热点代码尚未充分执行;
  • JIT 优化尚未形成;
  • 类刚刚加载;
  • 缓存可能尚未初始化;
  • 连接池可能刚创建;
  • 下游资源可能仍在建立连接。

如果旧实例原来承担:

1
2000 QPS

一个新实例刚刚注册就立刻分担完整流量,很容易:

1
2
3
4
5
6
7
冷启动

响应变慢

请求堆积

负载进一步升高

这也是为什么需要启动预热。


启动预热:刚启动的实例先少接一点流量

调用方已经知道 Provider 的:

1
启动时间 / 注册时间

于是负载均衡可以动态计算新节点权重。

概念上:

1
2
3
4
5
6
7
8
刚启动:
weight = low

运行一段时间:
weight ↑

达到 warmup 时间:
weight = configuredWeight
flowchart TD
    Start["Provider 上线"] --> W1["低权重"]
    W1 --> T{"达到下一预热阶段?"}
    T -->|否| W1
    T -->|是| W2["逐步提高权重"]
    W2 --> Done{"达到目标权重?"}
    Done -->|否| T
    Done -->|是| Normal["正常权重"]

资料图中以:

1
每阶段 +10%

表达渐进增加的思想。

真正实现可以采用其他平滑函数,关键不是固定 10%,而是:

不要让新实例从 0 流量瞬间跳到正常满载流量。


延迟暴露:应用真正 Ready 以后再注册服务

启动预热解决:

1
启动以后如何慢慢增加流量

但还有一个更早的问题:

1
服务甚至还没启动完成,就已经被注册中心发现了。

以 Spring 应用为例,Bean 加载过程中可能已经发现一个 RPC Service Bean。

如果此时马上:

1
注册到注册中心

调用方很快就会拿到该实例。

但整个 Spring ApplicationContext 可能仍然在初始化其他 Bean。

因此更合理的流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
发现 RPC Bean

注册到本地 BeanFactory

暂时不暴露到注册中心

整个应用启动完成

执行预热 Hook

确认资源 Ready

注册服务
flowchart LR
    A["加载应用"] --> B["应用启动完成"]
    B --> C["执行 Warmup Hook"]
    C --> D["初始化缓存 / 资源"]
    D --> E["注册到注册中心"]
    E --> F["开始接受 RPC 流量"]

这个 Hook 可以承担:

  • 预加载缓存;
  • 初始化连接;
  • 执行部分预热逻辑;
  • 检查关键依赖是否正常;
  • 让业务真正进入 Ready 状态。

于是启动和关闭形成一组非常漂亮的对称关系:

1
2
启动:由内向外
关闭:由外向内

也就是:

1
2
3
4
5
6
7
内部资源 Ready

再对外暴露服务

先停止对外服务

再释放内部资源

大规模重启不能只依赖启动预热

假设集群:

1
20 台 Provider

突然同时重启 15 台。

15 个新节点都处于低预热权重,那么大量请求自然会压到剩余 5 个旧节点。

因此预热不是发布容量规划的替代品。

更稳妥的策略包括:

  • 分批滚动重启;
  • 控制同时重启实例数;
  • 在业务低峰进行大批量变更;
  • 提前进行容量评估;
  • 必要时限制入口流量;
  • 配合自适应负载均衡控制旧节点负载。

Kubernetes 滚动发布虽然改变了部署工具,但对于单实例来说:

1
2
3
Ready 前不接流量
Terminating 后停止接新流量
已有请求等待完成

这些设计原则依然成立。


服务端保护:限流

即使节点完全健康,也不意味着它能够无限处理请求。

超过系统容量后可能出现:

  • 请求耗时上升;
  • CPU 满载;
  • 内存压力增加;
  • Full GC;
  • 线程池耗尽;
  • 服务直接宕机。

因此服务端应该具备:

1
Overload Protection

最直接的办法就是限流。

RPC 服务端可以在真正执行业务逻辑之前执行:

1
RateLimiter
flowchart LR
    Req["RPC Request"] --> Limit{"Rate Limit"}
    Limit -->|允许| Biz["Business Logic"]
    Limit -->|超过阈值| Reject["RateLimitException"]

限流应该作为 RPC Filter 或服务端调用链的一部分,而不需要让每个业务团队重复实现。


常见限流算法

资料提到了几种典型方案:

  • 计数器;
  • 滑动窗口;
  • 漏桶;
  • 令牌桶。

其中 Token Bucket(令牌桶)是非常常见的一种。

算法本身不是 RPC 独有的,RPC 更重要的问题是:

在哪个维度进行限流?


限流不能只有一个总 QPS

假设服务有多个调用方:

1
2
3
Consumer A:900 QPS
Consumer B:50 QPS
Consumer C:50 QPS

如果 A 流量异常,不应该让它吃掉所有容量并拖累 B、C。

因此限流 Key 可以包括:

1
2
3
Service
Consumer Application
Consumer IP

例如:

1
2
3
4
OrderService
├── app=A : 500 QPS
├── app=B : 200 QPS
└── app=C : 200 QPS

这也是 RPC 治理平台需要动态配置能力的原因。

限流规则可以:

1
2
3
4
5
治理平台

配置中心 / 注册中心

Provider

动态下发,而不是写死在代码中。


单机限流与集群限流

假设数据库只能支持:

1
10000 QPS

RPC 服务有:

1
10 个节点

最简单可以给每个节点:

1
1000 QPS

但扩容到 20 台以后:

1
500 QPS / node

就需要重新调整。

RPC 可以根据总节点数量自动折算单机阈值。

但即便如此仍然有误差。

例如:

1
2
3
4
5
20 个节点
每节点 500 QPS

P1 实际 500
P2 实际 450

P1 已经拒绝请求,但整个集群实际还没有达到:

1
10000 QPS

这是单机独立限流天然带来的精度损失。

更精确的方式是:

1
所有节点 → 集中式限流服务

由全局限流器维护整个服务的配额。

但代价也很明显:

  • 每次请求多一次限流判定;
  • 增加网络耗时;
  • 限流系统本身需要高可用;
  • 可能形成新的性能瓶颈。

所以这是一个典型工程取舍:

方案 优势 代价
本地限流 快、简单、无远程依赖 集群配额不够精确
集中限流 全局配额更准确 网络与系统成本更高

并不存在一种方案对所有服务都是最优答案。


调用端保护:熔断

假设调用链:

1
A → B → C

现在 C 变慢。

B 的线程不断等待 C:

1
2
3
4
B Thread 1 → waiting
B Thread 2 → waiting
B Thread 3 → waiting
...

A 又持续向 B 发送请求。

最后很可能出现:

1
2
3
4
5
6
7
C 异常

B 被拖垮

A 继续堆积

级联故障

当链路变成:

1
A → B → C → D → E

风险更加严重。

因此调用方需要熔断。


熔断器的三种状态

经典 Circuit Breaker 包含:

1
2
3
Closed
Open
Half-Open
stateDiagram-v2
    [*] --> Closed

    Closed --> Open: 错误率 / 慢调用达到阈值
    Open --> HalfOpen: 熔断时间结束
    HalfOpen --> Closed: 探测成功
    HalfOpen --> Open: 探测失败

Closed

正常调用下游。

同时采集:

  • 异常率;
  • 慢请求;
  • 调用失败等指标。

Open

达到熔断条件后,直接拒绝调用下游。

1
2
3
Consumer
X
Provider

快速失败,而不是继续消耗线程和连接。

Half-Open

等待一段时间后允许少量探测流量进入。

成功则恢复 Closed。

失败则重新 Open。


熔断器应该放在 RPC 调用的最前面

RPC 动态代理是一次远程调用最早的入口之一。

因此非常适合:

1
2
3
4
5
Proxy

CircuitBreaker

RPC Pipeline

如果熔断器已经 Open:

1
2
3
4
5
6
根本不需要:
服务发现
路由
负载均衡
序列化
网络发送

直接快速失败。

这也是基础能力下沉到 RPC 框架的价值:

业务只负责声明治理规则,不必在每一个调用点重复编写保护代码。


限流、熔断与降级不是一回事

它们经常一起出现,但解决的问题不同。

1
2
3
4
5
6
7
8
限流:
保护服务端不要被过量请求打垮

熔断:
保护调用端不要被故障下游拖垮

降级:
资源不足或依赖异常时,主动牺牲非核心能力

可以简单理解成:

1
2
3
流量太多 → 限流
下游太差 → 熔断
资源有限 → 降级非核心能力

它们最终目标相同:

不追求所有请求都成功,而是避免整个系统一起失败。


业务分组:从“有损限流”进一步走向流量隔离

限流和熔断本质上都可能产生业务损失。

还有一种更主动的治理手段:

在故障发生以前就把不同业务流量隔离开。

假设服务集群:

1
P1 P2 P3 P4

Consumer A 和 Consumer B 都使用全部节点:

flowchart LR
    A["Consumer A"] --> P1["P1"]
    A --> P2["P2"]
    A --> P3["P3"]
    A --> P4["P4"]

    B["Consumer B"] --> P1
    B --> P2
    B --> P3
    B --> P4

如果 Consumer A 突然产生巨大流量:

1
A → 整个 Provider Cluster

它就可能直接污染 Consumer B。


将 Provider 分成不同服务池

可以将服务实例划分为:

1
2
3
4
5
6
7
Group A
P1
P2

Group B
P3
P4

然后:

1
2
Consumer A → Group A
Consumer B → Group B
flowchart LR
    A["Consumer A"] --> GA["Group A"]
    B["Consumer B"] --> GB["Group B"]

    GA --> P1["P1"]
    GA --> P2["P2"]

    GB --> P3["P3"]
    GB --> P4["P4"]

这样 A 的流量高峰主要影响:

1
P1 / P2

而不会直接把:

1
P3 / P4

一起拖垮。

对于核心业务尤其有价值。

例如:

1
核心下单链路

可以拥有专门的 Provider Pool,而不是和大量非核心任务共享同一组资源。


分组实际上改变了服务发现的 Key

原本服务发现可能是:

1
2
3
ServiceName

Instances

引入 Group 后变成:

1
2
3
ServiceName + Group

Instances

Provider 注册:

1
2
interface = UserService
group = core

Consumer 订阅:

1
2
interface = UserService
group = core

才能得到对应实例。

这与普通路由存在一定差别。

路由更像:

1
2
先获得较大节点集合
再根据请求动态过滤

分组则更像:

1
从服务发现阶段就进入不同命名空间

因此分组特别适合长期、稳定的资源隔离。


分组以后为什么还要主备组

隔离会降低故障域,但同时也减少每个 Consumer 能使用的节点数量。

原来可以使用:

1
P1 P2 P3 P4 P5 P6

现在可能只有:

1
P1 P2

如果 P1、P2 所在交换机全部故障,这个 Consumer 就完全失去服务。

因此可以设计:

1
2
Primary Group
Secondary Group

平时:

1
只使用 Primary

Primary 全部不可用后:

1
临时借道 Secondary

Primary 恢复:

1
重新切回 Primary

也就是:

1
2
隔离优先
高可用兜底

两者并不冲突。


分组还能表达同一接口的不同实现

例如两个 Provider 都实现:

1
PriceService

但底层逻辑不同。

可以:

1
2
PriceService + group=A
PriceService + group=B

Consumer 根据 Group 选择不同实现。

因此 Group 不只是限流工具,还可以成为 RPC 服务标识的一部分。


环境隔离与分组隔离不要混为一谈

开发、测试、预发布、生产环境之间,如果条件允许,更适合采用:

1
独立注册中心

或者注册中心内部:

1
Namespace / Environment

进行硬隔离。

例如:

1
2
3
4
prod
qa
test
dev

相比依赖复杂路由规则,环境硬隔离通常更简单、更安全。

分组更适合解决:

1
同一个环境中的业务资源隔离

而不是拿来替代全部环境治理。

另外,如果真正目标是全链路隔离,仅仅给某一层 Provider 分组还不够。

假设:

1
A → B → C

只把 B 划分成:

1
2
B-core
B-normal

但二者仍共享同一个 C 集群,那么 C 依然可能成为共同故障点。

因此更进一步的业务隔离往往需要让分组、流量标签或者类似 SET 的标识沿调用链传递。


把这些能力重新放回一次 RPC 调用

到这里,可以构造一个更加完整的 RPC Consumer 数据面。

flowchart TD
    Proxy["动态代理"] --> CB{"熔断器"}
    CB -->|Open| FastFail["快速失败"]
    CB -->|Closed| Retry["容错 / Retry Context"]

    Retry --> Discovery["本地服务发现缓存"]
    Discovery --> Health["健康节点过滤"]
    Health --> Group["服务分组"]
    Group --> Route["路由规则过滤"]
    Route --> LB["负载均衡"]
    LB --> Conn["连接管理"]

    Conn --> Codec["序列化 / 协议编码"]
    Codec --> Transport["网络传输"]

    Transport --> ServerLimit["服务端限流"]
    ServerLimit --> Shutdown["关闭挡板"]
    Shutdown --> Biz["业务执行"]

    Biz --> Result["返回结果"]
    Result --> Metrics["更新耗时 / 成功率指标"]
    Metrics --> LB
    Metrics --> CB
    Metrics --> Health

    Transport -. "异常" .-> Retry

这里其实存在三个不同层面的系统。

数据面

真正承载每一次 RPC:

1
2
3
4
5
路由
负载均衡
连接
编码
传输

控制面

负责下发:

1
2
3
4
5
6
实例列表
路由规则
权重
限流配置
分组
治理参数

反馈面

根据实际运行结果生成:

1
2
3
4
5
健康状态
请求可用率
响应时间
CPU / Load
错误率

再反过来调整数据面。

一个成熟 RPC 框架并不是简单的:

1
request → response

而实际上是一个持续运行的反馈控制系统。


一个节点从启动到退出的完整生命周期

把优雅启动、健康检测和优雅关闭组合起来,可以得到更加完整的 Provider 生命周期:

stateDiagram-v2
    [*] --> Starting

    Starting --> Warming: 应用初始化完成
    Warming --> Ready: 预热完成 / 注册服务

    Ready --> SubHealthy: 心跳或业务指标异常
    SubHealthy --> Ready: 状态恢复
    SubHealthy --> Unhealthy: 持续异常
    Unhealthy --> Ready: 重连 / 恢复

    Ready --> Draining: 收到关闭信号
    SubHealthy --> Draining: 收到关闭信号

    Draining --> Stopped: 已有请求完成或达到超时
    Stopped --> [*]

几个阶段表达完全不同的流量语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Starting
不能接流量

Warming
少量接流量

Ready
正常接流量

SubHealthy
减少或谨慎接流量

Unhealthy
不接流量

Draining
不接新流量,但完成旧请求

Stopped
实例退出

如果 RPC 只维护:

1
UP / DOWN

就很难表达这些真实生产状态。


RPC 服务治理中最值得保留的设计原则

把所有机制放到一起之后,会发现它们背后有一些高度一致的设计思想。

核心稳定,变化插件化

网络传输、序列化、注册中心、路由、负载均衡都可能变化。

不要让这些变化直接侵入 RPC 核心调用流程。

更合适的是:

1
2
3
4
5
稳定内核
+
明确扩展点
+
可替换插件

控制面负责规则,数据面本地执行

例如路由:

不要让每一次请求都去注册中心计算。

更合理的是:

1
2
3
4
5
6
7
治理平台配置

配置中心下发

Consumer 本地 Router

每次请求直接计算

这既降低控制面压力,也避免控制面成为 RPC 调用的同步依赖。


服务发现更关注收敛,不必追求每一瞬间完全同步

服务发现数据变化允许短暂延迟。

因此可以使用:

1
2
3
4
5
本地缓存
Push 增量通知
Pull 全量校准
版本号
最终一致性

把注册中心从 RPC 热路径中移走。


坏消息应该尽快传播,好消息应该谨慎发布

Provider 故障或准备关闭:

1
尽快告诉 Consumer

Provider 刚刚启动:

1
2
先确认真正 Ready
再告诉 Consumer

这正是优雅启停背后的共同思想。


路由先缩小集合,负载均衡再选择节点

不要把两个概念混成一个算法。

1
2
3
4
5
Route:
决定哪里可以去

Load Balance:
决定这次去哪

健康检测必须接近真实业务体验

TCP Connected 不代表 Healthy。

Heartbeat Success 也不代表业务可用。

最终必须结合:

1
2
3
网络
应用
业务请求

多个维度判断。


Retry 必须受 Deadline 约束

不能把:

1
timeout = 5s

解释成:

1
每一次尝试都可以再花 5s

正确的约束对象应该是整个 RPC 调用。


自动重试必须考虑“请求可能已经执行”

这是分布式系统非常经典的“不确定性”:

1
2
3
没收到响应

服务端没执行

没有幂等保障时,自动重试宁可保守。


隔离比故障以后再限流更主动

限流是在:

1
流量已经超过容量

以后开始拒绝请求。

分组则是在系统设计阶段提前划定故障域:

1
2
核心业务资源
非核心业务资源

避免一次异常影响所有业务。


所有自适应算法都要警惕反馈滞后

无论:

1
2
3
4
健康检测
自适应负载均衡
熔断
动态限流

本质上都是:

1
2
3
4
5
6
7
采集状态

做出判断

改变流量

产生新状态

只要存在反馈,就存在:

  • 采样延迟;
  • 状态滞后;
  • 阈值抖动;
  • 过度调节。

因此“智能”并不意味着越复杂越好,稳定的控制策略往往比指标数量更重要。


一套完整 RPC 框架真正应该解决什么

如果重新回到最开始的问题:

一个 RPC 框架到底应该具备哪些能力?

可以将答案划分成几层。

第一层解决:

1
能不能调用?

包括:

1
2
3
4
5
动态代理
序列化
协议
网络传输
服务端方法执行

第二层解决:

1
集群里调用谁?

包括:

1
2
3
4
5
服务发现
连接管理
健康检测
路由
负载均衡

第三层解决:

1
失败以后怎么办?

包括:

1
2
3
4
5
超时
重试
容错
熔断
节点摘除

第四层解决:

1
流量太大怎么办?

包括:

1
2
3
4
限流
自适应负载
业务分组
流量隔离

第五层解决:

1
怎么安全发布?

包括:

1
2
3
4
5
灰度路由
启动预热
延迟暴露
优雅关闭
滚动升级

最后还需要解决:

1
以后怎样扩展?

这才回到最开始的微内核与插件化架构。

1
2
3
4
5
6
7
RPC
├── 通信
├── 集群
├── 容错
├── 治理
├── 生命周期
└── 扩展体系

只实现第一项,它是一个 Remote Call Library。

把后面这些能力逐渐补齐,它才会变成真正能够承载大规模微服务系统的 RPC Framework。


总结

RPC 最有意思的地方,并不是“把一个 Java 方法变成一次 TCP 请求”。

真正困难的是,当网络不可靠、节点会扩缩容、机器性能不同、发布每天发生、下游可能变慢、流量可能突然暴涨以后,仍然让业务开发者看到一个看似简单的:

1
service.method()

为了实现这一点,框架背后必须建立一整套协作机制。

服务发现维护:

1
服务 → 实例

健康检测不断判断:

1
实例 → 是否还能使用

路由负责:

1
请求 → 哪些实例符合规则

负载均衡负责:

1
候选实例 → 本次最终实例

异常重试负责:

1
瞬时失败 → 是否可以安全恢复

熔断与限流负责:

1
异常流量 → 不让故障继续扩散

业务分组负责:

1
不同业务 → 不同故障域

优雅启动与关闭负责:

1
实例生命周期 → 流量生命周期

而插件体系则保证这些机制未来还能继续演进。

从这个视角再看 RPC,会发现它早已不只是一个通信框架。

它真正解决的是:

如何在动态、复杂且不可靠的分布式环境中,让一次远程调用依然保持可发现、可选择、可控制、可恢复、可保护并且可持续演进。


RPC 框架进阶设计:从插件化架构到服务治理与无损上下线
https://allendericdalexander.github.io/2026/08/18/archtect/rpc/RPCFramework/
作者
AtLuoFu
发布于
2026年8月18日
许可协议