gRPC + Protobuf 服务间通信实战:从 JSON over HTTP 到二进制 RPC,代码生成、超时、流式与安全全流程

当你的服务开始互相喊话,JSON over HTTP 就不够用了

个人站长做久了,服务会越来越多。主站是一个 PHP 应用,旁边跑着一个 Python 的图片处理服务,还有一个 Go 写的短链服务,再往后可能加一个 Node 的推送网关。这些服务之间总得互相调用:主站要把用户上传的图片丢给处理服务,处理服务处理完要回调通知,短链服务要问主站这个域名是不是白名单。

一开始大家都很自然地上 REST + JSON:A 服务 POST 一个 JSON 过去,B 服务返回一个 JSON。简单、直观、随手就写。但当你真的开始在意性能、类型安全和接口演进时,会慢慢发现几个别扭的地方。

第一,JSON 太啰嗦、解析太慢。一个简单的用户对象,JSON 里每个字段名都要写一遍字符串,网络传输里一半以上的字节都是字段名本身;服务端 Java、Python 这类语言解析 JSON 要把字符串映射成对象,开销不小。第二,没有强类型契约。接口靠文档和约定维护,A 服务改了字段名,B 服务要等到运行时才报错。第三,没有代码生成。客户端、服务端、类型定义、序列化逻辑全靠手写,改一个字段要动五个地方。

这套问题的标准答案,就是 gRPC + Protocol Buffers。这篇文章讲清楚它们是什么、为什么快、怎么在自己的一两台服务器上跑起来,以及有哪些坑。

Protobuf 到底解决了什么

Protocol Buffers(简称 protobuf)是 Google 的序列化格式。和 JSON 相比,它有几个根本性的不同。

首先,它用数字编号代替字段名。你在 .proto 文件里定义消息时,会给每个字段一个编号(field number),序列化出来的二进制里只带着编号和值,不带字段名。一个 user_id 字段在 JSON 里要占 10 个字节("user_id":),在 protobuf 里只占 1 个字节的编号。对于字段多、调用频繁的接口,这一下就能省掉一半以上的带宽。

其次,它是强类型且可代码生成的。.proto 文件是接口的唯一事实来源,你用 protoc 编译器把它编译成 Go、Python、PHP、Java、Node 各种语言的代码。客户端和服务端用的是同一份定义,改字段时类型不匹配在编译期就报错,而不是等线上运行时。

第三,它天然支持向后兼容。因为字段是用编号标识的,你可以安全地新增字段、废弃字段,只要不重用同一个编号,新旧版本的服务就能互相通信。老版本收到不认识的编号会直接跳过,这正是「接口演进」最需要的能力。

一个最简的 proto 定义长这样:

// user.proto
syntax = "proto3";

package demo;

message User {
  int64  user_id    = 1;
  string username   = 2;
  string email      = 3;
  bool   is_active  = 4;
}

