第一章:Go context取消传播失效的本质与认知陷阱
Go 中 context.Context 的取消传播并非“自动广播”,而依赖于显式检查与协作式退出。其失效往往源于开发者误以为取消信号会穿透任意 goroutine 或阻塞操作,忽略了 context 仅在被主动监听时才生效这一根本约束。
取消信号不会穿透非 context-aware 操作
time.Sleep、sync.WaitGroup.Wait、chan receive without select 等原语完全无视 context。例如以下代码中,即使父 context 已取消,子 goroutine 仍会完整休眠 5 秒:
func badExample(ctx context.Context) {
go func() {
time.Sleep(5 * time.Second) // ❌ 不响应 ctx.Done()
fmt.Println("done after 5s — too late!")
}()
}
正确做法是使用 time.AfterFunc 结合 ctx.Done(),或改用 time.SleepContext(Go 1.22+):
func goodExample(ctx context.Context) {
go func() {
select {
case <-time.After(5 * time.Second):
fmt.Println("done after 5s")
case <-ctx.Done():
fmt.Println("canceled early:", ctx.Err()) // ✅ 响应取消
}
}()
}
子 context 必须由父 context 显式派生
直接创建 context.Background() 或 context.TODO() 作为子 goroutine 的上下文,将导致取消链断裂。常见错误包括:
- 在 goroutine 内部重新调用
context.WithCancel(context.Background()) - 忘记将传入的
ctx传递给下游函数(如 HTTP client、database query)
关键认知陷阱清单
- ❌ “只要父 context 取消,所有子 goroutine 都会立即停止”
- ❌ “
context.WithTimeout能中断正在执行的系统调用” - ❌ “未读取
ctx.Done()通道不会造成内存泄漏”(实际会导致 goroutine 泄漏) - ✅ 取消传播是协作协议:每个参与方必须在关键路径上
select监听ctx.Done()
| 场景 | 是否响应取消 | 补救方式 |
|---|---|---|
http.Client.Do(req.WithContext(ctx)) |
✅ 是 | 使用 req.WithContext() 包装请求 |
database/sql.QueryContext(ctx, ...) |
✅ 是 | 替换为 QueryContext / ExecContext |
io.Copy(dst, src) |
❌ 否 | 改用 io.CopyN + 定期检查 ctx.Err() 或封装带 cancel 的 reader |
取消传播失效不是 context 的缺陷,而是对“协作式并发控制”范式的误读。修复核心在于:每一次阻塞、每一次 IO、每一次循环迭代,都应成为一次 select 的机会。
第二章:WithCancel父节点提前释放的七重幻境
2.1 父ctx在goroutine启动前被cancel的竞态复现与pprof验证
复现场景构造
以下代码模拟父 ctx 在 goroutine 启动前被 cancel 的竞态窗口:
func reproduceRacyCancel() {
ctx, cancel := context.WithCancel(context.Background())
cancel() // ⚠️ 立即取消,但 goroutine 尚未启动
go func(ctx context.Context) {
select {
case <-ctx.Done():
fmt.Println("goroutine exited:", ctx.Err()) // 常见输出:context canceled
}
}(ctx)
runtime.Gosched() // 强制调度,放大竞态概率
}
逻辑分析:
cancel()返回后,ctx.Done()通道立即关闭;但go语句的调度存在延迟,导致子 goroutine 在启动时已面对已关闭的 channel。参数ctx是值传递,但其底层donechannel 引用共享。
pprof 验证关键指标
| 指标 | 正常场景 | 竞态触发后 |
|---|---|---|
goroutines |
~1 | 瞬时激增后快速归零 |
block |
低 | 出现 select 阻塞超时(误判为阻塞) |
goroutine trace |
清晰生命周期 | 显示 runtime.gopark 在 ctx.Done() 上无等待 |
根本机制
context.WithCancel创建的cancelCtx在cancel()调用时同步关闭donechannel- goroutine 启动是异步调度行为,与 cancel 操作无内存序约束 → 典型 data race on control flow
graph TD
A[main: ctx, cancel := WithCancel] --> B[main: cancel()]
B --> C[main: go func(ctx){...}]
C --> D[goroutine: select <-ctx.Done()]
D --> E[因 done 已关闭,立即返回]
2.2 defer cancel()误置于闭包外导致的生命周期错配实战剖析
问题现场还原
以下代码在 HTTP 处理器中错误地将 defer cancel() 提前声明,脱离了请求上下文:
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() // ❌ 错误:cancel() 在函数入口即注册,与实际业务逻辑脱钩
select {
case <-time.After(3 * time.Second):
w.Write([]byte("done"))
case <-ctx.Done():
http.Error(w, "timeout", http.StatusRequestTimeout)
}
}
逻辑分析:
defer cancel()绑定在handler函数退出时执行,但ctx实际仅需在select分支结束后释放。若handler因 panic 或提前 return 中断,cancel()仍会执行——看似安全,实则掩盖了「本应在子 goroutine 或异步操作中按需取消」的真实意图,导致父 ctx 过早终止,影响中间件链(如authMiddleware持有的 ctx.Value)。
生命周期错配后果
| 场景 | 表现 | 根本原因 |
|---|---|---|
| 并发请求共用同一 cancel | 上游中间件 ctx 被意外取消 | cancel() 作用于 r.Context() 衍生的顶层 ctx |
| defer 延迟至函数末尾 | 子任务未完成时 ctx 已失效 | 缺乏基于任务粒度的 cancel 控制 |
正确模式
应将 cancel() 与具体异步操作绑定:
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() // ✅ 正确:生命周期与 goroutine 对齐
// ... 异步处理
}()
}
2.3 context.WithCancel返回值未绑定到结构体字段引发的静默泄漏
当 context.WithCancel 返回的 cancel 函数未被保存为结构体字段时,其生命周期仅限于局部作用域,导致子 goroutine 无法被主动终止。
数据同步机制
type Worker struct {
ctx context.Context
// ❌ 缺失 cancel func 字段 → 泄漏根源
}
func (w *Worker) Start() {
ctx, cancel := context.WithCancel(w.ctx)
defer cancel() // 错误:defer 在 Start 返回时即调用,非运行时控制
go func() {
select {
case <-ctx.Done():
return // 永远不会触发
}
}()
}
cancel 未绑定字段,defer cancel() 在 Start() 结束时立即执行,子 goroutine 的 ctx 立即 Done,但后续无外部取消能力,实际形成“假启动真泄漏”。
关键修复模式
- ✅ 将
cancel作为结构体字段(如cancel context.CancelFunc) - ✅ 提供显式
Stop()方法调用该字段 - ✅ 在
Start()中仅启动,不defer cancel()
| 问题环节 | 后果 |
|---|---|
cancel 未持久化 |
goroutine 无法终止 |
defer cancel() 过早 |
上下文瞬间失效 |
2.4 单元测试中time.AfterFunc触发cancel时机不可控的断言失效案例
问题现象
time.AfterFunc 在测试中常被用于模拟异步超时逻辑,但其底层依赖系统调度器,无法保证精确触发时刻,导致 t.Cleanup 或手动 cancel() 调用与回调执行存在竞态。
失效代码示例
func TestAfterFuncRace(t *testing.T) {
var called bool
timer := time.AfterFunc(10*time.Millisecond, func() { called = true })
time.Sleep(5 * time.Millisecond)
timer.Stop() // ✅ 期望取消成功
if called { // ❌ 可能为 true:回调已入队但未执行,Stop 返回 false 却仍触发
t.Fatal("callback executed despite Stop")
}
}
逻辑分析:
timer.Stop()仅阻止尚未触发的回调;若 runtime 已将回调推入 goroutine 队列(但尚未执行),Stop()返回false且回调仍将执行。测试断言called == false因调度不确定性而间歇性失败。
根本原因对比
| 场景 | Stop() 返回值 | callback 是否执行 | 原因 |
|---|---|---|---|
| 回调未入队 | true | 否 | 成功取消 |
| 回调已入队未执行 | false | 是 | 调度器已接管,无法撤回 |
| 回调正在执行中 | false | 是(进行中) | Stop 不阻塞正在运行的函数 |
推荐解法
- 使用
context.WithTimeout+select替代AfterFunc - 测试中改用
time.AfterFunc(0)+ 显式同步(如sync.WaitGroup)控制时序
2.5 通道接收侧未检查ctx.Done()即调用cancel的反模式代码重构
问题代码示例
func badReceiver(ctx context.Context, ch <-chan int) {
cancel := func() { /* 取消逻辑 */ }()
for val := range ch {
if ctx.Err() != nil { // ❌ 检查滞后:cancel已执行,但未在select中同步响应
break
}
process(val)
}
cancel() // ⚠️ 危险:可能在ctx已超时/取消后仍调用
}
逻辑分析:cancel() 在循环结束后无条件执行,但 ctx.Done() 可能在任意时刻关闭。若 ch 阻塞且 ctx 已取消,range 不退出(因通道未关闭),cancel() 永不执行;反之,若 ch 关闭而 ctx 尚未取消,cancel() 又被冗余调用。
正确重构方式
- ✅ 始终在
select中监听ctx.Done() - ✅
cancel()仅由defer或明确控制流触发 - ✅ 使用
sync.Once防重复取消(如需)
对比关键行为
| 场景 | 反模式行为 | 重构后行为 |
|---|---|---|
| ctx 超时 + ch 有数据 | cancel() 延迟执行 |
select 立即响应并退出 |
| ch 关闭 + ctx 有效 | cancel() 被误调用 |
defer cancel() 精准释放 |
graph TD
A[启动接收] --> B{select<br>case val := <-ch:<br>case <-ctx.Done():}
B -->|接收到值| C[process val]
B -->|ctx.Done| D[调用 cancel<br>return]
C --> B
第三章:WithValue覆盖污染的隐蔽传导链
3.1 同key多层WithValue嵌套导致value被意外覆盖的调试追踪实验
数据同步机制
当 WithValue 在同一 context key 上多次嵌套调用时,底层 valueCtx 仅保留最后一次赋值——因 key 的 == 比较触发浅层覆盖,而非合并。
复现代码
ctx := context.WithValue(context.Background(), "token", "v1")
ctx = context.WithValue(ctx, "token", "v2") // 覆盖!
fmt.Println(ctx.Value("token")) // 输出: v2
逻辑分析:context.WithValue 不校验 key 是否已存在,直接构造新 valueCtx{parent, key, val};Value() 查找时从当前 ctx 向上遍历,首次匹配即返回,故 v1 永不可达。
覆盖路径示意
graph TD
A[ctx0: Background] --> B[ctx1: token=v1]
B --> C[ctx2: token=v2]
C -->|Value\("token"\)| D["return 'v2'"]
关键事实对比
| 场景 | 是否可恢复旧值 | 原因 |
|---|---|---|
| 同 key 多次 WithValue | ❌ | Value() 短路返回首个匹配 |
| 不同 key(如 token_v1/token_v2) | ✅ | 无冲突,可并存 |
3.2 http.Request.Context().WithValue()与中间件ctx传递断裂的HTTP trace断点分析
当在中间件中调用 ctx = ctx.WithValue(key, value),新上下文虽携带数据,但不继承父 Context 的 Deadline/Cancel 信号与 trace.Span。
常见断裂场景
- 中间件未将原始
req.Context()透传,而是新建context.WithValue(context.Background(), ...) WithValue被误用于跨 goroutine 传递 span,导致 OpenTracing/OpenTelemetry 上下文丢失
trace 断裂验证代码
func traceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ❌ 错误:从 Background 创建,脱离 request trace 链
ctx := context.WithValue(context.Background(), "user_id", "123")
r = r.WithContext(ctx) // 此 ctx 无 span、无 cancel func
next.ServeHTTP(w, r)
})
}
此处
context.Background()彻底切断了r.Context()中已注入的oteltrace.SpanContext,后续 span.Child() 将生成孤立 trace。
正确做法对比表
| 操作方式 | 是否保留 trace | 是否可取消 | 是否推荐 |
|---|---|---|---|
r.Context().WithValue(k,v) |
✅ 是 | ✅ 是 | ✅ 推荐 |
context.WithValue(context.Background(),k,v) |
❌ 否 | ❌ 否 | ❌ 禁止 |
graph TD
A[HTTP Request] --> B[r.Context\(\)]
B --> C[otelhttp.ServerHandler 注入 Span]
C --> D[中间件 r.WithContext\(.WithValue\.\.\.\)]
D --> E[正确:基于 B 衍生]
D --> F[错误:基于 context.Background\(\)]
F --> G[Trace 断点]
3.3 值类型误用指针导致context.Value()返回nil却不报错的panic规避策略
根本原因:值类型与指针类型的键不等价
Go 中 context.WithValue() 的键(key)是通过 == 比较的。若传入 int(42) 作为 key,后续却用 &int(42) 查找,二者内存地址不同,context.Value() 必然返回 nil —— 无 panic,无声失败。
典型错误示例
type UserID int
func badUsage(ctx context.Context) {
ctx = context.WithValue(ctx, UserID(123), "alice") // 值类型键
val := ctx.Value(UserID(123)) // ❌ 新建的 UserID(123) ≠ 上面那个!
fmt.Printf("%v\n", val) // <nil>,不报错但逻辑断裂
}
逻辑分析:
UserID(123)每次调用都生成独立的值实例;底层比较的是字面值副本,而非引用。即使类型相同、值相同,==在结构体/自定义类型中仍为逐字段复制比较,但此处是新分配的栈值,地址无关——关键在于 Go 不支持“键的语义相等”,只认字面同一性。
安全实践:统一使用导出的包级变量作键
| 方式 | 是否安全 | 原因 |
|---|---|---|
var UserKey = struct{}{} |
✅ 推荐 | 包级变量地址唯一,== 稳定 |
type Key string; Key("user") |
✅(需固定变量) | const UserKey Key = "user" 可行 |
int(1) 或 UserID(1) |
❌ 危险 | 每次字面量构造新值 |
graph TD
A[调用 context.WithValue] --> B{key 是包级变量?}
B -->|是| C[Value 查找成功]
B -->|否| D[返回 nil,静默失败]
第四章:HTTP handler中间件ctx未传递的链式崩塌
4.1 Gin/Echo框架中next(c)未传入c.Request.Context()引发的超时穿透现象
当中间件调用 next(c) 时,若未显式继承 c.Request.Context()(如未使用 c.Copy() 或 c.Request.WithContext()),新上下文将丢失父级 context.WithTimeout 的截止时间。
超时丢失的本质原因
Gin/Echo 的 c.Request 是指针类型,但 c.Request.Context() 默认为 context.Background(),除非在路由层显式注入超时上下文。
// ❌ 错误:未传递原始请求上下文
func timeoutMiddleware(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // 错!应基于 c.Request.Context()
defer cancel()
c.Request = c.Request.WithContext(ctx) // 必须显式注入
c.Next() // 否则下游 handler 仍用 background context
}
逻辑分析:
context.Background()无超时能力;c.Request.WithContext()才能将超时传播至c.Next()链路。参数ctx必须源自c.Request.Context(),而非context.Background()。
典型影响对比
| 场景 | 是否穿透超时 | 后果 |
|---|---|---|
next(c) 前未 WithCtx |
✅ 是 | handler 永不超时,阻塞 goroutine |
正确注入 c.Request.Context() |
❌ 否 | 超时准时触发 503 Service Unavailable |
graph TD
A[Client Request] --> B[Gin Engine]
B --> C{timeoutMiddleware}
C -->|c.Request.Context() 未继承| D[Handler 使用 background ctx]
C -->|c.Request.WithContext<br>正确注入| E[Handler 响应超时]
4.2 自定义中间件使用http.StripPrefix后ctx丢失的net/http源码级定位
问题现象
当在自定义中间件中组合 http.StripPrefix 与 http.Handler 链时,下游 handler 中 r.Context() 无法访问上游注入的值(如 context.WithValue 设置的键),表现为 ctx.Value(key) == nil。
根源定位
http.StripPrefix 返回的匿名 handler 内部未传递原始请求上下文,而是直接构造新请求:
func (s *stripPrefix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ⚠️ 关键:NewRequestWithContext 未继承 r.Context()
r2 := new(http.Request)
*r2 = *r
r2.URL = &url.URL{Path: strings.TrimPrefix(r.URL.Path, s.prefix)}
s.h.ServeHTTP(w, r2) // ← 原始 ctx 丢失!
}
*r2 = *r复制结构体字段,但r.Context()是接口值,复制后仍指向原 ctx;然而r2.URL重建导致r2的ctx字段未被显式设置。实际行为取决于 Go 版本——Go 1.21+ 中*r2 = *r不复制 context 字段(因context.Context是接口,且http.Request的ctx字段为 unexported)。
修复方案对比
| 方案 | 是否保留 ctx | 实现复杂度 | 推荐度 |
|---|---|---|---|
r.Clone(r.Context()) |
✅ 完整继承 | 低 | ⭐⭐⭐⭐ |
手动 r2 = r.WithContext(r.Context()) |
✅ | 中 | ⭐⭐⭐ |
改用 http.StripPrefix + http.HandlerFunc 显式透传 |
✅ | 高 | ⭐⭐ |
正确写法(推荐)
func StripPrefixWithCtx(prefix string, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r2 := r.Clone(r.Context()) // ← 关键:显式继承完整上下文
r2.URL = &url.URL{Path: strings.TrimPrefix(r.URL.Path, prefix)}
h.ServeHTTP(w, r2)
})
}
r.Clone(ctx) 确保新请求完整复刻原 Context、Header、Body 等状态,是 net/http 官方推荐的上下文安全复制方式。
4.3 context.WithValue(ctx, key, val)后未显式c.Request = c.Request.WithContext(ctx)的静默失效
在 Gin 框架中,context.WithValue 创建的新上下文若未重新绑定到 *http.Request,将完全丢失:
// ❌ 错误:ctx 被修改,但 Request 仍持有旧 ctx
newCtx := context.WithValue(c.Request.Context(), "user_id", 123)
// c.Request.Context() 依然返回原始 ctx —— 新值不可见!
逻辑分析:
c.Request是*http.Request实例,其Context()方法返回内部ctx字段副本;WithValue返回新context.Context,但c.Request本身未更新,故后续c.Request.Context().Value("user_id")返回nil。
数据同步机制
- Gin 的
c.Request与c(gin.Context)各自维护独立 context 引用 c.Request = c.Request.WithContext(newCtx)是唯一同步路径
| 操作 | 是否影响 c.Request.Context() |
是否影响 c.Value() |
|---|---|---|
context.WithValue(c.Request.Context(), ...) |
❌ 否 | ❌ 否(除非重赋值) |
c.Request = c.Request.WithContext(...) |
✅ 是 | ✅ 是(经 c.Copy() 或中间件透传) |
graph TD
A[原始 c.Request] -->|WithContext| B[新 Request]
B --> C[c.Request.Context() 可见新值]
A -->|无赋值| D[原 ctx 仍被 c.Request 持有]
4.4 流式响应(Streaming Response)中WriteHeader后ctx.Done()监听失效的边界条件复现
现象复现关键路径
当 HTTP handler 调用 w.WriteHeader() 后,ResponseWriter 内部状态切换为 written=true,此时 http.CloseNotifier(已弃用)及现代 ctx.Done() 的底层信号传递可能被 net/http 的连接缓冲/写入协程绕过。
失效触发条件
- 使用
flusher, ok := w.(http.Flusher)并持续Write+Flush - 客户端保持长连接但中途断开(如浏览器关闭标签页)
ctx.Done()未被net/http主循环主动轮询(仅依赖底层 TCP FIN/RST)
func handler(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK) // ⚠️ 此刻 ctx.Done() 监听开始不可靠
flusher.Flush()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Fprintf(w, "data: ping\n\n")
flusher.Flush()
case <-r.Context().Done(): // ❌ 此处可能永远阻塞
log.Println("context cancelled — but may never fire!")
return
}
}
}
逻辑分析:
WriteHeader()触发hijack或conn.serve()状态迁移后,r.Context()的取消信号不再同步注入到流式写协程。net/http未在每次Write/Flush前检查ctx.Err(),导致监听“静默失效”。
典型失效场景对比
| 场景 | ctx.Done() 是否及时触发 | 原因 |
|---|---|---|
| 普通 JSON 响应(无流式) | ✅ 是 | ServeHTTP 返回前统一检查 context |
| SSE 流式响应(WriteHeader+Flush) | ❌ 否 | 写操作脱离主 request goroutine 控制流 |
| gRPC-Web 流式(基于 HTTP/2) | ✅ 是 | 底层 http2 server 显式轮询 ctx.Done() |
graph TD
A[Client Request] --> B[net/http.serverHandler.ServeHTTP]
B --> C{WriteHeader called?}
C -->|Yes| D[conn.state = StateHijacked/Streaming]
C -->|No| E[Context check before Write]
D --> F[Write/Flush in separate goroutine]
F --> G[No ctx.Done poll → 静默挂起]
第五章:构建可观测、可验证、可演进的context治理范式
在微服务架构持续演进的生产环境中,某头部金融科技平台曾因 context 透传缺失导致跨12个服务链路的风控决策失效——用户交易被错误拦截,日均损失超37万元。根源在于 span context 仅携带 traceID,而业务语义关键字段(如 user_tier=premium、region_code=CN-SH、compliance_mode=GDPR)未被结构化注入、校验与版本化管理。该案例直接催生了其 context 治理范式的重构。
核心治理原则落地实践
- 可观测性:强制所有服务在 OpenTelemetry SDK 初始化时注入
ContextSchemaValidator中间件,自动采集 context 字段的出现率、类型一致性、缺失率指标,并推送至 Prometheus;Grafana 看板实时展示context_field_completeness_ratio{service="payment", field="user_tier"}指标,阈值低于99.5%触发企业微信告警。 - 可验证性:采用 JSON Schema 定义 context 元模型,例如
risk-context-v2.json明确声明user_tier为枚举值(["basic","silver","gold","premium"]),且compliance_mode必须存在;CI 流程中通过jsonschema --draft 2020-12 risk-context-v2.json test_context_payload.json自动校验每个 PR 提交的 context 示例数据。
演进机制设计
context 版本升级采用双写+灰度验证策略:v3 新增 device_fingerprint_hash 字段后,服务先以 context_v2+v3 双模式写入,同时启动影子消费者解析 v3 并比对 v2 衍生结果;当连续10万条记录偏差率
| 字段名 | v2 覆盖率 | v3 覆盖率 | v3 类型合规率 | v2→v3 衍生偏差率 |
|---|---|---|---|---|
| user_tier | 99.82% | 99.91% | 100% | 0.000% |
| device_fingerprint_hash | 0% | 94.37% | 99.99% | — |
工具链集成示例
通过自研 CLI 工具 ctxctl 实现治理闭环:
# 生成符合 schema 的 context 模板
ctxctl schema generate --version v3 --output context-v3-template.yaml
# 验证本地服务输出的 context 日志
ctxctl validate --schema risk-context-v3.json --log-file /var/log/payment/context.log
# 查询全链路 context 字段血缘(基于 Jaeger + 自定义 tag 注入)
ctxctl trace --trace-id 0a1b2c3d4e5f6789 --show-fields user_tier,compliance_mode
运行时防护机制
在 Envoy 代理层部署 WASM Filter,拦截非法 context 注入:当 HTTP Header 中 x-context-user-tier 值为 platinum(非 schema 枚举项)时,自动拒绝请求并返回 400 Bad Request,响应体包含纠错建议:
{
"error": "invalid_context_field",
"field": "user_tier",
"allowed_values": ["basic","silver","gold","premium"],
"suggestion": "use 'premium' instead of 'platinum'"
}
治理成效量化
上线6个月后,该平台跨服务 context 字段平均完整性从82.3%提升至99.97%,context 相关线上故障平均定位时间由47分钟缩短至3.2分钟,新业务线接入 context 治理框架的平均耗时从5人日压缩至0.5人日。所有 context 变更均通过 GitOps 流水线管控,每次 schema 提交附带自动化测试用例与历史兼容性报告。
