☰
RPC原理拆解与BRPC实战:从协议序列化到高并发调优
2026/10/21 7:39:28 网站建设 项目流程

那次线上事故至今印象很深。某个服务的P99延迟从30毫秒突然飙到800毫秒,第一反应是查慢SQL、查GC日志、查CPU监控,一圈下来全都没问题。最后把调用链拉出来,才定位到是下游一个RPC接口的超时重试配置不合理,多个请求叠加触发连锁等待。也就是从那时候起,我意识到很多后端对RPC的理解停留在"会调接口"的层面,对背后的协议封装、序列化、线程调度、超时语义这些底层机制完全没概念。

这篇文章就是围绕"RPC原理到底怎么回事"和"BRPC这个框架该怎么落地"来写的。前半部分拆解一次远程调用的完整链路,讲清楚协议、序列化、服务发现这些核心环节分别解决什么问题;后半部分直接用BRPC从零搭一个可运行的服务,然后讲参数调优、压测方法和线上排错思路。适合刚接触微服务、想搞懂RPC本质的后端同学,也适合已经用过一些RPC框架但没系统梳理过底层机制的人。我会尽量把每个"为什么"都说透,不搞玄学。

1. 从一次线上故障说起:RPC不是黑魔法,是工程问题

很多人在单机环境里写代码写习惯了,会觉得调用一个方法就是一行代码的事。但在分布式环境里,一次跨服务的调用要经过网络传输、数据编码、地址查找、异常重试等一系列环节,任何一个环节出问题,表现到业务层就是"接口变慢了"或者"调用失败了"。

那次事故的核心原因,说白了就是两个很容易被忽略的小问题叠加:一是下游服务的处理线程被慢请求占满,表现为偶发的长尾延迟;二是上游RPC客户端的超时时间设得过大,重试又在整体超时窗口内反复触发,导致大量请求堆积在等待中。这种问题在单机调用里根本不存在,因为单机方法调用要么成功要么抛异常,不会出现"不确定到底执行了没有"的中间状态。而远程调用不一样,网络可能丢包、服务可能重启、消息可能延迟,调用方必须面对一个残酷的事实:一个RPC请求发出去了,你并不知道它最终是成功了、失败了,还是只是慢一点。

1.1 那次延迟飙升事故的排查链路

先回顾一下完整的排查过程。最开始怀疑数据库,因为业务接口里有两个SQL查询,DBA反馈慢日志里没有新出现的慢查询,行数估算也没问题。接着怀疑缓存,检查命中率和超时配置,正常。然后怀疑机器,看了CPU、内存、IO、网络包量,也都平稳。

最后是把全链路追踪打开,逐个节点看耗时占比,才发现时间几乎全花在调用下游RPC服务的那一段上。再看下游服务的监控面板,发现它的平均响应时间其实只有5毫秒,但P99却高达300毫秒。这说明下游整体不慢,只是有小比例的请求被拖住了。而客户端这边的超时配了3000毫秒、最大重试2次,等于说一个请求最多可以等9秒,于是上游线程全被这些慢请求占住,雪球越滚越大。

这个案例给我的教训是:RPC调用的超时、重试、线程池大小,不是随便填几个数字就行的,它们之间有严格的联动关系。理解了原理,才能在设计阶段就避免这种坑。

1.2 单机调用的"想当然"在分布式环境里为什么失效

单机方法调用默认的可靠假设是:调用栈在同一个进程里、同一个内存空间,方法要么执行完返回结果,要么抛异常结束。整个过程耗时可能在微秒到毫秒级别,不存在网络波动,也不存在"对端是谁"的问题。

远程调用把这些假设全部打破了。首先,对端在另一台机器上,你得先把方法名、参数打包成一个消息,通过网络发送过去;其次,这个网络是共享的、不可控的,可能延迟、可能断开;最后,对端进程可能已经挂了,或者正在重启,你得有一套机制去发现它、绕过它、在它恢复后继续连上它。

所以RPC框架的本质,就是把这堆复杂且容易出错的分布式通信细节封装起来,对外提供一个"像调用本地方法一样调用远程服务"的接口。这句话每个RPC文档都会写,但只有当你真正去排查过一次线上故障,才会明白这个抽象背后埋了多少工程权衡。

2. 一次远程调用的完整旅程:协议、序列化、网络传输如何协同

理解RPC最好的方式,是跟着一个请求走完它的一生。假设客户端A要调用服务端B的GetUserInfo(user_id)方法,这个过程大致可以拆成七个步骤。

