1. 先聊个真实故障:RPC调用卡死30秒,日志里躺着connection timed out
昨晚凌晨两点,线上报警把我叫醒。支付网关的RPC调用接连失败,日志里躺着一条非常典型的错误:cannot finish rpc call in 30 seconds: null,紧接着是curl 56 recv failure: 连接超时。这个错误组合其实比我预想的更有信息量——它不是网络突然断开,而是调用方等够了超时时间后主动放弃的典型表现。
当时网关里的出账服务要调账户中心的余额扣减接口,平时P99延迟也就85ms,那天突然飙到20秒以上,最后直接超时。我照惯例先看了一下最直接的监控:目标服务的CPU和内存,看起来都很正常,Load也才0.2。然后我翻了网关到账户中心的连接池监控,发现活跃连接数一直是满的,连接队列排到了几十个。这就很有意思了:服务端看着没压力,为什么客户端这边所有连接都被占住?
继续往下查才发现,账户中心的某个数据库慢查询把线程池拖垮了。慢SQL本身执行了8秒,而账户中心的RPC处理线程池只有20个线程,8秒的慢查询很容易就把20个线程全部占满,剩下的请求全在排队。网关侧设置的RPC超时是30秒,所以请求超过30秒才会报cannot finish rpc call in 30 seconds。
排查到这里,我想起了一个经常被忽略的问题:RPC服务一旦定下来了,它的超时、线程池、上下游依赖的任何一个环节出问题,故障就会像滚雪球一样在调用链上蔓延。而这个事故,正好是今天这篇文章的起点——RPC和RESTful到底怎么选,怎么在同一个系统里共存,以及选了之后怎么避免类似的线上事故。
说实话,RPC和RESTful的争论在社区里已经持续了十年,但真正把这个问题问到底的开发者很少。大部分人只是听说"内部用gRPC,外部用RESTful"就完事了。这个结论没错,但远远不够。我自己在网关层、业务层、开放平台都做过接口设计,踩过不少坑,这篇文章想把经验完整梳理一遍:两种设计风格的本质差异、实际选型时的判断标准,以及如何在同一个系统中把两者融合起来,最后再回到RPC超时排障这个实操性最强的主题。
提示:文章里出现的故障现场、排查路径、参数配置,都是我基于生产环境真实经历提炼的,你可以直接参考,但一定要结合自己的业务流量和机器规格去调整,不要照搬数字。
1.1 从一行报错还原完整故障链路
先花点时间把这个报错彻底讲透,因为很多同学看到cannot finish rpc call in 30 seconds的第一反应是"网络问题",这其实容易把排查方向带偏。
一次RPC调用的完整链路上,至少有这么几层:
- 调用方应用层:业务代码发起调用,一个协程在等待结果。
- 调用方RPC框架:负责协议编码、超时控制、重试逻辑。
- 传输层:TCP连接,可能是短连接或长连接。
- 服务端RPC框架:负责接收请求、反序列化、把请求调度到业务线程池。
- 服务端业务代码:真正执行逻辑的地方。
cannot finish rpc call in 30 seconds这条日志,是由调用方RPC框架打出来的,意思是"我在30秒内没能完成这次调用"。它本身并不告诉你失败在哪一层。紧接着出现的curl 56 recv failure: 连接超时,看起来是一个独立的网络错误,但在我这个场景里其实是同一件事的不同表现。curl的56号错误是"接收数据失败",加上"连接超时"的提示,说明TCP层面的数据读取在这个时间点已经无法继续。后面的00 kib/s是传输速率监控,意思是传输吞吐已经归零。
把这几条信息拼在一起,故障链路就很清晰了:
- 数据库慢SQL,执行耗时8秒。
- 服务端线程池被占满,20个线程全部卡在慢查询等待上。
- 服务端不再及时返回数据,网关侧连接池里的连接被占用的时间越来越长。
- 新的调用拿不到可用连接,有的直接在连接池等待,有的在TCP层面就超时了。
- 调用方等够30秒,报出cannot finish rpc call in 30 seconds。
所以排查RPC超时,不要只看最后一条报错。真正要回答的问题是:在那30秒里,请求到底停在了哪个环节?这也就引出了接口设计里一个很重要的原则——可观测性。如果你的RPC框架不分层统计耗时,遇到这种问题就只能靠猜。
1.2 这次故障和接口选型有什么关系
这里说回接口选型。为什么一次RPC故障会让我想到RPC与RESTful的抉择?
因为我见过太多"这个接口本来用HTTP JSON也能干得好好的,非要用RPC框架,结果出了问题更难排查"的项目。反过来,也有"内部链路延迟要求几毫秒,用RESTful压测死活上不去,换RPC后性能直接翻倍"的项目。
接口选型不是"哪个高级用哪个",而是"哪个能让团队在运维、排障、扩展上花费最少的心智成本"。RPC和RESTful的本质差异,决定了它们各自适合的场景。如果一开始就把这两件事想明白,后面很多故障至少能少掉一半。
2. RPC与RESTful的本质差异:语义、契约与传输层的全面对比
很多人对RPC和RESTful的理解停在"一个传输快、一个可读性好"的层面。这个认知不算错,但太浅了。真正拉开差距的是三件事:语义模型、契约强度、传输特性。
2.1 语义模型:函数调用思维与资源操作思维
RPC的语义是过程调用。你在写代码的时候,感觉就像调用一个本地函数,服务端暴露的是一系列"动作"。比如用户服务里有GetUserById、CreateOrder、TransferBalance,调用方直接按函数名发请求。
RESTful的语义是资源状态。你操作的对象是URI代表的资源,动作由HTTP方法表达,返回的是资源状态或状态变更结果。比如GET /users/123、POST /orders、POST /accounts/123/transactions,调用的核心是资源,而不是函数。
RPC本质上是"动词思维",围绕服务能做什么来设计;RESTful是"名词思维",围绕系统有哪些资源来设计。这个差异看起来是哲学层面的,实际上会直接影响后续所有工作。
RPC适合"已经知道对方有什么功能"的紧密协作场景。同一个公司内部、同一个项目组、同一个中台体系里,服务之间对彼此的能力非常熟悉,动词思维效率最高。RESTful适合"服务方对调用方完全不设防、资源边界清晰"的开放场景,比如第三方开发者、跨部门不可信调用方。拿账户转账来说,RPC设计出来是transfer(fromAccount, toAccount, amount),RESTful设计出来是POST /accounts/{id}/transactions。两者在工程上都能跑,但放到浏览器、移动端、第三方开发者面前,RESTful的明显更容易理解和对接。
2.2 契约强度:IDL强约束与文档式默契
RPC的契约是强约束的。以gRPC为例,接口定义写在.proto文件里,通过protoc编译器生成客户端和服务端代码。参数类型、必填项、字段编号、枚举值,在编译阶段就确定了。调用方拿到生成的SDK后,传错一个字段类型根本编译不过去。Thrift也是同样的逻辑,IDL定义好之后,多语言代码一键生成。
RESTful的契约是文档式的。OpenAPI(Swagger)可以描述大部分接口细节,但它是"写给人看的约束",不是"编译器强制执行的约束"。调用方完全可以绕过你生成的SDK,直接用curl请求。好处是灵活,坏处是要靠团队自觉维护文档,文档一旦和代码脱节,吃亏的就是下游。
我经历过一件小事:某团队用RESTful暴露订单查询接口,文档里写的deliveryType取值是0和1。后来服务端为了兼容老逻辑,悄悄加了2这个枚举值,文档没同步。结果前端拿到2不知道什么意思,对比着旧逻辑猜,折腾了两天。这个错误如果发生在RPC里,IDL定义了枚举,服务端返回了新值,客户端反序列化就会失败或签名校验不通过,开发在联调第一天就会发现问题。
所以在我的经验里:跨团队、跨部门的接口,契约越强越好;开放平台、面对未知调用方的接口,契约可以适当放宽,但必须做好版本管理和废弃策略。
2.3 序列化与传输:二进制效率与文本可读性的取舍
性能差异是最容易被感知的,但很多人只记住了"二进制比JSON快",没想明白为什么快。
JSON序列化要做字符串解析、键名匹配。一个payload里有20个字段,就要反序列化20次键值对。protobuf或Thrift的二进制序列化,发送端和接收端共享IDL结构,传输时只编码字段序号和值,接收端按序号直接定位,完全省掉键名字符串的比较。
同样是订单结构,JSON序列化出来的体积大概是protobuf的3到5倍。在高QPS的内部接口下,这不仅仅是带宽成本,还是CPU成本——每秒钟几万个请求,每个请求都多花几十微秒解析,积少成多差异非常明显。
传输层上,gRPC基于HTTP/2,支持多路复用、首部压缩、双向流式传输。普通RESTful如果还跑在HTTP/1.1上,一个TCP连接同时只能有一个未完成的请求,并发一上来就得靠连接池堆连接。HTTP/2的多路复用让一个长连接里能同时跑几十个请求,这对内部服务间的密集调用非常友好。
我把RESTful和RPC在几个核心维度上的表现整理成了一张表,方便直接收藏:
| 维度 | RESTful | RPC(以gRPC为例) |
|---|---|---|
| 核心语义 | 资源操作(名词+动词) | 函数调用(动作) |
| 契约强度 | 文档式,靠OpenAPI维护 | 编译期强约束,IDL定义 |
| 序列化 | JSON/XML,可读性好,体积大 | protobuf,二进制,体积小 |
| 传输协议 | HTTP/1.1或HTTP/2 | 通常HTTP/2,支持多路复用 |
| 流式支持 | SSE、WebSocket等补充方案 | 原生支持双向流、服务端流、客户端流 |
| 调试便捷性 | curl直接可用,浏览器可访问 | 需要grpcurl、Postman扩展等专用工具 |
| 服务治理 | 依赖网关和中心化基础设施 | 框架自带或与注册中心深度集成 |
| 多语言支持 | 所有语言都有HTTP客户端 | 借助IDL生成多语言SDK |
2.4 服务治理生态:框架自带与基础设施补齐
RPC框架一般自带完整的服务治理能力:服务发现、负载均衡、健康检查、熔断、限流、链路追踪。比如Dubbo的注册中心机制,gRPC结合Consul或ETCD做服务发现,框架层面已经提供了完整的生命周期管理。
RESTful的服务之间通常通过网关或中心化负载均衡器做流量分发,服务自身的注册、发现要靠配套基础设施补齐,比如Nacos、Kubernetes Service、Consul。不是说RESTful不能做服务治理,而是它没有一个统一的、与接口定义绑定的治理模型,各团队实现方式五花八门,维护成本会明显高一些。
举个例子,同一个公司里,如果A团队的RESTful服务注册在Nacos,B团队的服务用Kubernetes Service暴露,C团队直接在负载均衡配置静态IP,那跨团队调用时,光是把服务地址找对就要花不少功夫。而如果用统一的gRPC + 同一个注册中心,服务发现和负载均衡的配置是标准化的,新服务接入的成本低很多。
3. 抉择的方法论:什么样的接口该用什么样式的协议
3.1 适合RESTful的场景
我见过的反面教材太多了:一个简单的用户信息查询接口,团队非要上gRPC,就为了"性能好一点"。结果要写IDL、配网关、做负载均衡,下游拿到SDK还得先升级依赖。本来一个HTTP GET就能解决的问题,硬是搞出了一套系统工程。
这些场景强烈建议RESTful:
- 面向外部的开放API,调用方可能是第三方开发者、浏览器页面、移动端H5。
- 接口数量不大、调用频率不高、性能要求一般。
- 需要被curl直接调试,需要人类可读的返回体。
- 团队规模小,没有专门的中间件团队去维护RPC基础设施。
我再补一个容易被忽略的判断点:你的调用方能不能方便地生成客户端?如果调用方是外部公司,你给他一个.proto文件,他还得先学protoc、再想办法生成对应语言的代码。给他一份Swagger文档,用现成的OpenAPI工具就能生成客户端,门槛完全不一样。
3.2 必须上RPC的场景
反过来,这些场景别图省事用RESTful:
- 内部服务间的高频核心链路,比如订单、库存、支付、账户。
- 延迟敏感型服务,比如搜索、推荐、风控。
- 需要流式传输的场景,比如大文件上传、实时日志推送、AI推理流式返回。
- 多语言团队协作,IDL能同时生成Java、Go、Python等多语言客户端。
一个很典型的案例:之前我负责过一个搜索中台,查询接口要返回大量结构化结果,RESTful JSON的体积让带宽成为瓶颈,而且每次都要做键名解析,CPU空转很严重。后来切到gRPC,同样的机器规格,QPS提升了将近一倍,延迟也从平均20ms降到了12ms左右。这不是说JSON有多慢,而是高频调用的场景下,序列化开销和带宽开销会被无限放大。
3.3 一张决策表
我整理过一张决策表,每次评审接口设计方案时直接拿来看:
| 判断维度 | 倾向RESTful | 倾向RPC |
|---|---|---|
| 调用方 | 外部开发者、浏览器、移动端 | 内部服务、可信调用方 |
| 请求频率 | 低频,每秒几十到几百 | 高频,每秒上千甚至更高 |
| 延迟要求 | 几十毫秒到秒级可接受 | 毫秒级,追求极致 |
| 数据结构 | 简单、嵌套浅、字段少 | 复杂、嵌套深、字段多 |
| 跨语言 | 客户端语言不确定 | 客户端语言已知,靠IDL生成 |
| 是否需要流式 | 否 | 是 |
| 团队运维能力 | 无专职中间件团队 | 有能力维护框架和基础设施 |
决策的核心原则不是"RPC是不是比RESTful快",而是"这个接口的调用方式、调用方、治理要求,是不是RPC范式能自然满足的"。把问题换成这个角度之后,很多纠结瞬间就消失了。
4. 融合实践:外网RESTful、内网RPC的分层架构设计
很多团队纠结"到底统一用哪个",其实从工程实践来看,最优解往往是分层融合:对外暴露RESTful,对内服务间用RPC。
4.1 分层架构的骨架:API网关与BFF层
以一个电商中台为例。接入层统一暴露RESTful API,通过API网关收口。网关后面是BFF(Backend For Frontend)层,负责把多个内部服务的数据聚合成一个适合客户端展示的对象。BFF再往下的服务间调用,都走gRPC。
这样设计的好处很明显:
- 对客户端友好:API文档清晰、调试方便、版本管理等都有成熟的RESTful生态。
- 对内部服务高效:gRPC的IDL和二进制序列化让内部调用低延迟、强契约。
- 变更影响可控:BFF层把内外数据结构隔离开,内部服务改字段不会直接波及客户端。
我见过一个团队在刚开始时图省事,内部服务全部用RESTful,外部也直接暴露这些服务。结果前端为了展示一个订单页面,要连续调6个不同的HTTP接口,每个接口都要做一次网络往返,页面首屏时间从1秒拖到了3秒。后来加了BFF层,内部切gRPC,一个聚合接口返回所有展示数据,首屏时间降回800ms。这就是融合的价值。
4.2 协议转换层的三个关键点
在做协议转换的时候,有几个细节特别容易踩坑。
第一,字段映射。RESTful返回的JSON和内部gRPC的proto结构往往不是一一对应的。你需要在BFF层做明确的映射逻辑,而不是简单地把proto转成JSON直接丢给客户端。否则,一旦内部服务改了proto字段名,客户端看到的就是一堆莫名奇妙的新字段。我一般会在BFF层定义独立的DTO(Data Transfer Object),用mapstruct或手写转换函数做显式映射,禁止直接把proto对象序列化后透传。
第二,错误码映射。gRPC的错误码是Google定义的Code枚举,比如NotFound、Internal、DeadlineExceeded。RESTful需要的是HTTP状态码+业务错误码的组合。我在实践中用的映射规则是:InvalidArgument映射到400,NotFound映射到404,DeadlineExceeded映射到504,Unavailable映射到503,Internal映射到500,PermissionDenied映射到403。业务错误码则在返回体的code字段里单独定义。
第三,超时传递。RESTful请求到达网关后,网关的技术超时一般设置在2秒。但BFF要调用对内的gRPC服务,如果gRPC超时也设2秒,而gRPC服务内部又有网络耗时,可能就超了。比较合理的做法是把超时分级:网关2秒、BFF到gRPC 1.5秒、gRPC服务内部再调用下游1秒。这个逐级递减的设置,才能保证调用链路上不发生"上游还没超时,下游先超时"的尴尬。
4.3 服务发现、文档与版本治理
融合方案里的服务发现,一般分成两套:对外RESTful由API网关管理路由规则和负载均衡;对内gRPC使用注册中心(如Nacos或Consul)做服务发现,或者如果跑在Kubernetes里,直接利用Headless Service的机制实现自动发现。
以gRPC为例,通常会借助ETCD或Consul做服务注册与发现:
resolver := consul.NewResolver() conn, err := grpc.NewClient( "consul://account-center/grpc", grpc.WithResolvers(resolver), grpc.WithTransportCredentials(insecure.NewCredentials()), ) defer conn.Close() client := accountpb.NewAccountServiceClient(conn)这套模式下,服务端启动时自动注册到Consul,客户端通过解析器获取可用地址列表,底层还会做健康检查剔除异常实例。配合网关层的RESTful路由,整个系统的服务发现链路是清晰的。
文档方面,RESTful接口必须维护OpenAPI文档,最好能在CI阶段自动生成并校验。gRPC接口的"文档"就是proto文件本身,团队内部需要一个proto管理规范,比如按服务模块划分目录、使用统一的版本管理方式,避免出现"改了一个.proto,所有下游都要跟着被迫升级"的问题。
5. 实操专题:RPC超时排障与稳定性设计
写到这里,必须回到文章开头那个故障,把超时、连接池、重试、熔断这些实战细节讲透。这是所有RPC项目里最核心的稳定性话题,也是我把网络热词里那些报错信息逐一拆开的落脚点。
5.1 超时参数设计:别只配一个全局超时
我在早期项目里就犯过错:全局设了一个30秒超时,心里想着"30秒够了吧",结果就是开头的故障——慢SQL让线程池堵住,所有请求全卡在30秒的悬崖边上。
正确的超时策略要分三层:
| 超时类型 | 建议范围 | 判断依据 |
|---|---|---|
| 连接建立超时 | 1~3秒 | TCP握手正常几十毫秒完成,超过1秒就该怀疑网络问题 |
| 请求处理超时 | 接口P99的2~3倍 | 比如P99是400ms,可设1秒 |
| 总调用超时 | 请求超时的3倍以内 | 包含重试在内的时间上限 |
每次调优前,先拿P99、P999延迟数据说话,不要凭感觉拍数字。我在一次调优中,把某个内部接口的请求超时从10秒缩到1秒之后,服务端的发动机水位明显下降了,因为大量慢请求被提前放弃,线程池不再被无效请求占住。
5.2 连接池与线程池的参数联动
RPC框架基本都内置连接池,默认最大连接数从几十到几百不等。连接池不是越大越好,因为每个连接都要占服务端的文件描述符、内存和线程资源。
开头的故障里,客户端侧活跃连接全满的直接原因,是服务端线程池被慢查询拖住了。连接池大小的设置,要和服务端线程数配合。一个通用的做法是:先设一个保守值起步,比如20,然后通过压测逐步调大,每次观察服务端的线程池活跃度、CPU、GC频率。如果服务端线程池经常打满,但客户端连接池还有余量,说明瓶颈在服务端线程数不够,得先把服务端的线程数和执行效率搞定,而不是继续加客户端连接数。
5.3 重试、幂等与熔断的配合
RPC框架一般支持自动重试,但如果在高并发场景下不做防护,一次故障可能直接演变成雪崩。
举例:A服务调用B服务,B服务已经快不行了,响应延迟变长。A的重试策略是"超时后重试一次",结果B的队列里同一个业务请求来了两遍,压力翻倍。B更慢了,A的重试更频繁,最终B被打挂。
正确做法:
- 重试必须配合幂等。RPC接口在业务层要做到同样的请求重复执行,结果一致。
- 重试次数尽量少,1到2次足够,而且故障高峰期要主动把重试降为0。
- 配合熔断器使用:连续错误率达到阈值(比如10%),直接短路,不再发请求到下游。
- 配合限流使用:网关层对关键接口做速率限制,保护下游。
我团队里现在的标准配置是:核心链路的RPC不开启自动重试,而是配合客户端的本地重试和全链路超时控制,宁可失败快速暴露,也不做无意义的重复请求。
5.4 把排障链路固化成checklist
那次凌晨故障之后,我做的第一件事不是调参数,而是把排障链路固化成一份checklist:
- 先看调用方RPC日志:超时是连接超时还是读超时?这决定是查网络还是查服务端。
- 再看连接池监控:活跃连接是否打满?打满的话,基本可以判断是服务端处理能力出问题。
- 看服务端线程池:线程是否全忙?忙的话,多半是某个上游依赖(数据库、缓存、第三方接口)慢了。
- 看数据库和缓存监控:慢查询、连接数、命中率。
- 最后才回到代码层面看嫌疑点。
这份checklist后来帮我处理了无数次类似的RPC故障。接口设计不是把接口写出来就完事了,可观测性、超时策略、重试策略、熔断降级,这些都是接口设计不可分割的一部分。你把RPC选型定下来的那一刻,这些事就已经跟着绑定好了。
我在实际做接口设计时,已经不太纠结"RPC好还是RESTful好"了。我把RESTful当成系统的门面,开放、可读、稳定;把RPC当成内部服务的高速公路,高效、强约束、低延迟。两者不是替代关系,而是不同边界上的不同工具。每次设计之前,先想清楚三件事:调用方是谁、延迟要求是多少、团队有没有能力维护对应的基础设施。想明白了,选择自然就出来了。