1. 为什么RPC值得单独拎出来聊
做Java后端的人,迟早会撞上RPC这个词。面试要问,项目要用,架构图里永远有一层叫“服务通信”。但很多人对RPC的理解停留在“像调本地方法一样调远程服务”这句话上,再往下问就说不清了。我自己早期也是这样,觉得RPC不就是发个HTTP请求吗,加个注解就完事了。直到有一次线上服务大面积超时,排查到最后发现是某个RPC接口的序列化方式选错了,导致单次响应体膨胀了将近8倍,带宽直接打满。那次之后我才认真把RPC从协议层到框架层捋了一遍。
这篇内容就是把我这些年对Java RPC的理解和实操经验整理出来。从最底层的网络通信、序列化、动态代理,到上层框架的选型、超时重试、服务治理,都会涉及。适合已经写过Java服务、但对RPC内部机制不够清晰的开发者,也适合正在做服务拆分、需要选型RPC框架的团队参考。我不会只讲概念,每个关键点都会配上代码示例和参数计算过程,尽量做到看完就能动手。
2. RPC的核心链路拆解与设计思路
2.1 一次RPC调用到底经历了什么
很多人觉得RPC神秘,是因为框架把太多东西藏起来了。你把一个接口注入进来,直接调方法,结果就回来了。但这中间至少经过了七个环节:代理层拦截调用、方法参数序列化、协议编码、网络传输、服务端解码、方法路由与执行、结果回传反序列化。
我用一个生活化的类比来解释。你给远方的朋友寄一份组装说明书,让他帮你拼一个柜子。代理层就是你找的跑腿小哥,你只需要告诉他“把这个说明书给朋友”;序列化是把说明书翻译成你们约定的暗号,因为直接寄中文他可能看不懂;协议编码是装信封、贴邮票、写地址;网络传输是快递运输;服务端解码是朋友拆信封、翻译暗号;方法路由是朋友根据说明书找到对应的工具和零件;执行就是真正拼柜子;最后把拼好的照片寄回来,就是结果反序列化。
这个链路里,任何一个环节出问题都会导致调用失败。序列化出问题会乱码,协议编码出问题会丢包,网络传输出问题会超时,方法路由出问题会找不到服务。所以理解RPC,本质上就是理解这条链路上每一环的设计取舍。
2.2 为什么不用HTTP直接调,非要搞RPC
这是我最常被问到的问题。答案不是“RPC比HTTP快”,而是RPC在特定场景下做了更合适的取舍。
HTTP是通用协议,它的头部信息非常丰富,Content-Type、User-Agent、Cookie、各种缓存控制字段,这些在浏览器场景下很有用,但在服务间调用场景下大部分是冗余的。一个典型的HTTP请求头可能占几百字节,而RPC框架自定义的二进制协议头部通常只有几十字节。在QPS上万的服务里,这个差距会被放大到非常可观的程度。
另一个关键差异是连接复用和异步能力。HTTP/1.1虽然支持Keep-Alive,但本质上还是请求-响应模型,一个连接同时只能处理一个请求。RPC框架通常基于长连接+多路复用,一个连接上可以并发跑成百上千个请求,这在微服务高频调用场景下优势明显。
但RPC也不是没有代价。它需要额外的服务注册发现、负载均衡、健康检查等配套组件,运维复杂度比直接暴露HTTP接口高。所以我的经验是:对外API用HTTP,内部服务间高频调用用RPC。不要为了技术而技术。
2.3 序列化选型的核心考量
序列化是RPC性能的隐形杀手。我见过太多项目默认用JDK序列化,然后抱怨RPC慢。JDK序列化的问题在于:体积大、速度慢、跨语言支持差。
实测数据:一个包含10个字段的简单对象,JDK序列化后大约500字节,JSON大约200字节,Protobuf大约80字节,Kryo大约60字节。序列化耗时方面,JDK大约是Protobuf的3到5倍。这个差距在单次调用里可能只有零点几毫秒,但在每天几十亿次调用的系统里,累积起来就是巨大的资源浪费。
选序列化方案时,我一般按这个优先级判断:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 内部服务、追求极致性能 | Protobuf / Kryo | 体积小、速度快 |
| 需要跨语言 | Protobuf / Thrift | 多语言支持成熟 |
| 调试友好、可读性优先 | JSON | 人眼可读,排查方便 |
| 遗留系统、不想改 | JDK序列化 | 兼容性好但性能差 |
注意:Kryo有个坑,它要求序列化的类必须注册,否则第一次序列化会慢很多。而且Kryo的版本兼容性比较脆弱,服务端和客户端用的Kryo版本不一致时容易出问题。生产环境用Kryo一定要做好版本管理。
3. 手写一个迷你RPC框架的核心细节
3.1 动态代理:RPC的门面
RPC框架给使用者的体验是“调本地方法”,这个体验全靠动态代理实现。Java里有两种动态代理:JDK原生代理和CGLIB。JDK代理基于接口,CGLIB基于继承。RPC框架通常用JDK代理,因为服务接口本来就是面向接口设计的。
核心逻辑很简单:代理对象在方法被调用时,拦截方法名和参数,然后走网络发送。我用代码展示一下最核心的部分:
public class RpcProxyHandler implements InvocationHandler { private String host; private int port; public RpcProxyHandler(String host, int port) { this.host = host; this.port = port; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 构造请求对象 RpcRequest request = new RpcRequest(); request.setInterfaceName(method.getDeclaringClass().getName()); request.setMethodName(method.getName()); request.setParameterTypes(method.getParameterTypes()); request.setParameters(args); // 通过网络发送请求并获取结果 RpcResponse response = sendRequest(request); if (response.hasException()) { throw response.getException(); } return response.getResult(); } }这段代码看起来简单,但有几个细节容易踩坑。第一,method.getDeclaringClass().getName()拿到的是接口全限定名,不是实现类,这个必须和服务端注册的名字一致。第二,参数类型数组必须按方法签名顺序传,否则服务端反射找不到对应方法。第三,如果方法有重载,光靠方法名和参数类型还不够,需要更精确的匹配策略。
3.2 网络传输:Netty还是NIO
手写RPC时,网络层可以用JDK原生NIO,也可以用Netty。我的建议是直接用Netty,除非你有特殊需求。JDK NIO的API太底层,缓冲区管理、粘包拆包、连接管理都要自己处理,写出来的代码又长又容易出bug。Netty把这些都封装好了,而且经过大规模生产验证。
用Netty做RPC传输时,核心是配置好ChannelHandler链。典型的链是这样的:解码器 -> 反序列化器 -> 业务处理器 -> 序列化器 -> 编码器。这里有个顺序陷阱:编码器和解码器的顺序是相反的,因为出站和入站方向不同。
// 服务端初始化 ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // 入站:先解码,再反序列化 pipeline.addLast(new RpcDecoder()); pipeline.addLast(new RpcRequestHandler()); // 出站:先序列化,再编码 pipeline.addLast(new RpcEncoder()); } });粘包拆包是网络编程的经典问题。RPC框架通常用长度字段来解决:在协议头里放一个固定长度的字段表示消息体长度,接收方先读长度,再按长度读消息体。Netty提供了LengthFieldBasedFrameDecoder,直接配置好长度字段的偏移量和长度就行。
3.3 服务注册与发现:RPC的通讯录
服务端启动后要告诉别人“我在哪”,客户端调用前要知道“目标在哪”,这就是服务注册与发现。最简单的做法是用一个Map在本地维护,但生产环境必须用注册中心。
注册中心的核心能力有三个:服务注册、服务订阅、健康检查。服务端启动时把自己的IP和端口注册上去,客户端订阅自己关心的服务,注册中心在服务列表变化时推送更新。健康检查是定期探测服务端是否存活,不存活就摘除。
我早期用ZooKeeper做注册中心,它的Watcher机制可以实时感知节点变化。但ZooKeeper有个问题:Watcher是一次性的,触发后需要重新注册,代码写起来比较繁琐。后来很多项目转向Nacos或Consul,它们提供了更友好的API和更完善的控制台。
实操心得:注册中心一定要做本地缓存。如果注册中心挂了,客户端至少还能用缓存的地址列表继续调用。我见过因为注册中心故障导致整个服务集群不可用的案例,就是因为客户端没有本地缓存,注册中心一挂全部抓瞎。
4. 生产环境RPC的治理与调优
4.1 超时与重试:最容易出事的两个参数
超时设置是RPC治理里最容易被忽视、也最容易引发雪崩的地方。默认超时通常很长或者干脆没有,一个慢请求会把调用方线程池占满,进而拖垮整个服务。
我的经验是:超时时间要分层设置。最底层是网络连接超时,一般1到3秒;中间是读超时,根据接口的P99耗时来定,通常是P99的1.5到2倍;最上层是业务整体超时,要小于上游调用方的超时时间。这样层层递减,保证问题在最内层就被截断。
重试是把双刃剑。重试能提高成功率,但也会放大流量。如果服务端已经过载,大量重试会直接把它打垮。我的原则是:只对幂等接口重试,只对网络超时重试,重试次数不超过2次,并且要加退避策略。
// 退避重试示例 int retryCount = 0; long backoff = 100; // 初始100ms while (retryCount < maxRetries) { try { return doCall(); } catch (TimeoutException e) { retryCount++; Thread.sleep(backoff); backoff *= 2; // 指数退避 } }4.2 熔断与限流:保护自己也保护别人
熔断器的作用是:当某个服务的错误率超过阈值时,直接切断调用,快速失败,给服务端恢复的时间。这就像家里的保险丝,电流过大时自动断开,防止火灾。
熔断器有三个状态:关闭、打开、半开。关闭状态正常放行;错误率超标后进入打开状态,所有请求直接失败;等待一段时间后进入半开状态,放少量请求试探,如果成功则恢复关闭,失败则继续打开。
限流是控制请求速率,防止突发流量打垮服务。常见算法有令牌桶和漏桶。令牌桶允许一定程度的突发,漏桶则严格匀速。RPC框架通常集成限流功能,可以按接口、按方法、按调用方维度配置。
| 治理手段 | 触发条件 | 作用 | 注意事项 |
|---|---|---|---|
| 超时 | 请求超过设定时间 | 防止线程堆积 | 分层设置,逐层递减 |
| 重试 | 网络超时或特定异常 | 提高成功率 | 只重试幂等接口 |
| 熔断 | 错误率超过阈值 | 防止雪崩 | 阈值要合理,太低误伤 |
| 限流 | QPS超过阈值 | 保护服务端 | 限流策略要区分优先级 |
4.3 链路追踪:排查问题的眼睛
微服务架构下,一个请求可能经过五六个服务,出了问题怎么定位?靠日志一行行翻是不现实的。链路追踪通过给每个请求分配一个全局唯一的TraceId,在调用链的每个节点传递,最后汇总展示。
实现链路追踪的核心是上下文传递。TraceId要放在RPC调用的附件里,随请求一起传输。服务端收到后取出TraceId,继续往下一跳传递。同时每个节点记录SpanId和ParentSpanId,形成调用树。
注意:TraceId的生成要保证全局唯一,通常用雪花算法或者UUID。但UUID太长,会占用额外的传输空间,雪花算法生成的64位长整型更合适。另外,采样率要可配置,全量采集在高QPS下对存储压力很大。
5. 常见问题与排查技巧实录
5.1 调用超时了,但服务端说没收到请求
这是最经典的问题之一。客户端报超时,服务端日志里找不到任何请求记录。可能的原因有几种:网络不通、连接池满了、序列化失败、请求在队列里排队。
排查顺序应该是:先看客户端和服务端的网络连通性,用telnet或nc测试端口;再看客户端连接池配置,如果最大连接数太小,高并发时请求会排队;然后检查序列化,如果序列化抛异常,请求根本发不出去;最后看服务端线程池,如果线程池满了,请求会在队列里等待。
我遇到过一次,客户端超时设置是1秒,但服务端线程池队列长度是1000,高峰期请求排队超过1秒,客户端超时了但请求还在队列里,服务端后来还是处理了。这种“超时但实际执行了”的情况,如果接口不是幂等的,会引发数据不一致。解决办法是超时时间要大于服务端队列等待时间+处理时间,或者用异步调用+回调。
5.2 序列化异常导致的诡异问题
序列化问题往往表现得很奇怪:有时候报ClassNotFoundException,有时候报InvalidClassException,有时候干脆返回null。根本原因通常是服务端和客户端的类定义不一致。
比如客户端用的类有10个字段,服务端只有8个,反序列化时可能报错也可能静默忽略。如果用了JDK序列化,serialVersionUID不一致会直接抛异常。用Protobuf的话,字段编号变了会导致数据错乱。
我的建议是:接口的DTO类要单独维护一个模块,服务端和客户端都依赖这个模块,保证类定义完全一致。如果做不到,至少要用兼容性好的序列化方案,比如JSON或Protobuf,它们对字段增减的容忍度更高。
5.3 注册中心推空导致的服务不可用
注册中心推空是指:注册中心因为网络抖动或自身故障,给客户端推送了一个空的服务列表,客户端拿到空列表后认为没有可用服务,所有调用直接失败。
这个问题的根源在于客户端对空列表的处理策略。正确的做法是:如果收到空列表,不要立即清空本地缓存,而是保留上一次的有效列表,同时告警。只有连续多次收到空列表,才认为是真的没有服务了。
另外,注册中心的高可用也很重要。至少部署三个节点,用一致性协议保证数据一致。客户端配置多个注册中心地址,一个挂了自动切换另一个。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 调用超时,服务端无日志 | 网络不通/连接池满 | telnet测试、查看连接池监控 | 检查网络、调大连接池 |
| 序列化异常 | 类定义不一致 | 对比两端类结构 | 统一DTO模块 |
| 服务列表为空 | 注册中心推空 | 查看注册中心日志 | 本地缓存+告警 |
| 调用偶发失败 | 网络抖动/GC停顿 | 查看GC日志、网络监控 | 重试+熔断 |
| 性能突然下降 | 序列化方式变更 | 对比序列化前后体积 | 回滚或优化序列化 |
6. 框架选型:Dubbo、gRPC还是Spring Cloud
6.1 三个主流方案的定位差异
Dubbo是国内用得最多的Java RPC框架,生态成熟,和Spring集成好,支持多种协议和序列化方式。gRPC是Google开源的,基于HTTP/2和Protobuf,跨语言能力最强,适合多语言混合架构。Spring Cloud严格来说不是RPC框架,而是一整套微服务解决方案,它的服务间调用通常用Feign,底层还是HTTP。
选型时我主要看三个维度:团队技术栈、性能要求、跨语言需求。纯Java团队、追求性能,选Dubbo;有多语言服务,选gRPC;已经在用Spring Cloud全家桶,继续用Feign最省事。
6.2 选型时容易忽略的隐性成本
框架本身的性能差距其实没有想象中那么大,真正影响项目进度的是周边生态和运维成本。
Dubbo需要配套的注册中心、配置中心、监控平台,如果团队没有相关经验,搭建和运维要花不少时间。gRPC需要维护Protobuf定义文件,每次接口变更都要重新生成代码,流程比Java接口麻烦。Feign虽然简单,但性能确实不如前两者,高并发场景下需要额外优化。
我的经验:不要为了性能指标选型,要为了团队能力选型。一个团队如果对Dubbo不熟,强行上Dubbo,出的问题可能比用Feign多得多。技术选型的第一原则是团队能hold住。
6.3 一个实际的选型决策过程
假设有一个电商系统,订单服务、库存服务、用户服务都是Java写的,QPS峰值在5000左右,团队5个人,之前用过Spring Cloud。这种情况下,我会推荐继续用Spring Cloud + Feign,因为团队熟悉,学习成本低,5000 QPS对Feign来说完全能扛住。如果QPS到了5万,或者有Go语言的服务要接入,那就值得考虑Dubbo或gRPC。
选型不是选最好的,是选最合适的。把团队能力、运维成本、性能需求放在一起权衡,才能做出不后悔的决定。
7. 我个人在RPC实践中的几点体会
RPC这东西,入门容易精通难。表面上看是“调远程方法像调本地方法”,但背后涉及网络、序列化、并发、服务治理一大堆东西。我踩过的坑里,大部分不是因为不懂原理,而是因为忽视了边界条件。
比如超时时间,我早期觉得设个3秒就够了,后来发现不同接口的耗时差异巨大,有的接口P99才50毫秒,有的要2秒,统一超时时间要么误杀要么放行太慢。后来改成按接口配置超时,问题就少了很多。
还有序列化,我一度觉得JDK序列化挺好,不用额外依赖。直到有一次做压测,发现CPU有30%花在序列化上,换成Protobuf后直接降到8%。这个教训让我明白,性能优化要从最底层的数据传输开始看。
最后分享一个小技巧:如果你在排查RPC问题时不知道从哪下手,先看网络层,再看序列化层,最后看业务层。大部分RPC问题都出在前两层,业务层的问题反而好定位。这个排查顺序帮我省了很多时间。