客户端业务代码调用的是本地代理对象,这个代理在RPC框架里叫stub。stub把方法名和参数打包成网络消息,这一步叫编码或序列化。接着通过底层网络模块把消息发到服务端,服务端收到后先按协议拆包,再反序列化成具体参数,然后定位到对应的业务方法对象执行。执行完后,结果再按同样的路径返回给客户端。客户端拿到响应数据,反序列化后交回业务代码。

这个过程你发现没有?它和打电话极其相似。你以为在跟对方说话,其实声音先被话筒编码成电信号,经过交换机和线路传输,再被对方听筒解码还原成声音。RPC里的stub就是话筒和听筒,交换机就是服务发现和负载均衡模块,线路就是网络传输层。

2.1 协议设计:为什么RPC必须解决"粘包"和"拆包"

网络传输层提供的是字节流,它不关心你的消息边界在哪。就好比你在一条传送带上不停放盒子,接收方必须知道每个盒子从哪里开始、到哪里结束。这个问题在TCP编程里叫粘包/拆包问题。

RPC协议一般会在每个消息前面加一个固定长度的头部,头部里记录协议版本号、消息类型、请求ID、消息体长度等信息,接收方先读头部,再根据长度字段读取完整的消息体。这是几乎所有RPC协议的基本设计,不管是二进制私有协议还是HTTP/2这种成熟协议,核心思路都是"用头部的元信息描述消息体"。

请求ID这个字段尤其重要。因为连接是复用的,同一个TCP连接上会同时跑很多个请求的响应,客户端必须能区分"这个响应对应哪个请求"。请求ID就是解决这个问题的,发请求时记录ID,收到响应时按ID匹配,和快递单号一个道理。

2.2 为什么主流RPC都在用二进制序列化而不是JSON

刚接触RPC的人经常会问:我直接用HTTP+JSON不也能实现远程调用吗,为什么还要搞Protobuf、Thrift这些序列化方案?

JSON确实可读性好,调试方便,但它有两个硬伤。第一是体积大,同样的数据用JSON表示,字段名、引号、逗号都要占字节;第二是解析慢,运行时要把字符串解析成对象,再按key查找字段。在高并发场景下,这两个问题会被放大到很明显的程度。

Protobuf这类二进制序列化的思路完全不同。它把字段名映射成数字编号,每个字段只用一小段空间表示编号和长度,不需要传字段名字符串;解析的时候直接按二进制布局定位字段,不需要字符串比较。我用过一个控制台服务,从JSON切到Protobuf后,单次请求平均耗时降了40%左右,payload体积也小了将近一半。

另外,Protobuf自带IDL定义文件,这个文件既是接口文档,又是代码生成器输入,服务端和客户端都能从中生成代码,天然保证了两边的数据结构一致。这种"契约先行"的开发方式,多人协作时特别省心。

2.3 服务发现、负载均衡与容错:调用方怎么找到服务方

一个RPC请求要发出去,首先得知道目标服务在哪台机器上、端口是什么。最简单的方式是配置文件里写死IP和端口,这在服务规模小、实例不流动时够用。但只要服务实例一变多、要扩容缩容,写死的地址列表就没办法维护了。

通用的做法是引入注册中心。服务端启动时把自己的地址注册上去,同时上报心跳;客户端启动时从注册中心拉取可用节点列表,并订阅变更。这样服务端挂了,注册中心会摘掉它的地址;新节点上线了,客户端也能及时拿到。负载均衡则是在客户端本地完成的,常见策略有随机、轮询、加权、一致性哈希,BRPC里在Channel的负载均衡策略里配置即可。

我自己项目里通常会在Channel初始化时指定命名服务地址,然后启动时打印一下拉到的节点列表,确认连接建立情况。这一步很多人忽略,等线上发现连的节点不对才回头查,浪费时间。

3. 选型对比与BRPC的核心设计:凭什么它能扛住高并发

市面上RPC框架不少,gRPC、Dubbo、Thrift、Finagle、BRPC各成一派。选型不是看谁名气大,而是看你的场景在哪里。

如果你追求跨语言、生态好,gRPC是很稳的选择,基于HTTP/2和Protobuf,浏览器、移动端都能直接调;如果你前后端都在某个技术栈里,Thrift和Protobuf的代码生成能带来很好的类型安全;如果你的服务发现、网关、治理都需要完整体系,Dubbo在Java系里产业链更完整。

BRPC的定位和它们不完全一样,它更偏向"高性能基础设施"这个角色。它在连接管理和线程调度上有自己的独特设计。这里我先不展开源码,而是从工程使用者的视角说说它跟"自己封装HTTP调用"有多大的差别。

3.1 自己封装HTTP调用和用RPC框架的差别

