返回文章列表

tonic 写起来很顺,线上跑起来要补这些边界

461·3 分钟阅读
Rust微服务

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 上。