gRPC 拦截器写错:metadata 透传与超时注入的 3 个坑

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [gRPC 拦截器写错:metadata 透传与超时注入的 3 个坑](#gRPC 拦截器写错:metadata 透传与超时注入的 3 个坑)
    • [一、拦截器是 gRPC 的中间件](#一、拦截器是 gRPC 的中间件)
    • [二、坑 1:出站 metadata 用错了 API](#二、坑 1:出站 metadata 用错了 API)
    • [三、坑 2:在拦截器里注入超时,覆盖了上游的 deadline](#三、坑 2:在拦截器里注入超时,覆盖了上游的 deadline)
    • [四、坑 3:拦截器链的注册方式](#四、坑 3:拦截器链的注册方式)
    • 五、还有两个细节
      • [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 造一个新的。

相关推荐
yz_aiks1 小时前
RestTemplate 废弃后迁移 RestClient 实战:Spring Boot 4.2 升级指南
java·spring boot·后端·微服务·restclient
java_nnnn1 小时前
分布式系统进阶:数据库分库分表、微服务与架构演进总结
数据库·redis·微服务·架构
用户40158233246215 小时前
对话一长,AI 就忘了你说过的话?Claude Code、Codex、Aider、WES Code 怎么压缩上下文
人工智能·go·ai编程
我的div丢了肿么办19 小时前
go语言抽离路由文件以及对路由进行分组
后端·go
孔令飞20 小时前
【OneX】OneX AI 工具安装、配置及功能介绍
云原生·go·ai编程
code_slave(码畜)21 小时前
微服务架构落地:分库分表实战(下) —— 分布式主键、跨分片查询与分页聚合防坑
数据库·spring boot·分布式·spring cloud·微服务·架构·分库分表
小满zs1 天前
Go语言第十四章(文件操作)
后端·go
vipxieliang1 天前
Go 1.24 工具链改进实战:工具依赖管理与 go vet 新分析器
后端·go
每天一道题1 天前
Kafka 为什么会重复消费?从 Producer ACK、Offset 到消息可靠性
后端·微服务