2023 年我把团队内部几个服务之间的调用,从 REST/JSON 改成了 gRPC。第一版我做得很省事:写了个 .proto 文件,生成客户端和服务端代码,服务之间直接互相调用。本地一测——真香:比 JSON 快,还带类型检查,改个字段编译器立刻报错。我心里很踏实:"gRPC 嘛,不就是一个更快、带类型的 HTTP 接口。"可等它真正上线、跑在真实的多服务环境里,一串问题冒了出来。第一种:我给 proto 加了一个字段、重新发布,结果还没升级的老服务调用过来,数据全错乱了——我以为加字段是无害的。第二种最要命:某个下游服务变慢了,结果调用它的服务,线程全被卡死,接着调用那个服务的服务也卡死——一条链路全线雪崩。第三种:我给一个下游扩到了三个实例做负载均衡,结果流量几乎全压在一个实例上,另外两个闲着。第四种:我处理错误的方式,还是 HTTP 那套——返回里塞个错误码字段,调用方得自己解析。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"gRPC 就是一个更快、带类型的 HTTP 接口"。这句话把 gRPC 降格成了"HTTP 接口的一种实现"。可它不是。gRPC 是一个建立在 HTTP/2 长连接之上的 RPC 框架,它有自己的一整套规则:契约怎么演进、超时怎么逐级传播、长连接下负载均衡为什么会失效、错误用什么模型表达、调用有哪四种模式——这些全都不是"HTTP 接口"的思维能覆盖的。真正用好 gRPC,核心不是"写个 proto、生成代码、互相调用",而是理解它作为一个 RPC 框架的契约规则、连接模型和错误模型。这篇文章就把 gRPC 梳理一遍:为什么"当成更快的 HTTP 接口"上线就出问题、proto 契约该怎么演进才不破坏兼容、deadline 为什么必须逐级传播、gRPC 长连接为什么在普通负载均衡下不均衡、错误为什么要用 status,以及四种调用模式、拦截器、连接保活这些把 gRPC 真正做对要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:把内部服务调用从 REST 换成 gRPC 后,上线冒出一串问题:给 proto 加字段导致老服务数据错乱;一个下游变慢引发整条链路雪崩;下游扩了多个实例流量却全压在一个上;错误处理还在沿用 HTTP 那套塞错误码字段。
我当时的错误认知:"gRPC 就是一个更快、带类型的 HTTP 接口,会写 HTTP 接口就会用它。"
真相:gRPC 是一个跑在 HTTP/2 长连接上的 RPC 框架,它有自己的规则:proto 契约靠字段编号演进,加删字段有严格规矩;deadline 必须沿调用链传播,否则一个慢服务会拖垮整条链;长连接会让 L4 负载均衡失效,需要客户端侧负载均衡;错误用 status code 表达,不是塞进返回体。用好 gRPC 的工程量,全在这些"它不是 HTTP 接口"的地方。
要把 gRPC 用对,需要几块认知:
- 为什么"当成更快的 HTTP 接口"会出问题——它是 RPC 框架,不是接口实现;
- proto 契约演进——字段编号是命脉,加字段安全、删改危险;
- deadline 传播——不设超时,一个慢服务拖垮整条调用链;
- 连接级负载均衡——长连接为什么让普通负载均衡失效;
- status 错误模型、四种调用模式、拦截器这些工程坑怎么处理。
一、为什么"当成更快的 HTTP 接口"会出问题
先把这件最根本的事钉死:gRPC 不是一个"接口",它是一整套 RPC 框架。它管的事情远不止"把请求发过去、把响应收回来":它用 proto 文件作为服务双方的强契约,用 HTTP/2 的一条长连接承载所有调用,用 deadline 机制做超时控制,用 status 体系表达错误。你只把它当"更快的 HTTP",就只用到了它的皮毛,而它那些不同于 HTTP 的机制,会在你没注意的地方反咬你一口。
下面是我那个"上线出问题"的第一版——一个 proto 和一段朴素的调用:
// user.proto —— 第一版,我没多想就这么写了
syntax = "proto3";
message GetUserRequest {
int64 user_id = 1;
}
message GetUserResponse {
int64 user_id = 1;
string name = 2;
}
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
import grpc
import user_pb2, user_pb2_grpc
# 反面教材:像调一个普通函数一样调 gRPC,什么都没多管
def get_user_name(user_id: int) -> str:
channel = grpc.insecure_channel("user-service:50051")
stub = user_pb2_grpc.UserServiceStub(channel)
resp = stub.GetUser(user_pb2.GetUserRequest(user_id=user_id))
return resp.name
# 破绽一:没设 deadline —— 下游一卡,这里就无限期地等下去。
# 破绽二:每次调用都新建 channel —— 浪费,且没法做连接级负载均衡。
# 破绽三:没有任何错误处理 —— 下游返回的 status 错误会直接抛异常。
这段代码能跑、本地也快,它的问题不在代码本身,而在一个被忽略的前提:它默认"调 gRPC 就像调一个本地函数,发过去、拿回来就完了"。可 gRPC 是远程调用,中间隔着网络、隔着一个可能会慢、会挂、会被扩容的下游服务。于是那串问题就有了解释:没设 deadline,下游一卡,调用方就无限期地干等,线程全被占住;每次新建 channel,既浪费又让负载均衡无从谈起;不处理 status,下游一报错就直接抛异常。而 proto 加字段出错,则是另一个层面的问题——它暴露了我对 proto 契约规则的无知。问题的根子清楚了:gRPC 的每一个机制——proto、连接、deadline、status——你不专门去理解它、用对它,它就会出问题。先从最容易踩、后果最隐蔽的 proto 契约说起。
二、Proto 契约演进:字段编号是命脉
proto 文件,是 gRPC 服务调用双方之间的契约。它不是一份"随便改改"的代码——它一旦被多个服务依赖,任何改动都得遵守严格的兼容规则。理解这些规则的钥匙,是搞懂一件事:proto 在网络上传输时,靠的根本不是字段名,而是字段后面那个数字——字段编号。
// user.proto —— 演进后的正确写法
syntax = "proto3";
message GetUserResponse {
int64 user_id = 1;
string name = 2;
// 安全:新增字段用一个全新的、从没用过的编号。
// 老客户端不认识 5 号字段,会直接忽略它 —— 不会出错。
string email = 5;
// 假设 3 号、4 号字段曾经存在、现已废弃:
// 必须用 reserved 把它们的编号和名字永久锁死,
// 杜绝后人复用这两个编号造成新老数据错乱。
reserved 3, 4;
reserved "phone", "address";
}
这里的规则必须刻进肌肉记忆。安全的操作:新增字段——只要用一个全新的、没被用过的编号,老客户端不认识它、会自动忽略,新客户端能用,双方相安无事。危险的操作:改字段编号——等于把这个字段彻底换了一个身份,新老服务对不上;复用废弃的编号——新字段借用了旧字段的编号,老服务发来的旧数据会被新服务当成新字段解析,数据彻底错乱(这正是我开头那个 bug)。改字段类型——比如把 int32 改成 string,二进制解析直接崩。所以正确的姿势是:字段只增不减;要废弃一个字段,不是删掉它,而是用 reserved 把它的编号和名字"封存"起来,像上面那样——这等于立一块墓碑,告诉所有后来者"这两个编号有主了,谁都不许再用"。把 proto 当成一份只能向后兼容地演进的契约来对待,你才不会再炸掉线上的老服务。契约的事理顺了,下一个更致命的,是超时。
三、Deadline 传播:不设超时,一个慢服务拖垮整条链
开头那个"一条链路全线雪崩",根子就是没有 deadline。在一个微服务系统里,调用往往是一条链:A 调 B,B 调 C,C 调 D。如果D 变慢了,而调用全程没有超时控制,会发生什么?C 无限地等 D,于是 C 的线程被占住;B 无限地等 C,B 的线程被占住;A 无限地等 B……一个服务的慢,会沿着调用链,把上游全部拖死。gRPC 给的解药,是 deadline:
import grpc
import user_pb2, user_pb2_grpc
def get_user_name(stub, user_id: int) -> str:
"""每一次 gRPC 调用,都必须带上 deadline。"""
try:
# timeout=2 表示:这次调用最多等 2 秒,到点还没返回就放弃
resp = stub.GetUser(user_pb2.GetUserRequest(user_id=user_id),
timeout=2.0)
return resp.name
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
# 到点了下游还没回 —— 快速失败,绝不无限期地等下去
raise TimeoutError(f"GetUser 超过 2 秒未返回")
raise
但 deadline 真正精妙的地方,是它会沿着调用链自动传播。gRPC 的 deadline,本质是一个"绝对的截止时间点"。当 A 带着"5 秒后截止"的 deadline 调 B,这个截止时间点会随着请求传给 B;B 再调 C 时,gRPC 知道"距离总截止只剩 3.5 秒了",于是 C 收到的 deadline 就是剩下的 3.5 秒。这意味着:一旦整条链的总时间到了 A 设定的上限,链路上所有还在进行的调用,会一起被取消。服务端要做的,是主动配合这个取消——在干重活之前,先看一眼调用方还等不等:
def GetUser(self, request, context):
"""服务端:动手干活前,先确认调用方还在等。"""
# 调用方可能早就超时放弃了,此时再吭哧吭哧干活纯属浪费
if not context.is_active():
context.cancel()
return user_pb2.GetUserResponse()
user = self._query_db(request.user_id) # 这步可能较慢
# 慢操作之后再确认一次:别把结果发给一个已经不等了的调用方
if not context.is_active():
return user_pb2.GetUserResponse()
return user_pb2.GetUserResponse(user_id=user.id, name=user.name)
deadline 这套机制的意义在于:它让"慢"这件事有了边界。没有 deadline,一个慢服务的影响是无限蔓延的;有了 deadline 且逐级传播,慢服务的影响被牢牢摁在一个时间窗口内——到点,整条链一起干净利落地失败,把线程、连接统统释放出来,而不是一起卡死等到天荒地老。所以有一条铁律:每一个 gRPC 调用,都必须带 deadline,一个都不能漏。超时管住了,下一个反直觉的坑,是负载均衡。
四、连接级负载均衡:长连接为什么让普通负载均衡失效
开头那个"三个实例,流量全压在一个上",是 gRPC 最反直觉的坑,根子在 HTTP/2 长连接。传统 HTTP/1.1 是一个请求一个连接(或短连接复用),所以一个 四层(L4)负载均衡器,能把一个个连接均匀地分到各个后端实例上。但 gRPC 跑在 HTTP/2 上,它建立一条长连接,然后把成千上万个请求,全都塞进这一条连接里。这下 L4 负载均衡就失效了:它只看到一条连接,把这一条连接分给了某个实例——于是这条连接上的所有请求,全去了那一个实例。解法是:把负载均衡从"连接级"提升到"请求级",而且要在客户端侧做:
import grpc
# 关键:用 dns 解析出下游【所有实例】,并启用客户端侧轮询
LB_CONFIG = '{"loadBalancingConfig": [{"round_robin": {}}]}'
def make_balanced_channel(target: str) -> grpc.Channel:
"""创建一个会在多个后端实例间做请求级轮询的 channel。"""
return grpc.insecure_channel(
f"dns:///{target}", # dns:/// 让 gRPC 解析出全部实例
options=[
("grpc.service_config", LB_CONFIG), # 启用 round_robin 策略
("grpc.enable_retries", 1),
],
)
# 这样 gRPC 会和每个实例各建一条连接,把【每个请求】
# 轮流发往不同实例 —— 负载均衡的粒度从"连接"变成了"请求"。
这段配置的关键,在 dns:/// 和 round_robin 这两样东西。dns:/// 让 gRPC 客户端自己去解析出下游服务的所有实例地址,而不是只拿到一个。round_robin 让客户端和每一个实例都建立一条连接,然后把每一个请求,轮流地发到不同实例上。这样,负载均衡的粒度就从"分配连接"变成了"分配请求"——三个实例这才真正均摊了流量。这里的认知要点是:gRPC 的负载均衡,通常要在客户端这一侧解决(或者借助专门理解 HTTP/2 的七层代理、或服务网格)。如果你只是在 gRPC 服务前面挂一个传统的 L4 负载均衡器,就以为万事大吉,那基本等于没做负载均衡。负载均衡说清了,最后是几个绕不开的工程坑。
五、错误处理与工程坑:status、调用模式与拦截器
五块设计之外,还有几个工程坑,不处理就会让 gRPC 用得别别扭扭。坑 1:错误要用 gRPC 的 status 模型,别沿用 HTTP 那套。我开头犯的错,是把错误码塞进正常的返回消息里。gRPC 有自己的一套错误模型:每个响应都自带一个 status code(OK、NOT_FOUND、PERMISSION_DENIED、DEADLINE_EXCEEDED 等)。服务端出错,就设置 status:
def GetUser(self, request, context):
"""服务端:用 status code 表达错误,而不是塞进返回消息。"""
if request.user_id <= 0:
# 参数错误:设置 status,而不是返回一个带 error 字段的空响应
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "user_id 必须为正")
user = self._query_db(request.user_id)
if user is None:
context.abort(grpc.StatusCode.NOT_FOUND, f"用户 {request.user_id} 不存在")
return user_pb2.GetUserResponse(user_id=user.id, name=user.name)
def safe_get_user(stub, user_id: int):
"""客户端:按 status code 分类处理错误。"""
try:
return stub.GetUser(user_pb2.GetUserRequest(user_id=user_id), timeout=2.0)
except grpc.RpcError as e:
code = e.code()
if code == grpc.StatusCode.NOT_FOUND:
return None # 用户不存在,业务上正常
if code == grpc.StatusCode.INVALID_ARGUMENT:
raise ValueError(e.details()) # 参数错,是调用方的锅
if code == grpc.StatusCode.UNAVAILABLE:
raise ConnectionError("下游服务不可用,可重试")
raise
用 status 模型的好处是:错误是一等公民,调用方能清晰地分类处理——哪些是业务正常情况(NOT_FOUND),哪些是自己传错了参数(INVALID_ARGUMENT),哪些是下游挂了可以重试(UNAVAILABLE)。坑 2:分清四种调用模式,别全用一元调用。gRPC 有四种模式:一元(unary,一问一答,最常用)、服务端流(一个请求、持续返回多个响应,适合推送、大结果集分批)、客户端流(持续上传多个请求、一个响应,适合上传)、双向流(两边都持续收发,适合实时交互)。把本该用流的场景(比如返回一个巨大的列表)硬塞进一元调用,会造成巨大的内存峰值。坑 3:横切逻辑用拦截器,别在每个方法里重复写。日志、鉴权、监控、错误统一处理这些每个 RPC 都要做的事,该用拦截器(interceptor)统一处理:
def logging_interceptor(continuation, handler_call_details):
"""服务端拦截器:统一记录每个 RPC 的方法名和耗时。"""
import time
start = time.time()
method = handler_call_details.method
response = continuation(handler_call_details) # 放行,执行真正的 RPC
cost = (time.time() - start) * 1000
print(f"RPC {method} 耗时 {cost:.1f}ms")
return response
坑 4:长连接要配 keepalive。gRPC 的连接是长连接,中间的代理、防火墙可能会悄悄掐掉一条长时间没有数据的连接。要配 keepalive,让 gRPC 定时发心跳,既探活、又防止连接被中间设备掐断:
KEEPALIVE_OPTIONS = [
("grpc.keepalive_time_ms", 30000), # 每 30 秒发一次心跳 ping
("grpc.keepalive_timeout_ms", 10000), # ping 出去 10 秒没回,判定连接死
("grpc.keepalive_permit_without_calls", 1), # 没有进行中的调用时也保持心跳
]
channel = grpc.insecure_channel("user-service:50051", options=KEEPALIVE_OPTIONS)
坑 5:channel 要复用,不要每次调用都新建。channel 背后是真实的 TCP 长连接和一套状态,建立它有成本。一个 channel 应该在服务启动时建好、全程复用,而不是像我第一版那样每次调用都新建一个——那样既慢,又让连接级负载均衡完全没法做。下面这张图,把一次健康的 gRPC 调用串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| gRPC 的本质 | 跑在 HTTP/2 长连接上的 RPC 框架,不是更快的 HTTP 接口 |
| proto 契约 | 服务双方的强契约,传输靠字段编号而非字段名 |
| 字段编号规则 | 新增用全新编号安全,改编号改类型复用旧编号都会出错 |
| reserved | 废弃字段不删,用 reserved 封存其编号和名字防被复用 |
| deadline | 每个调用都要带,本质是绝对截止时间点 |
| deadline 传播 | 截止时间沿调用链传递,到点整条链一起取消 |
| 连接级负载均衡失效 | HTTP/2 长连接让 L4 负载均衡只能分连接不能分请求 |
| 客户端侧负载均衡 | 解析出全部实例,用 round_robin 按请求粒度轮询 |
| status 错误模型 | 错误用 status code 表达,不要塞进正常返回消息 |
| 四种调用模式 | 一元服务端流客户端流双向流,大结果集该用流 |
避坑清单
- gRPC 是 RPC 框架不是更快的 HTTP 接口,它的契约连接超时错误都有自己的规则。
- proto 传输靠字段编号,新增字段用全新编号安全,改编号改类型会出错。
- 废弃字段不要直接删,用 reserved 封存编号和名字,防后人复用。
- 每一个 gRPC 调用都必须带 deadline,一个都不能漏。
- deadline 会沿调用链传播,不设超时一个慢服务会拖垮整条链路。
- 服务端干重活前先检查 context 是否还 active,别给已放弃的调用方干活。
- HTTP/2 长连接让 L4 负载均衡失效,要在客户端侧做请求级负载均衡。
- 错误用 status code 表达,别沿用 HTTP 那套把错误码塞进返回消息。
- 大结果集和推送场景该用流式调用,硬塞进一元调用会撑爆内存。
- channel 要复用不要每次新建,长连接还要配 keepalive 防被中间设备掐断。
总结
回头看那串"加个字段老服务就错乱、一个慢服务拖垮整条链、三个实例流量全压一个"的问题,以及我后来在 gRPC 上接连踩的坑,最该记住的不是某一段调用代码,而是我动手前那个想当然的判断——"gRPC 就是一个更快、带类型的 HTTP 接口"。这句话错在它用"接口"这个词,把 gRPC 框死在了一个太小的格子里。我以为切换到 gRPC 是一次纯粹的"性能升级":还是那套 HTTP 接口的玩法,只是传输更快了、多了类型检查。可它根本不是一次性能升级,而是一次思维方式的切换——从"调用一个远程接口"切换到"使用一个 RPC 框架"。一个框架,意味着它有一整套自己的规则和机制,而这些规则,恰恰是它和 HTTP 接口最不一样的地方:proto 是契约不是数据格式,deadline 是会传播的不是本地的,连接是长的、复用的不是一次性的,错误是一等公民不是返回体里的一个字段。
所以用 gRPC,真正的工程量不在"写个 proto、生成代码、互相调用"那几步上手操作上。那几步,任何快速教程十分钟就教完了。真正的工程量,在于你要理解并用对它作为一个 RPC 框架的每一项机制:proto 是契约,你就得遵守字段编号的兼容规则,加字段而不删字段,废弃就用 reserved 封存;调用是远程的、会沿链路传递的,你就得给每个调用带上 deadline,让"慢"有边界;连接是 HTTP/2 长连接,你就得明白它会让 L4 负载均衡失效,转而在客户端侧做请求级负载均衡;错误有专门的模型,你就得用 status code 而不是塞返回体。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚"当成更快的 HTTP 接口"为什么会出问题,再把 proto 契约的演进规则抠死,用 deadline 传播接住"慢服务拖垮链路",用客户端侧负载均衡解开"长连接下流量不均",最后是 status 错误模型、四种调用模式、拦截器、keepalive 这几个把 gRPC 用扎实的工程细节。
你会发现,gRPC 和 HTTP 接口的区别,和现实里"给某人打个电话"和"和某人签一份长期供货合同"的区别完全相通。打电话(这像松散的 HTTP 接口),你想说什么就说什么,对方大概能听懂就行,这次说错了下次改口就是。可签一份长期供货合同(这才是 gRPC),完全是另一回事:合同条款(proto)是白纸黑字的契约,你不能单方面随便改——尤其不能把第 3 条的内容偷偷换掉,因为对方是按条款编号来对接的,你改了编号对应的内容,对方整个就乱了(字段编号规则);合同得写明交货期限,而且这个期限是一环扣一环的——你给下游的期限,取决于你答应上游的总期限还剩多少(deadline 传播);你和这家供应商是长期合作、专线联系,这条专线得定期维护、确认还通着(keepalive);真出了问题,合同里有明确的违约条款和分类——是缺货、是规格不符、还是暂时无法供货,各有各的处理方式(status 错误模型)。把一次 RPC 调用,从"打个电话"的随意,升级为"签一份合同"的严谨——这才是用好 gRPC 的真正心法。
最后想说,gRPC 用没用对,差距永远不会在"本地两个服务对调"时暴露——本地调用没有网络延迟、没有慢服务、没有多实例、proto 两边永远是同一版,你会觉得"写个 proto 互相调用"这几个字已经是全部。它只在真实的、有调用链、有扩缩容、有版本错配的生产环境里才显形。那时候它会用最棘手的方式给你结账:用不好,你会像我一样,被一串诡异的连锁故障折磨——加个字段炸了老服务,一个下游变慢雪崩了整条链,扩了容负载却不均,你查遍代码每一行都"对",可系统就是不稳;而用对了,你的服务间调用会又快又稳:proto 平滑地一代代演进而从不破坏老服务,某个下游慢了影响被 deadline 死死摁在一个时间窗口里,扩了容流量立刻均匀分摊,错误清晰地分类、该重试的重试该放弃的放弃。所以别等连锁故障找上门,在你决定"上 gRPC"的那一刻就该想清楚:我用的不是一个更快的接口,而是一个有自己规则的 RPC 框架——它的契约、它的超时、它的连接、它的错误,我是不是每一样都真的理解了、用对了?这些问题都有了答案,你的 gRPC 才不只是一个"看起来更快了"的技术选型,而是一套真正稳健、可演进、扛得住生产环境的服务间通信基础设施。
—— 别看了 · 2026