我见过不少团队早期图省事,直接在业务代码里用HTTP客户端请求服务,把URL拼接在代码里,参数用JSON序列化,异常处理靠try-catch。这套方案在服务少、调用量小的时候确实能跑,但一旦服务多了,问题就开始冒头。

没有统一协议,每个团队定义的URL风格、错误码、超时语义都不一样;没有内置负载均衡,只能靠DNS或者前置代理分发,实例变更时要手动改配置;没有重试和链路追踪,出问题了只能一台台机器登录上去翻日志。

RPC框架把这些公共问题统一收编了。比如BRPC一个Channel对象就是一个"对某服务的连接抽象",它会帮你管理连接池、健康检查、失败重试。你只需要关心业务参数和返回结果,通信细节跟你的代码基本解耦。

3.2 bthread调度与连接复用:BRPC高性能的两个支柱

BRPC性能好的原因,拆开来看主要是两个层面。

第一是bthread。这是一个M:N线程调度模型,把大量用户态协程映射到少量系统线程上执行。传统线程切换要陷入内核态,成本高;bthread的切换完全在用户态完成,配合栈的复用和缓存,单机支持几十万并发协程是很正常的事情。你的同步RPC代码写起来还是同步的,但底层会被拆成异步调度,等待响应时不会白白占着一个系统线程。

第二是连接管理。BRPC默认支持"单连接"模式,也就是一个客户端进程对同一个服务端进程只维护一个TCP连接,所有请求在这个连接上并发复用。这带来了两个好处:一是连接数大幅减少,不会出现几千个连接挤占文件描述符的情况;二是连接复用率高,规避了频繁建连和断开的握手开销。代价是这个连接上的所有请求要共用一个带宽窗口,所以BRPC为单连接模式设计了专门的流量控制机制。

这套设计非常依赖epoll和多线程协作,但只要用好了,效果非常可观。我压测的时候遇到过单进程客户端用single连接模式就能打满一个小型服务集群的CPU。

3.3 BRPC在工程化上的"开箱即用"能力

除了性能,BRPC让我觉得顺手的是它自带的那套排查工具。服务启动后,访问/status可以看到服务在线信息;/vars暴露了海量运行指标,包括QPS、延迟分位、错误数、连接数;/connections能直接看到当前所有活跃连接。这些能力不用额外搭建监控系统,对定位线上问题帮助极大。

我记得有一次排查内存增长问题,就是靠/vars里的一个连接指标发现连接数异常膨胀,顺藤摸瓜才找到是客户端连接池参数配错了。如果没有这些内置服务,光靠日志去推,可能得折腾一整天。

4. BRPC实战:从proto定义到第一台服务的完整过程

讲了这么多原理,现在动手搭一个真实可运行的服务。目标很简单:客户端调用一个Echo方法,传一个字符串,服务端原样返回。这个例子虽然小,但完整包含了RPC框架的所有核心元素:IDL定义、代码生成、服务端注册、客户端调用。

整个流程分四步走:环境准备、定义接口、实现服务端、接入客户端。

4.1 环境准备:依赖与编译

BRPC的依赖包括Protobuf、gflags、leveldb,以及gtest(编译测试时需要)。在Linux环境下,建议先装好这些基础库,再拉取BRPC源码编译。

# 安装基础依赖(以Debian/Ubuntu为例) apt-get install -y git g++ cmake make libprotobuf-dev protobuf-compiler libgflags-dev libleveldb-dev # 拉取BRPC源码并编译 git clone <BRPC官方仓库地址> cd brpc mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release -DWITH_DEBUG_SYMBOLS=OFF .. make -j8

编译时间取决于机器配置,通常几分钟到十几分钟。如果只是为了跑通核心功能,可以把-DWITH_DEBUG_SYMBOLS=OFF加上,减少符号文件写入的IO开销。注意BRPC的版本迭代比较活跃,建议直接使用当前最新的稳定分支,避免老版本和现代编译器不兼容的问题。

4.2 定义接口:Protobuf IDL决定了通信契约

服务端和客户端必须先商量好"说什么语言、说哪些话"。这就是proto文件的作用。我在项目根目录下建一个echo.proto:

syntax = "proto3"; package demo; message EchoRequest { string message = 1; } message EchoResponse { string message = 1; } service EchoService { rpc Echo(EchoRequest) returns (EchoResponse); }

这里注意几个细节。package demo决定了生成代码的命名空间;message是请求和响应的数据结构;service定义RPC方法。字段后面的数字1、2是字段编号,一旦发布出去就不要改动,否则会破坏已有客户端的兼容性。

生成代码的命令:

