bigywater.tech
gRPC · 17

拦截器:unary 与 stream 拦截器的执行顺序

拦截器是 gRPC 里放日志、鉴权、限流、指标的地方。多个拦截器按什么顺序执行?能改什么?流式调用怎么办?handler panic 了会怎样?这篇逐项验证。

a b c → A B C → handler
客户端三层、服务端三层拦截器时,请求进入的顺序(返回时反过来)
退出码 2 / 0
handler panic 时没有与有 recover 拦截器,服务进程的结局
+0.8 µs
10 层空拦截器让每次调用多出的耗时(60.7 → 61.5 µs,接近测量误差)

拦截器是什么

实测 · 本机执行输出
  客户端a前 → 客户端b前 → 客户端c前 → 服务端A前 → 服务端B前 → 服务端C前 → handler → 服务端C后 → 服务端B后 → 服务端A后 → 客户端c后 → 客户端b后 → 客户端a后

在 handler 前后插入的函数:服务端拦截器包住 handler,客户端拦截器包住真正发请求的 invoker。一元和流式各有自己的类型。

执行顺序

实测 · 本机执行输出
  (先写 Chain 后写单个)客户端x前 → 客户端y前 → 客户端z前 → 服务端X前 → 服务端Y前 → 服务端Z前 → handler → 服务端Z后 → 服务端Y后 → 服务端X后 → 客户端z后 → 客户端y后 → 客户端x后
  (先写单个后写 Chain)客户端x前 → 客户端y前 → 客户端z前 → 服务端X前 → 服务端Y前 → 服务端Z前 → handler → 服务端Z后 → 服务端Y后 → 服务端X后 → 客户端z后 → 客户端y后 → 客户端x后

ChainUnaryInterceptor(A, B, C) 里 A 在最外层:A前 → B前 → C前 → handler → C后 → B后 → A后。单独用 UnaryInterceptor 设置的总是排在链的前面,和选项的书写顺序无关。

能改什么

实测 · 本机执行输出
  err=<nil>;客户端收到 Msg="handler 的返回值(被服务端拦截器改写)";handler 里从 ctx 取到 x-trace="t-123";客户端读到响应头 x-served-by=["interceptor"]

拦截器拿到 ctx、metadata、请求和响应对象,可以读写、改返回值、用 SetHeader 加响应头。

短路

实测 · 本机执行输出
  没带 token:code=Unauthenticated message="缺少 authorization";handler 被调用 0 次
  带 token:err=<nil>;handler 被调用 1 次
  客户端拦截器短路:code=ResourceExhausted;代理看到客户端发出的 HEADERS 0 个

不调用下一层就是短路:服务端鉴权失败时 handler 不会执行;客户端拦截器直接返回时请求不会发出(线上没有任何 HEADERS)。

流式拦截器

实测 · 本机执行输出
  一次服务端流(服务端发 5 条):StreamInterceptor 调用 1 次,一元拦截器 0 次;包装后统计 SendMsg 成功 5、RecvMsg 成功 1
  一次双向流(来回 7 条):StreamInterceptor 累计调用 2 次;SendMsg 成功 7、RecvMsg 成功 7
  再来一次 Unary:一元拦截器累计 1 次,StreamInterceptor 仍是 2 次

StreamInterceptor 每个流调用一次,而不是每条消息。要统计或检查每条消息,需要包装 ServerStream/ClientStream 的 SendMsg、RecvMsg。一元拦截器对流不起作用。

panic

实测 · 本机执行输出
  norecover 子进程退出码 2;第一行输出:panic: handler 里出错了
  recover   子进程退出码 0;第一行输出:出错的调用:code=Internal message="panic: handler 里出错了"
      之后的调用:err=<nil> msg="ok"(服务进程还活着)

grpc-go 不会替你 recover handler 里的 panic,不处理就是整个进程退出。加一个 recover 拦截器把它变成 Internal 错误。

开销

实测 · 本机执行输出
  每端  0 个拦截器:每次调用 60.7 µs(5 轮取中位数)
  每端  1 个拦截器:每次调用 60.8 µs(5 轮取中位数)
  每端 10 个拦截器:每次调用 61.5 µs(5 轮取中位数)

空拦截器的开销很小(本机实测 10 层不到 1 µs);真正的成本来自拦截器里做的事。

分步演示

拦截器
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1 / grpc-go v1.84.0 / protobuf-go v1.36.12 / golang.org/x/net v0.57.0 / protoc 36.2(Homebrew);服务端和客户端都是本机起的 Go 进程(127.0.0.1,端口 9800 以上,不访问任何外部服务);需要看线上字节时用我自己写的 TCP 抓包代理加 golang.org/x/net/http2 的 Framer 解析 HTTP/2 帧;计时用 time.Now 或 perf_counter,数字每次运行会有小幅抖动。顺序由每个拦截器往同一个日志里写“前/后”得到;“线上 HEADERS 0 个”来自抓包代理;panic 实验在子进程里运行,以退出码判断;开销实验是单进程内的顺序调用,5 轮取中位数,量级参考,不是严格基准
    • 没有验证:流式客户端拦截器(StreamClientInterceptor)的行为和包装 ClientStream;拦截器里的 context 取消与 deadline 传递;常用拦截器库(如 go-grpc-middleware)的行为;stats.Handler 与拦截器的区别;拦截器里发生 panic 时 grpc-go 之外的框架(如 gateway)的处理
    京ICP备2026067502号-1京公网安备11010502063487号