message GetUserRequest {
  int64 user_id = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

注意 = 1、= 2 这些编号不是赋值,是字段标识符。service 块则定义了 RPC 方法,这是 gRPC 的接口定义。

关于字段编号,有一条铁律必须记住:编号一旦上线就不能改、不能重用。你把 email 从 3 号删掉,以后就不能再用 3 号给别的字段,否则老客户端会把新字段的值当成 email 解析,数据直接错乱。废弃字段要显式标 reserved:

message User {
  int64  user_id = 1;
  string username = 2;
  reserved 3;                  // 曾经是 email
  reserved "old_field_name";   // 同时保留字段名
}

gRPC 是什么:把 protobuf 装进 HTTP/2

protobuf 只解决「数据长什么样、怎么序列化」。gRPC 是在它之上的一整套 RPC 框架:它规定了怎么发请求、怎么传错误、怎么流式传输、怎么做超时和认证。

gRPC 的传输层跑在 HTTP/2 上,这一点极其关键,它带来三个直接的好处:

  • 多路复用:一条 TCP 连接上可以并发跑无数个请求,互相不阻塞。不像 HTTP/1.1 那样一个连接同时只能处理一个请求(要靠开多条连接来并发)。
  • 头部压缩(HPACK):请求头里的重复内容(如 :method、content-type)被压缩,减少开销。
  • 二进制帧:可以双向流式传输,客户端和服务端可以像打电话一样持续互发消息。

gRPC 有四种调用模式:

  • 一元(Unary):一发一收,最常用,等价于普通 REST 调用。
  • 服务端流(Server streaming):客户端发一个请求,服务端持续推回多条消息,比如订阅日志。
  • 客户端流(Client streaming):客户端持续发多条,服务端最后回一个汇总,比如上传大文件分片。
  • 双向流(Bidirectional streaming):双方都能持续互发,比如实时聊天、协同编辑。

对比 REST:REST 用 URL 表达「资源」,靠 HTTP 方法(GET/POST)和状态码表达语义,适合对外、面向资源的 API;gRPC 用方法名直接表达「动作」,二进制传输,适合内部服务之间高频、强类型的调用。经验法则是:对内服务间通信用 gRPC,对外给浏览器和第三方用 REST。

动手:用 Python 起一个 gRPC 服务

先装工具链。protobuf 的编译器 protoc 加上语言对应的插件:

# Debian / Ubuntu
apt install -y protobuf-compiler
pip install grpcio grpcio-tools

然后从 .proto 生成代码。假设文件叫 user.proto:

python3 -m grpc_tools.protoc \
  -I. \
  --python_out=. \
  --grpc_python_out=. \
  user.proto

这会生成两个文件:user_pb2.py(消息类)和 user_pb2_grpc.py(服务桩)。这两个文件都是生成的,不要手改,改了重新生成就没了。

服务端代码:

# server.py
from concurrent import futures
import grpc
import user_pb2
import user_pb2_grpc

class UserService(user_pb2_grpc.UserServiceServicer):
    def GetUser(self, request, context):
        # request.user_id 是强类型的 int64,不需要手动 parse
        if request.user_id <= 0:
            context.abort(grpc.StatusCode.INVALID_ARGUMENT, "user_id 必须为正数")
        return user_pb2.GetUserResponse(
            user=user_pb2.User(
                user_id=request.user_id,
                username="alice",
                email="alice@example.com",
                is_active=True,
            )
        )

def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    user_pb2_grpc.add_UserServiceServicer_to_server(UserService(), server)
    # 只监听回环,避免裸奔
    server.add_insecure_port("127.0.0.1:50051")
    server.start()
    print("gRPC 服务已启动,监听 127.0.0.1:50051")
    server.wait_for_termination()

if __name__ == "__main__":
    serve()

客户端代码:

# client.py
import grpc
import user_pb2
import user_pb2_grpc

def run():
    with grpc.insecure_channel("127.0.0.1:50051") as channel:
        stub = user_pb2_grpc.UserServiceStub(channel)
        resp = stub.GetUser(user_pb2.GetUserRequest(user_id=42), timeout=3)
        print("用户名:", resp.user.username)
        print("是否活跃:", resp.user.is_active)

if __name__ == "__main__":
    run()

注意 timeout=3 这个参数——gRPC 调用一定要设超时。不设超时的话,对端如果卡死或网络半开,你的调用会一直挂着,把线程池耗光,最终整个服务雪崩。这是分布式系统里最容易被忽略、后果最严重的一个点。

用 PHP 调 gRPC:个人站最实际的场景

很多站长的主站是 PHP,那 PHP 侧怎么调用 gRPC?两种方式:

第一种是用 grpc/grpc 扩展(需要装 C 扩展 + grpc_php_plugin),性能最好,但安装相对麻烦,要编译。第二种是用纯 PHP 的 google/protobuf 包配合 HTTP/2 客户端。个人站更简单粗暴的做法是:gRPC 服务之间用 gRPC,主站和它们之间还是走 HTTP/JSON 网关。

具体就是在 gRPC 服务前面加一个反向代理(grpc-gateway 或者自己写一个薄封装),把 HTTP/JSON 转成 gRPC。这样 PHP 侧完全不用改,继续发 JSON,只在内部服务间享受 gRPC 的性能。这其实是一个非常实用的架构折中。

如果一定要在 PHP 里直接用 gRPC,用 protoc 的 PHP 插件生成代码:

protoc -I. \
  --php_out=./gen \
  --grpc_out=./gen \
  --plugin=protoc-gen-grpc=/usr/local/bin/grpc_php_plugin \
  user.proto

错误处理:不要用字符串传错误信息

gRPC 有一套标准的错误模型,比 HTTP 状态码更细。最重要的是 Status 和 StatusCode。常用的几个状态码:

  • OK:成功。
  • INVALID_ARGUMENT:参数不合法(对应 HTTP 400 的语义)。
  • NOT_FOUND:资源不存在。
  • PERMISSION_DENIED:无权限。
  • UNAUTHENTICATED:未认证。
  • DEADLINE_EXCEEDED:超时。
  • UNAVAILABLE:服务不可用(对端挂了或网络不通)。
  • RESOURCE_EXHAUSTED:限流或资源耗尽。

服务端用 context.abort() 返回错误,客户端捕获 grpc.RpcError 并读 e.code() 判断。这里有个常见的坑:不要把 UNAVAILABLE 和 DEADLINE_EXCEEDED 之外的所有错误都当成「可以重试」。INVALID_ARGUMENT 重试一百次还是失败,重试应该只针对 UNAVAILABLE、DEADLINE_EXCEEDED 这类暂态错误,而且要退避重试。

import grpc
import time

def call_with_retry(stub, req, retries=3):
    for i in range(retries):
        try:
            return stub.GetUser(req, timeout=3)
        except grpc.RpcError as e:
            if e.code() in (grpc.StatusCode.UNAVAILABLE,
                            grpc.StatusCode.DEADLINE_EXCEEDED):
                time.sleep(2 ** i)   # 指数退避
                continue
            raise                    # 其他错误直接抛出
    raise RuntimeError("重试耗尽")

安全:默认的 gRPC 是明文的

这是最容易出事的点。 gRPC 默认使用「insecure channel」——完全不加密、不认证。你的 .proto 定义里也不带任何鉴权信息。如果你把 gRPC 服务监听到 0.0.0.0:50051 并暴露公网,任何知道接口定义的人都能直接调用它,读写你的数据。

正确的做法:

  • 服务只监听回环或内网地址。跨机通信走内网/WireGuard/SSH 隧道,绝不直接暴露公网端口。
  • 启用 TLS。用 grpc.ssl_channel_credentials() 配置服务端证书,客户端用 grpc.secure_channel()。如果是自建 CA,客户端要带上 CA 根证书。
  • 加上认证。最简单的做法是通过 metadata 传一个共享 token,服务端用拦截器(Interceptor)校验;更正式的做法是用 mTLS(双向认证),客户端和服务端互相验证证书。
# 服务端启用 TLS + 拦截器认证
import grpc

def auth_interceptor(context, method, request, response):
    # 从 metadata 取 token 校验
    token = dict(context.invocation_metadata()).get("x-auth-token")
    if token != EXPECTED_TOKEN:
        context.abort(grpc.StatusCode.UNAUTHENTICATED, "无效 token")
    return True

五个必踩的坑

  1. 字段编号乱用 / 重用:上线后改了编号,或者把废弃字段的编号给新字段用,会导致新旧版本互相解析错乱。废弃一律用 reserved。
  2. 不设超时:gRPC 调用默认没有超时,对端卡死会拖垮整个调用方。每个 RPC 都要显式设 deadline。
  3. 明文暴露公网:默认 insecure channel + 无认证,公网暴露等于把接口送人。绑回环 / 内网 + TLS + 认证。
  4. 盲目重试所有错误:只对 UNAVAILABLE / DEADLINE_EXCEEDED 退避重试,参数错误重试毫无意义还会放大故障。
  5. 把生成的代码手改:_pb2.py、_pb2_grpc.py 都是生成产物,手改下次生成就丢。要定制就改 .proto 或在外面包一层。

小结

gRPC + Protobuf 解决的是「服务间高频、强类型、需要演进」的通信问题。它的核心优势是二进制紧凑、代码生成免手写、字段编号天然向后兼容、HTTP/2 多路复用不阻塞。对个人站来说,最实用的落法是:对外继续保持 REST/JSON 不变,把内部那些互相喊话的服务改成 gRPC,尤其是图片处理、推送、短链这类调用量大、数据结构固定的服务,收益最明显。

起步阶段不用一上来就搞全套 TLS + 拦截器,先从「监听到回环 + 显式超时 + reserved 管理字段编号」这三件最基础的事做起,就能避开绝大多数翻车场景。

Last modification:October 12th, 2026 at 09:27 pm

Leave a Comment