protoc --cpp_out=. echo.proto

执行后会得到echo.pb.h和echo.pb.cc,这两个文件包含了消息类和service骨架类的定义。

4.3 服务端实现:核心方法与启动参数

服务端的核心任务是实现proto里定义的接口,然后把实现挂到BRPC Server上去。

#include <brpc/server.h> #include <butil/logging.h> #include "echo.pb.h" class EchoServiceImpl : public demo::EchoService { public: void Echo(google::protobuf::RpcController* cntl_base, const demo::EchoRequest* request, demo::EchoResponse* response, google::protobuf::Closure* done) override { // ClosureGuard 保证 done 被调用,即使后面抛异常 brpc::ClosureGuard done_guard(done); response->set_message(request->message()); } }; int main(int argc, char* argv[]) { brpc::Server server; EchoServiceImpl service_impl; if (server.AddService(&service_impl, brpc::SERVER_DOESNT_OWN_SERVICE) != 0) { LOG(FATAL) << "Fail to add service"; return -1; } brpc::ServerOptions options; options.idle_timeout_sec = -1; if (server.Start(8888, &options) != 0) { LOG(FATAL) << "Fail to start EchoServer"; return -1; } server.RunUntilAskedToQuit(); return 0; }

几点说明:SERVER_DOESNT_OWN_SERVICE表示service对象由外部管理,Server关闭时不负责delete它,这样可以避免对象生命周期管理混乱;idle_timeout_sec = -1表示连接空闲不超时,压测时不会出现大量连接被主动断开的情况;Start的8888是监听端口,生产环境要注意端口规划。

4.4 客户端接入:Channel初始化与同步调用

客户端的核心是Channel。它负责建立连接、维护连接池、执行负载均衡和重试。

#include <brpc/channel.h> #include <brpc/controller.h> #include <butil/logging.h> #include "echo.pb.h" int main(int argc, char* argv[]) { brpc::Channel channel; brpc::ChannelOptions options; // 协议用baidu_std,这是BRPC默认的二进制协议 options.protocol = brpc::PROTOCOL_BAIDU_STD; // 单请求整体超时500ms options.timeout_ms = 500; // 最大重试次数 options.max_retry = 1; if (channel.Init("127.0.0.1:8888", &options) != 0) { LOG(FATAL) << "Fail to init channel"; return -1; } demo::EchoService_Stub stub(&channel); brpc::Controller cntl; demo::EchoRequest request; demo::EchoResponse response; request.set_message("hello brpc"); stub.Echo(&cntl, &request, &response, nullptr); if (cntl.Failed()) { LOG(ERROR) << "Call failed: " << cntl.ErrorText(); return -1; } LOG(INFO) << "Got response: " << response.message(); return 0; }

这段代码看起来和本地调用很像,但它背后做了很多事。channel.Init可以传入一个IP,也可以传入命名服务地址;cntl是BRPC的Controller,负责携带本次调用的元信息,比如超时、错误信息、附加数据;stub.Echo的最后一个参数传nullptr表示同步调用,如果想异步,就传一个Closure回调。

编译时把echo.pb.cc和客户端代码一起编译,然后链接BRPC:

g++ -std=c++17 echo.pb.cc client.cpp -o client -lbrpc -lprotobuf -lgflags -lleveldb -lssl -lcrypto -lpthread -ldl

跑起来之后,客户端会打印出Got response: hello brpc,这就说明整个RPC链路已经通了。

5. 参数调优与压测分析:同样的代码,性能为什么差三倍

很多人在BRPC上跑出来的性能数据差异很大,除了硬件因素,更多是参数配置的问题。同样的Echo服务,参数没调对可能只有几万QPS,调好了能飙到十几万甚至更高。我梳理三个影响最大的配置项,这三个都是线上经验最密集的地方。

5.1 超时、重试与连接类型:三个最容易踩坑的参数

先说超时。BRPC里的timeout_ms是"整个调用从发出到返回"的最大时间,不是单次网络往返的时间。这一点特别重要,因为它直接决定了重试行为。如果第一次请求用了300ms才失败,剩余时间只剩200ms,那重试机会就很少了,甚至等于没有重试。所以配置超时时,必须把重试时间预算算进去。

重试次数max_retry也不是越大越好。重试确实能提升短时故障下的成功率,但如果下游已经过载,重试只会加重雪崩。我一般建议核心服务max_retry=0或1,配合客户端快速失败和降级策略,而不是靠无限重试去硬扛。

