Skip to content

Qulamb/SheepRPC

Repository files navigation

SheepRPC - 手写 RPC 学习项目

Java 17 / Netty 通信 / ZooKeeper 注册中心 / 服务治理

Java 17 Spring Boot Netty ZooKeeper Maven

SheepRPC 是一个基于 Java 17 手写的 RPC 学习项目,此仓库 README 主要用于存放我在学习 RPC 底层原理过程中的一些思考。

这个项目对我来说,并不是“我一开始就已经会完整的 RPC 知识,然后再把它实现出来”,而是“先把最基础的调用链路跑通,再顺着问题一点点往下学”。所以这里的版本迭代,更像是我的学习进度,或者说我当时的理解推进到了哪一步。

这份文档不会去堆很多类职责和功能清单,我更想记下的是:这一阶段我理解到了什么、遇到了什么问题、最后为什么这样改。

版本迭代

Version 1:先把最基础的远程调用链路跑通

Version1流程图

这一版是我刚开始学 RPC 时的第一步。
我还不了解注册中心、序列化协议、负载均衡这些东西,只是试着把最核心的链路跑通:

  • 客户端怎么把一次本地方法调用包装成请求
  • 请求怎么发到服务端
  • 服务端怎么根据请求找到对应的方法并执行
  • 执行结果怎么再返回给客户端

这个阶段最重要的不是写得多完善,而是先把 RPC 最基本的样子看清楚。
也正因为这样,后面我才能比较清楚地知道问题是从哪里开始冒出来的。

Version 2:开始理解为什么需要抽象和拆分

Version2流程图

写完第一版之后,很快就能发现一些很直接的问题:

  • 服务端如果直接绑定固定服务,服务一多就得重复写代码
  • 服务监听和任务处理耦合在一起,后面不容易扩展
  • 整个结构更像一个例子,而不像一个能继续往下长的项目

所以这一版我开始把服务暴露、服务查找这些能力做抽象。
这一步对我来说,其实是在学习 RPC 时第一次真正体会到“单一职责”和“面向接口编程”为什么重要。

Version 3:开始从 BIO 往 Netty 走

Version3流程图

当结构慢慢清楚之后,BIO 的问题也越来越明显了。
BIO 性能太低了,每来一个请求都要分配一个线程去处理,这样服务端线程很快就会被耗光。
另外代码分层也非常不明显,很多代码都堆在一起,经常找代码找得头晕眼花。项目如果继续变大,这种结构肯定是撑不住的,所以这里也顺势做了一次重构。
这里把通信层换成了 Netty。
对我来说,这一版不是单纯“换个框架”,而是开始真正去理解高性能通信模型在 RPC 里的意义。

Version 4:开始理解协议和序列化为什么绕不过去

Version4

通信层换完之后,下一个绕不过去的问题就是:RPC 在网络上传的到底是什么。

只要真的开始处理字节流,就一定会碰到这些问题:

  • 消息边界怎么确定
  • 请求和响应怎么区分
  • 序列化方式怎么标识
  • 粘包拆包怎么处理

所以这一版我开始自己定义协议头,并实现编码器和解码器。
当前协议头固定为 8 字节:

  • 2 字节消息类型
  • 2 字节序列化方式
  • 4 字节消息长度

我目前实现了 Java 原生序列化和 FastJson 两种方式。
这一阶段我更关注的是先把协议边界定义清楚,后面如果继续优化,再考虑魔数、版本号,以及 Kryo 这类更偏性能方向的方案。

Version 5:开始真正碰注册中心这个问题

Version5.1 Version5.2

这里我开始真正接触注册中心这个组件,也第一次很直观地感受到它设计上的巧妙。
原本只是用 HashMap 存服务地址的时候,我还没觉得哪里特别不对,但一和 ZooKeeper 对比,就很明显能感觉到差距了。
所以这一版开始把注册中心、服务注册、服务发现做进来。 这一版对我来说很重要,因为我终于不是只在本地模拟“像远程调用一样的调用”,而是开始真正去处理:

  • 服务实例如何注册
  • 服务下线后如何摘除
  • 客户端如何发现可用服务
  • 服务列表变化之后怎么同步感知

也是从这里开始,我专门去想了一个问题:既然都是第三方组件进行数据的存储,以高性能著称的Redis 能不能代替 ZooKeeper呢?

Version 6:开始补服务治理能力

Version6

当服务发现做完之后,问题并没有结束,反而会继续冒出来:

  • 每次都查注册中心会不会有额外开销
  • 本地缓存加进来之后,一致性怎么处理
  • 轮询在动态服务列表下还合不合适
  • 调用失败之后要不要重试
  • 流量突发时服务怎么保护自己
  • 某个服务持续失败时,问题怎么别继续放大

所以这一版开始把本地缓存、负载均衡、重试、限流、熔断这些内容补进来。
这一阶段的学习重点,已经从“怎么完成一次 RPC 调用”,慢慢变成了“RPC 出问题时,系统应该怎么表现”。

