我真的是太爱学习了——这种念头冒出来的时候,通常不是因为刚学会了一个妙招,而是因为刚被一个不算难的框架折腾到凌晨一点。最近这个对象就是 gRPC。很长一段时间里,我提到 gRPC 都是能躲就躲:要写 .proto、要装 protoc、跨语言还得处理各种插件版本,光是 Windows 上编译 C++ 库这件事就能劝退一大半人。直到我给自己立了个 flag,说不管多难都把它整理成一套能回看的笔记,结果一进去就出不来。这篇就把我从零开始学 gRPC 的过程、踩过的坑,以及在 Go、Spring Boot、Hyperf、Python 四个栈里实测的体会全写出来,给正在学或者正准备学 gRPC 的同学当个参照。
1. 为什么 gRPC 值得花时间啃,而不是继续用 REST
很多人第一反应是:我现在 REST 用得好好的,HTTP + JSON + Swagger 也够用,为什么非要学个 gRPC?这问题我一开始也问过自己,后来学完才发现不是"能不能调"的问题,而是"大规模多语言团队里调得爽不爽"的问题。
1.1 先说结论:gRPC 解决的是技术债和协作成本
gRPC 不是一个简单的"更快的 HTTP",它是基于 HTTP/2 的远程过程调用框架,配合 Protocol Buffers(通常叫 protobuf)作为默认序列化协议。核心思路是用一个.proto文件把接口当成"契约"定死,消息结构、字段编号、服务方法全在里面,然后通过代码生成器自动生成各个语言的客户端和服务端骨架代码。
没有契约之前,REST 接口怎么写的?后端说"我返回一个用户对象",前端去翻文档,可能字段名是userId,另一个版本又变成user_id,改来改去,两边联调成本全花在扯皮上。有了.proto,数据是一行行定死的:字段几号、什么类型、是必填还是可选,生成出来的代码不会说谎,接口行为在编译期就能看出问题。
对我来说最有价值的一点是:所有参与方用的是同一份协议文件。Go 团队、Java 团队、Python 团队各看各的生成代码,谁也不会再"我以为你返回的是字符串"。
1.2 和 REST 对比,三个让我"真香"的点
第一是性能。protobuf 是二进制序列化,体积比 JSON 小得多。我之前压测过一个几百 KB 的 JSON 列表,换成 protobuf 之后请求体只有原来的五分之一左右,HTTP/2 多路复用还能在同一个连接上并发多个请求,避免了 HTTP/1.1 的队头阻塞。
第二是流式支持。REST 做流式很难受,要么 WebSocket 要么 SSE 自己造轮子。gRPC 天生支持四种调用形态:普通一元调用(Unary)、服务端流、客户端流、双向流。做实时推送、聊天、音视频帧处理这类场景,就不用再拼第二套通信协议了。
第三是代码生成。以前写 REST,客户端要自己封装 HTTP 请求、解析 JSON、拼参数;gRPC 里全是生成好的强类型方法,比如 Java 那边直接拿到GreeterGrpc.GreeterBlockingStub,调sayHello(request)就是一次 RPC。IDE 自动补全、编译期检查、错误处理全都在,省掉大量模板代码。
1.3 一个最小化判断清单:什么项目适合上 gRPC
也不是说所有项目都该换成 gRPC。我给自己划了个标准:对内微服务之间调用,或者跨语言协作、流量密度比较高、需要流式通信的场景,直接上。对外浏览器直接访问,或者接口简单到只有两个 CRUD,那继续 REST 完全没问题。gRPC 不是银弹,它最大的成本是引入了一套新的 IDL 和工具链,团队要愿意接受这套工作流,后面才会越用越舒服。
2. Windows + Visual Studio 编译 gRPC:比项目逻辑更折磨人的一关
我学 gRPC 计划里本来没有"编译 gRPC 源码"这一项,但后来在 C++ 项目里需要用启动代码生成插件,加上我又想在本地写点 C++ 例子,就一头扎进了 Windows 编译。这一关能刷掉的人比 gRPC 语法本身多得多,我把自己的处理过程完整写出来。
2.1 我为什么非要自己编译
直观原因是想跑通一个完整 C++ 环境。但更现实的原因是:gRPC C++ 生态在 Windows 上没有太多官方预编译产物,很多第三方包也经常跟不上版本。当时我用的示例代码是基于 v1.62 的 API,包里内置版本如果是旧的,接口都对不上。最后只能从源码编译,或者用包管理器帮你编译。
2.2 vcpkg vs CMake:两条路径的利弊
在 Windows 上最省心的是 vcpkg。安装完之后直接:
vcpkg install grpc:x64-windows它会帮你把 protobuf、abseil、c-ares、re2、zlib 等一大串依赖全部编译出来。好处是省心,坏处是第一次编译时间非常夸张——我当时笔记本风扇狂转了一下午,全程看着 CMake 刷屏。另一个缺点是大量依赖被编进静态库,最后自己的项目要集成时还得处理链接器参数。
想手动控制版本和编译选项,就克隆官方仓库跑 CMake:
git clone --recurse-submodules -b v1.62.0 https://github.com/grpc/grpc cd grpc cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=build/install cmake --build build --target install这条路的问题在于子模块太多,速度慢,而且 abseil 和 gRPC 的 CMake 配置一旦版本不匹配,你会看到一堆莫名其妙的编译错误。我第二次编译时改用 vcpkg,直接省下大半时间。
2.3 编译现场踩过的坑和解决记录
下面这几个问题,我怀疑只要在 Windows 上编译过 gRPC 的人,十有八九都遇过:
| 现象 | 原因 | 处理方案 |
|---|---|---|
| 编译过程中文件路径过长报错 | Windows 默认 260 字符路径限制 | Git 里执行git config --global core.longpaths true,并开启系统长路径支持;同时把源码放到C:\grpc这种短目录 |
| 找不到 Visual Studio 或 Windows SDK | 没有安装 C++ 桌面开发组件 | 打开 Visual Studio Installer,勾选"使用 C++ 的桌面开发",确认 Windows SDK 版本正确 |
| 链接时 abseil 或 re2 一堆 unresolved symbol | 编译出来的静态库和自己项目的运行时库不一致 | 保持 vcpkg triplet 和项目配置一致,比如统一用动态 CRT,或用x64-windows-static配-DMSVC_USE_STATIC_RUNTIME=ON |
| protoc 版本太旧 | 系统 PATH 里有老版本 protoc | 显式指定编译产物中的 protoc 路径,或用vcpkg提供的版本 |
还有一个隐藏坑:如果用 vcpkg 安装的 gRPC,在 Visual Studio 里要把VcpkgEnableManifest开起来,否则 IDE 找不到头文件。最省事的命令是vcpkg integrate install,之后 Visual Studio 会自动识别 include 路径和连接配置。
2.4 不想自己编译的备选方案
如果只是想写业务代码,完全没必要死磕源码编译。Go 里直接通过 go mod 拉官方库;Java 和 .NET 都有 NuGet 或 Maven 包;Python 直接 pip 装grpcio,这些官方 wheel 都是编译好的。只有 C++ 这块生态比较原始,一时半会儿绕不开。我的最终建议是:先跑通一门主流语言,理解了 RPC 链路之后,再回头处理 C++ 构建问题,心情会好很多,排错也会更快。
3. Golang Hello World:把 protobuf 到请求链路一次跑通
Go 是被公认为最适合入门 gRPC 的语言,官方示例多、插件齐、错误提示清楚。我建议所有人的第一步都从 Go 开始,因为它的链路短:写完 proto,生成代码,起一个 server,再写一个 client,大概半小时就能看到效果。
3.1 环境准备与 proto 文件
先装一个protoc,再去 Go 里装两个代码生成插件:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest常见的问题是安装完之后 PATH 里找不到protoc-gen-go。Go 默认会把GOBIN放到~/go/bin,确保它也加进系统 PATH。然后写一个最简单的hello.proto:
syntax = "proto3"; package helloworld; option go_package = "grpc-hello/helloworld"; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; }这里go_package千万别省。早期我写过不带这个包的 proto,生成的 Go 文件里 import 路径乱得一塌糊涂,编译起来全是 cannot find module。
3.2 生成代码与 server/client 实现
在项目根目录执行:
protoc --go_out=. --go-grpc_out=. --go_opt=paths=source_relative \ --go-grpc_opt=paths=source_relative hello.proto会生成hello.pb.go和hello_grpc.pb.go。服务端实现起来很直接:
package main import ( "context" "log" "net" "google.golang.org/grpc" pb "grpc-hello/helloworld" ) type server struct { pb.UnimplementedGreeterServer } func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) { return &pb.HelloReply{Message: "Hello " + req.GetName()}, nil } func main() { lis, _ := net.Listen("tcp", ":50051") s := grpc.NewServer() pb.RegisterGreeterServer(s, &server{}) log.Fatal(s.Serve(lis)) }客户端则要注意连接凭据。新版 gRPC 里grpc.WithInsecure()已经被删了,要用:
conn, _ := grpc.NewClient("127.0.0.1:50051", grpc.WithTransportCredentials(insecure.NewCredentials())) defer conn.Close() client := pb.NewGreeterClient(conn) resp, err := client.SayHello(context.Background(), &pb.HelloRequest{Name: "Aoi"})这里我特意用了grpc.NewClient,因为从 grpc-go v1.63 开始官方推荐它替代grpc.Dial。版本差异很容易把上网抄的旧代码直接报红。
3.3 第一个坑:版本不一致带来的静默失败
我实际踩过的一个坑是:protoc-gen-go和protoc-gen-go-grpc版本都很新,但项目里grpc库是旧的,结果生成的接口和服务端怎么都对不上。还有一次是 proto 文件更新了,我忘记重新生成代码,客户端调了半天还是老接口,最后才发现少了--go-grpc_out这条命令。这类问题没有统一报错,排查思路只能是:
- 先看
.pb.go文件头部的protoc-gen-go v版本号; - 再看 go.mod 里 grpc 版本是否和生成代码要求的版本相匹配;
- 改了 proto 之后,养成"生成代码"和"编译"同时完成的肌肉记忆。
3.4 Go 生态的 gRPC 体验小结
在 Go 里,gRPC 基本是亲儿子待遇。社区维护活跃,官方示例多,拦截器、负载均衡、健康检查、反射协议都有现成实现。对于刚接触 gRPC 的同学,Go 的代码量最少,概念最直白,从proto到server到client没有中间商,调试起来最舒服。
4. Spring Boot 与 gRPC:REST 老相好也能优雅共存
Java 技术栈里学 gRPC 最大的疑问是:我的项目已经是 Spring Boot 了,怎么办?是要推倒重来,还是能按新接口加进去?答案是可以共存,而且不用改老代码。
4.1 端口不是问题,问题是"怎么接入"
Spring Boot 的默认 Web 服务住 8080,gRPC 服务完全可以在另一个端口上运行。你不需要让老接口下线,只要新接口走 gRPC 端口就行。我常用的方式是:gRPC 服务跑在9090,REST 控制器继续占用8080,两边各聊各的,业务层共享同一些 Service 类。
Java 这边生态里用的最多的是net.devh的 starter,不是官方出品,但事实标准,文档齐全。版本上注意和 Spring Boot 对齐,Spring Boot 2 用 1.x,Spring Boot 3 用 2.x,JDK 版本也别太旧。
4.2 Maven 配置与 proto 生成
先在src/main/proto里放一个和之前一样的hello.proto,然后在pom.xml加上生成插件:
<plugin> <groupId>org.xolstice.maven.plugins</groupId> <artifactId>protobuf-maven-plugin</artifactId> <version>0.6.1</version> <configuration> <protocArtifact>com.google.protobuf:protoc:3.25.3:exe:${os.detected.classifier}</protocArtifact> <pluginId>grpc-java</pluginId> <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.62.2:exe:${os.detected.classifier}</pluginArtifact> </configuration> <executions> <execution> <goals> <goal>compile</goal> <goal>compile-custom</goal> </goals> </execution> </executions> </plugin>同时把依赖加上:
<dependency> <groupId>net.devh</groupId> <artifactId>grpc-server-spring-boot-starter</artifactId> <version>2.15.0.RELEASE</version> </dependency>生成的代码会出现在target/generated-sources/protobuf/,IDE 里如果没显示,把它目录加为 source root 就行。
4.3 @GrpcService 与 @GrpcClient 的实际用法
服务端实现类要继承生成代码里的GreeterGrpc.GreeterImplBase,再标注@GrpcService:
@GrpcService public class GreeterService extends GreeterGrpc.GreeterImplBase { @Override public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) { HelloReply reply = HelloReply.newBuilder() .setMessage("Hello " + req.getName()) .build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } }这里有个细节:gRPC 服务端方法是单向流接口,方法里一定要手动调用onNext和onCompleted,漏掉一个,客户端就会一直等到 deadline,这是新手最容易踩的坑。
客户端就简单了,在application.yml里配置:
grpc: client: greeter: address: static://127.0.0.1:9090然后在服务里注入:
@Service public class HelloClientService { @GrpcClient("greeter") private GreeterGrpc.GreeterBlockingStub greeterStub; public String sayHello(String name) { HelloRequest request = HelloRequest.newBuilder().setName(name).build(); return greeterStub.sayHello(request).getMessage(); } }@GrpcClient("greeter")里的名字要跟 yml 里的 client key 对上。另外同一个 Spring 容器里最好只注册一个同名 client stub,不然启动时可能因为多个 Bean 而报错。
4.4 跨语言调用 Java 服务的一个实测场景
我实际验证过一条链路:Go 写的 client 调用 Spring Boot 写的 gRPC server,再让 server 里的方法去调一个 Redis 缓存。整条链路里 proto 文件是同一个,Go 客户端生成代码后直接用;Java 服务端用 starter 接住请求,处理完再返回。整个过程没有 JSON 解析、没有手写 HTTP 客户端,联调体验比 REST 舒服得多。唯一要注意的是超时控制,Java 这边拦截器里统一设了 3 秒 deadline,避免上游慢接口拖垮下游连接池。
5. Hyperf 里的 gRPC:PHP 项目的微服务新姿势
看到"Hyperf gRPC"这个热搜词的时候我挺感慨的,PHP 在微服务领域经常被低估,但基于 Swoole 的 Hyperf 确实把 gRPC 这件事做得很顺。如果你在 PHP 项目里想接入 gRPC,Hyperf 是一个非常现实的路线。
5.1 为什么 PHP 项目也需要 gRPC
很多团队的技术栈是 PHP 业务 + Go/Java 服务并存。业务接口在 PHP 里写得很嗨,但想调用 Go 那边的推荐系统,没有现成 REST 接口,只能自己拼 HTTP 包;或者有内部告警平台、数据分析平台用的是 protobuf 协议,PHP 这边接不进去就很被动。Hyperf 基于 Swoole 常驻内存和协程,能跑 HTTP/2,所以 gRPC 的跑麦在 PHP 里是成立的。
5.2 以 Hyperf 3.x 为例的接入流程
先装组件:
composer require hyperf/grpc-server hyperf/grpc-client同样写一份hello.proto,然后用 Hyperf 提供的代码生成工具或者在官方文档指引下运行protoc插件,生成对应的 PHP 消息类和服务接口,一般会落到app/Grpc目录。接着在config/autoload/server.php里加一个 HTTP/2 监听,端口用50051,底层 socket 类型要支持 HTTP/2,并且开启对应的open_http2_protocol设置。然后实现服务:
#[GrpcService] class GreeterService implements GreeterInterface { public function SayHello(HelloRequest $request): HelloReply { return (new HelloReply())->setMessage('Hello ' . $request->getName()); } }注解方式注册服务,系统会扫描并注册路由,不需要手写路由表。客户端调用时用 Hyperf 的GrpcClient,在协程环境下调用是异步友好的,不会像传统 PHP-FPM 那样阻塞整个进程。这里版本不同,注解和基类名称会有差异,安装后照官方文档对应版本的示例来写就行。
5.3 coroutine 下的注意事项
Hyperf 的协程模型决定了你在服务方法里不能做太长时间的死循环或同步阻塞操作,比如直接 file_get_contents 一个慢接口会拖垮整个 Worker 进程。gRPC 方法内部如果还有外部依赖调用,最好走 Hyperf 的协程客户端(Redis、MySQL、HTTP 都有协程实现),保持事件循环不被卡住。
还有一点:如果客户端用的是长连接,连接池设置要注意数量。gRPC 连接本身是 HTTP/2 多路复用,一个连接就能扛很多并发请求,不用开太多连接,开多了反而浪费内存。PHP 项目接入 gRPC 之后,最大的收益不是"变快了多少",而是从"别人给什么接口我就调什么"变成"我也可以提供一份标准的 protobuf 服务给别人调"。
6. Python gRPC 并发问题的复盘:从堆线程到线程安全
Python 是热搜里的 "python grpc 并发问题",我能猜到大家碰到的是哪类问题:明明 gRPC 说的是高性能,自己在 Python 里起个服务,几十个请求进来直接卡住或者超时。我刚开始也有这个困惑,后来把线程池、channel 复用、同步异步混用这几个点理清楚,才算是真正解决。
6.1 并发场景里最常见的两类错误
第一类是服务端ThreadPoolExecutor的线程数设置太低。grpc.server()默认使用线程池,如果你的接口处理里有个很慢的阻塞调用(比如同步请求第三方 REST API),默认 10 个 worker 很快就会被占满,后面的请求全部排队,表现就是你看到的"并发一高就超时"。
第二类是客户端每个请求都新建 channel。我见过项目代码里每次请求都调grpc.insecure_channel(),用完也不关。这样等于每次 RPC 都重建 TCP 连接,再加上 HTTP/2 握手和连接建立的开销,性能自然上不去。channel 是连接池抽象,官方设计就是让你复用的。
6.2 server 端线程池与 message size 配置
Python 服务端建议这样建:
from concurrent import futures import grpc server = grpc.server( futures.ThreadPoolExecutor(max_workers=32), options=[ ('grpc.max_send_message_length', 100 * 1024 * 1024), ('grpc.max_receive_message_length', 100 * 1024 * 1024), ] ) hello_pb2_grpc.add_GreeterServicer_to_server(GreeterServicer(), server) server.add_insecure_port('[::]:50051') server.start() server.wait_for_termination()线程数不能瞎设,要看业务是 IO 型还是 CPU 型。IO 型请求线程数可以多一点,比如 32 或 64;CPU 密集计算型开太多反而因为 GIL 互相抢,性能下降。消息大小限制也很重要,protobuf 默认对单条消息大小有限制,传大文件或图片时不做配置会直接报 ResourceExhausted。
6.3 client 端 channel 复用与 async 选择
客户端正确姿势是全局只建一个 channel,多个线程/协程共用:
channel = grpc.insecure_channel('127.0.0.1:50051') stub = hello_pb2_grpc.GreeterStub(channel) # 多个线程里直接复用 stub 调方法Python 的 channel 和 stub 设计是线程安全的,不用担心多线程同时调用会崩。如果项目用了 asyncio,那就不要用同步 stub 硬调,否则一个阻塞就把整个事件循环卡住。正确方式是走grpc.aio:
import asyncio import grpc import hello_pb2 import hello_pb2_grpc async def main() -> None: async with grpc.aio.insecure_channel('127.0.0.1:50051') as channel: stub = hello_pb2_grpc.GreeterStub(channel) resp = await stub.SayHello(hello_pb2.HelloRequest(name='Aoi')) print(resp.message) asyncio.run(main())6.4 一个压测对比出来的结论
我在本地用 200 个并发请求做了对比实验。第一轮每请求新建 channel,吞吐量惨不忍睹,还伴随大量连接建立失败;第二轮复用同一个 channel,同样的并发规模,吞吐直接上了一个量级。第三轮改用grpc.aio,在异步场景下事件循环不再被阻塞,平均延迟又降了一截。所以 Python gRPC 并发问题,绝大多数不是 gRPC 本身不行,而是使用姿势不对:该复用的没复用,该异步的用了同步。
| 问题 | 常见原因 | 解决方法 |
|---|---|---|
| 并发高时服务端排队超时 | 线程池太小 | 按 IO/CPU 类型调整max_workers |
| 客户端大量 TIME_WAIT | 每请求新建 channel | 全局复用 channel 和 stub |
| asyncio 里卡住整个事件循环 | 使用同步 stub | 改用grpc.aio |
| 大消息传不进去 | 默认消息大小限制 | 配置grpc.max_send_message_length等选项 |
7. 学习路径复盘:我的"爱学习"方法论
学习 gRPC 之后最大的收获,不只是会调接口了,而是建立了一套"跨语言同一套协议"的思维方式。这轮折腾下来,我想分享几个不一定写在官方文档里的经验。
7.1 每个栈留一份"最小可运行项目"
我后来给自己定了个规矩:任何一个技术栈的 gRPC 学习,都要留一个能一键运行的最小项目,包含三样东西——一份hello.proto、一个 server、一个 client。Go 的、Java 的、Python 的、PHP 的都留了。原因很简单:隔几个月再打开这些项目,不用重新查文档,跑一遍就能快速回到上下文。而且这个最小项目是排错工具箱,线上有问题时,把它跑起来做对照实验非常快。
7.2 调试工具用起来:grpcurl 和 grpcui
很多 gRPC 新手还用 curl 去测 gRPC 接口,结果发现根本不行,因为默认是二进制协议。这里推荐grpcurl,用法类似 curl,能直接连 gRPC 服务反射协议,列出方法、调用方法:
grpcurl -plaintext -d '{"name": "Aoi"}' 127.0.0.1:50051 helloworld.Greeter/SayHellogrpcui则是网页版工具,自动生成可视化表单,调试字段特别方便。没有这两个工具之前,我只能靠写代码起 client 来验证接口,效率低很多。
7.3 后续进阶方向:Streaming、拦截器、负载均衡
如果已经能跑通 Helloworld,下一步建议按这个顺序推进:
- 四种调用模式:把服务端流、客户端流、双向流各写一遍,真正理解 RPC 的流式语义;
- 拦截器:Java 和 Go 里分别写一个日志和鉴权拦截器,理解请求链路里横切逻辑的位置;
- deadline 和 metadata:给 RPC 设置超时、传递 trace 信息,生产环境排查问题必备;
- 负载均衡:在多实例部署时区分直连、
round_robin、xds,理解 gRPC 客户端负载均衡和服务端负载均衡的区别。
最后分享一个小体会。学习这类和语言无关的框架,靠的不是"我很爱学习"这个口号,而是把学习过程拆成一个个可以验证的小实验。每跑通一个实验,就整理一份笔记,记录当时用的版本、生成命令和踩坑点。等笔记攒到一定程度,你会突然发现,自己已经能带着团队从 REST 平滑过渡到 gRPC,而且能自信地说出哪一步会有坑、该提前怎么防。我现在依然很爱学习,但已经不爱脑袋一热就折腾底层编译了——能站着把功能做出来,就别跪着啃一天源码。