
刚开始接触 gRPC 的时候我走了不少弯路总以为它是个特别重的框架得先啃完一整本协议文档才能动手。后来被一个内部项目逼着做接口迁移硬着头皮把 Python 版 gRPC 的链路完整跑了一遍才发现核心路径其实很短写一份 proto 文件生成两套桩代码服务端实现逻辑客户端直接调 stub。这篇文章就用一个“图书查询服务”作为例子把 gRPC 框架在 Python 里的使用方式从环境准备、接口定义、代码生成、服务端实现到客户端调用完整拆开再把流式 RPC 和线上排错经验一起讲清楚。适合第一次接触 gRPC、想在项目里落地的同学直接照着做。1. gRPC 到底解决了什么问题为什么值得用1.1 先搞懂 gRPC 和 REST API 的本质差异很多人一提到 gRPC第一反应就是“性能比 REST 好”。这句话其实不完整。gRPC 和 REST API 的差异至少有三个层面序列化方式、传输协议、接口契约。REST 默认走 JSON 加 HTTP/1.1每个请求都要携带大量文本字段格式松散联调时字段拼错了经常要等到运行时才能发现。gRPC 默认用 protobuf 做二进制序列化走 HTTP/2接口结构提前在 .proto 文件里锁定字段类型、编号、可选必选关系全都定义清楚客户端和服务端拿着同一份契约干活。打个比方JSON 接口像送快递每个包裹都要贴一张写满收件人、地址、重量、备注的标签快递员每到一个节点就要重新识别一遍。protobuf 更像货物提前装进标准托盘编号固定中转时不需要重复拆包。所以 gRPC 在请求量大、接口多的内部场景里收益非常明显带宽占用低解析耗时少字段一致性有保障前后端不用反复对着接口文档抠字段名。但 gRPC 也不是没有代价。HTTP/2 需要维护连接状态protobuf 生成的代码不直观调试的时候不能直接在浏览器里复制 URL 访问得靠 grpcurl 或者写小脚本。对浏览器客户端gRPC 原生支持不友好通常还要架一层转换网关。所以我的建议很明确跨系统、微服务内部的接口适合 gRPC对外暴露给第三方或者前端直接调的 REST API暂时没必要迁移。1.2 四种调用模式分别用在什么场景gRPC 最大的特点就是它不止支持“请求-响应”这一种模式。官方定义下它有四种 RPC 类型我用一个表格简单对应到常见场景模式典型场景Unary 一元 RPC普通查询、下单、登录一次请求一个响应Server streaming 服务端流式拉取列表、订阅推送一次请求连续返回多条数据Client streaming 客户端流式批量上报、文件分段上传连续发送多个请求后拿到一个汇总结果Bidi streaming 双向流式实时对话、日志清洗双方同时连续读写这个分类不是理论空谈。比如“批量上传一万条日志”用 REST 你只能拆成一万个请求或者自己设计一个分批接口用 gRPC 客户端流式你可以在一个长连接里把数据持续推上去最后服务端返回一个汇总结果。再比如“实时推送监控指标”服务端流式天然就合适连接建立后数据像流水一样往下推。四种模式里Unary 是基础也是日常最高频的用法。流式模式在工程上会带来连接管理、取消、资源释放的额外成本所以我会在第 5 节单独展开先把概念讲透再给实战写法。1.3 Python 版 gRPC 适合哪些真实场景有人觉得 gRPC 是 Go 和 Java 的主场Python 只是“能用”。从我接触过的项目看这个观点过时了。grpcio 官方库维护很积极Python 里的机器学习推理服务、内部数据同步服务、任务调度控制面用 gRPC 的非常多。特别是模型推理这类场景特征数据往往是高维数组用 JSON 传输既臃肿又慢换成 protobuf 之后体积和时延都有明显优化。Python 版 gRPC 的使用方式也非常统一定义 proto生成代码服务端实现接口客户端生成 stub。API 设计和 Java、Go 版一脉相承团队里切换语言的学习成本不高。当然也要承认短板Python 本身在并发性能上不如 Go如果你的线上服务需要超高并发支撑可以把 gRPC 用在控制面或者推理服务这类场景大流量的核心转发层还是交给更偏底层的语言更稳妥。2. 从零搭建环境准备与第一个 proto 文件2.1 安装依赖与版本判断第一步是安装两个包grpcio 和 grpcio-tools。grpcio 是运行时依赖库服务端和客户端都要用到grpcio-tools 只在生成代码时用它内部封装了一个 protoc 编译器和对应插件。两个包的版本最好保持一致不然生成代码和运行时版本错位会出现一些很诡异的异常。安装命令很简单pip install grpcio grpcio-tools如果网络环境受限可以指定镜像源。装完以后建议验证一下版本pip show grpcio python -c import grpc; print(grpc.__version__)只要正常输出版本号说明基础环境已经通了。需要留意的是不同 Python 版本对 grpcio 的预编译 wheel 支持会有差异遇到编译报错时先看是不是版本不匹配优先选择与当前解释器兼容的版本。大多数情况下直接用 pip 安装新版就能跑起来很少需要源码编译。2.2 定义一份最小可用的 proto 文件我以“图书查询服务”举例项目目录结构建议这样放project/ ├── protos/ │ └── books.proto ├── generated/ │ ├── __init__.py │ └── ... ├── server.py └── client.pybooks.proto 的内容如下syntax proto3; package books; service BookService { rpc GetBook(BookRequest) returns (Book); rpc ListBooks(Empty) returns (stream Book); rpc AddBook(stream Book) returns (AddResult); } message BookRequest { int32 id 1; } message Book { int32 id 1; string title 2; string author 3; } message AddResult { int32 count 1; } message Empty {}几点说明第一行syntax proto3是必须的proto2 和 proto3 的字段规则差异很大初学就认准 proto3。package用来做命名空间隔离一般和你的模块名保持一致。每个字段后面等号右边的数字是字段编号不是值它在消息内部必须唯一而且一旦上线就不要改改编号意味着破坏线上兼容性这是 proto 设计里最容易踩的坑。service 块里声明的 rpc 签名就是接口定义的核心。GetBook是一元调用ListBooks的返回类型带stream关键字表示服务端流式响应AddBook的请求参数带stream表示客户端流式请求。这几个关键字是 proto3 里描述 RPC 类型的标准语法后面实现服务端和客户端时都要严格对应。2.3 用 grpc_tools.protoc 生成代码有了 proto 文件接下来生成 Python 代码。在项目根目录下执行python -m grpc_tools.protoc -I ./protos \ --python_out./generated \ --grpc_python_out./generated \ ./protos/books.proto参数含义要搞清楚-I指定 proto 的 import 搜索路径这里指向 protos 目录--python_out生成消息类也就是books_pb2.py--grpc_python_out生成服务相关的类也就是books_pb2_grpc.py。执行之后generated 目录下会多出两个文件。这两个文件的作用要分清。books_pb2.py里的类对应 proto 里定义的 message比如 Book、BookRequest主要负责序列化和反序列化books_pb2_grpc.py里有两组东西服务端的 BaseServicer 和客户端的 Stub。BaseServicer 是让你继承去实现业务逻辑的Stub 是客户端用来发起调用的。生成代码不需要手工修改每次改 proto 文件后重新生成一遍就行。有个导入路径问题经常坑人如果服务端和客户端脚本跟 generated 目录不在同一级直接import books_pb2会报 ModuleNotFoundError。最省事的做法是把 generated 目录作为一个包在里面放一个空的__init__.py然后在业务代码里写from generated import books_pb2。我帮同事排查过几次报错大多数都卡在这里。3. 服务端实现真正干活的那一头3.1 继承生成的 Servicer 实现接口逻辑服务端的主体就是一个类继承生成的BookServiceServicer重写 proto 里声明的 rpc 方法。我先做一份内存图书表模拟真实数据源import grpc from concurrent import futures import books_pb2 import books_pb2_grpc BOOKS [ books_pb2.Book(id1, title并发编程入门, authorA作者), books_pb2.Book(id2, title分布式系统设计, authorB作者), books_pb2.Book(id3, titlePython工程实践, authorC作者), ] class BookService(books_pb2_grpc.BookServiceServicer): def GetBook(self, request, context): for book in BOOKS: if book.id request.id: return book context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(fbook {request.id} not found) return books_pb2.Book() def ListBooks(self, request, context): for book in BOOKS: yield book def AddBook(self, request_iterator, context): count 0 for book in request_iterator: count 1 return books_pb2.AddResult(countcount)一元方法GetBook的逻辑很简单从 BOOKS 列表里查到目标书就返回查不到就通过 context 设置错误码。这里有个容易忽略的细节即使返回错误状态方法仍然要返回一个空的 Book 对象不能直接return None因为 protobuf 要求方法必须返回对应的消息类型空对象配合非 OK 的状态码客户端就知道这次调用出错了。ListBooks用 yield 逐个返回结果这是服务端流式 RPC 的标准写法。只要方法内部是生成器框架就会把每个 yield 出的对象编码并通过同一个 HTTP/2 流发送给客户端。AddBook的第一个入参request_iterator不是单个请求对象而是一个迭代器框架会持续把客户端发来的每个 Book 对象迭代给你所有数据接收完后在方法里返回一个汇总结果客户端就拿到了最终响应。3.2 启动服务线程池、端口和消息体大小服务实现好以后启动脚本如下def serve(): server grpc.server( futures.ThreadPoolExecutor(max_workers10), options[ (grpc.max_send_message_length, 10 * 1024 * 1024), (grpc.max_receive_message_length, 10 * 1024 * 1024), ], ) books_pb2_grpc.add_BookServiceServicer_to_server(BookService(), server) server.add_insecure_port(127.0.0.1:50051) server.start() print(server started on 50051) server.wait_for_termination()线程池参数怎么定max_workers决定服务端能同时处理多少个 RPC。如果业务逻辑里大量是 IO 等待比如查数据库、调下游接口这个值可以调大一些20 到 50 都可以如果逻辑以纯计算为主设成 CPU 核数附近比较合理。grpc.max_send_message_length和grpc.max_receive_message_length是消息体大小上限默认只有 4MB实际项目中传图片或者批量特征数据很容易超建议一开始就调大省得线上出现莫名其妙的报错。add_insecure_port里用127.0.0.1表示服务只在本机监听跨机器部署时要改成0.0.0.0:50051同时用防火墙限制访问来源。生产环境不建议长期用 insecure 模式至少要用 TLS 证书加密。wait_for_termination()会阻塞主线程直到进程被外部终止适合作为常驻服务的收尾。3.3 context 对象的隐藏能力服务端每个 rpc 方法都会收到一个 context 参数很多人把它当成摆设实际上它承载了服务端几乎所有会话级能力。比如读取客户端传来的自定义信息可以用dict(context.invocation_metadata())主动中断一个耗时调用可以调用context.cancel()需要返回自定义错误码和提示信息就用context.set_code配合context.set_details。一个很常见的用法是鉴权客户端在 metadata 里塞 token服务端在每个方法入口先校验 token不合法就直接设置grpc.StatusCode.UNAUTHENTICATED并 return。另一种是慢请求控制方法里检查context.is_active()如果连接已断开就不再继续耗时计算直接 return避免无谓的资源占用。context 属于“不用也能跑用好了能扛事”的能力建议把官方文档里 context 相关的章节翻一遍。4. 客户端调用像本地函数一样调远程服务4.1 从 channel 到 stub 的基本写法客户端代码比服务端更简单核心只有两步建立 channel创建 stub。channel 是客户端到服务端的连接抽象内部维护了 HTTP/2 连接池stub 是 channel 上 RPC 方法的绑定器生成代码已经把每个 rpc 方法映射成了 Python 方法。import grpc import books_pb2 import books_pb2_grpc def main(): channel grpc.insecure_channel(127.0.0.1:50051) stub books_pb2_grpc.BookServiceStub(channel) resp stub.GetBook(books_pb2.BookRequest(id1), timeout5) print(got book:, resp.title) resp_list list(stub.ListBooks(books_pb2.Empty())) print(book count:, len(resp_list)) channel.close()一元调用GetBook的返回就是一个 Book 对象拿到的瞬间已经完成了反序列化。服务端流式调用ListBooks返回的是一个迭代器需要用 for 循环或者 list 才能把数据全部拉出来。这里有个实际经验如果你只关心结果数量不要直接list(stub.ListBooks(...))数据量大时内存会直接爆掉改成逐条处理、边遍历边统计才是合理方案。channel 创建成本不低一个进程里应该复用一个 channel 创建多个 stub而不是每发一次请求就新建一个。我见过有人在循环里反复调用grpc.insecure_channel(...)线上 qps 一上来端口和连接数立刻失控。正确做法是初始化时创建一次程序退出时 close 一次。4.2 超时、重试和调用策略网络环境再稳定也必须有超时控制。gRPC Python 的每个 stub 方法都支持 timeout 参数单位是秒超过时间后客户端会抛grpc.FutureTimeoutError。比如超时设 2 秒try: resp stub.GetBook(books_pb2.BookRequest(id1), timeout2) except grpc.FutureTimeoutError: print(timeout)重试策略要分场景。幂等接口比如查询、列表可以放心重试非幂等写操作就要先想清楚重复执行会不会造成脏数据。最简单的兜底方案是封装一个带指数退避的重试函数第一次失败后等 0.5 秒第二次等 1 秒第三次等 2 秒最多重试三次每次根据异常类型决定是否继续。我的经验是重试一定要限制次数和总时长否则服务雪崩时大量客户端同时重试只会把问题放大。4.3 异步调用的入门写法如果你的服务本身就是 asyncio 架构gRPC 也提供了异步版本。生成的books_pb2_grpc.py里会同时包含同步 Stub 和异步 Stub异步版本类名带 AsyncStub 后缀。用法和同步版很像只是方法和调用都以协程方式跑import grpc import books_pb2 import books_pb2_grpc async def main(): async with grpc.aio.insecure_channel(127.0.0.1:50051) as channel: stub books_pb2_grpc.BookServiceAsyncStub(channel) resp await stub.GetBook(books_pb2.BookRequest(id1)) print(resp.title)使用async with可以自动关闭 channel不用手动 close。如果在一个事件循环里并发发起几十个调用异步版比开几十个线程的同步版要优雅很多这是异步模式的主要收益。5. 流式 RPC 怎么用四种模式一次讲清5.1 服务端流式把数据通过生成器推给客户端服务端流式是最容易上手的流式模式。服务端方法用 yield 产出多个对象客户端通过 for 循环接收。前面已经写过ListBooks的服务端实现这里重点讲两个细节。第一个是退出条件。如果客户端中途不想要了会在迭代器上主动 break此时服务端上下文的context.is_active()会变成 False。服务端在循环里应该时不时检查这个状态发现连接已断就主动 return而不是继续反复生成数据。第二个是流量控制。虽然 gRPC 底层有 HTTP/2 流控但服务端生成数据的速度远大于客户端消费速度时内存缓冲还是会积累。如果业务上允许在每批数据产出之间加一个很小的 sleep或者限制每次 yield 的批次大小都能改善。客户端接收端其实很直观一个 for 循环就能把所有数据拉回来。数据量很大时建议在循环里做流式处理不要一次性把所有结果都收集到内存里再统一处理。5.2 客户端流式一次性提交一批数据客户端流式在“批量上报日志”“批量导入记录”这类场景很常见。服务端方法签名里第一个参数是 request_iterator代码前面已经展示过。服务端要做的事情就是对这个迭代器做 for 循环读完全部客户端发来的对象最后返回一个汇总结果。客户端上传数据时只需要给 stub 方法传入一个可迭代对象比如生成器def book_gen(): for i in range(1, 10001): yield books_pb2.Book(idi, titlefbook-{i}, authorA作者) resp stub.AddBook(book_gen()) print(added:, resp.count)要注意的是当这个迭代器抛出异常或者生成过程被中断时整个 RPC 也会失败。另外客户端流式不太适合每条数据之间都等很久的场景如果生成器需要从外部拉数据建议使用异步生成器推送避免长时间占用 RPC 连接不发送数据导致服务端超时。5.3 双向流式的设计要点双向流式在状态设计上最复杂因为它把“请求序列”和“响应序列”完全并行。一个典型的例子是实时审核流水客户端不断提交待审核条目服务端每处理完一部分就立刻返回当前结果。服务端的代码模式通常是在 for 循环里处理 request_iterator 中的每个请求同时用 yield 返回处理结果。def ProcessStream(self, request_iterator, context): for req in request_iterator: result do_process(req) yield result双向流最容易翻车的地方是关闭时机。协议规定当任何一方的消息流关闭比如客户端发送完请求或者服务端不再 yield另一方的循环就会自然结束。实现时不要试图在一个方法里同时用 for 和 yield 做很复杂的条件退出先把“读到输入停止就退出”这个模型想清楚再叠加业务逻辑。并发方面同一个 context 上不要同时在两个线程里进行发送和接收官方文档明确建议使用单线程交替读写除非你非常清楚流控制的机制否则就用简单的 for 加 yield 模式最稳妥。6. 常见错误和线上避坑清单6.1 高频报错速查表我整理了一份自己踩过、也帮同事排查过的高频错误可以直接对照处理报错或现象常见原因处理方法ModuleNotFoundError: No module named grpc没安装 grpciopip install grpcio_InactiveRpcError提示 failed to connect to all addresses服务端没启动、端口不对、防火墙拦截检查 server 进程、端口监听、网络策略TypeError: ... got an unexpected keyword argument代码生成版本和运行时版本不一致统一 grpcio 和 grpcio-tools 版本重新生成代码StatusCode.UNAVAILABLE连接被服务端拒绝、TLS 配置不匹配检查监听地址和证书配置消息超过 4MB 被拒绝未调整消息体大小限制服务端和客户端都设置 max_send/receive_message_length服务端进程不退出没有调用 wait_for_termination或存在非 daemon 线程主线程阻塞等待终止信号表格里第三行最隐蔽。生成代码的版本如果和运行时 grpcio 差异太大stub 方法签名可能不一致直接表现就是客户端报 unexpected keyword argument。遇到这种报错不要急着改代码先重新生成一遍再统一两个包的版本问题通常就消失了。6.2 三个值得提前避开的深坑第一个坑是连接不释放。channel 没有 close程序退出时 gRPC 会等待连接自然关闭如果服务端的连接很多就会出现大量 TIME_WAIT。解决办法前面提过channel 复用程序退出时 close。第二个坑是手改生成代码。我之前排查过一个“本地好好的上线就崩”的问题最后发现是有人为了临时修 bug 手工改了_pb2文件里的某个字段默认值重新生成代码时把改动覆盖了线上和本地行为不一致。建议定条规则所有_pb2.py和_pb2_grpc.py文件人工不可改统一由 proto 文件驱动生成。第三个坑是消息体上限没规划。默认 4MB 的限制传批量特征、图片、日志块时非常容易触发。注意服务端和客户端两端都要配置而且配置要对称。只改服务端不改客户端超大消息客户端发送或接收仍然会出问题报错还容易让人误以为是对方服务的问题。6.3 生产环境至少要做的四件事启用 TLS 加密insecure_channel只适合开发和内网调试。生产环境要用证书构建安全 channel或者在网关层终结 TLS否则业务数据在网络上相当于明文传输。配置健康检查gRPC 有一套标准健康检查协议服务端实现 HealthServicer 后负载均衡和发布系统就能通过 gRPC 探活而不是只靠 TCP 端口探活。设计优雅关闭server.stop(grace)可以给正在处理的请求一个宽限期收到退出信号后不要立刻强杀进程否则连接中的数据会被截断。预留 proto 演进空间字段编号只追加不复用删除字段用 reserved 关键字打标消息里预留一个 map 或 repeated 字段做扩展位可以显著减少破坏性变更。这四件事不一定第一天就全做但架构评审的时候至少要出现在检查清单里。gRPC 的优势建立在契约稳定之上契约管理越规范后期维护成本越低。最后说点个人体会。刚开始用 gRPC 时我觉得 proto 文件是个额外负担后来才发现它其实是整个系统的接口合同。只要合同定义清楚服务端和客户端的负责人可以完全并行开发甚至用不同语言实现同一份协议都不需要反复对接口文档。我自己有个习惯每次改完 proto先把生成的代码 git diff 一遍确认没有破坏性变更再提交。这个习惯已经帮我挡掉好几次本来可能变成线上事故的问题。如果你正准备在 Python 项目里引入 gRPC先用这套最小示例把链路跑通再去做高级特性会顺利很多。