连接类型connection_type有多个选项:single、pooled、short。short每次请求新建连接,适合低频短连接场景,但高并发下会浪费大量CPU在建连上;pooled用连接池,适合连接数可控的中型规模;single是单连接多路复用,连接数最少、资源最省,也是BRPC在高并发场景下最常用的配置。我压测时发现,从pooled切到single,客户端机器负载能降不少,因为TCP握手和端口占用都大幅减少了。

5.2 压测方法与指标解读:从P99看真实服务水平

压测RPC服务,不要只盯平均耗时和QPS。平均耗时会被大量快请求拉低,掩盖长尾问题。正确姿势是看分位值,特别是P99和P999。

我自己习惯用一个简单的压测工具循环发请求,同时记录每次调用的耗时,结束后计算分位数。也可以用BRPC自带的压测工具或者wrk这类通用压测工具。指标解读上,P99=20ms的意思是99%的请求在20ms内完成,剩下的1%可能慢很多。如果P99明显大于P50,说明服务存在周期性抖动或者线程竞争,需要深入排查。

压测时还要留意成功率。很多压测工具只看QPS和延迟,不统计错误码,导致结果虚高。我的做法是压测程序里同时统计非0返回码的比例,任何非零错误都视为失败,这样压出来的数据才真实可信。

5.3 内置监控与排查工具:/status、/vars怎么用

服务跑起来后,BRPC自带一个HTTP端口,提供多种内置服务。我最常用的三个是/status、/vars和/connections。

/status展示服务的基本状态,包括监听的端口、注册的service列表、是否在正常运行。运维新加节点时,我用它确认服务是否注册成功。

/vars是按名字暴露出的一组运行时指标,和监控系统的metric结构类似。里面有大量以rpc_开头的指标,比如rpc_server_elapsed_ns_percent系列可以看延迟分布,rpc_server_error_code系列可以看错误码计数。线上排查时,我会按变量名过滤,把异常指标和调用量变化对应起来。

/connections直接列出当前所有TCP连接的对端地址和连接状态。有一次我从这里一眼看出某个客户端地址的连接数异常多,随即联系对应团队调整了连接池配置。

6. 几个真实踩过的坑与排查思路

最后分享几个我在实际项目里踩过的坑。这些都不是什么高级技巧,但每一个都切切实实坑过团队,写出来算是帮大家省点时间。

6.1 线程阻塞导致整体延迟抬升的真凶

有一次服务改造,把同步调用换成异步以后,P99反而更差了。排查发现是业务代码里混用了同步阻塞调用,某个第三方库的API内部会阻塞线程等待锁,导致bthread worker被长期占住。BRPC的baidu_std协议在连接上是"顺序请求并发处理"的模型,worker被占满后,新请求就只能排队,表现为P99被不断拉高。

这个问题的排查思路是:先看/vars里worker队列深度或者线程状态,确认瓶颈不在CPU而在阻塞;然后把可能阻塞的调用逐个替换成非阻塞版本,或者给这些调用单独设置短超时,避免无限等待。现在我对所有线程池、协程模型都有同一个原则:不要在共享worker里放任何可能长时间blocking的调用。

6.2 超时重试设置不当引起的雪崩

开头提到的线上事故本质就是这个。客户端配了3000ms超时加2次重试,看起来是为了"更可靠",实际上当对端出现短暂故障时,所有请求都会卡满整个超时窗口,上游线程池迅速耗尽,整个服务跟着雪崩。

现在我的做法是分层控制:给RPC调用设置一个偏紧的超时,比如P99的5到10倍;重试次数严格控制,且只在"明确知道对端没收到请求"的情况下重试;配合快速失败的开关,让系统在异常时优先保护自己,而不是硬扛。这其实和熔断的思路是一致的:宁可局部失败,不要整体拖垮。

6.3 客户端连接打满后出现大量快速失败

还有一次是连接池参数没调好。客户端用pooled连接模式,但连接池上限设得很小,高流量一上来,连接全部被占用,新请求拿不到连接直接快速失败。现象上服务端CPU没满,客户端却大量报错,看起来像网络问题,实际上就是连接管理器不够用。

排查时从/connections看到连接数涨到上限就不再增长,报错集中在连接获取失败,马上就定位到问题。后来把连接类型切到single,同时提高连接池上限,再配合重试策略,问题就解决了。

那次之后我养成了一个习惯:每次新上线涉及RPC的服务,先做一轮小规模压测,把超时、重试、连接类型、线程数这几个核心参数都验证一遍,确认没有资源瓶颈再放量。RPC这东西,原理搞清楚了,参数调对了,其实是很好用的工具;怕的就是不理解内部机制,按直觉配置,出了问题都不知道该往哪个方向排查。希望这篇文章能帮你省掉当初我踩的那些坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询