第一章:Go context取消传播链的本质与面试高频误区
Go 中的 context.Context 不是简单的“取消信号开关”,而是一条具备单向广播性、不可逆性、树状继承性的传播链。其核心机制在于:当父 Context 被取消时,所有通过 WithCancel、WithTimeout 或 WithDeadline 派生的子 Context 会同步接收取消通知,但子 Context 无法反向取消父 Context —— 这正是“传播链”而非“双向通道”的本质。
取消传播的底层触发逻辑
取消动作实际由 cancelCtx.cancel() 方法完成,它会:
- 原子设置
c.donechannel 为已关闭状态; - 遍历并调用所有注册的
children的 cancel 方法(递归向下); - 清空
children映射,防止重复取消。
关键点:关闭 channel 是唯一通知方式,所有 select { case <-ctx.Done(): ... } 语句均依赖该 channel 的零值接收行为。
面试高频误区辨析
- ❌ “Context 可以跨 goroutine 传递取消权” → 实际上子 Context 只能被其创建者或显式持有者调用
cancel();其他 goroutine 仅能监听,不能发起取消。 - ❌ “ctx.WithCancel(parent) 返回的 cancel 函数可安全多次调用” → 多次调用会导致 panic(
panic: sync: negative WaitGroup counter若内部含sync.WaitGroup)或静默失败,应确保只调用一次。 - ❌ “HTTP 请求中 context.Background() 和 context.TODO() 可互换” →
Background()用于主函数、初始化等无上级 Context 场景;TODO()仅作占位符,提示“此处需补全 Context”,生产代码中禁止使用。
验证传播链行为的最小可运行示例
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 确保释放资源
child1, cancel1 := context.WithCancel(ctx)
child2, _ := context.WithCancel(child1)
go func() {
select {
case <-child2.Done():
fmt.Println("child2 received cancellation") // ✅ 将打印
}
}()
time.Sleep(10 * time.Millisecond)
cancel() // 触发整条链取消
time.Sleep(10 * time.Millisecond)
}
执行后输出 child2 received cancellation,证明取消信号从 ctx → child1 → child2 逐层广播。若将 cancel() 替换为 cancel1(),则仅 child1 及其后代(如 child2)被取消,ctx 本身仍有效 —— 这体现了链的单向性与局部性。
第二章:context.WithValue的隐式陷阱与性能反模式
2.1 WithValue键类型安全与接口{}滥用导致的运行时panic
Go 标准库 context.WithValue 要求键(key)具备可比较性,但不强制类型约束——这为 interface{} 键埋下隐患。
键类型不一致引发静默失效
当使用 map[interface{}]any 存储值时,[]byte("k") 与 string("k") 虽逻辑等价,却因底层类型不同无法命中:
ctx := context.WithValue(context.Background(), []byte("k"), "v")
val := ctx.Value(string("k")) // 返回 nil —— 类型不匹配!
WithValue内部用==比较键,而[]byte和string是不可比较类型,实际比较的是指针/头结构,必然失败。
安全实践:键应为导出的未导出类型
| 方式 | 安全性 | 原因 |
|---|---|---|
type key int |
✅ | 包级唯一,不可被外部构造 |
interface{} |
❌ | 多类型实例可意外碰撞 |
正确键定义示例
type userIDKey struct{} // 未导出结构体,零值唯一
const UserIDKey = userIDKey{}
ctx := context.WithValue(ctx, UserIDKey, 123)
id := ctx.Value(UserIDKey).(int) // 类型安全,编译期可检
使用具名未导出类型作键,既避免冲突,又支持类型断言——
interface{}在此处被彻底规避。
2.2 值传递链路中内存泄漏的典型场景(goroutine+map+闭包)
闭包捕获导致的隐式引用延长
当 goroutine 在循环中启动并捕获外部变量(如循环变量 i 或 map 键值),若该变量指向大对象或未被及时清理,会阻止 GC 回收。
func leakyMapLoop() {
m := make(map[string]*bytes.Buffer)
for i := 0; i < 1000; i++ {
key := fmt.Sprintf("key-%d", i)
m[key] = bytes.NewBufferString(strings.Repeat("x", 1024*1024)) // 1MB 每项
go func(k string) { // ❌ 闭包捕获 k,但 goroutine 生命周期不可控
time.Sleep(time.Hour) // 长期驻留
_ = m[k] // 强引用 m 和其 value
}(key)
}
}
逻辑分析:
k是按值传入,但闭包内通过m[k]反向持有对m的引用;而m是局部变量,本应在函数返回后被回收——但由于活跃 goroutine 持有对m中任意 value 的引用(且m是指针类型底层结构),整个 map 及其所有 value 均无法被 GC。bytes.Buffer底层[]byte占用堆内存,持续累积即构成泄漏。
典型泄漏链路
- goroutine 持有闭包 →
- 闭包引用 map 中的 value →
- value 间接持有 map header(因 map 迭代/访问需 runtime.maptype)→
- map 无法释放 → 所有键值对常驻内存
| 成分 | 是否可被 GC | 原因 |
|---|---|---|
m(map 变量) |
否 | 被活跃 goroutine 间接引用 |
m[key] value |
否 | 被闭包显式访问 |
循环变量 k |
是 | 按值传递,无生命周期延长 |
graph TD
A[goroutine 启动] --> B[闭包捕获 k]
B --> C[访问 m[k]]
C --> D[强引用 m 的底层 buckets]
D --> E[整个 map 结构驻留]
2.3 基于pprof和go tool trace实测WithValue嵌套深度对GC压力的影响
Go 的 context.WithValue 本身不分配堆内存,但其嵌套链会延长 context 树深度,间接影响 GC 标记阶段的遍历开销。
实验设计
- 构造深度为
d ∈ [1, 100, 500, 1000]的WithValue链; - 每轮创建 10k 个 context 并触发强制 GC;
- 用
go tool pprof -http=:8080 mem.pprof分析堆分配热点,go tool trace观察 GC STW 时间分布。
关键代码片段
func buildDeepContext(parent context.Context, depth int) context.Context {
if depth <= 0 {
return parent
}
// key 使用 uintptr 避免 interface{} 动态分配(减少噪声)
return context.WithValue(buildDeepContext(parent, depth-1), uintptr(depth), depth)
}
此递归构造确保 context 链严格线性增长;
uintptr(depth)作为 key 可避免字符串/结构体分配,使观测聚焦于 引用链长度 本身对 GC 标记栈深度的影响。
GC 压力对比(单位:μs,STW 平均值)
| 嵌套深度 | GC STW (avg) | 堆对象数 |
|---|---|---|
| 1 | 12.3 | 10,000 |
| 100 | 14.7 | 10,000 |
| 500 | 28.9 | 10,000 |
| 1000 | 53.1 | 10,000 |
数据表明:当嵌套深度 ≥500 时,GC 标记阶段需遍历更长指针链,导致 STW 显著上升。
2.4 替代方案对比:结构体字段注入 vs context.Value vs middleware参数透传
三种方式的核心差异
- 结构体字段注入:显式、类型安全、依赖清晰,但需改造 handler 签名或中间件封装逻辑
- context.Value:动态、灵活、零侵入,但丧失类型检查与 IDE 支持,易引发 panic
- middleware 参数透传:通过闭包捕获中间值,类型安全且作用域可控,但需统一中间件抽象层
性能与可维护性对比
| 方式 | 类型安全 | 调试友好性 | GC 压力 | 适用场景 |
|---|---|---|---|---|
| 结构体字段注入 | ✅ | ✅ | 低 | 核心业务链路(如订单ID) |
| context.Value | ❌ | ❌ | 中 | 跨层调试标识(如 traceID) |
| middleware 透传 | ✅ | ✅ | 低 | 认证上下文、租户信息 |
// middleware 透传示例:通过闭包绑定 reqID
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
ctx := context.WithValue(r.Context(), "reqID", reqID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
该写法将 reqID 安全注入 context,避免全局 context.WithValue 的泛滥使用;r.WithContext() 创建新请求副本,确保不可变性与并发安全。参数 reqID 来自 HTTP 头,经中间件统一提取,下游 handler 可通过 r.Context().Value("reqID") 获取——兼顾类型推导(配合自定义 key 类型更佳)与链路一致性。
2.5 滴滴真实业务代码片段复盘:因WithValue误用引发的超时级联失效
数据同步机制
订单状态更新依赖 RedisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS),但某次迭代中误用 setValue()(无过期参数)替代 set(),导致缓存永不过期。
关键问题代码
// ❌ 错误写法:setValue 不支持超时,缓存长期滞留
redisTemplate.opsForValue().setValue(orderKey, orderJson);
// ✅ 正确写法:显式声明超时,避免雪崩
redisTemplate.opsForValue().set(orderKey, orderJson, 30, TimeUnit.SECONDS);
setValue() 仅执行 SET 命令,不带 EX 参数;而业务要求强时效性(如30秒内状态必须刷新),误用后旧订单状态锁死,下游服务轮询等待超时,触发级联5s→30s→2min逐层放大。
影响范围对比
| 组件 | 正常RT | 故障RT | 触发条件 |
|---|---|---|---|
| 订单查询API | 80ms | 5.2s | 缓存未更新 |
| 调度引擎 | 120ms | 32s | 连续3次查缓存失败 |
| 司机端推送 | 200ms | >2min | 重试+退避策略生效 |
graph TD
A[订单状态变更] --> B{调用 setValue}
B --> C[Redis缓存永驻]
C --> D[查询始终返回脏数据]
D --> E[下游服务超时重试]
E --> F[线程池耗尽/连接打满]
第三章:WithCancel的传播机制与竞态本质
3.1 cancelCtx.cancel函数执行时的原子性保障与内存屏障实现
数据同步机制
cancelCtx.cancel 必须确保 done channel 关闭、err 字段写入、子节点遍历三者对所有 goroutine 可见且不重排序。
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if atomic.LoadUint32(&c.err) != 0 { // 原子读:避免重复 cancel
return
}
atomic.StoreUint32(&c.err, 1) // 原子写:标记已取消(非零即 error)
atomic.StorePointer(&c.done, unsafe.Pointer(uintptr(1))) // 写屏障:保证 done 关闭前 err 已写入
close(c.done) // 实际关闭 channel
}
atomic.LoadUint32/&c.err:用原子读避免竞态判断;atomic.StorePointer插入写屏障,防止编译器/CPU 将close(c.done)重排到err写入之前;uintptr(1)是 Go runtime 识别“已关闭 done”的特殊标记(非真实指针)。
内存屏障类型对比
| 屏障类型 | 在 cancelCtx 中的作用 | 是否由 atomic 包隐式提供 |
|---|---|---|
| StoreStore | 确保 err 写入在 done 关闭前完成 |
✅(StoreUint32 + StorePointer) |
| LoadLoad | 子节点读 c.err 前确保看到最新 done 状态 |
❌ 需显式 atomic.LoadUint32 |
graph TD
A[goroutine 调用 cancel] --> B[原子写 c.err = 1]
B --> C[StorePointer 设置 done 标记]
C --> D[close c.done]
D --> E[所有监听者 observe err + closed channel]
3.2 多层WithCancel嵌套下Done通道关闭顺序与select优先级陷阱
Done通道的级联关闭机制
WithCancel 创建父子 Context,父 Context 的 Done() 关闭会立即触发子 Done() 关闭;但反向不成立。关闭是单向广播,无等待或同步。
select 的隐式优先级陷阱
当多个 case <-ctx.Done() 同时就绪时,select 伪随机选择(非 FIFO),可能掩盖取消传播延迟:
select {
case <-parentCtx.Done(): // 可能被选中,即使 childCtx.Done() 更早就绪
log.Println("parent cancelled")
case <-childCtx.Done(): // 实际已关闭,但未被选中
log.Println("child cancelled")
}
分析:
parentCtx.Done()和childCtx.Done()在父取消后几乎同时就绪,但select不保证顺序。若业务逻辑依赖子 Context 精确捕获取消时机(如资源清理),将产生竞态。
嵌套取消时序对比表
| 场景 | parent.Cancel() 调用后 | child.Done() 关闭耗时 | 父子 Done 同步性 |
|---|---|---|---|
| 正常嵌套 | ≤ 纳秒级 | ≤ 纳秒级 | 强一致(无延迟) |
| 高负载 goroutine 切换 | 可达微秒级 | 同量级 | 仍一致,但 select 无法反映细微时序 |
graph TD
A[调用 parent.Cancel()] --> B[关闭 parent.doneCh]
B --> C[通知所有子 context]
C --> D[同步关闭 child.doneCh]
D --> E[所有监听者 receive]
3.3 goroutine泄漏检测:如何用goleak库精准定位未释放的cancelFunc
goleak 是专为 Go 单元测试设计的 goroutine 泄漏检测工具,能自动捕获测试前后残留的 goroutine。
安装与基础用法
go get -u github.com/uber-go/goleak
检测 cancelFunc 遗留导致的泄漏
func TestHTTPClientWithTimeout(t *testing.T) {
defer goleak.VerifyNone(t) // 自动比对测试前后 goroutine 状态
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() // ✅ 正确释放
client := &http.Client{Timeout: 50 * time.Millisecond}
_, _ = client.GetContext(ctx, "https://httpbin.org/delay/1")
}
goleak.VerifyNone(t)在测试结束时扫描所有非守护 goroutine;若cancel()被遗漏,context.WithCancel/WithTimeout创建的内部监控 goroutine 将持续存活,被精准捕获。
常见泄漏模式对比
| 场景 | 是否触发 goleak 报警 | 原因 |
|---|---|---|
defer cancel() 缺失 |
✅ 是 | context goroutine 永不退出 |
time.AfterFunc 未清理 |
✅ 是 | 后台 timer goroutine 残留 |
http.Server.ListenAndServe(测试中) |
❌ 否(需显式 goleak.IgnoreCurrent()) |
属于预期长期运行 |
检测原理简图
graph TD
A[测试开始] --> B[记录当前活跃 goroutine 栈]
B --> C[执行业务逻辑]
C --> D[测试结束]
D --> E[再次快照 goroutine]
E --> F[比对栈差异]
F --> G{存在不可忽略的新增 goroutine?}
G -->|是| H[报错并打印栈追踪]
G -->|否| I[通过]
第四章:WithTimeout/WithDeadline的精度陷阱与跨系统时钟漂移
4.1 time.Timer底层基于netpoller的调度延迟实测(Linux vs macOS差异)
Go 的 time.Timer 在运行时依赖 netpoller 实现低开销定时唤醒,但其实际延迟受操作系统事件循环机制影响显著。
Linux epoll 与 macOS kqueue 差异
- Linux 使用
epoll_wait,支持纳秒级超时精度(内核 ≥5.1),且timerfd_settime可触发精确就绪通知; - macOS 的
kqueue对EVFILT_TIMER仅保证毫秒级下限,且kevent()调用存在最小调度粒度(通常 10–15ms)。
延迟实测对比(10ms 定时器,1000 次采样)
| 系统 | P50 延迟 | P99 延迟 | 最大抖动 |
|---|---|---|---|
| Linux 6.8 | 10.02 ms | 10.38 ms | ±0.4 ms |
| macOS 14 | 10.15 ms | 12.71 ms | ±2.7 ms |
// 启动高精度延迟测量(需 GOMAXPROCS=1 避免调度干扰)
t := time.NewTimer(10 * time.Millisecond)
start := time.Now()
<-t.C
elapsed := time.Since(start) // 实际触发时刻与期望时刻差值
该代码块中,time.Now() 在通道接收前立即打点,elapsed 即为调度延迟。netpoller 将 timerfd/kqueue 事件注册到 I/O 多路复用器,但 macOS 的 kqueue 无法将定时器事件优先于其他就绪事件分发,导致排队延迟放大。
graph TD
A[time.Timer.Start] --> B{OS netpoller}
B -->|Linux epoll| C[epoll_wait timeout=10ms]
B -->|macOS kqueue| D[kqueue kevent timeout=10ms]
C --> E[精确唤醒,低抖动]
D --> F[内核调度粒度限制,高抖动]
4.2 子context超时时间被父context提前取消的传播边界判定逻辑
核心判定原则
子 context 的取消信号是否传播,取决于 parent.Done() 是否早于 child.WithTimeout 设定的 deadline 触发,且子 context 未调用 cancel() 显式终止。
取消传播的边界条件
- ✅ 父 context 被主动
cancel()→ 子 context 立即收到取消(无视其自身 timeout) - ✅ 父 context 因超时关闭 → 子 context 的
Done()通道立即关闭(抢占子 deadline) - ❌ 子 context 已自行
timer.Stop()并完成清理 → 不再响应父取消(传播终止)
关键代码逻辑
// 构建父子 context 链
parent, parentCancel := context.WithCancel(context.Background())
child, _ := context.WithTimeout(parent, 5*time.Second)
// 模拟父 context 提前取消(t=1s)
time.AfterFunc(1*time.Second, parentCancel)
select {
case <-child.Done():
// 此处触发:因父取消传播,非超时
fmt.Println("child cancelled by parent") // 输出此行
case <-time.After(10 * time.Second):
}
逻辑分析:
child.Done()底层复用parent.Done()通道(见context.withCancel实现),一旦父 cancel,子donechannel 立即可读;WithTimeout仅在父未取消时启动独立 timer。参数parent是传播源头,5*time.Second在父已取消时不生效。
传播边界判定表
| 场景 | 父状态 | 子 timer 是否启动 | 取消是否传播 | 边界是否达成 |
|---|---|---|---|---|
| 父主动 cancel | Done() closed |
否(未启动) | ✅ | 是(立即传播) |
| 父超时关闭 | Done() closed |
是(但被抢占) | ✅ | 是(传播优先级更高) |
子已调用 cancel() |
Done() closed |
否(已 stop) | ❌ | 是(传播终止) |
graph TD
A[父 context 取消] --> B{子 context 是否已调用 cancel?}
B -->|是| C[传播终止:子 Done channel 仍关闭,但无副作用]
B -->|否| D[传播生效:子 Done channel 关闭,err=Context.Canceled]
4.3 分布式链路中NTP时钟偏移对Deadline传播一致性的影响建模
在跨可用区微服务调用链中,各节点本地时钟因NTP同步误差导致系统时钟偏移(Clock Skew),直接影响基于绝对时间戳的Deadline传播精度。
数据同步机制
NTP典型偏移分布服从 ±50ms(局域网)至 ±250ms(跨公网)区间。当服务A向B传递 deadline = now() + 500ms 时,若B的时钟快80ms,则其实际剩余超时仅为420ms,引发误判熔断。
偏移补偿模型
def adjust_deadline(raw_deadline: float, ntp_offset_ms: float) -> float:
# raw_deadline: 发送方生成的绝对时间戳(UTC毫秒)
# ntp_offset_ms: 本地方差估计值(正表示本地快于UTC)
return raw_deadline - ntp_offset_ms # 补偿本地时钟漂移
该函数将接收方本地视图对齐至发送方时间参考系,要求ntp_offset_ms通过持续采样PTP/NTP响应延迟估算。
影响量化对比
| 偏移量 | Deadline误判率(10万次调用) | 平均提前触发延迟 |
|---|---|---|
| ±10 ms | 0.3% | 9.2 ms |
| ±100 ms | 24.7% | 86.5 ms |
graph TD
A[Client: deadline=1712345678900] -->|未补偿| B[ServerB: now_utc≈1712345678820]
B --> C{B判定已超时?}
C -->|是| D[错误拒绝]
A -->|补偿后| E[ServerB: adjusted=1712345678900+80ms]
E --> F[正确受理]
4.4 基于go test -bench与time.Now()采样分析timeout抖动的统计分布
在高并发超时控制场景中,仅依赖 context.WithTimeout 的声明式语义不足以揭示实际调度延迟。需结合基准测试与毫秒级时间采样,定位 select + time.After 路径中的非确定性抖动。
混合采样策略设计
- 使用
go test -bench提供稳定压测环境(固定 GOMAXPROCS、禁用 GC) - 在
BenchmarkTimeoutPath内部高频调用time.Now()记录进入/退出点,规避runtime.nanotime()调用开销差异
核心采样代码
func BenchmarkTimeoutJitter(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
start := time.Now() // 采样起点:goroutine 调度就绪时刻
select {
case <-time.After(10 * time.Millisecond):
case <-time.After(50 * time.Millisecond): // 强制超时分支
}
end := time.Now() // 采样终点:select 返回时刻
jitter := end.Sub(start) - 10*time.Millisecond // 实际抖动 = 观测延迟 - 期望延迟
b.ReportMetric(float64(jitter.Microseconds()), "us/op")
}
}
逻辑说明:
time.After底层依赖timer堆和netpoll事件驱动,start/end差值包含 goroutine 抢占延迟、定时器轮询周期(默认 20ms)、以及select多路复用的调度排队时间;jitter直接反映 timeout 机制在真实负载下的统计偏差。
抖动分布特征(10k 次运行)
| 分位数 | 抖动值(μs) | 含义 |
|---|---|---|
| P50 | 127 | 中位数调度延迟 |
| P99 | 8,942 | 极端尾部延迟 |
| P99.9 | 32,156 | GC STW 或调度饥饿 |
graph TD
A[goroutine 创建] --> B[等待 timer 触发]
B --> C{timer 堆轮询周期}
C -->|~20ms| D[netpoll 等待]
D --> E[select 选中超时通道]
E --> F[返回 jitter 样本]
第五章:从滴滴TOP1挂人题到生产级context治理规范
在2023年滴滴内部技术峰会公布的“年度TOP1挂人题”中,一道看似简单的Go微服务Context传递题让超过76%的后端工程师在Code Review环节栽了跟头:
func handleOrder(ctx context.Context, orderID string) error {
// 误用:将原始ctx直接传入异步goroutine
go func() {
db.QueryContext(ctx, "UPDATE orders SET status=? WHERE id=?", "processing", orderID)
}()
return nil
}
该代码在高并发下单场景下,因父Context超时或取消导致子goroutine中db操作被意外中断,引发订单状态不一致——这正是生产环境context泄漏的典型切口。
Context生命周期必须与业务语义对齐
在滴滴订单履约链路中,我们定义了三类标准化Context派生策略:
WithTimeout(ctx, 3s)仅用于下游HTTP/gRPC调用(如调用风控服务)WithValue(ctx, traceIDKey, traceID)仅注入不可变业务标识(traceID、orderID)WithCancel(parent)严格限定于需主动终止的长轮询场景(如实时运单位置推送)
拒绝context.WithValue的滥用陷阱
我们通过AST扫描工具在CI阶段拦截所有WithValue调用,强制要求满足以下任一条件: |
场景类型 | 允许键名前缀 | 示例键名 | 审计方式 |
|---|---|---|---|---|
| 全局上下文 | global. |
global.userID |
需关联RBAC权限白名单 | |
| 请求上下文 | req. |
req.deviceType |
需在API Gateway层注入 | |
| 调试上下文 | debug. |
debug.forceRoute |
需配置中心开关控制 |
构建context健康度看板
基于OpenTelemetry Collector采集的Span数据,我们构建了实时监控看板:
graph LR
A[Context创建点] --> B{是否携带deadline?}
B -->|否| C[告警:无超时保障]
B -->|是| D{deadline是否≤上游?}
D -->|否| E[告警:超时膨胀]
D -->|是| F[通过]
A --> G{是否携带cancel channel?}
G -->|否| H[告警:无法主动终止]
G -->|是| I[通过]
强制执行的CI门禁规则
在GitLab CI流水线中嵌入以下检查:
- 扫描所有
.go文件,禁止出现context.Background()在handler函数内直接调用 - 检测
context.WithValue调用深度,超过2层嵌套立即阻断合并 - 对
http.HandlerFunc签名自动注入context.WithTimeout(r.Context(), 15*time.Second)
生产环境context泄漏根因分析
2024年Q1线上事故复盘显示,83%的context相关故障源于:
- 数据库连接池未设置
SetConnMaxLifetime,导致旧Context绑定的cancel channel持续占用goroutine - 日志中间件错误地将
ctx.Value("user")转为字符串并拼接进日志字段,触发fmt.Sprintf隐式调用导致context.Value链路阻塞 - Prometheus指标上报使用
context.TODO()而非context.WithoutCancel(parent),造成指标采集goroutine永久驻留
上下文传播的协议化改造
我们将gRPC Metadata与HTTP Header统一映射为标准化Context Key:
// 统一key定义
const (
TraceIDKey = "x-trace-id" // HTTP Header / gRPC Metadata key
UserIDKey = "x-user-id" // 同上,但值必须经JWT解析校验
EnvKey = "x-env" // 环境标识,仅允许prod/staging/test
)
// 自动注入逻辑
func injectContext(ctx context.Context, md metadata.MD) context.Context {
if traceID := md.Get(TraceIDKey); len(traceID) > 0 {
ctx = context.WithValue(ctx, TraceIDKey, traceID[0])
}
return ctx
} 