RPC 框架进阶设计:从插件化架构到服务治理与无损上下线
RPC 的核心目标看似简单:把本地方法调用转换成网络请求,再在远端恢复方法语义。但真正进入生产环境后,问题很快会从“如何完成一次远程调用”扩展到“如何发现实例、判断节点是否健康、控制流量、处理失败、保护上下游以及实现无损发布”。本文从 RPC 框架整体架构出发,系统梳理插件化设计、服务发现、健康检测、路由、负载均衡、异常重试、优雅启停、熔断限流与业务分组,并把这些能力重新组织成一套完整的 RPC 服务治理体系。
从“能调用”到“能治理”
如果只讨论最基础的 RPC,一次调用可以被概括成:
拦截本地方法调用,将方法、参数等信息转换成可以在网络上传输的二进制数据,在服务提供方恢复调用语义并执行目标方法,再把结果返回给调用方。
也就是:
1 | |
做到这里,只解决了 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 | |
而不必关心底层数据到底经过了多少个 TCP Segment。
传输层本身也应该是可替换的。一个成熟的框架不应该把整个体系永久锁死在某一种传输实现上。
协议层:保证数据能够被正确“断句”和恢复
Java 对象不能直接通过网络传输,因此需要序列化。
但仅仅把对象转换成 byte[] 还不够。连续 TCP 字节流还必须知道:
1 | |
这就是协议 framing 的问题。
资料中用“断句”来描述这个过程。工程实现并不意味着一定需要真正放置特殊分隔符,也可以通过协议头中的长度字段等方式确定消息边界。
例如:
1 | |
无论使用长度字段、分隔符还是其他 framing 机制,目标都一样:
让接收方能够准确恢复每一个独立的 RPC 消息。
协议层通常还适合放置压缩能力。当请求参数或者返回结果较大时,可以根据阈值进行无损压缩,在另一端使用相同算法解压,以降低实际网络传输的数据量。
集群层:RPC 服务治理的核心
一旦接口背后存在多个服务提供方,RPC 就进入集群调用阶段。
集群层需要解决:
1 | |
因此这里聚集了大量核心能力:
- 服务发现;
- 连接管理;
- 健康检测;
- 路由;
- 负载均衡;
- 容错与重试;
- 配置管理。
很多 RPC 框架真正拉开差距的地方,并不是“能不能发 TCP 请求”,而是这一层的治理能力。
入口层:让远程调用看起来像本地调用
对业务开发者而言,理想的调用方式应该是:
1 | |
而不是:
1 | |
因此 RPC 框架一般会通过动态代理隐藏这些细节。
在 Spring 场景中,RPC 接口还可以被包装成 Bean,统一交给 BeanFactory 管理,业务代码直接通过依赖注入获得代理对象。
这也是 RPC “像调用本地方法一样调用远程服务”的真正入口。
为什么 RPC 架构必须插件化
前面的四层架构解决了“应该有哪些组件”,但还没有解决另一个很重要的问题:
如果明天想换掉序列化算法、注册中心、路由策略或者负载均衡算法怎么办?
最直接但也最糟糕的做法,是在核心代码里不断增加:
1 | |
功能越多,核心代码就越容易演变成巨大的条件分支集合。
更合适的做法,是把变化点抽象成扩展接口。
例如:
1 | |
核心框架只依赖接口:
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 | |
声明某个接口的 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 | |
现在部署了:
1 | |
调用方真正需要维护的是一种映射:
1 | |
服务节点随时可能:
- 扩容;
- 缩容;
- 重启;
- 宕机;
- 迁移;
- 改变状态。
显然不可能把 IP 硬编码在调用端。
于是产生了注册中心。
flowchart TD
Provider["服务提供方"] -->|注册服务实例| Registry["注册中心"]
Consumer["服务调用方"] -->|订阅服务| Registry
Registry -->|下发实例列表| Consumer
Consumer -->|RPC 调用| Provider
核心过程可以概括为:
服务注册
服务提供方启动后,把接口与自己的地址注册到注册中心。
服务订阅
调用方启动后订阅对应服务,获得全部服务实例,并缓存到本地。
服务变更通知
节点新增、删除或状态变化后,注册中心将变化通知调用方。
为什么仅靠 DNS 很难满足传统 RPC 服务发现
从本质上看,DNS 也解决:
1 | |
那么为什么不直接:
1 | |
问题不在于 DNS 完全不能做,而在于传统 RPC 对节点变化感知的实时性和治理粒度要求通常更高。
DNS 查询往往存在多级缓存:
1 | |
因此节点摘除、扩容以后,变化传播会受到 TTL、客户端缓存以及运行时配置等因素影响。
需要说明的是,JVM DNS 缓存行为会受到 JDK 版本与安全配置影响,不应简单把某一种历史默认值理解成所有环境下都永久不变。真正需要关注的核心问题是:
DNS 的缓存体系并不是专门按照 RPC 实例快速上下线和细粒度治理设计的。
在 DNS 前增加 VIP 为什么仍然不是理想答案
另一种做法是:
1 | |
它能隐藏后端实例变化,但也改变了 RPC 的数据路径。
请求不再是:
1 | |
而变成:
1 | |
这会带来:
- 增加中间网络跳转;
- 需要部署额外代理或负载均衡设施;
- 服务节点上下线需要和负载层协同;
- 每个服务、分组需要不同治理策略时配置变复杂;
- RPC 客户端无法直接利用完整实例信息做细粒度路由。
因此传统 RPC 更常见的思路是:
注册中心只负责告诉客户端“有哪些节点”,真正的节点选择由 RPC 客户端自己完成。
基于 ZooKeeper 的服务发现
一个典型的 ZooKeeper 服务目录可以设计成:
1 | |
服务提供方注册时,在 provider 下创建临时节点。
调用方订阅时:
- 在
consumer下建立自己的节点; - 获取
provider下的全部实例; - Watch Provider 目录;
- 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,通常也只是:
1 | |
并不会直接造成业务错误。
这意味着服务发现能够容忍短时间的旧数据。
于是超大规模场景下可以优先考虑:
1 | |
而不是让每次注册变更都成为整个注册中心集群的同步瓶颈。
基于消息总线的最终一致性注册中心
一种思路是让每个注册中心节点都维护完整服务数据,并通过消息总线传播变更。
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 | |
注册中心收到消息后进行版本比较:
1 | |
推拉结合
只依赖 Push 有丢通知的风险。
只依赖 Pull 又会增加延迟。
因此采用:
1 | |
可以概括成一个非常值得记住的设计原则:
推拉结合,以拉为准。
两级缓存
第一层:
1 | |
第二层:
1 | |
Consumer 平时直接基于本地实例列表完成路由和负载均衡,从而避免每次 RPC 调用都访问注册中心。
旧节点真的被选中了怎么办
最终一致性意味着:
1 | |
可能短时间落后于真实状态。
例如某个 Provider 已经停止提供接口,但 Consumer 尚未接收到最新通知。
此时仍然需要 RPC 数据面兜底:
1 | |
这说明:
服务发现不是 RPC 高可用的唯一防线。
最终一致性能够成立,是因为健康检测、节点拒绝、异常重试等其他机制一起兜底。
健康检测:连接存在不代表服务健康
服务发现解决的是:
1 | |
健康检测解决的是:
1 | |
这是两个完全不同的问题。
TCP 没断,不代表应用没问题
最简单的判断是:
1 | |
但现实情况可能是:
1 | |
也就是说:
网络层活着,不代表应用层活着。
因此 RPC 还需要主动探活。
心跳机制与三态健康模型
最经典的方法就是 Heartbeat。
调用方周期性询问:
1 | |
服务提供方回应自身状态。
一个简单健康状态模型可以划分为:
- Healthy:健康;
- SubHealthy:亚健康;
- Dead:死亡。
stateDiagram-v2
[*] --> Healthy: 建连成功
[*] --> Dead: 建连失败
Healthy --> SubHealthy: 连续心跳失败
SubHealthy --> Healthy: 连续探活恢复
SubHealthy --> Dead: 持续失败
Dead --> Healthy: 重连成功
初始化阶段一般没有必要先进入亚健康状态:
1 | |
之后再根据实际探活结果动态变化。
资料给出的经验示例是:
1 | |
以及大约:
1 | |
这些数字应该理解成示例和经验参数,而不是所有 RPC 框架的统一默认值。
心跳间隔太短:
1 | |
心跳间隔太长:
1 | |
最终需要结合业务流量和网络环境确定。
“半死不活”的节点为什么最难发现
真正麻烦的往往不是完全宕机,而是这种节点:
1 | |
如果判断规则只是:
1 | |
那么这种节点很可能永远达不到阈值。
RPC 会继续认为它是健康节点,然后负载均衡继续把业务请求发送过去。
这就是单一心跳指标的局限。
将业务可用率加入健康检测
健康检测最终服务的不是 TCP,而是业务。
因此可以在一个时间窗口内统计:
1 | |
当某节点可用率持续低于阈值时,将它降级到亚健康状态。
这样就把:
1 | |
和:
1 | |
结合起来。
为什么不能只看失败次数?
因为不同接口调用频率差异巨大:
1 | |
固定失败 5 次对两个接口表达的意义完全不同。
为什么也不能简单只看 TPS?
因为接口本身耗时差异同样巨大:
1 | |
因此成功率通常比绝对失败次数更具有普适性。
但可用率也需要足够的样本量。例如窗口内只有:
1 | |
立即判定:
1 | |
很容易误判。
实际实现时应同时考虑最小样本数量、统计窗口以及业务特征。
健康检测最重要的是减少误判
还有一种场景:
1 | |
Provider 自己其实完全正常。
如果只有一个探测点,就可能错误地下线健康实例。
对于独立监控系统,一种降低误判的方法是:
1 | |
分别进行检测。
这样可以区分:
1 | |
和:
1 | |
在 RPC 客户端视角下,调用方自己观察到的请求质量依然非常重要,因为不同调用方到同一个 Provider 的网络路径也可能并不相同。
所以健康状态并不是一个简单的“全局真值”,而是:
1 | |
共同决定的。
路由:先决定“哪些节点可以用”
假设:
1 | |
目前有:
1 | |
如果三台节点完全等价,那么直接做负载均衡即可。
但生产环境中经常需要:
1 | |
例如:
- 灰度发布;
- 定点调用;
- 黑白名单;
- 机房路由;
- 测试流量隔离;
- 新老版本切换。
这就是 Router 的职责。
为什么路由逻辑不应该塞进注册中心
理论上,注册中心可以根据调用方身份直接返回不同 Provider 列表。
例如:
1 | |
但这会让注册中心逐渐承担复杂计算逻辑。
而注册中心的核心职责更适合保持为:
1 | |
如果所有灰度、业务条件、参数判断全部在注册中心执行,随着节点与规则增加:
- 注册中心计算压力增加;
- 需要对开源注册中心二次开发;
- 控制面逻辑越来越复杂;
- 注册中心故障域扩大。
更合理的方式是:
1 | |
让控制面负责“配置规则”,数据面负责“执行规则”。
IP 路由:让特定调用方访问灰度实例
假设新上线:
1 | |
希望只有:
1 | |
先验证新版本。
可以配置:
1 | |
于是:
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 | |
而不能出现:
1 | |
否则同一个业务对象的操作可能穿梭在不同版本逻辑之间。
此时可以:
- 给 Provider 打上新旧版本标签;
- 调用时读取请求中的业务参数;
- 根据统一路由规则筛选对应 Provider 集合;
- 再执行负载均衡。
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 | |
路由执行后:
1 | |
负载均衡最终:
1 | |
所以:
| 能力 | 回答的问题 |
|---|---|
| 服务发现 | 有哪些节点? |
| 健康检测 | 哪些节点还正常? |
| 路由 | 当前请求允许访问哪些节点? |
| 负载均衡 | 候选节点中最终选哪一个? |
一个非常实用的记忆方式是:
Route 是 Filter,Load Balance 是 Select。
典型顺序就是:
1 | |
RPC 负载均衡为什么通常放在客户端
传统 Web 架构常见:
1 | |
RPC 更常见:
1 | |
客户端已经知道完整 Provider 列表,因此可以直接:
1 | |
这样避免额外中间网络跳转,并让不同接口、不同服务甚至不同调用方采用不同负载均衡策略。
常见负载均衡策略
随机
从实例中随机选择节点。
实现简单,节点足够多、请求足够多以后,整体分布会趋于均匀。
加权随机
为不同节点配置不同权重。
例如:
1 | |
在长期统计意义上,P3 获得的流量大约是 P1/P2 的一半。
这种策略特别适合:
- 机器配置不同;
- 灰度节点;
- 手动降权;
- 启动预热。
轮询与加权轮询
轮流选择服务节点。
普通 Round Robin 更适合节点处理能力接近的场景。
机器配置存在明显差异时,可以增加权重。
最少活跃调用
统计每个节点正在处理的调用数量。
某节点活跃调用越少,意味着当前压力相对较低,更有机会接收下一次请求。
Hash / 一致性 Hash
当业务希望相同 Key 更稳定地进入特定节点时,可以使用 Hash 类策略。
它更适合存在请求亲和性需求的场景。
固定权重仍然解决不了节点实时负载差异
假设:
1 | |
可以人工:
1 | |
但机器能力并不是唯一变量。
运行过程中还可能出现:
1 | |
即使三台机器静态配置相同,实时处理能力也已经完全不同。
这就产生了自适应负载均衡。
自适应负载均衡:根据节点状态动态调权
基本思想并不复杂:
状态好的节点多接流量,状态差的节点少接流量。
Consumer 可以收集 Provider 指标:
- CPU 核数;
- CPU Load;
- 内存;
- 平均请求耗时;
- TP99;
- TP999;
- 健康状态;
- 其他运行时指标。
然后给每个指标配置权重。
抽象来看:
1 | |
假设:
1 | |
则可以调整为:
1 | |
再交给加权随机算法选择。
flowchart TD
Metrics["指标采集器"] --> Score["节点打分"]
Latency["请求耗时统计"] --> Score
Health["健康状态"] --> Score
Score --> Weight["计算动态权重"]
Base["静态基础权重"] --> Weight
Weight --> LB["加权负载均衡"]
LB --> Node["选择 Provider"]
这里其实形成了一个反馈控制回路:
1 | |
自适应策略也存在控制滞后
自适应并不意味着“自动就一定正确”。
假设一个空闲节点当前评分很高:
1 | |
于是大量 Consumer 同时增加它的流量。
几秒后它可能突然被打满,但 Consumer 使用的仍然是上一个统计周期的状态。
这体现了任何基于指标反馈的负载算法都需要面对:
1 | |
采集太慢,状态不新鲜。
采集太快,又会增加额外负担,并可能产生频繁权重震荡。
因此自适应负载均衡的重点并不是堆指标,而是保证整个反馈回路稳定。
异常重试:失败以后不能简单再发一次
RPC 最常见的问题之一就是瞬时网络故障。
例如:
1 | |
发送过程中刚好出现一次网络抖动。
如果这是读取用户资料之类的请求,业务通常希望尽可能成功,而不是立刻暴露一次偶发网络错误。
于是 RPC 可以提供框架级重试。
1 | |
但“自动重试”也是 RPC 中最危险的能力之一。
不是什么异常都应该重试
例如服务端明确抛出了:
1 | |
再次发送相同请求通常没有任何意义。
而这些异常更可能适合重试:
1 | |
所以 RPC 应该先判断异常类型。
flowchart TD
E["RPC Exception"] --> Q{"是否允许重试?"}
Q -->|网络 / 框架允许重试异常| R["Retry"]
Q -->|业务异常| F["直接返回"]
Q -->|用户配置白名单| R
某些业务异常虽然来自服务端,但业务明确知道它可以重试,这时可以提供:
1 | |
由业务声明哪些异常允许进入框架重试流程。
重试最大的安全前提:幂等
考虑这样一次请求:
1 | |
调用方发送成功:
1 | |
Provider 已经完成数据库写入。
但是返回响应时网络断了:
1 | |
从 Consumer 视角:
1 | |
它无法知道:
1 | |
如果 RPC 立即重试:
1 | |
就可能重复创建数据。
所以只要请求存在:
1 | |
自动重试就必须建立在业务幂等基础上。
如果业务不是天然幂等,也可以在业务协议中增加唯一 Request ID:
1 | |
服务端记录:
1 | |
再次收到同一个 ID 时直接返回第一次结果。
但这种方案已经跨越 RPC 与业务边界,需要团队统一约定,而不能由 RPC 框架凭空保证。
重试不能让整体超时时间失效
假设:
1 | |
第一次调用耗费:
1 | |
失败后如果重新给第二次请求完整:
1 | |
那么这次 RPC 最终可能耗费:
1 | |
已经违背调用者设置的 10 秒超时约束。
因此可靠的设计应该维护:
1 | |
而不是每次 Retry 都重新获得完整超时时间。
例如:
1 | |
也就是:
1 | |
如果:
1 | |
立即返回 Timeout。
这一点非常重要:
所谓“重置重试超时时间”,应该理解为根据剩余总预算重新计算下一次调用 timeout,而不是把它重新恢复成原始完整值。
重试时还应该避开刚刚失败的节点
如果第一次:
1 | |
重试后负载均衡又得到:
1 | |
那这次重试的意义明显下降。
因此一次调用可以维护:
1 | |
重试选择节点时把已经失败的 Provider 排除。
1 | |
如果所有节点都尝试失败,再根据整体重试策略决定是否继续。
对于实时 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 | |
它远比:
1 | |
复杂。
优雅关闭:服务停止前先把流量切走
应用发布往往意味着实例重启。
最危险的时间窗口是:
1 | |
此时:
1 | |
负载均衡就有可能继续选择它。
仅依赖注册中心下线通知并不能完全无损
一种很自然的流程是:
1 | |
问题在于服务发现通常采用异步传播,并且在大规模场景强调最终一致性。
因此可能出现:
1 | |
这也是为什么:
服务发现不能独自承担无损停机的全部责任。
Provider 可以直接通知 Consumer
RPC 通常存在长连接,因此 Provider 可以记录连接自己的 Consumer。
关闭时主动发送:
1 | |
Consumer 接收到后:
1 | |
这比:
1 | |
链路更短。
但它依然存在并发窗口:
1 | |
通知和业务请求甚至可能只相差不到 1ms。
因此还需要最后一道防线。
关闭挡板:已经关门,就不要再接新业务
一旦 Provider 进入关闭状态,应该立刻设置:
1 | |
随后所有新请求经过调用链时先检查:
1 | |
可以返回一个专门的:
1 | |
它向 Consumer 表达一个非常重要的语义:
请求已经到达我这里,但是我处于关闭阶段,因此没有执行业务逻辑。
既然业务没有执行,这个请求就可以安全切换到其他节点重试。
这就是“挡板”的价值。
已经开始执行的请求不能直接杀掉
关闭新请求以后,还可能存在:
1 | |
如果进程立即退出,还是会产生业务损失。
因此 Provider 可以维护:
1 | |
每开始一个请求:
1 | |
请求结束:
1 | |
Shutdown 时:
1 | |
但也不能无限等待。
如果某个请求永远阻塞:
1 | |
服务将永远无法关闭。
因此还需要:
1 | |
资料给出了约 10 秒作为经验示例,这同样应该根据业务最大处理时间调整,而不是理解成固定标准。
Java 中如何捕获关闭事件
在 Java 中,可以通过 JVM Shutdown Hook 捕获进程正常关闭过程:
1 | |
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 | |
一个新实例刚刚注册就立刻分担完整流量,很容易:
1 | |
这也是为什么需要启动预热。
启动预热:刚启动的实例先少接一点流量
调用方已经知道 Provider 的:
1 | |
于是负载均衡可以动态计算新节点权重。
概念上:
1 | |
flowchart TD
Start["Provider 上线"] --> W1["低权重"]
W1 --> T{"达到下一预热阶段?"}
T -->|否| W1
T -->|是| W2["逐步提高权重"]
W2 --> Done{"达到目标权重?"}
Done -->|否| T
Done -->|是| Normal["正常权重"]
资料图中以:
1 | |
表达渐进增加的思想。
真正实现可以采用其他平滑函数,关键不是固定 10%,而是:
不要让新实例从 0 流量瞬间跳到正常满载流量。
延迟暴露:应用真正 Ready 以后再注册服务
启动预热解决:
1 | |
但还有一个更早的问题:
1 | |
以 Spring 应用为例,Bean 加载过程中可能已经发现一个 RPC Service Bean。
如果此时马上:
1 | |
调用方很快就会拿到该实例。
但整个 Spring ApplicationContext 可能仍然在初始化其他 Bean。
因此更合理的流程是:
1 | |
flowchart LR
A["加载应用"] --> B["应用启动完成"]
B --> C["执行 Warmup Hook"]
C --> D["初始化缓存 / 资源"]
D --> E["注册到注册中心"]
E --> F["开始接受 RPC 流量"]
这个 Hook 可以承担:
- 预加载缓存;
- 初始化连接;
- 执行部分预热逻辑;
- 检查关键依赖是否正常;
- 让业务真正进入 Ready 状态。
于是启动和关闭形成一组非常漂亮的对称关系:
1 | |
也就是:
1 | |
大规模重启不能只依赖启动预热
假设集群:
1 | |
突然同时重启 15 台。
15 个新节点都处于低预热权重,那么大量请求自然会压到剩余 5 个旧节点。
因此预热不是发布容量规划的替代品。
更稳妥的策略包括:
- 分批滚动重启;
- 控制同时重启实例数;
- 在业务低峰进行大批量变更;
- 提前进行容量评估;
- 必要时限制入口流量;
- 配合自适应负载均衡控制旧节点负载。
Kubernetes 滚动发布虽然改变了部署工具,但对于单实例来说:
1 | |
这些设计原则依然成立。
服务端保护:限流
即使节点完全健康,也不意味着它能够无限处理请求。
超过系统容量后可能出现:
- 请求耗时上升;
- CPU 满载;
- 内存压力增加;
- Full GC;
- 线程池耗尽;
- 服务直接宕机。
因此服务端应该具备:
1 | |
最直接的办法就是限流。
RPC 服务端可以在真正执行业务逻辑之前执行:
1 | |
flowchart LR
Req["RPC Request"] --> Limit{"Rate Limit"}
Limit -->|允许| Biz["Business Logic"]
Limit -->|超过阈值| Reject["RateLimitException"]
限流应该作为 RPC Filter 或服务端调用链的一部分,而不需要让每个业务团队重复实现。
常见限流算法
资料提到了几种典型方案:
- 计数器;
- 滑动窗口;
- 漏桶;
- 令牌桶。
其中 Token Bucket(令牌桶)是非常常见的一种。
算法本身不是 RPC 独有的,RPC 更重要的问题是:
在哪个维度进行限流?
限流不能只有一个总 QPS
假设服务有多个调用方:
1 | |
如果 A 流量异常,不应该让它吃掉所有容量并拖累 B、C。
因此限流 Key 可以包括:
1 | |
例如:
1 | |
这也是 RPC 治理平台需要动态配置能力的原因。
限流规则可以:
1 | |
动态下发,而不是写死在代码中。
单机限流与集群限流
假设数据库只能支持:
1 | |
RPC 服务有:
1 | |
最简单可以给每个节点:
1 | |
但扩容到 20 台以后:
1 | |
就需要重新调整。
RPC 可以根据总节点数量自动折算单机阈值。
但即便如此仍然有误差。
例如:
1 | |
P1 已经拒绝请求,但整个集群实际还没有达到:
1 | |
这是单机独立限流天然带来的精度损失。
更精确的方式是:
1 | |
由全局限流器维护整个服务的配额。
但代价也很明显:
- 每次请求多一次限流判定;
- 增加网络耗时;
- 限流系统本身需要高可用;
- 可能形成新的性能瓶颈。
所以这是一个典型工程取舍:
| 方案 | 优势 | 代价 |
|---|---|---|
| 本地限流 | 快、简单、无远程依赖 | 集群配额不够精确 |
| 集中限流 | 全局配额更准确 | 网络与系统成本更高 |
并不存在一种方案对所有服务都是最优答案。
调用端保护:熔断
假设调用链:
1 | |
现在 C 变慢。
B 的线程不断等待 C:
1 | |
A 又持续向 B 发送请求。
最后很可能出现:
1 | |
当链路变成:
1 | |
风险更加严重。
因此调用方需要熔断。
熔断器的三种状态
经典 Circuit Breaker 包含:
1 | |
stateDiagram-v2
[*] --> Closed
Closed --> Open: 错误率 / 慢调用达到阈值
Open --> HalfOpen: 熔断时间结束
HalfOpen --> Closed: 探测成功
HalfOpen --> Open: 探测失败
Closed
正常调用下游。
同时采集:
- 异常率;
- 慢请求;
- 调用失败等指标。
Open
达到熔断条件后,直接拒绝调用下游。
1 | |
快速失败,而不是继续消耗线程和连接。
Half-Open
等待一段时间后允许少量探测流量进入。
成功则恢复 Closed。
失败则重新 Open。
熔断器应该放在 RPC 调用的最前面
RPC 动态代理是一次远程调用最早的入口之一。
因此非常适合:
1 | |
如果熔断器已经 Open:
1 | |
直接快速失败。
这也是基础能力下沉到 RPC 框架的价值:
业务只负责声明治理规则,不必在每一个调用点重复编写保护代码。
限流、熔断与降级不是一回事
它们经常一起出现,但解决的问题不同。
1 | |
可以简单理解成:
1 | |
它们最终目标相同:
不追求所有请求都成功,而是避免整个系统一起失败。
业务分组:从“有损限流”进一步走向流量隔离
限流和熔断本质上都可能产生业务损失。
还有一种更主动的治理手段:
在故障发生以前就把不同业务流量隔离开。
假设服务集群:
1 | |
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 | |
它就可能直接污染 Consumer B。
将 Provider 分成不同服务池
可以将服务实例划分为:
1 | |
然后:
1 | |
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 | |
而不会直接把:
1 | |
一起拖垮。
对于核心业务尤其有价值。
例如:
1 | |
可以拥有专门的 Provider Pool,而不是和大量非核心任务共享同一组资源。
分组实际上改变了服务发现的 Key
原本服务发现可能是:
1 | |
引入 Group 后变成:
1 | |
Provider 注册:
1 | |
Consumer 订阅:
1 | |
才能得到对应实例。
这与普通路由存在一定差别。
路由更像:
1 | |
分组则更像:
1 | |
因此分组特别适合长期、稳定的资源隔离。
分组以后为什么还要主备组
隔离会降低故障域,但同时也减少每个 Consumer 能使用的节点数量。
原来可以使用:
1 | |
现在可能只有:
1 | |
如果 P1、P2 所在交换机全部故障,这个 Consumer 就完全失去服务。
因此可以设计:
1 | |
平时:
1 | |
Primary 全部不可用后:
1 | |
Primary 恢复:
1 | |
也就是:
1 | |
两者并不冲突。
分组还能表达同一接口的不同实现
例如两个 Provider 都实现:
1 | |
但底层逻辑不同。
可以:
1 | |
Consumer 根据 Group 选择不同实现。
因此 Group 不只是限流工具,还可以成为 RPC 服务标识的一部分。
环境隔离与分组隔离不要混为一谈
开发、测试、预发布、生产环境之间,如果条件允许,更适合采用:
1 | |
或者注册中心内部:
1 | |
进行硬隔离。
例如:
1 | |
相比依赖复杂路由规则,环境硬隔离通常更简单、更安全。
分组更适合解决:
1 | |
而不是拿来替代全部环境治理。
另外,如果真正目标是全链路隔离,仅仅给某一层 Provider 分组还不够。
假设:
1 | |
只把 B 划分成:
1 | |
但二者仍共享同一个 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 | |
控制面
负责下发:
1 | |
反馈面
根据实际运行结果生成:
1 | |
再反过来调整数据面。
一个成熟 RPC 框架并不是简单的:
1 | |
而实际上是一个持续运行的反馈控制系统。
一个节点从启动到退出的完整生命周期
把优雅启动、健康检测和优雅关闭组合起来,可以得到更加完整的 Provider 生命周期:
stateDiagram-v2
[*] --> Starting
Starting --> Warming: 应用初始化完成
Warming --> Ready: 预热完成 / 注册服务
Ready --> SubHealthy: 心跳或业务指标异常
SubHealthy --> Ready: 状态恢复
SubHealthy --> Unhealthy: 持续异常
Unhealthy --> Ready: 重连 / 恢复
Ready --> Draining: 收到关闭信号
SubHealthy --> Draining: 收到关闭信号
Draining --> Stopped: 已有请求完成或达到超时
Stopped --> [*]
几个阶段表达完全不同的流量语义:
1 | |
如果 RPC 只维护:
1 | |
就很难表达这些真实生产状态。
RPC 服务治理中最值得保留的设计原则
把所有机制放到一起之后,会发现它们背后有一些高度一致的设计思想。
核心稳定,变化插件化
网络传输、序列化、注册中心、路由、负载均衡都可能变化。
不要让这些变化直接侵入 RPC 核心调用流程。
更合适的是:
1 | |
控制面负责规则,数据面本地执行
例如路由:
不要让每一次请求都去注册中心计算。
更合理的是:
1 | |
这既降低控制面压力,也避免控制面成为 RPC 调用的同步依赖。
服务发现更关注收敛,不必追求每一瞬间完全同步
服务发现数据变化允许短暂延迟。
因此可以使用:
1 | |
把注册中心从 RPC 热路径中移走。
坏消息应该尽快传播,好消息应该谨慎发布
Provider 故障或准备关闭:
1 | |
Provider 刚刚启动:
1 | |
这正是优雅启停背后的共同思想。
路由先缩小集合,负载均衡再选择节点
不要把两个概念混成一个算法。
1 | |
健康检测必须接近真实业务体验
TCP Connected 不代表 Healthy。
Heartbeat Success 也不代表业务可用。
最终必须结合:
1 | |
多个维度判断。
Retry 必须受 Deadline 约束
不能把:
1 | |
解释成:
1 | |
正确的约束对象应该是整个 RPC 调用。
自动重试必须考虑“请求可能已经执行”
这是分布式系统非常经典的“不确定性”:
1 | |
没有幂等保障时,自动重试宁可保守。
隔离比故障以后再限流更主动
限流是在:
1 | |
以后开始拒绝请求。
分组则是在系统设计阶段提前划定故障域:
1 | |
避免一次异常影响所有业务。
所有自适应算法都要警惕反馈滞后
无论:
1 | |
本质上都是:
1 | |
只要存在反馈,就存在:
- 采样延迟;
- 状态滞后;
- 阈值抖动;
- 过度调节。
因此“智能”并不意味着越复杂越好,稳定的控制策略往往比指标数量更重要。
一套完整 RPC 框架真正应该解决什么
如果重新回到最开始的问题:
一个 RPC 框架到底应该具备哪些能力?
可以将答案划分成几层。
第一层解决:
1 | |
包括:
1 | |
第二层解决:
1 | |
包括:
1 | |
第三层解决:
1 | |
包括:
1 | |
第四层解决:
1 | |
包括:
1 | |
第五层解决:
1 | |
包括:
1 | |
最后还需要解决:
1 | |
这才回到最开始的微内核与插件化架构。
1 | |
只实现第一项,它是一个 Remote Call Library。
把后面这些能力逐渐补齐,它才会变成真正能够承载大规模微服务系统的 RPC Framework。
总结
RPC 最有意思的地方,并不是“把一个 Java 方法变成一次 TCP 请求”。
真正困难的是,当网络不可靠、节点会扩缩容、机器性能不同、发布每天发生、下游可能变慢、流量可能突然暴涨以后,仍然让业务开发者看到一个看似简单的:
1 | |
为了实现这一点,框架背后必须建立一整套协作机制。
服务发现维护:
1 | |
健康检测不断判断:
1 | |
路由负责:
1 | |
负载均衡负责:
1 | |
异常重试负责:
1 | |
熔断与限流负责:
1 | |
业务分组负责:
1 | |
优雅启动与关闭负责:
1 | |
而插件体系则保证这些机制未来还能继续演进。
从这个视角再看 RPC,会发现它早已不只是一个通信框架。
它真正解决的是:
如何在动态、复杂且不可靠的分布式环境中,让一次远程调用依然保持可发现、可选择、可控制、可恢复、可保护并且可持续演进。