关于 ZooKeeper 和 Redis 的对比

做到注册中心这一步的时候,我中间确实认真想过:
ZooKeeper 看起来好像也只是存了一下 服务名称 -> host:port 这样的映射关系,这一点 Redis 好像也能做。那为什么注册中心这件事,最后还是更常落到 ZooKeeper 身上?

后来我了解了一下原因,主要有这几个:

1. 注册中心存的不只是地址,更重要的是实例状态

ZooKeeper 是树形结构,并且节点分成持久节点和临时节点。
这个特性和注册中心场景是很贴的:

  • 持久节点可以用来保存服务名、接口名这些相对稳定的信息
  • 临时节点可以用来保存服务实例地址

临时节点的生命周期和客户端会话绑定。也就是说,服务实例一旦下线,对应的临时节点就会自动删除。
这个特性和注册中心非常契合,因为它天然就把“服务是否还活着”这个问题带进去了。

如果换成 Redis,也不是完全不能做,但往往就得借助过期时间去模拟这个过程。
问题是过期时间本身并不好拿捏,太短容易误删,太长又会让失效实例残留太久。

2. 服务列表变化之后,客户端怎么第一时间感知

ZooKeeper 还有 Watcher 机制,可以监听某一路径下子节点的变化。
只要有服务实例新增或者删除,监听方就能尽快收到通知。

如果换成 Redis,通常会想到发布订阅。
但发布订阅更像一种消息通知机制,它的问题在于:如果客户端在消息发出时正好不在线,那么它是拿不到这段历史变更的。

而注册中心这个场景里,客户端错过一次变更通知,影响就会直接落到后续服务调用上。
从这个角度看,ZooKeeper 的模型会更贴合一些。

3. 最后我的结论

如果只是单纯存一份映射关系,Redis 当然也能做。
但注册中心真正麻烦的地方,并不是“把地址存进去”,而是:

  • 服务下线后怎么自动摘除
  • 客户端怎么及时感知变化
  • 服务列表变化之后,系统怎么尽快收敛到新状态

对比下来,ZooKeeper 和注册中心的契合度确实更高。
所以这里我最后还是选择了 ZooKeeper,而不是 Redis。

关于服务发现里本地缓存的一些思考

1. 为什么还要给客户端加本地缓存

服务发现如果每次都直接查注册中心,逻辑当然最直接。
但随着调用频率上来,我会觉得“查服务地址”这件事不应该每次都压到注册中心上,所以这里还是加了客户端本地缓存。

加完缓存之后,服务发现的链路会更轻一些,但新的问题也会立刻出现:
本地缓存一旦参与进来,一致性问题就一定会跟着出现。

2. 关于一致性的思考

只要后端服务是集群部署,本地缓存就会有一个绕不过去的问题:
当某个服务实例发生上下线,或者地址有变化的时候,不同客户端节点里的本地缓存,可能会在一段时间内和真实状态不一致。

如果要让所有节点的本地缓存都立即更新或删除,那就需要一套额外的通知机制。
比如广播消息,所有实例监听广播,再在本地同步更新缓存。这个方向不是不能做,但实现上会明显更重。

所以在当前版本里, Watcher 主动更新本地缓存 这条路线简直再适合不过。

3. 本地缓存加进来之后,为什么轮询开始不太合适

这一点是我做到后面才真正意识到的。
一开始负载均衡用轮询很自然,但当本地缓存也参与进来之后,服务节点数量一旦变化,重新取模就会导致大量请求映射发生变化。

这会让请求分配位置发生大面积变化,缓存大量失效,所以轮询在这个场景里就没有那么合适了。

也正因为这样,后面我把负载均衡策略换成了一致性哈希。

重试、限流、熔断

一个成熟的RPC框架一定是可用性极强的,所以对于服务器高压下的策略就很重要了。这里我使用了重试、限流、熔断来避免服务端崩溃 但是每一种方式真正实现的又会有各种各样的问题

重试

我一开始也会下意识觉得,失败了就再试一次,好像总归更稳一点。
但真正往下想就会发现,重试并不是天然安全的。

如果接口本身不是幂等的,那么一次失败之后再补一次请求,反而可能把问题放大。
比如某些带状态修改的操作,重试本身就可能带来重复执行的问题。

所以我这里没有把重试做成一个默认动作,而是让服务提供方来决定该服务是否允许重试。
服务注册时会把这个信息一起写到 ZooKeeper,客户端发现该服务允许重试时,才会走 Guava Retry。

我现在比较认同的思路是:

  • 重试是对偶发失败的一种修正手段,不应该无差别开启
  • 能不能重试,应该由服务本身来声明,而不是由客户端本地拍脑袋决定
  • 重试次数和等待时间都应该收敛,不能把一次小故障放大成更多次无意义请求

限流

