tonic 写起来很顺,线上跑起来要补这些边界
gRPC 的开发体验很好,但生产环境真正考验的不是
.proto,而是 deadline、metadata、消息大小、错误语义和流式背压。
用 tonic 写一个 demo 很快:定义 proto,生成 trait,实现 service,启动 server。类型安全、代码干净、客户端调用像本地函数。
但如果把 gRPC 当成“更快的 HTTP JSON”,很多边界会被忽略:
请求有没有 deadline?
metadata 会不会过大?
单条消息超过默认限制怎么办?
Status 是业务错误还是系统错误?
streaming 客户端太慢时怎么办?前面 手搓 gRPC 已经拆过协议本体。这篇不再讲 5 字节帧和 HTTP/2,而是讲 tonic 在应用层最容易漏的工程边界。
本文代码环境:
# Cargo.toml
[dependencies]
prost = "0.13"
tokio = { version = "1", features = ["full"] }
tonic = "0.12"
tracing = "0.1"proto 不是随便写的结构体
很多 gRPC 问题从 proto 就开始了。
message CreateOrderRequest {
string user_id = 1;
repeated string sku_ids = 2;
string idempotency_key = 3;
}这里最关键的字段不是 user_id,而是 idempotency_key。
服务间调用迟早会遇到重试。只要接口可能被重试,协议里就要给幂等留下位置。否则客户端超时以后不知道服务端到底有没有执行成功,再发一次就可能重复创建。
proto 设计里有几个保守规则:
| 规则 | 原因 |
|---|---|
| 字段只增不改 tag | tag 是 wire format 的身份 |
| 不复用删除字段的 tag | 老客户端可能还在发 |
| 请求里保留幂等键 | 给超时重试留出口 |
| 大 payload 不走 unary | 改成 streaming |
| 错误不要塞进 response | 用 Status 表达调用失败 |
proto 是长期契约,不是内部 struct 的导出版。
Status 要分清业务错误和系统错误
tonic 里错误通常返回 Status:
use tonic::Status;
fn validate_user_id(user_id: &str) -> Result<(), Status> {
if user_id.is_empty() {
return Err(Status::invalid_argument("user_id is empty"));
}
Ok(())
}这比随便返回 internal 好很多。
gRPC 的 status code 是给调用方做决策的。invalid_argument 不该重试,unavailable 可以重试,deadline_exceeded 说明预算用完。
| 场景 | Status |
|---|---|
| 参数非法 | invalid_argument |
| 资源不存在 | not_found |
| 权限不足 | permission_denied |
| 下游不可用 | unavailable |
| 超时 | deadline_exceeded |
| 未知 bug | internal |
我的偏好是:只有真的不可预期,才用 internal。
把所有错误都变成 internal,等于让调用方失去重试、降级和告警分类的依据。
metadata 不是垃圾桶
gRPC metadata 很方便,认证 token、request-id、trace 信息都可以放进去。
use tonic::{Request, Status};
fn request_id<T>(req: &Request<T>) -> Result<String, Status> {
req.metadata()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.ok_or_else(|| Status::invalid_argument("missing x-request-id"))
}但 metadata 不适合塞业务字段,也不适合塞大对象。
metadata 本质上走 HTTP/2 header。它会受到 header 大小、代理配置、网关限制影响。塞太多东西进去,问题会很隐蔽:本地通了,经过网关就被拒。
适合放 metadata 的东西:
- request-id
- traceparent
- authorization
- tenant-id
- 小型路由标签
不适合放:
- 大 JSON
- 业务 payload
- 可变字段集合
- 调试时临时塞的一堆上下文
metadata 是调用上下文,不是业务请求体。
deadline 要从入口一路传下去
gRPC 比 HTTP JSON 更容易让人忘记 deadline,因为调用方式太像本地函数。
服务端应该把当前请求预算继续传给下游,而不是每层重新开一个固定超时。
use std::time::Duration;
use tonic::{Request, Status};
fn remaining_budget<T>(_req: &Request<T>) -> Duration {
// 真实项目里通常从 grpc-timeout 或统一上下文里解析。
Duration::from_millis(800)
}下游调用时用这个预算:
async fn call_inventory<T>(req: Request<T>) -> Result<(), Status> {
let budget = remaining_budget(&req);
tokio::time::timeout(budget, async {
Ok::<_, Status>(())
})
.await
.map_err(|_| Status::deadline_exceeded("inventory timeout"))??;
Ok(())
}这里的重点不是代码,而是策略:gRPC 方法不是本地函数,它仍然是网络调用,必须有时间预算。
没有 deadline 的 gRPC,比没有 timeout 的 HTTP 更危险,因为它更容易被包装成“普通 async 函数”到处传。
单条消息大小要明确
gRPC 默认不适合传大文件。
大 payload 用 unary 调用会遇到三个问题:
- 单条消息大小限制
- 内存一次性分配
- 失败后只能整条重试
如果确实要传大对象,优先考虑流式分块:
#[derive(Debug)]
struct UploadChunk {
offset: u64,
data: Vec<u8>,
}
fn validate_chunk(chunk: &UploadChunk) -> Result<(), Status> {
if chunk.data.len() > 512 * 1024 {
return Err(Status::invalid_argument("chunk too large"));
}
Ok(())
}chunk 大小不要拍脑袋写很大。每个 chunk 都是内存压力、网络重传成本和背压粒度。
大对象用 streaming,小对象用 unary。 这条规则很朴素,但能避开很多性能和稳定性问题。
streaming 的核心是背压
Server streaming 看起来像这样:
use tokio::sync::mpsc;
fn stream_numbers() -> mpsc::Receiver<Result<i32, Status>> {
let (tx, rx) = mpsc::channel(16);
tokio::spawn(async move {
for i in 0..100 {
if tx.send(Ok(i)).await.is_err() {
break;
}
}
});
rx
}这里故意用有界 channel。
如果客户端读得慢,无界 channel 会一直堆消息。streaming 本来是为了避免一次性加载大对象,结果你在服务端又造了一个无限 buffer。
有界 channel 的好处是:客户端慢,下游 send 就会等,压力能传回生成数据的地方。
这和 背压 是同一个问题:streaming 不是无限发送,streaming 是边读边受控地发送。
tonic 适合什么,不适合什么
我的判断:
| 场景 | 是否适合 tonic |
|---|---|
| 内部微服务强类型 RPC | 适合 |
| 多语言服务间通信 | 适合 |
| 双向流、服务端流 | 很适合 |
| 浏览器直接调用 | 不适合,除非 gRPC-Web |
| 对外开放 API | 谨慎,HTTP JSON 更容易接入 |
| 大文件传输 | 用 streaming,别 unary |
tonic 的优势不是“比 HTTP JSON 快一点”,而是强类型契约、streaming、HTTP/2 和生态集成。
如果服务只是 CRUD,前端和第三方都要直接接,HTTP JSON 仍然更省心。
结论
tonic 把 gRPC 写得很顺,但生产边界仍然要自己设计。
可以直接记这几条:
- proto 是长期契约,不要当内部 struct 导出
- status code 要表达调用方可执行的决策
- metadata 只放调用上下文,不放业务大对象
- deadline 要从入口传下去
- 大 payload 用 streaming,不要硬塞 unary
- streaming 必须考虑背压
- 对外 API 不要默认 gRPC,先看接入成本
tonic 负责把 gRPC 变好用,你负责把调用语义设计清楚。
没有 deadline、错误分类、消息大小和背压,gRPC 只是把问题从 JSON 接口搬到了 HTTP/2 stream 上。