最近在梳理微服务技术栈时,经常被问到同一个问题:微服务之间到底怎么通信? 其实答案绕不开一个老家伙——RPC(Remote Procedure Call,远程过程调用)。很多刚入门的朋友会把RPC单纯理解成“远程请求”,要么觉得它高不可攀,要么觉得它有REST就够了。但真正踩过微服务性能、协作、治理这些坑之后,你会发现RPC不是选项,而是基本功。这篇分享就围绕“RPC是什么”“为什么微服务绕不开RPC”“如何通过RPC把微服务串起来”展开,结合一套实际可跑的示例代码和真实踩坑记录,给打算上手或正在优化微服务通信的工程师一份可以直接参考的实战参考。
1. 微服务架构下的通信困局
1.1 服务拆了,通信问题就来了
单体应用时代,模块之间调用就是“方法里调方法”,一个进程内部的事情,没有网络开销,没有序列化成本,出了问题一个堆栈就能追到底。但当服务拆成独立进程、独立部署在不同机器甚至不同机房之后,“调用”这件事就变成了网络通信,整个复杂度瞬间上来了。
微服务架构图里,服务A要调用服务B,中间隔着的不只是网络,还有服务发现、负载均衡、超时重试、熔断降级、链路追踪这些“配套设施”。如果每次通信都用最原始的HTTP短连接去怼,开发时确实简单,但上了生产环境,连接数一多、链路一深,问题就会密集涌现:超时、雪崩、日志串不起来、排查无从下手。
我自己刚把系统拆成微服务时,第一版选的就是最朴素的RESTful API互相调用。当时觉得“HTTP是通用的,安全、兼容、谁都会写”,后来线上开始出状况:某个核心服务的QPS一上来,Tomcat线程池直接被下游慢接口拖死;排查时又拿不到上游调用的具体参数和响应时间,只能一台台机器翻日志。后来才明白,HTTP用于微服务之间的大规模通信,本质上是拿“通用性”去换“效率、可控性”,而这两样恰恰是微服务场景里最不能妥协的。
1.2 RPC能解决什么问题
RPC的核心思想其实很简单:让调用远程服务像调用本地方法一样自然。它屏蔽了网络编码、协议解析、传输细节,给你一个本地接口,底层自动把参数序列化、发送到远端、再把结果反序列化后返回。
这不是什么新鲜概念,1984年Birrell和Nelson就写了好几篇经典论文。但放到微服务架构下,RPC的价值被重新放大了:
- 性能优势:相比HTTP+JSON,像gRPC、Thrift这类RPC框架采用二进制编码,传输体积小、TCP长连接复用成本低,高并发场景下性能和资源占用差距非常明显。
- 接口契约化:通过IDL(接口描述语言)定义服务接口,生成代码,客户端和服务端强约束,接口不一致在编译期就能发现,不用等联调时对半天字段。
- 服务治理能力强:成熟的RPC框架自带注册中心、负载均衡、超时控制、重试、熔断等能力,这些都是微服务稳定性的底座。
一句话总结:在微服务架构里,RPC不是“会不会用”的问题,而是“用得好不好”的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次RPC调用的完整拆解
2.1 从方法调用到网络传输
当服务A调用服务B的某个方法时,表面上你写的是userService.getUserById(1001),但实际在底层走了一遍完整链条:
- 客户端拿到本地生成的代理对象(Stub),你以为调的是本地实现,其实代理在干活。
- 代理把你的方法名、参数类型、参数值、超时配置等等信息交给序列化器。
- 序列化器把内存对象转成字节流,交给传输层。
- 传输层通过TCP长连接/连接池,把字节流发送到服务端指定端口。
- 服务端接收到字节流后反序列化,根据方法名定位到具体实现类,真正执行业务逻辑。
- 返回值同样经过序列化、网络传输、反序列化,回到客户端代理。
- 客户端代码拿到返回结果,仿佛就是本地方法返回的一样。
整个过程里,对应用层开发透明的地方在于,你不用关心Socket怎么连、数据怎么封装、报文怎么解析,但你必须理解这些环节,因为线上问题恰恰都出在这些环节里。
2.2 实现RPC的最低配置
如果你要自己从零实现一个简化版RPC框架,面至少需要这些东西:
- 服务接口定义
- 代理对象(动态代理负责发送请求并封装结果)
- 传输协议(怎么区分一个完整的请求/响应,比如“4字节长度+内容”)
- 序列化方式(Java原生、JSON、Protobuf、Hessian等)
- 服务注册与发现(否则客户端不知道去哪找服务端)
- 异常处理和超时控制
这里面每条单独拎出来都是可以写几千行的工程,所以现实中很少有人徒手写RPC,基本都是站在成熟框架的肩膀上。下面我会用gRPC作为例子,走一遍“通过RPC实现微服务通信”的完整过程,因为gRPC是CNCF项目,结合Protobuf、HTTP/2、长连接,在微服务生态里接受度最高,也非常适合演示RPC的核心机制。
3. 实战:用gRPC实现两个微服务的通信
3.1 场景设定与架构
假设我们要实现一个最典型的微服务调用链:订单服务(Order Service)在创建订单时,需要读取用户信息,而用户信息由用户服务(User Service)独立维护。两个服务都是独立进程,订单服务作为gRPC客户端,用户服务作为gRPC服务端。
整体结构:
user-service:提供GetUserById接口,端口50051。order-service:通过gRPC客户端调用user-service,拿到用户信息后组装订单数据。- 注册中心:这里为了演示先不引入外部组件,直接用直连方式;生产环境会换成etcd/Nacos/Consul,原理一致。
为了更大程度贴近真实场景,我使用Go语言编写。Go在微服务生态里非常常见,写RPC服务代码量少、部署方便,特别适合演示这类机制。
3.2 定义IDL:一切从proto开始
先创建一个user.proto文件,定义用户服务接口和消息结构:
protobuf复制syntax = "proto3";
package user;
option go_package = "github.com/example/userpb";
message UserRequest {
int64 user_id = 1;
}
message UserResponse {
int64 user_id = 1;
string name = 2;
string email = 3;
}
service UserService {
rpc GetUserById(UserRequest) returns (UserResponse);
}
这里有几处值得解释。proto3是当前主流版本,字段编号= 1、= 2不是废话,它们是二进制编码里的唯一标识,字段名改了没关系,编号不能随便改,否则老数据会解析错乱。service关键字声明了服务的方法签名,后续客户端和服务端的代码都靠它生成。
编译proto时,我们需要protoc编译器以及对应的Go插件。执行命令:
bash复制protoc --go_out=. --go-grpc_out=. user.proto
生成的文件里包含两类内容:
user.pb.go:消息结构体(UserRequest、UserResponse)以及序列化逻辑。user_grpc.pb.go:服务端接口、客户端Stub。
这个过程体现了RPC框架的一个核心优势:接口契约由IDL统一管理,客户端和服务端各自基于同一份proto生成代码,字段不一致的问题在编译期就暴露,而不是留到联调时扯皮。
3.3 服务端实现:把业务逻辑注册到gRPC服务里
用户服务端代码要做三件事:实现我们从proto生成的接口、启动gRPC服务器、监听端口并接受请求。
go复制package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
pb "github.com/example/userpb"
)
type userServer struct {
pb.UnimplementedUserServiceServer
}
func (s *userServer) GetUserById(ctx context.Context, req *pb.UserRequest) (*pb.UserResponse, error) {
// 真实项目这里会查数据库或缓存,这里用内存数据模拟
log.Printf("received request: userId=%d", req.GetUserId())
return &pb.UserResponse{
UserId: req.GetUserId(),
Name: "张三",
Email: "zhangsan@example.com",
}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &userServer{})
log.Println("user-service listening on :50051")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
grpc.NewServer()可以传入很多服务端选项,生产环境一般会加拦截器做日志、鉴权、限流;在Go 1.22及之后的版本中,grpc.NewServer默认会自动注册grpc.health.v1.Health健康检查服务,方便探活。这里先演示最小可用版本,后续我们会谈到生产级配置。
3.4 客户端实现:像调用本地函数一样发起RPC
订单服务要调用用户服务,只需要创建一个gRPC连接,并构造客户端Stub:
go复制package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "github.com/example/userpb"
)
func main() {
// 生产环境这里会从注册中心获取服务地址,而不是写死
conn, err := grpc.NewClient("127.0.0.1:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(4*1024*1024),
grpc.MaxCallSendMsgSize(4*1024*1024),
),
)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := client.GetUserById(ctx, &pb.UserRequest{UserId: 1001})
if err != nil {
log.Fatalf("could not get user: %v", err)
}
log.Printf("got user: %+v", resp)
}
这段代码里有几个细节值得注意:
- 我用了
grpc.NewClient,而不用老的grpc.Dial。新版gRPC-Go里grpc.Dial已经被标记为废弃,NewClient默认是懒连接模式,即连接不会在创建时立即建立,而是第一条RPC发出时才真正握手。对于有些健康检查逻辑,你要么在启动时主动调用一次接口唤醒连接,要么显式指定grpc.WithBlock()等待建连成功,否则可能看到“rpc正在连接但状态一直空闲”的假象。 context.WithTimeout非常关键,它给每次RPC调用设了明确的上限。如果不设超时,一旦服务端卡死,客户端可能会无限等下去,这种问题在微服务调用链里会成倍放大。- 每家公司的生产实践中,对消息大小上限都会做控制,
MaxCallRecvMsgSize和MaxCallSendMsgSize就是在客户端侧控制消息体积防患于未然,避免把不必要的内存压在RPC链路上。
3.5 编译运行与验证
服务端和客户端都写好后,分别启动:
bash复制go run user-service/main.go
go run order-service/main.go
客户端日志输出:
code复制got user: userId:1001 name:"张三" email:"zhangsan@example.com"
到这里,一个最小可用的RPC微服务通信链路已经跑通。整个过程里,订单服务对用户服务的调用,代码上跟调用本地方法几乎没有区别,但远程通信、序列化、网络握手都由gRPC框架自动完成了。
4. RPC框架选型到底在选什么
4.1 gRPC、Thrift、Dubbo、HTTP对比
很多人在选型时,第一反应是“哪个快选哪个”。但真实业务里,快不是唯一标准,生态、语言支持、团队熟悉度、服务治理整合能力,每一项都非常重要。这里给一张我个人的经验对照表:
| 维度 | gRPC | Apache Thrift | Dubbo | HTTP+JSON(REST) |
|---|---|---|---|---|
| 序列化 | Protobuf(二进制,体积小,解析快) | Thrift协议(二进制,但实现多样) | Hessian / JSON等可插拔 | JSON(文本,体积大) |
| 传输层 | HTTP/2(多路复用、二进制帧、双向流) | Socket / HTTP | TCP长连接 | HTTP/1.1或HTTP/2 |
| 跨语言 | 优秀(官方/社区支持语言非常多) | 较好,但代码生成工作流较重 | 主要为Java服务生态服务 | 天然优秀 |
| 服务治理 | 需结合外部组件(Consul/etcd/K8s) | 需自研或第三方集成 | 内置注册中心、负载均衡、容错 | 几乎全部自建 |
| 学习门槛 | 中等,proto语法简单 | 中高,IDL和生成代码较繁琐 | 较低(对Java团队) | 最低 |
| 适合场景 | 云原生、多语言微服务、流式通信 | 多语言、对吞吐有极致要求 | Java技术栈内部服务 | 外部开放API、简单场景 |
在我看来,选型的真正逻辑是:如果团队统一Java技术栈,Dubbo是很成熟的国内方案;如果微服务要跨语言、要云原生,gRPC基本是当前的最优解;如果只是对外暴露接口给前端或第三方,REST/HTTP不会消失。 RPC解决的是服务间通信,REST解决的是系统间互操作,两者不是对立关系,而是各自待在自己的生态位上。
4.2 同样是RPC,为什么gRPC能被大规模应用
gRPC不是第一个RPC框架,但它把很多最佳实践沉淀下来了。首先是HTTP/2,它解决了HTTP/1.1队头阻塞和连接利用率低的问题,一条TCP连接上可以并发多个Stream,大大减少了连接总数。其次是Protobuf定义接口,字段省掉了大量冗余信息,同样的接口调用,传输体积可能只有JSON的十分之一。再者是流式通信支持,包括服务端流、客户端流、双向流,这让一些需要持续推送数据的场景(比如实时监控、订阅通知)变得特别自然。
我在一段时间里用gRPC和REST各搭过一套用户服务,gRPC的响应时间在压力测试下大约比REST平均低30%到50%,CPU占用也要低不少。这个差距在高并发链路里是实实在在的成本。
5. 注册中心、负载均衡与高可用
5.1 客户端拿到的不应该是一个IP
我这几年看很多人写微服务,RPC通信地址是硬编码的。演示阶段可以,生产环境一定不行。因为服务实例会扩容、缩容、故障重启,IP是动态变化的。要让RPC调用自动化地找到可用服务,必须引入注册中心。
注册中心的角色简单说就是“服务通讯录”:服务提供方启动时把自身IP、端口、元数据注册上去,关闭时注销,同时上报健康状态;服务消费方从注册中心拿到可用节点列表,再通过负载均衡选一个节点发起调用。
常见注册中心选择:
- etcd:分布式键值存储,配合gRPC生态非常好用,Kubernetes内部也用它存数据。
- Consul:自带健康检查、多数据中心能力,Web UI友好。
- Nacos:国内流行,同时支持注册中心和配置中心,对Dubbo、Spring Cloud都很友好。
5.2 客户端负载均衡是怎么做的
以gRPC Go为例,注册中心一般会提供一个Resolver接口,把服务名解析成节点列表。gRPC内置了pick_first、round_robin两种负载均衡策略。pick_first是逐个尝试直到成功,round_robin是轮询分发。
生产环境里更推荐使用round_robin,并配合健康检查剔除异常节点:
go复制conn, err := grpc.NewClient(
"dns:///user-service:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
这里的dns:///是一种内置解析器,用DNS做服务发现,适合部署在Kubernetes环境里,通过Service域名自动解析到后端Pod。如果使用etcd做注册中心,通常需要引入对应Resolver插件,比如github.com/etcd-io/etcd/client/v3配合自定义Resolver。
5.3 高可用三板斧:超时、重试、熔断
RPC调用要变得高可用,光靠连接复用远远不够,必须从调用策略上做兜底。
- 超时:每个RPC调用都必须在上下文中设置超时,否则网络分区或下游卡死时,资源会被无限期占用。超时值要结合业务耗时设置,比如普通查询300ms到1s,写操作可以适当放宽,但建议有硬上限。
- 重试:对于幂等查询接口,遇到瞬时错误(如连接中断、超时)可以重试,但重试次数要克制(建议1到2次),否则下游抖动时你会成为“流量放大器”。重试要配合退避策略,比如指数退避加上随机抖动。
- 熔断:当下游错误率超过阈值时,熔断器直接打开,后续请求快速失败,不再打到下游,给下游留出恢复时间。类似Sentinel、Hystrix或gRPC拦截器可以实现熔断逻辑。
主题里提到“alibaba sentinel:深度解析微服务高并发流量治理”,这确实是RPC调用链上另一个大话题。核心的思路就是在RPC入口处拦截流量,对依赖资源做隔离、限流、降级,防止一个下游故障拖垮整个调用链。如果你的RPC服务已经成了核心链路,一定不要跳过这层防护。
6. 常见RPC故障排查与实战实录
6.1 rpc调用30秒超时
热搜词里的cannot finish rpc call in 30 seconds,我遇到过,不过通常出现在一些配置了默认超时30秒的框架中(比如某些Java框架默认超时30秒)。问题往往不是“RPC不够快”,而是下游处理太慢或网络抖动导致请求没在超时窗口内完成。
排查思路:
- 先确认是不是下游代码本身慢,查看日志里方法执行时间。
- 看网络链路,是不是跨可用区调用,延迟本身就高。
- 看连接池,连接是否已被占满,新请求排队等待。
- 最后再考虑调大超时值,但不要轻易调大,因为超时调大意味着故障恢复时间变长,熔断反而更重要。
6.2 curl 56 / RPC连接中断类错误
热搜里的rpc failed; curl 56 gnutls recv error或schannel: server closed abruptly,表面是Git传输问题,但底层的“连接被服务端关闭”和RPC连接中断本质上很相似。在RPC场景里,如果客户端报连接中断,通常是这几个原因:
- 服务端崩溃或重启,旧连接被内核RST掉。
- 服务端空闲连接因防火墙策略被清掉,客户端还不知道。
- 客户端使用了过期的负载均衡节点信息,连到了一个已下线的实例。
解决这些问题的常用办法:给连接加keepalive,定期主动探测;客户端监听连接状态变化;注册中心及时下线故障实例。gRPC的keepalive配置一般长这样:
go复制grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 3 * time.Second,
PermitWithoutStream: true,
})
6.3 排查RPC问题必备手段
排查RPC问题,第一件事是把链路信息串起来,不然你会陷入“下游说我没问题,上游明确报错”的僵局。
一定要做的三件事:
- 全链路追踪:给每个RPC调用注入TraceID,从网关入口一直透传到下游服务,日志里带上TraceID。这样查问题可以从调用链入口一秒定位到具体节点。
- 出口和入口日志:在RPC客户端记录请求和响应摘要(方法名、耗时、状态、错误信息),服务端同样记录。两端日志对齐后,误差一眼可见。
- 指标监控:RPC调用量、成功率、P99延迟、慢调用数,都用Prometheus这类指标系统接好。出现问题时先看指标,再翻日志,效率远超凭感觉排查。
7. 生产级RPC微服务通信踩坑总结
7.1 六个必须做与六个不要做
先说必须做的事:
- 统一使用IDL管理接口契约,客户端和服务端从同一份proto生成代码。
- 每个RPC调用都设置超时,并配套合理的重试和熔断策略。
- 注册中心做服务发现,绝对不要在生产使用硬编码IP。
- 线上开启gRPC健康检查,配合负载均衡定期剔除不健康节点。
- 在上报指标时打上服务名、方法名、节点IP等维度,方便聚合分析。
- 对请求消息大小和连接数都做好限制,防止异常流量打爆内存。
不要做的事:
- 不要无限调大超时,超时越短越容易容灾。
- 不要在非幂等接口上随便重试,下单、转账这种操作要尤其小心。
- 不要同步调用一个不需要同步结果的RPC,能异步尽量异步。
- 不要忽略上下文取消,
context被取消时必须及时关闭连接、释放资源。 - 不要让多个服务共享同一份proto而各自维护副本,版本零散后就是灾难。
- 不要一开始就引入太多服务治理组件,链路复杂了你都定位不了是自己写的有问题还是框架有问题。
7.2 一个小经验:从“RPC报错”到“链路可观测”
早期我遇到RPC报错时,第一反应是连到服务器上抓包、打日志,折腾半天。后来我养成了一个习惯:每接一个RPC框架,先把链路追踪和基础指标做了,再写业务代码。 这不是浪费工时,而是为未来所有排查工作铺路。只要TraceID、服务名、方法名、状态码被标准记录,线上99%的问题都能在五分钟内缩小到具体服务和具体方法。
还有一个小技巧:上线新的RPC服务,不要一上来就接核心流量。先在测试环境跑一轮全链路压测,把超时、重试、限流的参数调到一个相对正常的范围,再逐步放量。这样可以避开多数低级问题。
8. 延伸:从RPC到微服务治理
8.1 RPC不是全部,但RPC是一切的入口
有些团队认为微服务落地就是拆服务、用gRPC互相调用,这其实只完成了第一步。RPC之上的治理才是真正让系统稳定运行的关键。比如服务间依赖关系越来越复杂之后,靠人工维护调用关系完全不现实,需要引入自动化拓扑展示。再比如某个服务的某个接口出现慢调用,通过RPC拦截器自动统计并限流,能最大限度避免故障扩散。
Sentinel这类流量治理组件,本质上就是在RPC调用链路上做“交通管制”。它可以针对不同服务、不同方法设置QPS阈值和并发线程数阈值,当流量超过阈值时快速降级,保护下游不被撑爆。把RPC与这类治理能力结合起来,微服务的“高并发流量治理”才真正落地。
8.2 越简单的通信,越要重视契约
最后想强调一点:RPC让远程调用像本地调用一样简单,但也容易让人忽略它和本地调用本质的区别。本地调用没有网络延迟、没有部分失败、没有消息乱序,RPC全都有。所以每一层细节,都要怀着“网络不可靠、服务可能崩、消息可能迟到”的心态去设计。
写RPC服务时,我总是会推演一遍极端情况:如果下游服务挂了,我的服务会怎样?如果下游返回变慢,我的线程池够不够?如果超时时间设置太大,故障恢复会不会遥遥无期?把这些问题想清楚,RPC带来的便利才是真正的便利。否则,你收获的只是一套看起来高大上、跑起来全是事故的微服务架构。
我在实际项目中最大的体会是:RPC框架本身带来的性能差距,远小于使用姿势带来的稳定性差距。 与其纠结框架选型,不如先把超时、重试、熔断、可观测这些基本功做实。这也是我这次分享的核心想法——技术选型可以随团队和业务变化,但围绕RPC的治理意识,永远是微服务从业者必须具备的底层素养。
