我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [gRPC 拦截器写错:metadata 透传与超时注入的 3 个坑](#gRPC 拦截器写错:metadata 透传与超时注入的 3 个坑)
-
- [一、拦截器是 gRPC 的中间件](#一、拦截器是 gRPC 的中间件)
- [二、坑 1:出站 metadata 用错了 API](#二、坑 1:出站 metadata 用错了 API)
- [三、坑 2:在拦截器里注入超时,覆盖了上游的 deadline](#三、坑 2:在拦截器里注入超时,覆盖了上游的 deadline)
- [四、坑 3:拦截器链的注册方式](#四、坑 3:拦截器链的注册方式)
-
- [只能注册一个 UnaryInterceptor](#只能注册一个 UnaryInterceptor)
- 拦截器的执行顺序
- 五、还有两个细节
-
- [1. Stream 拦截器必须包装 Stream](#1. Stream 拦截器必须包装 Stream)
- [2. 拦截器里的 panic](#2. 拦截器里的 panic)
- 六、带走物
gRPC 拦截器写错:metadata 透传与超时注入的 3 个坑
一、拦截器是 gRPC 的中间件
gRPC 的 interceptor 相当于 HTTP 的 middleware,用来做日志、鉴权、链路追踪、限流、重试这些横切逻辑。
两种:
go
grpc.UnaryInterceptor(...) // 一元调用(一请求一响应)
grpc.StreamInterceptor(...) // 流式调用
服务端和客户端各有各的:
go
// 服务端
server := grpc.NewServer(
grpc.UnaryInterceptor(serverInterceptor),
)
// 客户端
conn, _ := grpc.Dial(addr,
grpc.WithUnaryInterceptor(clientInterceptor),
)
拦截器的签名很简单,但有三个坑几乎人人踩过。
二、坑 1:出站 metadata 用错了 API
这是最经典的一个。gRPC 的 metadata(相当于 HTTP header)分入站 和出站两个方向,API 完全不同:
| 方向 | 客户端 | 服务端 |
|---|---|---|
| 发送(出站) | metadata.NewOutgoingContext(ctx, md) |
grpc.SetHeader(ctx, md) / grpc.SendHeader |
| 接收(入站) | grpc.Header(&md) / grpc.Trailer(&md) |
metadata.FromIncomingContext(ctx) |
最高频的错误:在客户端拦截器里用 FromIncomingContext 写 metadata。
go
// ❌ 错误:FromIncomingContext 是"读"的,而且读的是服务端视角的入站
func clientInterceptor(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
md, _ := metadata.FromIncomingContext(ctx) // 客户端这里拿到的是空!
md.Set("trace-id", tid) // 写到哪去了?哪也没去
return invoker(ctx, method, req, reply, cc, opts...)
}
// ✅ 正确:客户端要用 NewOutgoingContext 或 AppendToOutgoingContext
func clientInterceptor(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx, "trace-id", tid)
return invoker(ctx, method, req, reply, cc, opts...)
}
metadata.NewOutgoingContext 会覆盖已有的 metadata,AppendToOutgoingContext 是追加。 拦截器链里通常用 Append,否则前一个人写的就被你冲掉了。
服务端读取:
go
func serverInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx) // 服务端用这个读
if !ok {
md = metadata.MD{}
}
tids := md.Get("trace-id")
...
return handler(ctx, req)
}
还有一个细节 :metadata 的 key 会被自动转小写。所以 md.Get("Trace-ID") 和 md.Get("trace-id") 是一样的,但二进制 key 必须以 -bin 结尾,否则会被当字符串处理。
三、坑 2:在拦截器里注入超时,覆盖了上游的 deadline
很多人想在拦截器里"统一加超时":
go
// ❌ 危险:这样会丢掉上游传过来的 deadline
func timeoutInterceptor(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // 丢了父 ctx!
defer cancel()
return invoker(ctx, method, req, reply, cc, opts...)
}
context.Background() 是一切的起点,没有 deadline。 这样写之后,上游给的 500ms 超时被完全丢弃,每次调用都会用满 3 秒。
正确写法是从入参 ctx 派生:
go
// ✅ 正确:从入参派生,取更短的
func timeoutInterceptor(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // 从 ctx 派生
defer cancel()
return invoker(ctx, method, req, reply, cc, opts...)
}
context.WithTimeout(ctx, ...) 的语义是取父 deadline 和新 deadline 里更早的那个。所以上游给 500ms、你这里写 3s,最终生效的是 500ms------这就是想要的"逐层收敛"。
更稳妥的做法:先检查上游有没有 deadline,有就不覆盖:
go
func timeoutInterceptor(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if _, ok := ctx.Deadline(); !ok {
// 上游没给 deadline,才注入一个兜底值
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, defaultTimeout)
defer cancel()
}
return invoker(ctx, method, req, reply, cc, opts...)
}
这样上游的超时控制永远是最高优先级。
四、坑 3:拦截器链的注册方式
只能注册一个 UnaryInterceptor
grpc.NewServer(grpc.UnaryInterceptor(f)) 只能传一个,传多个会直接 panic:
go
// ❌ 会 panic: duplicate interceptor
grpc.NewServer(
grpc.UnaryInterceptor(logInterceptor),
grpc.UnaryInterceptor(authInterceptor), // panic
)
必须用 grpc_middleware.ChainUnaryServer 或者自己实现链式:
go
// 方案 1:用 go-grpc-middleware
import "github.com/grpc-ecosystem/go-grpc-middleware"
server := grpc.NewServer(
grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
logInterceptor,
authInterceptor,
recoveryInterceptor,
)),
)
go
// 方案 2:自己串(核心逻辑就这么几行)
func chainUnary(interceptors ...grpc.UnaryServerInterceptor) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
// 从最后一个开始包,这样第一个拦截器在最外层
chained := handler
for i := len(interceptors) - 1; i >= 0; i-- {
chained = build(interceptors[i], chained, info)
}
return chained(ctx, req)
}
}
拦截器的执行顺序
外层先执行,但"后半段"是倒序的。一个三段拦截器链的执行顺序:
text
A 前半段 → B 前半段 → C 前半段 → handler → C 后半段 → B 后半段 → A 后半段
所以:
- 打日志/计时要放最外层(A),才能测到完整耗时;
- 鉴权要放靠外层,尽早拒绝;
- recovery/panic 捕获必须放最外层,否则内层 panic 它捕获不到。
顺序错了的典型后果 :defer 里记的耗时不包含外层拦截器的开销,或者 panic 没被 recovery 拦住导致整个进程退出。
五、还有两个细节
1. Stream 拦截器必须包装 Stream
流式调用的拦截器签名不同,而且必须返回一个包装过的 grpc.ServerStream,否则你改的 ctx 不会生效:
go
func streamInterceptor(srv interface{}, ss grpc.ServerStream,
info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
ctx := ss.Context()
ctx = metadata.AppendToOutgoingContext(ctx, "x", "y") // 这样改没用!
// 必须包装 Stream,把新 ctx 塞进去
wrapped := &wrappedStream{ServerStream: ss, ctx: ctx}
return handler(srv, wrapped)
}
type wrappedStream struct {
grpc.ServerStream
ctx context.Context
}
func (w *wrappedStream) Context() context.Context {
return w.ctx
}
2. 拦截器里的 panic
gRPC 默认不会因为 handler panic 而崩溃(它自己会恢复),但恢复后返回的是一个 Unknown 错误 ,原始 panic 信息丢失。所以必须加 recovery 拦截器:
go
func recoveryInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
log.Printf("panic in %s: %v\n%s", info.FullMethod, r, buf[:n])
err = status.Errorf(codes.Internal, "internal error: %v", r)
}
}()
return handler(ctx, req)
}
放在链的最外层,否则内层 panic 它接不住。
六、带走物
一份可直接用的服务端拦截器链:
go
server := grpc.NewServer(
grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
recoveryInterceptor, // 1. 最外层:捕获 panic
logInterceptor, // 2. 计时/日志(要测完整耗时,放外层)
authInterceptor, // 3. 鉴权,尽早拒绝
metricsInterceptor, // 4. 指标
)),
grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
recoveryStreamInterceptor,
logStreamInterceptor,
)),
)
三个坑的速查:
| 坑 | 症状 | 修法 |
|---|---|---|
| 出站 metadata 用错 API | 服务端收不到 trace-id | 客户端用 AppendToOutgoingContext |
| 超时从 Background 派生 | 上游 deadline 被覆盖 | 从入参 ctx 派生,或先检查有没有 deadline |
| 重复注册 UnaryInterceptor | panic: duplicate | 用 ChainUnaryServer |
一句话结论:拦截器里最容易错的两件事是"方向"和"父子"------metadata 要用对入站/出站的 API,context 一定要从入参派生而不是从 Background 造一个新的。