第一章:Go访问外部API的Context机制本质解析
Context 不是 Go 中的“超时控制开关”,而是跨 goroutine 传递取消信号、截止时间、请求范围值(request-scoped values)的有向传播树根节点。在调用外部 API(如 HTTP 请求、数据库查询、gRPC 调用)时,Context 的核心价值在于建立可取消的协作契约——调用方声明“我最多等多久”或“我随时可能放弃”,被调用方则需主动监听 ctx.Done() 通道并及时中止非阻塞操作、释放资源、返回 context.Canceled 或 context.DeadlineExceeded 错误。
Context 的生命周期与传播语义
- 每个 Context 实例由父 Context 派生,形成单向依赖链(不可逆向修改父节点)
context.WithCancel、context.WithTimeout、context.WithDeadline和context.WithValue均返回新 Context 及其对应的CancelFunc- 一旦父 Context 被取消,所有派生子 Context 自动进入 Done 状态(但反之不成立)
HTTP 客户端中 Context 的正确使用方式
func fetchUser(ctx context.Context, userID string) (*User, error) {
// 将 context 注入 http.Request —— 这是关键一步!
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://api.example.com/users/%s", userID), nil)
if err != nil {
return nil, err
}
// http.Client 会自动监听 ctx.Done() 并中断底层连接
resp, err := http.DefaultClient.Do(req)
if err != nil {
// 注意:err 可能是 context.Canceled 或 net/http.ErrHandlerTimeout
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
// ... 解析响应体
}
常见误用模式对照表
| 误用场景 | 后果 | 正确做法 |
|---|---|---|
在 http.NewRequest() 后再手动设置 req = req.WithContext(ctx) |
上下文未绑定到 Transport 层,超时/取消无效 | 使用 http.NewRequestWithContext() 构造初始请求 |
忘记调用 defer cancel() 导致 goroutine 泄漏 |
子 Context 持续存活,内存与 goroutine 积压 | 显式管理 CancelFunc 生命周期,尤其在循环或长生命周期函数中 |
在 select 中仅监听 ctx.Done() 却忽略 default 分支 |
阻塞等待取消,丧失非阻塞轮询能力 | 结合 default 实现优雅降级或重试逻辑 |
第二章:Context泄漏的六大高危场景之“未覆盖”剖析
2.1 基于http.Client Do方法的隐式Context丢弃(理论+实战复现与修复)
http.Client.Do 不接受 context.Context 参数,若直接传入带超时/取消语义的 *http.Request,其 Request.Context() 可能被底层 Transport 静默覆盖,导致上游 Context 失效。
复现场景
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/delay/2", nil)
// ❌ Do 方法不校验 req.Context() 是否被 Transport 覆盖
resp, err := http.DefaultClient.Do(req) // 实际使用 transport.roundTrip 的默认 context
分析:
http.Transport.roundTrip内部会用req = req.WithContext(t.getConnReqContext())替换原始 Context,使ctx.Done()完全失效。关键参数:req.Context()在进入 Transport 前即被丢弃,超时由 Transport 自行管理。
修复方案对比
| 方案 | 是否保留 Cancel | 是否控制 DNS/连接超时 | 推荐度 |
|---|---|---|---|
http.Client.Timeout |
❌(仅限制整个请求) | ✅ | ⭐⭐ |
context.WithTimeout + http.NewRequestWithContext + 自定义 Transport |
✅ | ✅(需配置 DialContext, TLSHandshakeTimeout 等) |
⭐⭐⭐⭐ |
graph TD
A[用户创建带Cancel的Context] --> B[NewRequestWithContext]
B --> C[Client.Do]
C --> D[Transport.roundTrip]
D --> E[req.WithContext\nt.getConnReqContext\]
E --> F[原始Context丢失]
2.2 goroutine启动时未显式传递Context导致的生命周期失控(理论+竞态模拟与ctx.WithCancel注入)
根本问题:goroutine脱离父Context管理
当 go fn() 启动协程却未接收 context.Context 参数时,该goroutine无法感知父上下文取消信号,形成“孤儿协程”。
竞态模拟:无Context的泄漏协程
func leakyWorker() {
time.Sleep(5 * time.Second) // 模拟长任务
fmt.Println("work done — but context may be canceled!")
}
// 启动方式错误:go leakyWorker() → 无法响应cancel
逻辑分析:leakyWorker 无ctx入参,无法调用 ctx.Done() 监听或 ctx.Err() 检查;time.Sleep 不受外部中断影响,造成资源滞留。
正确注入:ctx.WithCancel 构建可终止生命周期
parentCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go func(ctx context.Context) {
select {
case <-time.After(5 * time.Second):
fmt.Println("work completed")
case <-ctx.Done():
fmt.Println("canceled:", ctx.Err()) // 输出 context deadline exceeded
}
}(parentCtx)
| 方案 | 可取消性 | 资源回收确定性 | 生命周期可见性 |
|---|---|---|---|
| 无Context启动 | ❌ | 低 | 不可见 |
| WithCancel注入 | ✅ | 高 | 显式可控 |
graph TD
A[main goroutine] -->|WithCancel| B[derived Context]
B --> C[goroutine fn(ctx)]
C --> D{select on ctx.Done()}
D -->|cancel called| E[exit gracefully]
D -->|timeout| F[exit via timeout]
2.3 中间件/装饰器链中Context被意外替换或截断(理论+HTTP RoundTripper透传验证实验)
当多个中间件(如日志、超时、重试)依次包装 http.RoundTripper 时,若某层未正确透传原始 ctx,而是创建新 context.WithTimeout() 或 context.WithValue() 后直接丢弃父 ctx.Done() 和 ctx.Value() 链,将导致下游感知到的 Context 被截断。
Context 透传失效的典型模式
- ❌ 错误:
newCtx := context.WithTimeout(context.Background(), 5*time.Second) - ✅ 正确:
newCtx := context.WithTimeout(ctx, 5*time.Second)(以入参ctx为父)
HTTP RoundTripper 实验验证
type ContextChecker struct{ next http.RoundTripper }
func (c *ContextChecker) RoundTrip(req *http.Request) (*http.Response, error) {
// 检查是否继承原始 ctx(含 cancel channel 和自定义 value)
if req.Context().Value("trace-id") == nil {
log.Println("⚠️ Context value lost at this middleware!")
}
return c.next.RoundTrip(req)
}
逻辑分析:该装饰器不修改
req.Context(),仅做观测。若日志触发,说明上游某中间件调用req.WithContext(newCtx)时传入了非继承上下文(如context.Background()),导致trace-id等关键键值丢失。
| 中间件位置 | 是否透传 Done() | 是否保留 Value(“trace-id”) | 风险等级 |
|---|---|---|---|
| 第1层(日志) | ✅ | ✅ | 低 |
| 第2层(超时) | ❌(误用 Background) | ❌ | 高 |
| 第3层(重试) | ✅ | ✅ | 低 |
graph TD
A[Client.Do req] --> B[LogMiddleware]
B --> C[TimeoutMiddleware]
C --> D[RetryMiddleware]
D --> E[HTTP Transport]
C -.->|错误:ctx = context.Background<br>+ WithTimeout| F[丢失父级Done & Values]
2.4 第三方SDK封装层绕过Context参数的“黑盒调用”陷阱(理论+go list -deps + trace分析法)
当SDK封装层隐式透传context.Background()或硬编码context.WithTimeout(nil, ...)时,上游调用方的超时/取消信号被彻底截断——形成典型的“黑盒调用”陷阱。
追踪依赖链定位隐患点
go list -deps ./pkg/sdk | grep -E "(http|grpc|database)"
该命令快速暴露间接引入的底层网络/IO组件,结合GODEBUG=nethttptrace=1可捕获未受控的context生命周期。
典型错误封装示例
func NewClient() *Client {
// ❌ 隐式丢失调用方context
return &Client{ctx: context.Background()} // 参数ctx本应由上层传入
}
context.Background()作为根上下文无取消能力;若该Client被复用在HTTP handler中,将导致goroutine泄漏与超时失效。
| 分析维度 | 安全封装 | 黑盒陷阱 |
|---|---|---|
| Context来源 | 显式传入(func New(ctx context.Context)) |
硬编码Background()或TODO() |
| 可观测性 | 支持trace.StartSpan注入 |
无trace span关联 |
graph TD
A[Handler ctx] --> B[SDK Wrapper]
B --> C[底层HTTP Client]
C --> D[net/http.Transport]
style B stroke:#ff6b6b,stroke-width:2px
2.5 defer语句中异步操作引用已超时Context引发的延迟panic(理论+time.AfterFunc + ctx.Done()反模式演示)
问题根源
defer 中启动的 goroutine 若持有已取消的 context.Context,并在后续访问 ctx.Done() 或调用 ctx.Err(),将触发不可预测的 panic——尤其当 ctx 已被释放或其内部 channel 关闭后。
反模式代码演示
func badDefer(ctx context.Context) {
defer func() {
// ❌ 危险:ctx 可能在 defer 执行时已超时/取消,且 time.AfterFunc 异步执行
time.AfterFunc(time.Second, func() {
select {
case <-ctx.Done(): // 可能 panic:read on closed channel
log.Println("done")
}
})
}()
}
逻辑分析:ctx 生命周期早于 defer 执行时机;time.AfterFunc 启动的 goroutine 在 ctx 被 cancel 后仍尝试读取已关闭的 ctx.Done() channel,导致 panic。ctx 本身不保证 Done() channel 在 cancel 后长期有效。
安全替代方案
- 使用
ctx.Err()前先检查ctx != nil - 改用
sync.Once+ 显式状态标记 - 优先在同步路径中消费
ctx.Done()
| 方案 | 是否规避 panic | 是否推荐 |
|---|---|---|
直接读 ctx.Done() in time.AfterFunc |
❌ 否 | 否 |
先 select { case <-ctx.Done(): ... default: } |
✅ 是 | 中等 |
使用 context.WithTimeout 并在 defer 外管理生命周期 |
✅ 是 | ✅ 强烈推荐 |
第三章:Context生命周期管理的核心原则与边界约束
3.1 Context不可变性与派生树结构对API调用链的真实影响
Context的不可变性强制每次派生都生成新实例,使调用链天然具备快照语义——下游变更无法污染上游状态。
数据同步机制
// 派生新Context,携带独立value(不可修改原ctx)
child := ctx.WithValue(parent, key, "req-id-123")
// ⚠️ parent.Value(key) 仍为 nil;child.Value(key) == "req-id-123"
逻辑分析:WithValue 返回新结构体指针,底层 readOnly 字段隔离读写;key 类型需满足 == 可比性,value 建议为不可变类型(如 string/int)以避免隐式共享。
调用链示意图
graph TD
A[API Gateway] --> B[Auth Middleware]
B --> C[Service A]
C --> D[DB Layer]
style A fill:#4CAF50,stroke:#388E3C
style D fill:#f44336,stroke:#d32f2f
| 层级 | Context派生次数 | 状态隔离性 | 调用延迟增幅 |
|---|---|---|---|
| Gateway→Middleware | 1 | 完全隔离 | +0.02ms |
| Middleware→Service | 2 | 隔离+超时继承 | +0.05ms |
3.2 超时、取消、值注入三类派生Context在HTTP客户端中的行为差异
行为本质差异
三类派生 Context 在 http.Client 的请求生命周期中干预时机与作用域截然不同:
- 超时 Context(
context.WithTimeout):强制终止阻塞操作,触发context.DeadlineExceeded错误; - 取消 Context(
context.WithCancel):主动中断,适用于用户交互或条件性中止; - 值注入 Context(
context.WithValue):仅传递元数据(如 traceID、authToken),不改变执行流程。
关键行为对比
| 特性 | 超时 Context | 取消 Context | 值注入 Context |
|---|---|---|---|
| 是否影响执行流 | 是(自动 cancel) | 是(需显式调用) | 否 |
| 错误类型 | context.DeadlineExceeded |
context.Canceled |
无 |
| 适用场景 | 防止长连接挂起 | 手动中止上传/轮询 | 请求透传上下文信息 |
req, _ := http.NewRequestWithContext(
context.WithValue( // 注入 traceID
context.WithTimeout(ctx, 5*time.Second), // 超时控制
"traceID", "abc123"
),
"GET", "https://api.example.com/data", nil,
)
此处
WithValue嵌套于WithTimeout内部:超时仍主导生命周期,而traceID仅被http.Transport或中间件读取,不影响RoundTrip的阻塞/唤醒逻辑。
graph TD
A[原始Context] --> B[WithTimeout]
A --> C[WithCancel]
A --> D[WithValue]
B --> E[HTTP请求发起]
C --> E
D --> F[Request.Header/Transport.RoundTrip]
3.3 Context.Value的合理边界:何时该用struct传参而非ctx.Value
context.Value 是为传递请求范围的元数据(如 traceID、用户身份)而设计的,而非业务参数载体。
何时应拒绝 ctx.Value
- 值在函数签名中被高频读取或校验(破坏显式性)
- 类型非
any安全子集(如自定义 struct,需频繁类型断言) - 需要零分配、高并发写入(
ctx.WithValue创建新 context,开销不可忽视)
对比:struct 显式传参更优的场景
| 场景 | ctx.Value | struct 参数 |
|---|---|---|
| 请求重试次数控制 | ❌ 隐式、难追踪 | ✅ 显式、可文档化 |
| 数据库查询超时设置 | ❌ 与 context 生命周期耦合 | ✅ 精确作用于单次调用 |
// 推荐:结构体封装关键业务参数
type DBQueryParams struct {
Timeout time.Duration
Retry int
ShardID uint8
}
func Query(ctx context.Context, p DBQueryParams) error {
// ...
}
此写法避免
ctx.Value的类型断言开销与运行时 panic 风险;Timeout/Retry语义明确,IDE 可自动补全,单元测试易 mock。
graph TD
A[HTTP Handler] --> B{参数来源?}
B -->|业务逻辑强依赖| C[struct 参数]
B -->|跨中间件透传元数据| D[ctx.Value]
C --> E[类型安全/可测试/易调试]
D --> F[仅限 traceID/authInfo 等轻量键值]
第四章:工程级Context安全加固实践体系
4.1 静态检查:基于go vet和自定义Analyzer拦截Context裸传漏洞
context.Context 不应作为普通参数裸传(如 func Handle(req *http.Request, ctx context.Context)),而应置于函数首参且显式派生,否则易导致取消传播失效、超时丢失或 goroutine 泄漏。
为什么裸传 Context 危险?
- 中间件无法注入 deadline/cancel
ctx.WithValue语义被弱化,链路追踪上下文断裂- 静态分析难以识别“伪派生”(如直接
return ctx而非ctx.WithTimeout)
自定义 Analyzer 拦截逻辑
func run(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
if f, ok := n.(*ast.FuncDecl); ok && f.Type.Params != nil {
for i, field := range f.Type.Params.List {
if len(field.Type.Names) == 0 && isContextType(pass.TypesInfo.TypeOf(field.Type)) {
if i > 0 { // 非首参 → 裸传告警
pass.Reportf(field.Pos(), "context.Context must be the first parameter, not position %d", i+1)
}
}
}
}
return true
})
}
return nil, nil
}
该 Analyzer 遍历函数签名,检查
context.Context类型是否出现在非第0位。pass.TypesInfo.TypeOf()精确识别泛型/别名类型;pass.Reportf()触发go vet -vettool=...可见告警。
检查项对比表
| 工具 | 能力 | 裸传检测精度 | 可扩展性 |
|---|---|---|---|
go vet 原生 |
基础空指针/死代码 | ❌ 不支持 | ❌ 固定规则 |
| 自定义 Analyzer | 上下文位置/派生链分析 | ✅ 精确到参数序号 | ✅ Go 插件式 |
graph TD
A[源码AST] --> B{FuncDecl遍历}
B --> C[提取参数类型]
C --> D[isContextType?]
D -->|是| E[检查参数索引==0?]
D -->|否| F[跳过]
E -->|否| G[Report 裸传告警]
E -->|是| H[通过]
4.2 运行时防护:Context泄漏检测中间件与pprof+trace联动诊断
Context 泄漏常因 Goroutine 持有已取消的 context.Context 导致资源无法释放。我们实现轻量级中间件,在 HTTP handler 入口自动注入带生命周期钩子的 Context:
func ContextLeakDetector(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 绑定 goroutine ID 与 context,便于后续追踪
trackedCtx := context.WithValue(ctx, "goroutine_id", goroutineID())
r = r.WithContext(trackedCtx)
next.ServeHTTP(w, r)
})
}
逻辑分析:该中间件不阻塞请求流,仅注入可追溯元数据;
goroutineID()通过runtime.Stack提取当前 ID(生产环境建议用goid库优化性能);context.WithValue为诊断埋点,非业务逻辑耦合。
联动诊断流程
- pprof CPU profile 定位长时 Goroutine
- trace 查看
context.WithCancel调用链与 cancel 时间戳 - 结合
runtime.ReadMemStats检查Mallocs增速异常
| 工具 | 关键指标 | 触发阈值 |
|---|---|---|
| pprof | Goroutine 累计存活 >5min | go tool pprof -http=:8080 cpu.pprof |
| trace | Context cancel 延迟 >100ms | go tool trace trace.out |
graph TD
A[HTTP 请求] --> B[ContextLeakDetector 中间件]
B --> C[注入 goroutine_id & 计时器]
C --> D[Handler 执行]
D --> E{是否显式调用 ctx.Done?}
E -->|否| F[pprof 发现高 Goroutine 数]
E -->|是| G[trace 显示 cancel 未传播]
F & G --> H[定位泄漏点]
4.3 单元测试强化:为HTTP Client注入mock context并断言Done通道关闭时机
在高并发 HTTP 客户端测试中,context.Context 的生命周期管理直接影响 Done() 通道的触发时机,需精准模拟超时、取消与正常完成三种状态。
模拟 Context 生命周期
使用 testify/mock 或原生 context.WithCancel/WithTimeout 构建可控上下文:
func TestHTTPClient_DoneChannel(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() // 主动触发 Done()
client := &HTTPClient{ctx: ctx}
go func() { time.Sleep(50 * time.Millisecond); cancel() }()
select {
case <-client.ctx.Done():
assert.Equal(t, context.Canceled, client.ctx.Err()) // 断言错误类型
case <-time.After(200 * time.Millisecond):
t.Fatal("Done channel not closed in time")
}
}
逻辑分析:
cancel()调用后,ctx.Done()立即可读;defer cancel()确保资源清理;select配合超时防止测试挂起。ctx.Err()返回context.Canceled是取消行为的权威标识。
关键断言维度对比
| 维度 | 正常完成 | 超时触发 | 主动取消 |
|---|---|---|---|
ctx.Done() 可读 |
✅(请求结束) | ✅(Deadline 到) | ✅(cancel() 调用) |
ctx.Err() 值 |
nil |
context.DeadlineExceeded |
context.Canceled |
测试覆盖要点
- ✅ 注入
context.WithCancel并显式调用cancel() - ✅ 使用
select+time.After实现非阻塞通道监听 - ✅ 断言
ctx.Err()类型而非仅检查<-ctx.Done()是否返回
4.4 CI/CD集成:在GHA中嵌入context-lint工具链实现PR级准入控制
为保障语义上下文一致性,将 context-lint 工具链深度集成至 GitHub Actions 流水线,在 PR 提交时自动校验变更是否符合领域建模约束。
集成策略设计
- 在
pull_request触发器下启用on: [opened, synchronize, reopened] - 使用
actions/checkout@v4获取完整上下文(含历史提交) - 通过
matrix并行执行多版本 lint 检查(v1.2+ / v2.0+)
工作流核心片段
- name: Run context-lint
run: |
npm ci --silent
npx context-lint --mode=pr --base=${{ github.event.before }} --head=${{ github.head_ref }}
# 参数说明:
# --mode=pr:启用PR专用规则集(如跨文件实体引用检测)
# --base:对比基准提交哈希(确保增量分析准确性)
# --head:当前分支最新提交(由GHA自动注入)
检查结果分级响应
| 级别 | 行为 | 示例场景 |
|---|---|---|
| error | 阻断合并,标记失败检查项 | 新增API未声明上下文依赖 |
| warning | 注释PR,不阻断 | 命名风格轻微偏离规范 |
graph TD
A[PR opened] --> B[Checkout code]
B --> C[Run context-lint]
C --> D{Exit code == 0?}
D -->|Yes| E[Approve merge]
D -->|No| F[Post annotation + fail]
第五章:从Context泄漏到可观测性演进的技术反思
在微服务架构大规模落地的第三年,某支付中台团队遭遇了一次典型的“幽灵内存泄漏”:线上服务 RSS 持续攀升,GC 频率每小时增加 37%,但堆内对象统计却无明显异常。最终通过 jstack + async-profiler 联动分析,定位到 ThreadLocal<TracingContext> 在异步线程池中未清理——一个被忽略的 RequestContextHolder.reset() 缺失,导致 Span 引用链锁住整个 HTTP 请求上下文,生命周期意外延长至线程复用周期。
Context泄漏的典型根因模式
| 场景类型 | 触发条件 | 检测难点 | 修复方案 |
|---|---|---|---|
| 线程池复用泄漏 | CompletableFuture.supplyAsync() 使用共享 ForkJoinPool.commonPool() |
堆外内存增长、ThreadLocalMap entry 泄漏 |
显式指定自定义线程池 + ThreadLocal.remove() Hook |
| Filter链中断泄漏 | Spring Security SecurityContextPersistenceFilter 被自定义Filter提前return |
SecurityContext 未绑定到新线程 |
使用 SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL) |
| Reactor上下文断裂 | Mono.subscriberContext() 未传递至下游 flatMap 内部 |
MDC日志丢失、traceId断连 | 改用 ContextView.putAll() + Mono.deferWithContext() |
可观测性栈的代际跃迁路径
早期团队仅依赖 Prometheus 暴露 /actuator/metrics 中的 jvm.memory.used,但该指标无法区分是业务对象膨胀还是 ThreadLocal 持有泄漏。2022年升级为 OpenTelemetry Collector + Jaeger 后,通过注入 otel.instrumentation.common.thread-local.enabled=true,首次捕获到 thread_local_entry_count 指标突增与 GC pause 的强相关性(相关系数 r=0.92)。
// 修复后的异步调用模板(Spring Boot 3.1+)
public CompletableFuture<Order> processAsync(Order order) {
// 快照当前MDC与TraceContext
Map<String, String> mdcCopy = MDC.getCopyOfContextMap();
Context otelContext = Context.current();
return CompletableFuture.supplyAsync(() -> {
// 还原上下文
if (mdcCopy != null) MDC.setContextMap(mdcCopy);
Scope scope = otelContext.makeCurrent();
try {
return orderService.enrich(order);
} finally {
scope.close(); // 关键:显式释放Scope
MDC.clear();
}
}, customThreadPool);
}
从被动监控到主动防御的工程实践
某电商大促前夜,SRE 团队将 ThreadLocal 生命周期检测嵌入 CI 流水线:通过 ByteBuddy 在测试阶段动态织入 onEnter/onExit 钩子,统计每个 ThreadLocal.set() 后未匹配 remove() 的实例数。当检测到 TracingContext 实例存活超 5 分钟,自动触发 @PreDestroy 清理并告警。该机制上线后,Context 相关 OOM 事故下降 100%。
flowchart LR
A[HTTP请求进入] --> B{是否经过WebMvcConfigurer}
B -->|是| C[注入TracingContextResolver]
B -->|否| D[手动调用ContextPropagation.bindToCurrent\\n并注册ThreadLocalCleanupHook]
C --> E[Controller层获取context]
E --> F[异步调用前调用ContextSnapshot.capture\\n并传入CompletableFuture]
F --> G[线程池执行时restore context]
G --> H[finally块强制remove所有ThreadLocal]
该演进过程并非单纯工具替换,而是将可观测性能力下沉至 JVM 运行时语义层:当 ThreadLocalMap 的 Entry[] 数组扩容阈值被突破时,OTel Java Agent 自动触发 ThreadLocalLeakDetector 扫描,并将泄漏路径序列化为 span attribute,使原本不可见的线程局部状态变为可追踪、可聚合、可告警的一等公民。
