跳转至

Metadata 与上下文传递机制

在分布式微服务架构中,RPC 调用往往需要传递跨切面的非业务上下文数据,例如:分布式链路追踪 ID(TraceID / SpanID)、身份认证 Token、租户标识(Tenant ID)以及灰度流量标签。

在 gRPC 中,承担这一职责的核心载体是 Metadata(元数据)


1. Metadata 核心模型与规范

Metadata 本质上是一个键值映射(Key-Value pairs),由一组 ASCII 键和对应的字符串或二进制值构成。它在传输层映射为 HTTP/2 的 HEADERS 帧或 TRAILERS 帧。

graph LR
    subgraph Client ["客户端"]
        CTX["Context (携带 Metadata)"]
    end

    subgraph Wire ["HTTP/2 传输"]
        H2H["HEADERS 帧 (Initial-Metadata)"]
        H2T["TRAILERS 帧 (Trailing-Metadata)"]
    end

    subgraph Server ["服务端"]
        SCTX["服务端 Context (解析读取)"]
    end

    CTX -->|编码发送| H2H
    Server -->|返回补充信息| H2T
    H2H --> SCTX
    H2T -->|接收端解析| CTX

键名命名规范

  1. 纯文本键(ASCII Header)
  2. 必须由小写字母(a-z)、数字(0-9)以及破折号(-)、下划线(_)和点号(.)组成。
  3. 大写字符会被自动转为小写。
  4. 二进制键(Binary Header)
  5. 必须以 -bin 后缀结尾(如 trace-data-bincustom-token-bin)。
  6. gRPC 运行时会自动对值进行标准的 Base64 编码 与解码,允许传输任意原始二进制字节或压缩流。

2. 初始元数据 vs 尾部元数据

gRPC 将元数据分为两类: - Initial-Metadata(初始元数据):在发送任何消息内容之前传递(对应请求阶段的 Request Headers 或响应阶段的首个 Response Headers)。 - Trailing-Metadata(尾部元数据):在所有消息发送完成、准备关闭当前流时传递(对应 HTTP/2 Trailers)。常用于传递错误详情、处理耗时或资源消耗统计。


3. 实战代码:读写 Metadata

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc/metadata"
)

// 客户端注入元数据
func clientSendMetadata(ctx context.Context) context.Context {
    // 创建包含文本与二进制的 Metadata
    md := metadata.Pairs(
        "authorization", "Bearer eyJhbGciOi...",
        "request-id", "req-1024",
        "session-token-bin", string([]byte{0x01, 0x02, 0x03, 0x04}),
    )
    // 绑定到 context 中
    return metadata.NewOutgoingContext(ctx, md)
}

// 服务端读取元数据
func serverReceiveMetadata(ctx context.Context) {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        fmt.Println("未找到 Incoming Metadata")
        return
    }

    if vals := md.Get("request-id"); len(vals) > 0 {
        fmt.Printf("收到 Request ID: %s\n", vals[0])
    }
}
import grpc

# 客户端注入元数据 (以 tuple 列表形式传入)
def client_call(stub, request):
    metadata = (
        ('authorization', 'Bearer eyJhbGciOi...'),
        ('request-id', 'req-1024'),
        ('session-token-bin', b'\x01\x02\x03\x04'),
    )
    response, call = stub.SayHello.with_call(
        request,
        metadata=metadata
    )
    # 读取服务端返回的初始元数据与尾部元数据
    print("Initial Metadata:", call.initial_metadata())
    print("Trailing Metadata:", call.trailing_metadata())

# 服务端读取元数据
class GreeterServicer:
    def SayHello(self, request, context):
        metadata = dict(context.invocation_metadata())
        request_id = metadata.get('request-id', 'unknown')
        print(f"Server received request-id: {request_id}")

        # 服务端回传尾部元数据
        context.set_trailing_metadata((
            ('server-latency-ms', '15'),
        ))
        return ...

4. 分布式链路传播的最佳实践

在多层微服务调用链路中(A 服务 $\to$ B 服务 $\to$ C 服务),上下文不能丢失:

sequenceDiagram
    participant SvcA as Service A
    participant SvcB as Service B
    participant SvcC as Service C

    SvcA->>SvcB: RPC (Header: traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01)
    Note over SvcB: 提取 Incoming Metadata 并挂载到本地 Context
    SvcB->>SvcC: RPC (将提取的 TraceID 作为 Outgoing Metadata 传递)
    Note over SvcC: 链路打通,全局可观测

[!IMPORTANT] 在 Go 中,metadata.FromIncomingContext(ctx) 提取的元数据不会自动变成当前客户端发出的 OutgoingContext。如果在下游调用中需要传递上下文,必须显式提取并包装为 NewOutgoingContext,或者借助 OpenTelemetry gRPC Instrumentation 拦截器自动完成 W3C TraceContext 注入与提取。