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 ChannelInitializerSocketChannel() { 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 Cloud6.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问题都出在前两层业务层的问题反而好定位。这个排查顺序帮我省了很多时间。