SheepRPC 是一个基于 Java 17 手写的 RPC 学习项目,此仓库 README 主要用于存放我在学习 RPC 底层原理过程中的一些思考。
这个项目对我来说,并不是“我一开始就已经会完整的 RPC 知识,然后再把它实现出来”,而是“先把最基础的调用链路跑通,再顺着问题一点点往下学”。所以这里的版本迭代,更像是我的学习进度,或者说我当时的理解推进到了哪一步。
这份文档不会去堆很多类职责和功能清单,我更想记下的是:这一阶段我理解到了什么、遇到了什么问题、最后为什么这样改。
这一版是我刚开始学 RPC 时的第一步。
我还不了解注册中心、序列化协议、负载均衡这些东西,只是试着把最核心的链路跑通:
- 客户端怎么把一次本地方法调用包装成请求
- 请求怎么发到服务端
- 服务端怎么根据请求找到对应的方法并执行
- 执行结果怎么再返回给客户端
这个阶段最重要的不是写得多完善,而是先把 RPC 最基本的样子看清楚。
也正因为这样,后面我才能比较清楚地知道问题是从哪里开始冒出来的。
写完第一版之后,很快就能发现一些很直接的问题:
- 服务端如果直接绑定固定服务,服务一多就得重复写代码
- 服务监听和任务处理耦合在一起,后面不容易扩展
- 整个结构更像一个例子,而不像一个能继续往下长的项目
所以这一版我开始把服务暴露、服务查找这些能力做抽象。
这一步对我来说,其实是在学习 RPC 时第一次真正体会到“单一职责”和“面向接口编程”为什么重要。
当结构慢慢清楚之后,BIO 的问题也越来越明显了。
BIO 性能太低了,每来一个请求都要分配一个线程去处理,这样服务端线程很快就会被耗光。
另外代码分层也非常不明显,很多代码都堆在一起,经常找代码找得头晕眼花。项目如果继续变大,这种结构肯定是撑不住的,所以这里也顺势做了一次重构。
这里把通信层换成了 Netty。
对我来说,这一版不是单纯“换个框架”,而是开始真正去理解高性能通信模型在 RPC 里的意义。
通信层换完之后,下一个绕不过去的问题就是:RPC 在网络上传的到底是什么。
只要真的开始处理字节流,就一定会碰到这些问题:
- 消息边界怎么确定
- 请求和响应怎么区分
- 序列化方式怎么标识
- 粘包拆包怎么处理
所以这一版我开始自己定义协议头,并实现编码器和解码器。
当前协议头固定为 8 字节:
- 2 字节消息类型
- 2 字节序列化方式
- 4 字节消息长度
我目前实现了 Java 原生序列化和 FastJson 两种方式。
这一阶段我更关注的是先把协议边界定义清楚,后面如果继续优化,再考虑魔数、版本号,以及 Kryo 这类更偏性能方向的方案。
这里我开始真正接触注册中心这个组件,也第一次很直观地感受到它设计上的巧妙。
原本只是用 HashMap 存服务地址的时候,我还没觉得哪里特别不对,但一和 ZooKeeper 对比,就很明显能感觉到差距了。
所以这一版开始把注册中心、服务注册、服务发现做进来。
这一版对我来说很重要,因为我终于不是只在本地模拟“像远程调用一样的调用”,而是开始真正去处理:
- 服务实例如何注册
- 服务下线后如何摘除
- 客户端如何发现可用服务
- 服务列表变化之后怎么同步感知
也是从这里开始,我专门去想了一个问题:既然都是第三方组件进行数据的存储,以高性能著称的Redis 能不能代替 ZooKeeper呢?。
当服务发现做完之后,问题并没有结束,反而会继续冒出来:
- 每次都查注册中心会不会有额外开销
- 本地缓存加进来之后,一致性怎么处理
- 轮询在动态服务列表下还合不合适
- 调用失败之后要不要重试
- 流量突发时服务怎么保护自己
- 某个服务持续失败时,问题怎么别继续放大
所以这一版开始把本地缓存、负载均衡、重试、限流、熔断这些内容补进来。
这一阶段的学习重点,已经从“怎么完成一次 RPC 调用”,慢慢变成了“RPC 出问题时,系统应该怎么表现”。
做到注册中心这一步的时候,我中间确实认真想过:
ZooKeeper 看起来好像也只是存了一下 服务名称 -> host:port 这样的映射关系,这一点 Redis 好像也能做。那为什么注册中心这件事,最后还是更常落到 ZooKeeper 身上?
后来我了解了一下原因,主要有这几个:
ZooKeeper 是树形结构,并且节点分成持久节点和临时节点。
这个特性和注册中心场景是很贴的:
- 持久节点可以用来保存服务名、接口名这些相对稳定的信息
- 临时节点可以用来保存服务实例地址
临时节点的生命周期和客户端会话绑定。也就是说,服务实例一旦下线,对应的临时节点就会自动删除。
这个特性和注册中心非常契合,因为它天然就把“服务是否还活着”这个问题带进去了。
如果换成 Redis,也不是完全不能做,但往往就得借助过期时间去模拟这个过程。
问题是过期时间本身并不好拿捏,太短容易误删,太长又会让失效实例残留太久。
ZooKeeper 还有 Watcher 机制,可以监听某一路径下子节点的变化。
只要有服务实例新增或者删除,监听方就能尽快收到通知。
如果换成 Redis,通常会想到发布订阅。
但发布订阅更像一种消息通知机制,它的问题在于:如果客户端在消息发出时正好不在线,那么它是拿不到这段历史变更的。
而注册中心这个场景里,客户端错过一次变更通知,影响就会直接落到后续服务调用上。
从这个角度看,ZooKeeper 的模型会更贴合一些。
如果只是单纯存一份映射关系,Redis 当然也能做。
但注册中心真正麻烦的地方,并不是“把地址存进去”,而是:
- 服务下线后怎么自动摘除
- 客户端怎么及时感知变化
- 服务列表变化之后,系统怎么尽快收敛到新状态
对比下来,ZooKeeper 和注册中心的契合度确实更高。
所以这里我最后还是选择了 ZooKeeper,而不是 Redis。
服务发现如果每次都直接查注册中心,逻辑当然最直接。
但随着调用频率上来,我会觉得“查服务地址”这件事不应该每次都压到注册中心上,所以这里还是加了客户端本地缓存。
加完缓存之后,服务发现的链路会更轻一些,但新的问题也会立刻出现:
本地缓存一旦参与进来,一致性问题就一定会跟着出现。
只要后端服务是集群部署,本地缓存就会有一个绕不过去的问题:
当某个服务实例发生上下线,或者地址有变化的时候,不同客户端节点里的本地缓存,可能会在一段时间内和真实状态不一致。
如果要让所有节点的本地缓存都立即更新或删除,那就需要一套额外的通知机制。
比如广播消息,所有实例监听广播,再在本地同步更新缓存。这个方向不是不能做,但实现上会明显更重。
所以在当前版本里, Watcher 主动更新本地缓存 这条路线简直再适合不过。
这一点是我做到后面才真正意识到的。
一开始负载均衡用轮询很自然,但当本地缓存也参与进来之后,服务节点数量一旦变化,重新取模就会导致大量请求映射发生变化。
这会让请求分配位置发生大面积变化,缓存大量失效,所以轮询在这个场景里就没有那么合适了。
也正因为这样,后面我把负载均衡策略换成了一致性哈希。
一个成熟的RPC框架一定是可用性极强的,所以对于服务器高压下的策略就很重要了。这里我使用了重试、限流、熔断来避免服务端崩溃 但是每一种方式真正实现的又会有各种各样的问题
我一开始也会下意识觉得,失败了就再试一次,好像总归更稳一点。
但真正往下想就会发现,重试并不是天然安全的。
如果接口本身不是幂等的,那么一次失败之后再补一次请求,反而可能把问题放大。
比如某些带状态修改的操作,重试本身就可能带来重复执行的问题。
所以我这里没有把重试做成一个默认动作,而是让服务提供方来决定该服务是否允许重试。
服务注册时会把这个信息一起写到 ZooKeeper,客户端发现该服务允许重试时,才会走 Guava Retry。
我现在比较认同的思路是:
- 重试是对偶发失败的一种修正手段,不应该无差别开启
- 能不能重试,应该由服务本身来声明,而不是由客户端本地拍脑袋决定
- 重试次数和等待时间都应该收敛,不能把一次小故障放大成更多次无意义请求
在 RPC 场景中,服务端既要应对突发的流量脉冲,又不能被长时间的高负载击垮,因此限流算法的选择必须兼顾弹性和保护。
固定窗口算法存在临界突变问题,且窗口内严格计数,即使服务有能力短时处理更多请求,也会因到达阈值而拒绝,浪费了系统的瞬时处理能力。
滑动窗口 虽然解决了临界问题,但其本质仍然是平滑速率限制,无法利用系统资源处理突发流量——它把流量均匀化,而 RPC 服务往往有线程池缓冲,短时间能消化比平时更多的请求,滑动窗口会过早拒绝本可服务的突发请求。
漏桶 强制以固定速率出水,输出完全平滑,但这也意味着即使服务端有空闲资源,请求也只能排队等待,无法快速处理积压,导致响应延迟增加甚至超时,相当于主动降低了吞吐量。
令牌桶 则正好相反:它以固定速率往桶里放令牌,允许请求在桶内有令牌时突发消耗,桶的大小决定了突发上限,补充速率控制了长期平均负载。这恰好匹配 RPC 的特点——平时积累“信用”,高峰期短暂透支,直到超过系统承受极限才明确拒绝,既充分利用了资源,又实现了自我保护。
调用失败如果持续累积,不做处理的话,问题很容易沿着调用链继续放大。
尤其是服务已经明显不健康的时候,如果客户端还在不断请求、不断重试,那么故障只会越来越重。
所以这里我补了熔断逻辑:
失败达到阈值之后,先进入打开状态,短时间内直接拒绝请求;恢复时间到了之后,再进入半开状态,放少量请求过去试探服务是否恢复。
我现在对熔断和重试的理解是:
- 重试解决的是偶发失败,希望用少量额外请求换回一次成功
- 熔断解决的是持续失败,目的是别让已经不健康的服务继续拖垮调用链
也就是说,这两者不是一回事,更不是谁能替代谁。
不过做到这里我也发现了一个很实际的问题:当前熔断器虽然已经有状态机和阈值控制,但调用成功和失败结果的回写还没有完全接进闭环。这也提醒我,熔断器不是“把类写出来”就结束了,它必须真的接进调用链,状态变化才有意义。
这些问题和前面的 ZooKeeper vs Redis 一样,偶尔会想到一些问题感觉还不是很清晰
写熔断器的时候,一个很实际的问题是:熔断状态到底应该挂在哪一层?
- 按服务接口维度
- 按方法维度
- 按具体服务实例维度
粒度太粗,会让本来没问题的调用也一起被熔掉;
粒度太细,又会让状态管理变得复杂。
我当前实现里,熔断器更接近按方法名维度在做。
但做到这里我也意识到,这个粒度还不够严谨。因为如果不同接口里恰好有同名方法,那它们理论上可能会共享同一个熔断状态,这其实是不合理的。
所以这个问题我现在的答案是:
熔断粒度至少应该是 接口名 + 方法名,如果后面继续细化,甚至可以下沉到具体服务实例。
这个问题也是我做到中途才真正反应过来的。
协议头里我已经预留了“序列化方式”字段,解码器也支持根据这个字段去选择对应的序列化器,但当前客户端和服务端初始化时,默认都还是走 JSON。
这说明一件事:
协议具备扩展能力,和系统真正完成“多序列化协商”,其实不是一回事。
我现在把这个字段保留下来,是因为它能先把协议边界定义清楚,也为后面继续扩展留了位置。
但如果后面真的要把多序列化方案落完整,还需要继续解决:
- 客户端和服务端如何约定当前使用哪种序列化方式
- 某种序列化方案不支持时如何降级
- 默认方案和协商方案如何统一
限流这件事看起来好像只要“加上算法”就够了,但真正做的时候也会发现,粒度其实同样重要。
我当前实现里,限流是按接口维度做的。
这样做的好处是简单直接,也能比较清楚地保护某个服务入口。
但继续往下想就会发现,接口限流只是一个起点。
后面还可以继续问:
- 某个接口下不同方法要不要分开限
- 不同服务实例之间限流状态要不要共享
- 是保护整个服务,还是只保护某类热点方法
这也是我后来越来越能感受到的一点:
很多工程能力真正难的地方,并不是“有没有这项能力”,而是“这项能力的边界和粒度应该放在哪里”。
这些 vlog 对我来说,其实就是这个项目一路做下来的学习记录。后面如果继续往下学,我还会继续补。
- Vlog 01:从本地方法调用到远程调用,先把 RPC 最小闭环跑通
- Vlog 02:服务一多代码就开始乱,为什么我开始补抽象和职责拆分
- Vlog 03:BIO 性能不足,以及我为什么切到 Netty
- Vlog 04:消息为什么不能“直接发对象”,协议和序列化到底在解决什么
- Vlog 05:注册中心为什么最后还是选了 ZooKeeper,而不是 Redis
- Vlog 06:服务发现为什么要加本地缓存,一致性问题我是怎么想的
- Vlog 07:为什么轮询在这里不合适,最终选择了一致性哈希
- Vlog 08:RPC 不只是调用成功时的故事,重试、限流、熔断到底在补什么
- Vlog 09:熔断粒度、限流粒度、序列化协商,这些细节为什么后面越想越多
- 把测试补完整一点,尤其是一致性哈希、重试、熔断这些地方
- 协议再往前走一步,补上魔数、版本号、压缩标记
- 再试试 Kryo,protobuf 这类方案,如果没有跨语言需求Kryo就很好,如果有跨语言需求到时候可以一步到位弄protobuf
- 继续整理这条学习路线上的 vlog / 复盘记录
- Java 17
- Maven
- ZooKeeper
127.0.0.1:2181
- 启动 ZooKeeper
- 运行
com.xiaoyang.Server.TestServer - 运行
com.xiaoyang.Server.TestServer2 - 运行
com.xiaoyang.Client.TestClient






