1. RPC到底是什么,为什么微服务离不开它
先说一个最直白的理解:RPC(Remote Procedure Call,远程过程调用)就是让程序像调用本地函数一样,去调用另一台机器上的函数。你不需要关心网络连接、数据包怎么组装、对方服务部署在哪里,这些细节全部被框架藏起来了。对调用方来说,一次远程调用和一次本地调用的体验是无限接近的。
微服务火了之后,RPC这个词出现的频率越来越高。原因很简单:微服务把一个单体应用拆成几十个甚至上百个独立部署的小服务,A服务要拿订单数据,B服务要扣库存,A和B必须跨进程通信。这个通信方式如果靠手写HTTP请求、手写JSON解析、自己处理超时和重试,那代码里到处都是模板化的网络逻辑,维护成本直接爆炸。RPC恰恰解决了这一层问题:它把“跨服务通信”这个动作,抽象成了一个普通的函数调用,开发人员只需要定义接口、填参数、拿返回值,剩下的交给RPC框架。
这篇内容我会从底层原理开始讲,然后一步步走到实际落地,用真实可跑的代码演示怎么基于RPC把微服务打通。无论你是在选型阶段纠结“到底用HTTP还是RPC”,还是已经在Dubbo、gRPC这类框架里踩坑,这篇文章都会有些参考价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RPC的核心技术拆解,一次调用背后发生了什么
2.1 RPC调用的完整生命周期
很多人用过RPC,但说不清楚一次调用从发出到返回,中间到底经历了什么。其实拆开来看就八个环节:
- 客户端调用本地代理方法(Stub)。
- 代理把方法名、参数值、参数类型等元信息序列化。
- 序列化后的二进制数据通过网络传输到服务端。
- 服务端接收数据并反序列化,还原出调用请求。
- 服务端根据方法名找到对应的服务实现类。
- 执行真正的业务逻辑,拿到返回结果。
- 返回结果序列化后回传给客户端。
- 客户端反序列化结果,把返回值交给调用方。
这八个环节里,最核心的两个技术点是序列化和网络传输。序列化决定了数据包大小、解析速度和跨语言能力;网络传输决定了连接方式、并发模型和可靠性。不同RPC框架的差异,本质上就是在这两个点上做了不同的取舍。
我用一个生活化的例子帮助理解:你打电话订餐,你不需要知道电话信号经过多少基站、交换机怎么路由,你只需要拨号、说出需求、听到确认。RPC里的Stub就是你的“拨号动作”,序列化就是把你的需求转成电话线上的电信号,服务端就是餐厅前台。
2.2 序列化协议怎么选:JSON、Hessian、Protobuf还是其他
序列化往往是RPC性能的分水岭。一个简单的Java对象,用JDK原生序列化能产生一大坨带类描述信息的字节流;用Hessian会小很多;用Protobuf则能做到体积小、解析快,但代价是要写.proto文件来定义消息结构。
实际选型时我一般这么判断:
- 开发调试优先、跨语言需求强:选JSON,可读性好,出问题容易排查,配合HTTP/2可以用gRPC的JSON映射。
- 纯Java内部调用、追求开发效率:选Hessian或Dubbo默认的Hessian2,改接口不用维护额外描述文件。
- 性能为王、有明确的跨语言协作需求:选Protobuf,压缩率高、序列化反序列化速度极快,但要注意版本兼容和字段编号管理。
- 数据量大、要求极致压缩:可以考虑Kryo或FST,但要确认框架是否内置支持。
新手容易踩的坑是:只看了序列化协议的性能对比,忽略了跨语言场景下的兼容性。比如微服务里A服务是Java、B服务是Go,如果直接用Hessian,Go那边基本没法解析。这种场景老老实实上Protobuf才是正解。
2.3 网络传输模型:从BIO到NIO再到响应式
网络层是RPC的另一个关键决策点。早期的RMI基于BIO,一个连接一个线程,并发高时线程数飙升,调度开销巨大。后来Netty带火了NIO模型,少量线程可以管理大量连接,Dubbo和gRPC的底层都是基于Netty或类似NIO框架实现的。
用一个通俗类比:BIO是每个窗口配一个服务员,客人来了就全程一对一服务;NIO是几个服务员来回巡检,哪个窗口有需求就过去处理一下,没有需求就继续转。客人多了以后,前者的服务员数量撑不住,后者几个人就够用。
实际项目中我遇到过这样的事:某系统把HTTP接口改成Dubbo RPC后,等长连接数量从几百掉到几十,但吞吐量反而上去了。原因就是BIO模式下线程频繁阻塞切换,而NIO模式下线程处于事件驱动循环里,利用率高了一个数量级。
2.4 服务寻址与负载均衡:没有注册中心的RPC是半残废
RPC调用要找到目标服务,就像快递要找到收货地址。如果地址是写死在配置文件里的,那服务一扩容、一缩容,配置就要跟着改,运维直接崩溃。所以现代RPC框架基本都会搭配注册中心,让服务启动时自动注册、下线时自动摘除。
常见的注册中心有ZooKeeper、Nacos、Consul、Etcd。Dubbo早期默认用ZooKeeper,后来Nacos因为集成了配置中心功能,在Spring Cloud Alibaba体系里用得非常多。gRPC则更常见搭配Consul或Etcd做服务发现。
有了注册中心后,RPC框架一般会内置多种负载均衡策略:随机、轮询、一致性哈希、最小活跃数等。我之前在生产环境把随机改成最小活跃数后,短连接场景下的尾部延迟明显下降,因为请求会优先打到当前处理量最少的实例上。
这里有一个容易忽略的细节:注册中心挂了,RPC还能不能继续调?答案是能,但要分情况。如果客户端已经把服务列表拉到了本地缓存,注册中心短暂宕机不影响调用;但如果服务列表发生变更而客户端没有感知到,就可能把请求发到已下线的节点上。所以生产环境一定要给注册中心做高可用,同时在客户端配置合理的服务列表刷新周期和失败重试策略。
3. 为什么微服务架构里RPC是“刚需”而不是“可选项”
3.1 HTTP和RPC,到底谁更适合微服务内部通信
这是一个隔三差五就被拿出来讨论的问题。我的结论很明确:如果做的是内部服务间的高频调用,RPC是更合适的选择;如果做的是对外开放API,那HTTP/REST依然是事实标准。
两边的核心差异在于:
- HTTP/REST是面向资源的,语义清晰,适合跨系统、跨团队、对外暴露接口,因为HTTP协议本身的生态非常成熟,浏览器、移动端、各种语言都有完善的客户端支持。
- RPC是面向方法的,强调的是“调用”,在内部服务间效率更高。它天然支持长连接复用,序列化协议可以选二进制格式,传输体积更小、性能更高。
- HTTP每次请求头部的开销较大,哪怕是一个只有几字节的业务数据,也要穿上几十字节甚至上百字节的HTTP头。RPC框架内部使用二进制协议时,这些冗余几乎不存在。
- RPC框架通常标配服务注册发现、负载均衡、熔断降级、链路追踪,HTTP方案要自己把这些能力一个个集成进去。
这不是说HTTP方案不行。很多创业团队初期就用REST接口硬撑,服务规模小的时候确实没问题。但服务数量到了几十个、调用链拉长之后,你会发现大量时间花在“写HTTP客户端封装”“维护服务地址列表”“处理重试超时”这些琐碎事上。RPC框架把这些都沉淀成了通用能力,团队可以更专注业务本身。
3.2 微服务调用链中的RPC定位
在典型的微服务架构里,一次用户请求往往要穿越多个服务节点。以电商下单为例:
- 网关服务接收用户请求。
- 网关调用用户服务获取用户信息。
- 网关调用订单服务创建订单。
- 订单服务调用库存服务扣减库存。
- 订单服务调用支付服务发起扣款。
- 支付结果回传给网关,网关再响应前端。
这里每个服务间的通信都可以是RPC调用。而一旦调用链变长,任何一个环节超时或异常都可能拖垮整个链路。这就是为什么RPC框架除了“调用”,还必须具备超时控制、重试机制、熔断降级和链路追踪这些附加能力。
这其实也解释了为什么单纯用HTTP Client自己做微服务通信走不远:你只解决了“能通信”,但没解决“通信出了故障怎么办”。RPC框架把这些故障处理逻辑内置了,开发人员不用每次都在业务代码里手写try-catch和重试机制。
3.3 服务化拆分后的痛点,恰好是RPC的主场
微服务带来的不只是拆分红利,还有一系列分布式问题:服务发现、负载均衡、容错、限流、分布式链路追踪。你会发现这些问题恰好都是RPC框架的主场。
- 服务注册发现:服务实例动态上下线,客户端自动感知。
- 负载均衡:调用方根据策略挑选合适的目标节点。
- 容错机制:调用失败自动重试、熔断、降级。
- 流量治理:部分框架内置限流、权重控制。
- 链路追踪:RPC上下文信息可以透传到追踪系统,还原完整调用链。
这些能力如果全部自己从零开始写,没有一两个月做不完,而且稳定性没保障。用成熟的RPC框架,等于把这些行业实践直接拿过来用。
4. 基于RPC实现微服务的完整实战过程
4.1 技术选型:Dubbo还是gRPC
先给结论:如果你身在Java技术栈,并且服务规模不算太小,直接选Dubbo;如果你有多语言协作需求,或者想紧跟云原生趋势,选gRPC。
Dubbo在国内互联网公司用得极广,生态成熟,服务治理能力强,和Spring Boot/Spring Cloud Alibaba的整合非常顺滑。它的玩法是“接口 + 实现”,服务方定义接口和实现,消费方引入接口依赖就能调用,几乎零学习成本。
gRPC是Google开源的RPC框架,基于HTTP/2和Protobuf,跨语言能力很强,C++、Go、Java、Python都有官方支持。它的玩法是先写.proto文件,然后通过工具生成各语言的客户端和服务端代码。学习曲线比Dubbo陡一些,但对于云原生、Kubernetes环境下的服务通信,gRPC是非常主流的选择。
我做了一个简单的对比表格,方便你参考:
| 对比维度 | Dubbo | gRPC |
|---|---|---|
| 默认序列化 | Hessian2 | Protobuf |
| 传输协议 | 自定义TCP协议 | HTTP/2 |
| 跨语言支持 | 较弱,主要面向Java | 强,多语言官方支持 |
| 服务治理 | 成熟(负载均衡、限流、熔断) | 基础能力有,需搭配Envoy等 |
| 学习成本 | 低,接口式调用 | 中等,需要掌握.proto |
| 云原生友好度 | 一般 | 高 |
| 适用场景 | 内部Java服务集群 | 多语言混合、边缘计算、网关等 |
4.2 实战环境准备
本次实战我以Dubbo 3 + Spring Boot 2 + Nacos 2作为组合来演示,这是目前国内最常见的微服务RPC方案。环境清单如下:
- JDK 8+
- Maven 3.6+
- Nacos 2.x(单机模式即可)
- Spring Boot 2.7.x
- Dubbo 3.2.x
- 一个Provider服务、一个Consumer服务
Nacos的启动很简单,下载压缩包后解压,进入bin目录执行startup.cmd(Windows)或startup.sh(Linux/macOS),单机模式不需要额外配置。
4.3 定义共享接口模块
RPC调用最关键的设计是“接口共享”。我通常把接口单独拆成一个Maven模块,比如叫api模块,服务提供方实现它,服务消费方依赖它。
先在api模块里定义一个用户查询接口:
java复制public interface UserService {
UserInfo getUserById(Long userId);
UserInfo getUserByName(String userName);
}
再定义一个实体类,注意一定要实现Serializable,因为RPC调用过程中对象要经过网络传输和序列化:
java复制public class UserInfo implements Serializable {
private Long userId;
private String userName;
private String email;
private Integer age;
// 省略getter/setter
}
这里有个新手特别容易忽视的问题:实体类和接口一旦发布,字段和方法的变更要非常谨慎。Provider和Consumer用的是同一份接口定义,如果接口改了而另一个服务还在用旧版本,就会导致反序列化失败或者NoSuchMethodError。所以接口模块建议做版本管理,改动不兼容契约时,要么新开包名,要么升级版本号并做好兼容。
4.4 服务提供方(Provider)实现
Provider侧要做两件事:实现接口,然后配置Dubbo暴露服务。
新建一个provider工程,pom里引入:
xml复制<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-spring-boot-starter</artifactId>
<version>3.2.x</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>你的公司坐标</groupId>
<artifactId>api模块</artifactId>
<version>1.0.0</version>
</dependency>
实现UserService接口:
java复制@Service
public class UserServiceImpl implements UserService {
@Override
public UserInfo getUserById(Long userId) {
UserInfo user = new UserInfo();
user.setUserId(userId);
user.setUserName("用户" + userId);
user.setEmail("user" + userId + "@example.com");
user.setAge(18 + userId.intValue() % 10);
return user;
}
@Override
public UserInfo getUserByName(String userName) {
UserInfo user = new UserInfo();
user.setUserId(10001L);
user.setUserName(userName);
user.setEmail(userName + "@example.com");
user.setAge(25);
return user;
}
}
然后在application.yml里配置Dubbo和Nacos:
yaml复制dubbo:
application:
name: user-provider
protocol:
name: dubbo
port: -1
registry:
address: nacos://127.0.0.1:8848
protocol.port设为-1表示随机端口,Dubbo启动时会选取空闲端口进行暴露。registry.address指向Nacos的地址,服务启动后会自动注册到Nacos。
启动Provider后,你能在Nacos的控制台服务列表里看到user-provider这个服务,说明注册成功。
4.5 服务消费方(Consumer)实现
Consumer侧只需要引入接口依赖,配置好注册中心地址,然后注入接口即可。
新建consumer工程,pom依赖与Provider类似,加上api模块依赖。关键配置:
yaml复制dubbo:
application:
name: user-consumer
registry:
address: nacos://127.0.0.1:8848
然后写一个测试接口来调用UserService:
java复制@RestController
public class UserController {
@DubboReference
private UserService userService;
@GetMapping("/getUser")
public UserInfo getUser(@RequestParam Long userId) {
return userService.getUserById(userId);
}
}
@DubboReference是Dubbo 3里的注解,用来声明远程引用。启动Consumer后,访问 http://localhost:8080/getUser?userId=1001 ,如果看到返回的JSON数据,说明RPC调用链路已经通了。
这里说明一下,Consumer调用远程接口时,并不是真的在本地执行UserService实现,而是通过动态代理生成了一个代理对象,这个代理对象会通过网络把请求发到Provider那边执行,再把结果返回给调用方。你在Consumer本地写的那段调用代码,只是碰巧看起来和本地调用一样。
4.6 配置超时与重试,保护你的调用链
生产环境下必须给RPC调用配置合理的超时时间和重试策略。Dubbo默认超时是1000ms,如果你的业务查询耗时经常超过这个值,就会直接报超时异常。
在Consumer侧配置:
yaml复制dubbo:
consumer:
timeout: 3000
retries: 2
也可以在@DubboReference注解上逐个接口配置:
java复制@DubboReference(timeout = 5000, retries = 2)
private UserService userService;
重试次数这里要特别提醒:如果下游服务是新增或修改操作,重试可能导致数据重复执行。比如订单服务调用支付服务时超时了,但支付服务其实已经扣款成功,此时如果Consumer盲目重试,用户会被扣两次钱。对这种非幂等操作,建议把retries设为0,宁可抛出异常让上层感知,也不要盲目重发。
我的一次真实教训:某个库存扣减接口没做幂等,重试配置为2,结果一次超时触发了三次库存扣减,库存直接变负数。后面排查半天才发现是重试策略导致的。所以超时和重试看起来是很简单的配置,但配错了一样能搞出生产事故。
4.7 服务编排:Consumer如何感知Provider的上下线
当Provider启动时,会向Nacos注册自己的IP和端口;Provider优雅停机时,会主动从Nacos注销。Consumer侧通过订阅Nacos上的服务列表,实时感知提供方节点的变化。
这里有两个细节值得关注:
第一,服务列表是异步刷新的。当Provider下线时,Consumer可能还会在短时间内继续向该节点发起调用。所以RPC框架一般都内置了故障转移机制:如果当前节点调用失败,会自动切换到其他可用节点。
第二,Provider的JVM崩溃退出,来不及向注册中心发注销请求。此时Consumer会一直把请求发到宕机节点上,直到注册中心的心跳检测机制判定该节点不健康并摘除。Dubbo默认的心跳检测间隔和超时时间对大多数场景是够用的,但如果你希望下线感知更快,可以适当调短Nacos的临时实例心跳配置。
5. 微服务RPC实战中的常见问题与排查技巧
5.1 服务一直注册不上Nacos
这类问题出现最多的原因有三个:Nacos地址配错、依赖版本冲突、服务启动顺序不对。
Nacos地址配错最好排查,看启动日志里报的连接异常就知道了。依赖版本冲突比较恶心,典型表现是启动时找不到Nacos相关类或者Dubbo相关类,一般是spring-cloud-alibaba和dubbo-spring-boot-starter的版本没有对齐。我建议统一使用Spring Cloud Alibaba的BOM进行版本管理,这样好控制。
服务启动顺序问题也很有迷惑性:如果你的Provider在Nacos还没完全启动时就试图注册,会失败。Nacos单机模式启动很快,但如果你用的是集群模式,一定要等所有节点都就绪后再启动业务服务。
5.2 调用时一直报超时,但服务明明正常
这种情况我见得多了。服务本身响应很快,但Consumer这边隔三差五报超时。可能的原因:
- Consumer和Provider之间的网络延迟高,比如跨机房调用、走公网调用。
- Provider所在机器负载高,CPU频繁争抢,请求处理被推迟。
- Provider的线程池被打满,请求在排队。
- Dubbo默认的1000ms超时对于慢SQL或慢接口不够用。
排查时先去Provider日志看实际响应时间,确认是否真的慢。如果Provider日志显示很快就执行完了,就要检查网络往返耗时,用ping和traceroute看链路质量。如果Provider日志显示耗时很长,那就是Provider本身的问题,需要优化业务逻辑、加缓存或者调大线程池。
还有一个容易被忽略的点:Consumer端的超时时间配置的是总耗时,包括网络传输时间、Provider处理时间、序列化时间。所以即使Provider执行只要200ms,但网络延迟高、或者序列化数据量很大,总耗时也可能超过1000ms。
5.3 序列化异常:ClassCastException或字段丢失
这类错误通常是Provider和Consumer的接口版本不一致导致的。比如Provider侧在UserInfo里加了新字段,但Consumer侧用的还是旧版本jar包,旧代码反序列化时遇到未知字段会报错;反过来如果Consumer侧新增了字段但Provider没更新,新字段就会是null。
解决办法:
- 尽量用统一版本管理,发布时把接口模块和实现模块一起升级。
- 实体类设计时预留扩展字段,或者用Map之类的灵活结构。
- 配置序列化框架的忽略未知字段选项。比如gRPC的Protobuf在遇到未知字段时会保留到UnknownFields里,不会直接报错。
- 修改实体类时不要在中间直接删字段,尽量采用增量式演进。
5.4 负载均衡不均,一台机器流量特别大
Dubbo默认的负载均衡策略是随机,理论上流量分布应该接近均匀,但实际情况中某些节点CPU高、某些节点低的情况很常见。原因往往是机器规格不同,或者服务实例的连接数不同。
如果机器配置差异大,可以改成加权轮询,给性能好的机器设置更大的权重。如果是因为Connections一直在同一个连接上复用,某些长连接上堆积的请求过多,可以调整Dubbo的连接数配置。有一种更优秀的做法是选择最小活跃数策略,它会优先把请求发给当前处理请求数最少的节点,在长耗时接口比例高的时候效果很明显。
5.5 链路追踪接入:RPC调用怎么排查整条调用链
微服务规模一上来,没有链路追踪系统根本没法排查问题。RPC框架的上下文信息可以很方便地接进链路追踪体系。
Dubbo可以通过Filter机制把traceId透传到Provider端,然后通过Zipkin、SkyWalking等系统进行链路数据上报。SkyWalking对Dubbo支持的很好,在agent配置里加上收集器地址,就能自动拦截Dubbo调用并生成链路信息。
我自己用得比较多的是SkyWalking + Nacos + Dubbo这套组合。线上排查问题时,只要输入订单号关联到traceId,就能一屏看到这个请求经过的所有服务和每一步的耗时。没有这套东西的话,微服务出了问题就是盲人摸象。
注意:链路追踪的数据上报本身也会有性能开销,生产环境建议采用采样上报,比如只上报一部分请求的链路数据,不要把全部请求的trace都灌进去。
6. 微服务RPC架构的演进趋势,以及我的选型建议
6.1 从RPC到服务网格:未来还有必要学RPC吗
这个问题的答案很明确:有必要,而且很重要。服务网格(Service Mesh)确实把很多通信能力下沉到了Sidecar代理里,看起来开发者不再需要直接面对RPC框架了,但底层的原理依然是RPC那套:服务发现、负载均衡、熔断、重试、序列化、网络传输。理解了RPC,你才能理解服务网格里那些配置项的意义,出了问题才不至于对着日志无从下手。
另外需要说清楚的是,服务网格并不会完全取代RPC框架。在很多企业内部,Dubbo和gRPC依然是主流选型。服务网格更多是和RPC框架共存演进,比如gRPC Mesh、Dubbo Mesh都是把RPC框架能力与云原生基础设施进行整合的方案。
6.2 我心中的RPC框架选型决策树
抛开复杂的架构理论,我直接给一套简单粗暴的选型思路:
- 纯Java团队,内部服务通信频繁,业务迭代快:选Dubbo,省心、成熟、社区资源多。
- 团队里有多种语言,服务边界清晰,需要严格接口约束:选gRPC。
- 业务已经上了Kubernetes,想借云原生生态扩展通信能力:选gRPC,配合Istio或Envoy做流量管理。
- 老项目已经用了Spring Cloud,不想换技术栈:可以继续用OpenFeign + HTTP,但规模大了之后强烈建议逐步替换为Dubbo或gRPC。
- 公司已有成熟的RPC中间件,并且业务团队已经习惯:不要轻易换,迁移成本远大于技术红利。
选型最忌讳的是“听说XX好就全公司推广”。我在不同公司见过多轮技术栈更替,每次都是一地鸡毛。技术选型要考虑团队熟悉度、运维基础设施、业务生命周期,而不是单纯比较技术指标。
6.3 从单体改造成微服务RPC架构,我的三条实操建议
第一,先拆分再上RPC。不要一上来就把所有服务间调用改成RPC。建议先把模块边界画清楚,把高频调用、链路清晰的服务优先RPC化,低频的或者和外部系统对接的部分保持HTTP更稳妥。
第二,接口契约先行。在动手编码前,先用接口模块定义好每个服务的对外接口,评审通过后再并行开发Provider和Consumer。这个习惯能减少大量的联调返工。
第三,监控和SRE一定要同步建立。RPC框架把网络细节藏起来了,但分布式故障的复杂度并没有消失,而是转移到了监控系统里。如果你的服务还没有接入日志聚合、指标监控、链路追踪,先别急着拆微服务,否则出了问题根本定位不到。
7. 从零落地的完整步骤回顾
如果你要在一个新项目里落地这套RPC微服务方案,按下面这个顺序走会比较顺:
- 部署Nacos并确认控制台可以访问。
- 创建接口模块,定义服务接口和数据模型。
- 创建Provider工程,实现接口并配置Dubbo + Nacos。
- 启动Provider,在Nacos控制台确认服务注册成功。
- 创建Consumer工程,引入接口依赖并配置注册中心。
- 在Consumer中通过@DubboReference注入接口,编写测试接口。
- 启动Consumer,调用测试接口验证链路。
- 配置超时、重试、负载均衡策略。
- 接入日志和链路追踪系统。
- 补充监控告警,完成生产部署。
整个过程踩过几次坑之后,我的个人体会是:RPC的技术选型和落地,真正的难点不在于“怎么调通”,而在于“怎么在复杂环境里稳定运行”。像超时重试、服务发现、幂等控制这些和生产稳定性强相关的细节,一定要在前期设计时就想清楚,而不是等出了事故再去补。