在 RPC 场景中,服务端既要应对突发的流量脉冲,又不能被长时间的高负载击垮,因此限流算法的选择必须兼顾弹性和保护。

固定窗口算法存在临界突变问题,且窗口内严格计数,即使服务有能力短时处理更多请求,也会因到达阈值而拒绝,浪费了系统的瞬时处理能力。

滑动窗口 虽然解决了临界问题,但其本质仍然是平滑速率限制,无法利用系统资源处理突发流量——它把流量均匀化,而 RPC 服务往往有线程池缓冲,短时间能消化比平时更多的请求,滑动窗口会过早拒绝本可服务的突发请求。

漏桶 强制以固定速率出水,输出完全平滑,但这也意味着即使服务端有空闲资源,请求也只能排队等待,无法快速处理积压,导致响应延迟增加甚至超时,相当于主动降低了吞吐量。

令牌桶 则正好相反:它以固定速率往桶里放令牌,允许请求在桶内有令牌时突发消耗,桶的大小决定了突发上限,补充速率控制了长期平均负载。这恰好匹配 RPC 的特点——平时积累“信用”,高峰期短暂透支,直到超过系统承受极限才明确拒绝,既充分利用了资源,又实现了自我保护。

熔断和降级

调用失败如果持续累积,不做处理的话,问题很容易沿着调用链继续放大。
尤其是服务已经明显不健康的时候,如果客户端还在不断请求、不断重试,那么故障只会越来越重。

所以这里我补了熔断逻辑:
失败达到阈值之后,先进入打开状态,短时间内直接拒绝请求;恢复时间到了之后,再进入半开状态,放少量请求过去试探服务是否恢复。

我现在对熔断和重试的理解是:

  • 重试解决的是偶发失败,希望用少量额外请求换回一次成功
  • 熔断解决的是持续失败,目的是别让已经不健康的服务继续拖垮调用链

也就是说,这两者不是一回事,更不是谁能替代谁。
不过做到这里我也发现了一个很实际的问题:当前熔断器虽然已经有状态机和阈值控制,但调用成功和失败结果的回写还没有完全接进闭环。这也提醒我,熔断器不是“把类写出来”就结束了,它必须真的接进调用链,状态变化才有意义。

做到后面才发现的几个问题

这些问题和前面的 ZooKeeper vs Redis 一样,偶尔会想到一些问题感觉还不是很清晰

1. 熔断器到底该按什么粒度做

写熔断器的时候,一个很实际的问题是:熔断状态到底应该挂在哪一层?

  • 按服务接口维度
  • 按方法维度
  • 按具体服务实例维度

粒度太粗,会让本来没问题的调用也一起被熔掉;
粒度太细,又会让状态管理变得复杂。

我当前实现里,熔断器更接近按方法名维度在做。
但做到这里我也意识到,这个粒度还不够严谨。因为如果不同接口里恰好有同名方法,那它们理论上可能会共享同一个熔断状态,这其实是不合理的。

所以这个问题我现在的答案是:
熔断粒度至少应该是 接口名 + 方法名,如果后面继续细化,甚至可以下沉到具体服务实例。

2. 协议里已经写了序列化类型字段,为什么默认链路还是固定 JSON

这个问题也是我做到中途才真正反应过来的。
协议头里我已经预留了“序列化方式”字段,解码器也支持根据这个字段去选择对应的序列化器,但当前客户端和服务端初始化时,默认都还是走 JSON。

这说明一件事:
协议具备扩展能力,和系统真正完成“多序列化协商”,其实不是一回事。

我现在把这个字段保留下来,是因为它能先把协议边界定义清楚,也为后面继续扩展留了位置。
但如果后面真的要把多序列化方案落完整,还需要继续解决:

  • 客户端和服务端如何约定当前使用哪种序列化方式
  • 某种序列化方案不支持时如何降级
  • 默认方案和协商方案如何统一

3. 限流到底应该按接口做,还是按更细粒度做

限流这件事看起来好像只要“加上算法”就够了,但真正做的时候也会发现,粒度其实同样重要。

我当前实现里,限流是按接口维度做的。
这样做的好处是简单直接,也能比较清楚地保护某个服务入口。

但继续往下想就会发现,接口限流只是一个起点。
后面还可以继续问:

  • 某个接口下不同方法要不要分开限
  • 不同服务实例之间限流状态要不要共享
  • 是保护整个服务,还是只保护某类热点方法

这也是我后来越来越能感受到的一点:
很多工程能力真正难的地方,并不是“有没有这项能力”,而是“这项能力的边界和粒度应该放在哪里”。

个人 Vlog / 学习记录

这些 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

本地体验

  1. 启动 ZooKeeper
  2. 运行 com.xiaoyang.Server.TestServer
  3. 运行 com.xiaoyang.Server.TestServer2
  4. 运行 com.xiaoyang.Client.TestClient

About

No description, website, or topics provided.

Resources

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages