最近把一个内部服务从 REST/JSON 迁到了 gRPC(Python 版),前后折腾了两三个晚上,踩了不少坑。说实话,gRPC 框架在 Python 里的上手难度比想象中低,但前提是你得先把 .proto 的规则、生成代码的逻辑这几个关键点搞明白。这篇博文就围绕 gRPC 框架在 Python 环境里的简单使用方法展开,从协议文件、代码生成,到服务端/客户端实现,再到超时、错误码这些实操细节,按我实际跑通的过程一步步写下来,希望能给准备入门的人省点时间。
1. 为什么要用 gRPC:一个小需求引发的框架选型思考
1.1 从 REST 接口的痛点说起
我手上有个内部项目,需要让 Python 写的后端服务去调用另一个服务取数。最开始用的 REST + JSON,接口不多,跑起来也还行。但随着调用频率上来,几个问题越来越明显:JSON 序列化开销不小,字段名靠约定,传错一个键名要跑到业务代码里才能发现;文档一更新,下游同学还是照着旧字段对接,联调时间都浪费在"到底哪个字段叫什么"上面。一次在线流量涨上去之后,单看耗时,光序列化和 HTTP 头就占了不少,这才认真考虑换协议。
换协议不是赶时髦,核心是解决三件事:第一,RPC 调用要像本地函数一样直白;第二,接口契约要机器可校验,而不是靠人读文档;第三,传输效率要高,能扛住高频调用。当时列了一圈方案,最终把目光落在 gRPC 框架上。它由 HTTP/2 承载,默认用 Protocol Buffers(经常简称 protobuf)做序列化,接口定义写在 .proto 文件里,client 和 server 只需要依赖同一份协议文件,就能生成对应的 Python 代码。
1.2 gRPC 在 Python 里的生态定位
Python 生态里,gRPC 官方支持比较完整,核心库是 grpcio,代码生成工具是 grpcio-tools,也就是 grpcio 内置的 protoc 封装。和很多偏底层的库不一样,grpcio 装完直接用 pip 能装到的版本基本覆盖了常见平台,不需要自己编译,这对想快速上手的人来说很友好。
很多人一听到 protobuf 就担心学习成本,其实 Python 版只需要掌握三件事:写 .proto 文件、跑 protoc 生成 pb2 代码、把自己定义好的 service 类继承过来实现方法。熟悉这三步之后,心跳检测、REST 网关、分布式调用链这些进阶话题再慢慢展开。但前期不要被这些概念吓到,我大概是花了一个晚上把 demo 跑通,第二天就已经把某个内部函数调用的流量迁了一部分过去。
1.3 什么场景其实不适合用 gRPC
老实说,gRPC 也并不是银弹。如果只是几个后端服务之间低频互相调用,接口字段就那么三五个,REST 完全够用,引入 gRPC 反而要维护 .proto 和生成代码,有额外成本。另外,浏览器直接访问 gRPC 比较受限,虽然有 grpc-web,但整体成熟度和 REST 还是差一截,所以对外面向浏览器的 API 我还是保留了 REST 网关,内部服务间调用才走 gRPC。
这个"内部 RPC、外部 REST"的分工思路,是我建议大多数人采用的。项目里如果已经有网关层,那只要在网关层做协议转换即可,业务服务内部统一 gRPC,开发效率反而更高。
2. 环境准备与 .proto 文件:一切接口的契约源头
2.1 安装 grpcio 和 grpcio-tools
安装过程没什么花活,就是两条依赖:
pip install grpcio grpcio-tools安装完成后可以验证一下:
python -c "import grpc; print(grpc.__version__)"这里有一个值得注意的点:grpcio-tools 的版本最好跟 grpcio 保持一致,不然生成代码里 import 的模块版本可能出现对不上。我遇到过 grpcio 装成 1.51,grpcio-tools 还是 1.48,生成出来的 pb2 文件在新版本库里也能跑,但有些新特性不生效,排查起来非常隐蔽。
为了稳定,建议在项目里建一个 requirements.txt,把 major 版本固定住:
grpcio>=1.50,<2 grpcio-tools>=1.50,<2这样后续升级也好控制,不会因为库大版本变化导致生成代码行为不一致。实际项目里如果用的是虚拟环境或者容器镜像,这一步更要固定,否则同一份代码在不同环境生成的 pb2 对不上,会浪费大半天在定位环境差异上。
2.2 写第一个 .proto 文件
gRPC 的接口定义全靠 .proto,它同时承担了"类型定义"和"接口描述"两种职责。拿最经典的 HelloWorld 举例:
syntax = "proto3"; package helloworld; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; }几个容易忽略的点:
- 第一行
syntax = "proto3";是必须的,缺了它,protoc 会默认按 proto2 处理,生成代码的字段访问语义会不一样。 - 每个字段后面的数字是字段编号,不是默认值。这个编号一旦发布,就不要改了,老客户端依赖的是编号而不是字段名来解析二进制数据。
- service 里的 rpc 方法名一般用大写驼峰,生成代码后客户端桩上的方法名会保持一致,所以名字定下来之后尽量别频繁改。
另外,.proto 还支持import其他 proto 文件,比如把公共的 message 抽到一个 common.proto 里,再在业务 proto 里import "common.proto";。这样多个服务共用同一套数据结构定义,字段变更时只改一处,是比较标准的做法。
2.3 生成 Python 代码
把 .proto 文件放到 protos 目录,执行命令:
python -m grpc_tools.protoc -I./protos --python_out=./generated --grpc_python_out=./generated ./protos/helloworld.proto其中:
-I指定 proto 的 import 搜索路径。--python_out生成 pb2 文件,里面是消息类型。--grpc_python_out生成 pb2_grpc 文件,里面是服务端抽象类和客户端桩。
执行后 generated 里会出现helloworld_pb2.py和helloworld_pb2_grpc.py。这两个文件都是自动生成的,不要手改。团队协作时,可以约定提交生成文件到仓库,但前提是所有人都用同一版本工具,并且每次改完 .proto 后重新生成,否则会出现"我本地改的字段,线上还是旧结构"这种问题。
3. 服务端实现:从骨架代码到真实业务逻辑
3.1 先跑通一个能响应的 Server
服务端核心就三步:定义业务子类、把子类注册进 server、启动监听。先用最少的代码跑通:
from concurrent import futures import grpc import helloworld_pb2 import helloworld_pb2_grpc class GreeterServicer(helloworld_pb2_grpc.GreeterServicer): def SayHello(self, request, context): return helloworld_pb2.HelloReply(message=f"Hello, {request.name}!") def serve(): server = grpc.server( futures.ThreadPoolExecutor(max_workers=10), options=[ ("grpc.max_send_message_length", -1), ("grpc.max_receive_message_length", -1), ] ) helloworld_pb2_grpc.add_GreeterServicer_to_server(GreeterServicer(), server) server.add_insecure_port("[::]:50051") server.start() print("gRPC server started on :50051") server.wait_for_termination() if __name__ == "__main__": serve()重点解释几个设计:
futures.ThreadPoolExecutor(max_workers=10)控制并发处理线程数,max_workers 并不是越大越好。如果服务里有数据库连接或阻塞 I/O,线程太多反而容易把资源打满;普通内部服务先给 10~20 就可以。
add_GreeterServicer_to_server是生成代码提供的注册函数,你只需要传实例,它会把 service 的实现和 descriptor 挂到 server 上。
server.wait_for_termination()会一直阻塞直到进程收到退出信号。写在这种小工具服务里很合适,生产环境一般交给进程管理器托管。
3.2 context 对象里藏着的实用功能
很多新手不知道方法里那个 context 参数能做什么。它有三大作用:设置响应元数据、控制状态码、取消请求。举个例子,如果入参不合法,你可以不 return 正常结构,而是直接终止:
def SayHello(self, request, context): if not request.name.strip(): context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("name 不能为空") return helloworld_pb2.HelloReply() return helloworld_pb2.HelloReply(message=f"Hello, {request.name}!")context.set_code配合set_details是服务端抛业务错误的标准姿势。客户端拿到grpc.RpcError后,可以通过e.code()判断种类,用e.details()拿具体文案。这样比把错误信息塞进 message 字段里规范得多,调用方也方便针对不同 Code 做异常处理。
3.3 从 HelloWorld 到带日志和监控的最小实现
一个能上线的基础服务,光有 SayHello 还不够。我通常会给 server 至少加上最简单的日志。在没有任何中间件的情况下,先在每个方法里打一行日志:
import logging logging.basicConfig(level=logging.INFO) def SayHello(self, request, context): logging.info("received SayHello: %s", request.name) return helloworld_pb2.HelloReply(message=f"Hello, {request.name}!")这样做的原因是:流量上来之后,你总得知道每个 RPC 的被调用频率和响应时长。先有日志,后面做指标采集才有数据基础。后面如果服务数量多了,再用拦截器做统一日志,避免每个方法到处复制粘贴。
4. 客户端调用:三种调用模式跑通
4.1 一元调用的最简单姿势
客户端最基础的使用方式是这样的:
import grpc import helloworld_pb2 import helloworld_pb2_grpc channel = grpc.insecure_channel("localhost:50051") stub = helloworld_pb2_grpc.GreeterStub(channel) response = stub.SayHello(helloworld_pb2.HelloRequest(name="python")) print(response.message)注意 stub 是线程安全的,多个线程可以共用同一个 stub,不需要每个请求都新建一个 channel。channel 建议全局复用一个,连接复用和 HTTP/2 多路复用才是 gRPC 的性能优势;频繁创建 channel 反而会增加握手开销。我见过有人写成一个调用就grpc.insecure_channel(...)一次,结果服务端连接数飙高,QPS 一上来就报 "Too many connections"。
4.2 服务端流式:一次请求持续拿结果
如果业务需要服务端持续推送一批数据,用服务端流式就很方便。先改接口定义:
service Greeter { rpc SayHello (HelloRequest) returns (stream HelloReply); }客户端写法不变,差异在于返回的是可迭代对象:
for reply in stub.SayHello(helloworld_pb2.HelloRequest(name="python")): print(reply.message)服务端改用 yield 返回:
def SayHello(self, request, context): for i in range(3): yield helloworld_pb2.HelloReply(message=f"Hello {request.name}, msg {i}")这种模式适合分页拉取大数据、实时日志推送、批量任务状态推送等场景。注意,yield 后面的每一条消息都在同一个 RPC 流里传输,客户端 for 循环会在服务端关闭流之后结束。
4.3 客户端流式:动态提交一组请求
客户端流式对应服务端接收一个请求流。客户端要传一个可迭代对象给 stub:
def generate_requests(): for i in range(5): yield helloworld_pb2.HelloRequest(name=f"client-{i}") response = stub.SayHello(generate_requests())服务端通过for request in request_iterator来消费:
def SayHello(self, request_iterator, context): names = [req.name for req in request_iterator] return helloworld_pb2.HelloReply(message=", ".join(names))这一类适合批量上报、日志采集、文件分片上传。注意 proto 里 rpc 声明的是单请求还是流式请求,客户端调用方式完全不同:单请求传对象,流式请求传迭代器或生成器。
4.4 双向流式:边发边收
双向流是最灵活的模式。服务端和客户端各自维护一个流对象,常见做法是客户端用生成器把请求丢进流里,然后循环读响应。
rpc Chat (stream ChatMessage) returns (stream ChatMessage);客户端代码:
def request_stream(): for i in range(10): yield chat_pb2.ChatMessage(text=f"hi {i}") time.sleep(0.1) for msg in stub.Chat(request_stream()): print(msg.text)双向流不是必须的,大多数内部接口用一元调用就够。但如果做聊天服务、实时协同、任务执行过程回传,双向流会省掉很多轮询的麻烦。代价是调试和测试会更复杂,建议前期谨慎引入。
5. 调通之后才真正开始:超时、错误码、数据体积这些坑
5.1 不加 timeout 的调用可能卡到天荒地老
第一次跑通 demo 后,我犯过的最大错误是客户端调用没有设置超时时间。默认情况下 channel 是长连接,如果服务端假死或者网络异常,一个 RPC 请求可能会一直挂着,直到操作系统层面的 TCP 超时才报错,这中间业务线程全被占住。所以线上客户端调用尽量显式传 timeout:
response = stub.SayHello(helloworld_pb2.HelloRequest(name="x"), timeout=3.0)如果超时,会抛出grpc.RpcError,捕获后判断一下:
try: response = stub.SayHello(req, timeout=3.0) except grpc.RpcError as e: if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED: print("调用超时") else: print(e.code(), e.details())timeout 这个参数看起来不起眼,实际上直接决定了故障场景下的恢复速度。内部服务之间的调用建议先设 3 到 5 秒,后续根据真实时延再调整。
5.2 常见 StatusCode 和应对方式
gRPC 有一套标准状态码,下面这几个是我平时遇到最多的:
| Code | 含义 | 常规应对 |
|---|---|---|
| OK | 正常 | 无 |
| CANCELLED | 调用被取消 | 检查客户端超时或主动取消 |
| DEADLINE_EXCEEDED | 超时 | 调大 timeout 或优化服务端耗时 |
| UNAVAILABLE | 服务不可达 | 检查服务端是否启动、网络和负载均衡配置 |
| INTERNAL | 服务端内部错误 | 看服务端日志 |
| UNIMPLEMENTED | 方法未实现 | 检查生成的 pb2_grpc 注册是否正确 |
调试阶段最经常撞见的是 UNIMPLEMENTED,十有八九是服务端子类只写了部分方法,或者add_XXX_to_server传了错误的对象。看到这个 Code 就按这个方向排查,基本能省一半时间。
5.3 消息大小限制
gRPC 默认单个消息接收上限是 4MB。如果业务里传一张大图或几千条记录,默认值会直接报RESOURCE_EXHAUSTED。解决办法有两个:
一是在 server 启动时配置:
server = grpc.server( futures.ThreadPoolExecutor(max_workers=10), options=[ ("grpc.max_receive_message_length", 50 * 1024 * 1024), ("grpc.max_send_message_length", 50 * 1024 * 1024), ] )二是在客户端 channel 上同样要配置,因为发送和接收是双向的:
channel = grpc.insecure_channel( "localhost:50051", options=[ ("grpc.max_send_message_length", 50 * 1024 * 1024), ("grpc.max_receive_message_length", 50 * 1024 * 1024), ] )最隐蔽的情况是服务端调了上限,客户端没调,报错依然是RESOURCE_EXHAUSTED。所以建议在封装公共连接的工具函数里让两端配置保持一致,这样后续维护只用改一个公共常量。
5.4 protobuf 字段变更要当心
proto3 里所有字段默认都是"有默认值"的,和 proto2 的optional语义不一样。在 proto3 的默认写法下,字段没有赋值时取到的是零值,比如 string 取空字符串,int 取 0。Python 生成的代码里,request.name直接能取到值,但没法区分"没传 name"和"传了空字符串 name"。
这一点在接口兼容上非常重要。旧客户端没传新字段,新服务端读到的是默认值;如果你把 0 或空字符串当成"真实输入"处理,就可能产生线上问题。常规做法是:给字段设计合理的默认值语义,比如 0 就是合法状态;或者在 proto 里显式用optional修饰需要区分未赋值的字段,再用HasField()判断。以我的经验,内部接口在早期尽量别为了省事复用语义重叠的默认值。
5.5 端口占用和启动失败
服务端启动时报 "Address already in use" 很常见。但 gRPC 的add_insecure_port在端口被占用时不会立即抛错,而是返回 0,所以从日志看可能根本没输出报错。建议启动逻辑里判断返回值:
port = server.add_insecure_port("[::]:50051") if port == 0: raise RuntimeError("端口绑定失败,请检查 50051 是否被占用")这样失败时能快速定位。我因为在端口绑定了两个相同服务,折腾过一阵子,总觉得是代码逻辑问题,最后发现就是端口冲突没有及时暴露。
6. 进阶使用:拦截器、健康检查与生产环境建议
6.1 拦截器:横切逻辑的正确入口
前面说每个方法手动加日志,等接口多了就发现到处重复。更规范的做法是写拦截器。gRPC 在 Python 里支持客户端拦截器和服务端拦截器。客户端拦截器里的一个实用场景是统一设置默认超时:
class DefaultTimeoutInterceptor(grpc.UnaryUnaryClientInterceptor): def intercept_unary_unary(self, continuation, client_call_details, request): client_call_details = client_call_details._replace(timeout=5.0) return continuation(client_call_details, request) channel = grpc.insecure_channel("localhost:50051") channel = grpc.intercept_channel(channel, DefaultTimeoutInterceptor())这样所有通过该 channel 发起的一元调用都会带上 5 秒超时,省去每个业务方法自己传 timeout 的重复工作。服务端拦截器适合做统一鉴权和链路追踪,思路类似,只是包装的是 service handler,代码相对复杂一些,用到的时候再研究也不迟。
6.2 健康检查:让负载均衡知道你活着
内部服务接入服务发现或者负载均衡时,最好实现 gRPC 健康检查协议。这里可以直接用现成实现,省得自己造轮子:
pip install grpcio-health-checking服务端接入:
from grpc_health.v1 import health, health_pb2, health_pb2_grpc health_servicer = health.HealthServicer(experimental_thread_safe=True) health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server) health_servicer.set("", health_pb2.HealthCheckResponse.SERVING)这样负载均衡组件就能通过专用的 health 接口确认服务实例的真实状态,而不是靠"TCP 端口通不通"来判断。端口通和业务可用完全是两回事,健康检查是生产环境不应该省的环节。
6.3 channel 复用与连接调优
高并发场景,我会优先复用全局 channel,同时打开 keepalive:
channel = grpc.insecure_channel( "target:50051", options=[ ("grpc.keepalive_time_ms", 30000), ("grpc.keepalive_timeout_ms", 10000), ("grpc.http2_max_pings_without_data", 0), ] )keepalive 的作用是定期发 ping 包探测连接健康,避免中间网络设备把空闲连接回收。但 keepalive 间隔不要设太短,比如 5 秒,连接数量大时会平白增加不少 ping 流量。30 秒是比较平衡的值,既不会让空闲连接被回收,也不会把心跳包刷得太频繁。
6.4 要不要开 TLS
内部网络如果可信,开发阶段用insecure_channel完全没问题。但真实生产环境建议上 TLS,尤其是跨机房或者经过公网路径的调用。gRPC 的 TLS 在 Python 里用grpc.secure_channel传入凭据即可,不建议在应用层自己做加密,直接依赖传输层更可靠。
如果暂时不能上证书,至少要在访问边界上加好来源 IP 白名单和防火墙策略,把服务端口暴露范围控制到最小。这个建议不是理论说教,我们内部就出现过一次测试端口暴露到公网,被扫描器盯上的情况,从那以后新服务一律开启双向 TLS 或者走内部专用网络。
6.5 把生成代码纳入构建流水线
最后一点是我最想强调的:proto 编译这一步尽量固定下来。要么用 Makefile,要么写在 CI 脚本里,确保"源码改动 -> 重新生成 pb2 -> 跑测试"这个流程是自动的。很多团队手改生成代码或者不校验版本,最后线上接口出现诡异的字段错乱,查到底都是生成代码和服务端版本没对齐。
我在某个项目里用的是这种方式,在 Makefile 里写:
proto: python -m grpc_tools.protoc \ -I./protos \ --python_out=./generated \ --grpc_python_out=./generated \ ./protos/*.proto每次 proto 有变更,记得同时更新两端依赖的版本,并重新编译生成 pb2。版本号、proto 文件哈希这些信息建议和构建日志挂在一起,方便回滚定位。
如果你也正在"要不要上 gRPC"的边缘犹豫,我的建议是先拿一个非核心接口练手,把 .proto 编译、服务端启动、客户端调用这条链路跑通,再渐进式扩大范围。把 timeout 和错误码的理解放在第一批要解决的清单里,这两件事决定了你线上踩坑时的排查速度。我在实际迁移中最大的体会是:协议选择本身并不神秘,真正让人头疼的往往是版本、生成代码和连接配置这些小细节,把这些基础打牢,gRPC 用起来其实很顺手。