第一章:Goroutine泄漏的本质与危害全景图
Goroutine泄漏并非语法错误或运行时panic,而是指启动的goroutine因逻辑缺陷长期阻塞、无法退出,持续占用内存与调度资源,最终拖垮整个服务。其本质是生命周期失控:goroutine脱离开发者预期的存活范围,在系统中“幽灵般”常驻。
为什么泄漏难以察觉
- Go运行时不提供主动回收阻塞goroutine的机制;
runtime.NumGoroutine()仅返回当前数量,无法区分活跃/泄漏goroutine;- 泄漏初期对QPS影响微弱,常在高负载压测或长周期运行后才暴露OOM或CPU飙升。
典型泄漏场景与验证代码
以下代码模拟常见泄漏模式——未关闭的channel读操作:
func leakExample() {
ch := make(chan int)
go func() {
// 永远等待写入:ch未被关闭,也无写端,此goroutine永不退出
<-ch // 阻塞在此,goroutine泄漏
}()
// 忘记 close(ch) 或向ch发送数据
}
执行后调用 runtime.NumGoroutine() 可观察到goroutine计数异常增长;结合 pprof 可定位阻塞点:
# 启动HTTP pprof端点(需在main中注册)
import _ "net/http/pprof"
# 访问 http://localhost:6060/debug/pprof/goroutine?debug=2 查看所有goroutine栈
危害层级全景
| 危害类型 | 表现形式 | 扩散路径 |
|---|---|---|
| 资源耗尽 | 内存持续增长、OOM Killer介入 | 影响同宿主机其他进程 |
| 调度失衡 | 大量goroutine争抢M/P,延迟毛刺升高 | 降低整体吞吐与响应稳定性 |
| 监控失真 | 指标(如GC频率、heap_inuse)持续恶化 | 掩盖真实业务瓶颈 |
根本治理需从设计阶段引入约束:显式超时控制、使用context.WithCancel管理生命周期、避免无缓冲channel单向等待,并在CI中集成go vet -race与pprof自动化检测。
第二章:监控盲区一——HTTP Server长连接未优雅关闭
2.1 HTTP/1.1 Keep-Alive 机制与 Goroutine 生命周期理论分析
HTTP/1.1 的 Connection: keep-alive 允许复用 TCP 连接,避免频繁握手开销。但 Go 的 net/http 服务器为每个请求启动独立 Goroutine,其生命周期由请求上下文(ctx)和连接状态共同约束。
Goroutine 启停边界
- 启动:
ServeHTTP调用时由server.go中的conn.serve()派生 - 终止:响应写入完成 且 连接被显式关闭或超时(
ReadTimeout/WriteTimeout)
关键同步点
// src/net/http/server.go 片段(简化)
func (c *conn) serve() {
for {
w, err := c.readRequest(ctx)
if err != nil { break }
serverHandler{c.server}.ServeHTTP(w, w.req)
// ↓ 此处隐式等待 responseWriter.Close() 完成
if !w.conn.rwc.SetWriteDeadline(time.Now().Add(c.server.WriteTimeout)) {
break // 写超时 → goroutine 自然退出
}
}
}
readRequest 返回前会检查 ctx.Done();ServeHTTP 执行后,若连接可复用(keep-alive 且未达 MaxConnsPerHost),Goroutine 不立即销毁,而是等待下个请求 —— 此即“连接复用态下的 Goroutine 复用假象”。
Keep-Alive 状态流转(mermaid)
graph TD
A[New TCP Conn] --> B{Keep-Alive?}
B -->|Yes| C[Handle Request → Reset Timer]
B -->|No| D[Close Conn after Response]
C --> E{Idle Timeout?}
E -->|Yes| D
E -->|No| C
| 参数 | 作用 | 默认值 |
|---|---|---|
Server.IdleTimeout |
控制 Keep-Alive 连接最大空闲时间 | 0(禁用) |
Server.ReadTimeout |
从读取请求头开始计时 | 0(禁用) |
ResponseWriter.Flush() |
强制刷新缓冲并检测连接存活 | — |
2.2 实战复现:net/http.Server.Serve 中 goroutine 持久化场景
当 http.Server 启动后,Serve 方法为每个连接启动一个长期存活的 goroutine,直至连接关闭或超时。
goroutine 生命周期关键点
conn.serve()在独立 goroutine 中运行,持有conn、server及ctx引用- 即使 handler 返回,goroutine 仍持续处理后续请求(如 HTTP/1.1 keep-alive)或等待
ReadTimeout
复现持久化场景的最小代码
srv := &http.Server{Addr: ":8080", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second) // 模拟长耗时 handler
w.Write([]byte("done"))
})}
log.Fatal(srv.ListenAndServe())
此代码中,每个 TCP 连接对应一个
conn.serve()goroutine;若客户端复用连接发送多个请求,该 goroutine 将持续存在,不因单个 handler 返回而退出。conn结构体持有了server、tlsConn(如有)、buf等资源引用,形成隐式持久化。
常见持久化诱因对比
| 诱因类型 | 是否导致 goroutine 持久化 | 说明 |
|---|---|---|
| HTTP/1.1 keep-alive | ✅ | conn.serve() 循环读取新 request |
Handler panic |
❌(触发 recover 后仍继续) | 仅中断当前 request 处理 |
Server.Shutdown() |
⚠️(优雅终止中等待活跃 conn) | goroutine 主动退出前可阻塞数秒 |
graph TD
A[Accept 新连接] --> B[go c.serve()]
B --> C{Read Request?}
C -->|是| D[执行 Handler]
C -->|否,EOF/timeout| E[conn.close(); goroutine exit]
D --> C
2.3 诊断方案:pprof + net/http/pprof 的连接级 goroutine 快照比对
当服务偶发卡顿但 CPU/内存无明显异常时,阻塞型 goroutine 泄漏常是元凶。net/http/pprof 提供的 /debug/pprof/goroutine?debug=2 接口可导出带栈帧的完整 goroutine 快照。
获取两次快照
# 在疑似问题时段前后各抓取一次(间隔 5s)
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines-before.txt
sleep 5
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines-after.txt
debug=2返回含完整调用栈的文本格式;debug=1仅返回摘要统计,无法定位具体阻塞点。
差分分析关键模式
- 过滤持续存在的长生命周期 goroutine(如
net/http.(*conn).serve、runtime.gopark) - 重点关注
select,chan receive,sync.(*Mutex).Lock等阻塞原语栈顶
| 指标 | 正常值 | 异常征兆 |
|---|---|---|
| goroutine 总数增长 | 突增 >1000/5s | |
runtime.gopark 栈占比 |
>40% 且栈深 >8 层 |
自动化比对流程
graph TD
A[GET /debug/pprof/goroutine?debug=2] --> B[解析 goroutine ID + stack]
B --> C[按栈指纹哈希聚类]
C --> D[计算两次快照的 delta 集合]
D --> E[输出新增/未退出的阻塞栈]
2.4 修复实践:设置 ReadTimeout、WriteTimeout 与 IdleTimeout 的黄金组合
网络连接的稳定性高度依赖三类超时的协同配置——它们并非孤立参数,而是构成会话生命周期的三角支柱。
超时语义辨析
ReadTimeout:从 socket 接收缓冲区读取单次数据块的最大等待时间WriteTimeout:向 socket 发送单次写操作的阻塞上限IdleTimeout:连接空闲(无读/写活动)时自动关闭的守候阈值
黄金比例建议(HTTP 长连接场景)
| 场景 | ReadTimeout | WriteTimeout | IdleTimeout |
|---|---|---|---|
| 微服务间调用 | 5s | 5s | 30s |
| 数据同步任务 | 30s | 10s | 60s |
client := &http.Client{
Transport: &http.Transport{
// 单次读/写不超5秒,但允许连接空闲30秒后复用
ResponseHeaderTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 30 * time.Second,
},
}
ResponseHeaderTimeout 实质承担 ReadTimeout 职责(首字节到达时限),ExpectContinueTimeout 辅助控制 WriteTimeout 行为;IdleConnTimeout 独立管理连接池生命周期。三者叠加避免“半开连接”堆积与响应悬挂。
2.5 验证闭环:基于 httptest.UnstartedServer 的泄漏回归测试用例
httptest.UnstartedServer 是 Go 标准库中被低估的利器——它创建未启动的 *httptest.Server,允许手动控制监听生命周期,精准模拟服务启停边界场景。
为何选择 UnstartedServer?
- 避免端口占用与竞态干扰
- 支持在
TestMain或TestXXX中显式调用srv.Start()/srv.Close() - 天然适配资源泄漏(goroutine、connection、timer)的观测窗口
关键测试模式
func TestHandlerLeak(t *testing.T) {
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 模拟异步 goroutine 泄漏(无 cancel 控制)
go func() { time.Sleep(time.Second) }() // ⚠️ 危险模式
w.WriteHeader(http.StatusOK)
}))
srv.Start()
defer srv.Close() // Close() 会等待活跃连接关闭,并终止 listener
// 发起请求触发 handler
http.Get(srv.URL + "/test")
// 此处可注入 runtime.GoroutineProfile 或 net/http/pprof 检查
}
逻辑分析:
UnstartedServer延迟启动,确保defer srv.Close()能捕获所有由 handler 启动但未退出的 goroutine;srv.Close()内部调用srv.Listener.Close()并等待srv.Handler完成,构成可观测的“启停-观测”闭环。
| 检测维度 | 工具建议 | 触发时机 |
|---|---|---|
| Goroutine 泄漏 | runtime.NumGoroutine |
srv.Close() 前后对比 |
| 连接泄漏 | netstat -an \| grep :port |
srv.Listener.Addr() 端口 |
| Timer 泄漏 | pprof.Lookup("goroutine").WriteTo |
过滤 time.Sleep/AfterFunc |
graph TD
A[NewUnstartedServer] --> B[Start: 绑定端口+启动 listener]
B --> C[发起 HTTP 请求]
C --> D[Handler 启动长期 goroutine]
D --> E[调用 srv.Close()]
E --> F[Listener 关闭 + 等待活跃连接终止]
F --> G[快照 goroutine/profile 对比]
第三章:监控盲区二——Context 取消传播断裂
3.1 Context 树结构失效原理与 goroutine 孤儿化模型推演
Context 树依赖父子引用维系生命周期,一旦父 context 被 cancel,子 context 应同步终止。但若子 goroutine 持有 context.Context 接口却未监听 Done() 通道,或误用 context.WithValue 替代 WithCancel,树结构即告断裂。
数据同步机制失效场景
func spawnChild(ctx context.Context) {
child, _ := context.WithTimeout(ctx, 5*time.Second)
go func() {
// ❌ 忽略 <-child.Done(),未响应取消信号
time.Sleep(10 * time.Second) // 孤儿化执行
fmt.Println("goroutine finished")
}()
}
逻辑分析:child 上下文虽绑定超时,但 goroutine 未 select 监听其 Done() 通道,导致无法被父 context 取消;ctx 的取消信号无法穿透,形成孤儿 goroutine。
孤儿化判定条件
- 父 context 已 cancel 或 timeout
- 子 goroutine 未消费
ctx.Done() - 无外部同步机制(如 channel、WaitGroup)约束生命周期
| 维度 | 健康状态 | 孤儿化状态 |
|---|---|---|
| Done() 监听 | ✅ select 中 | ❌ 完全忽略 |
| 父子引用链 | 强引用保持 | 接口持有但无传播逻辑 |
graph TD
A[Parent Context] -->|cancel| B[Child Context]
B --> C{Goroutine listens<br>on <-B.Done()?}
C -->|Yes| D[Graceful exit]
C -->|No| E[Orphaned execution]
3.2 典型反模式:select 中漏写 default 或错误嵌套 cancel() 调用
select 漏写 default 的隐蔽风险
当 select 语句缺少 default 分支,且所有 channel 均未就绪时,goroutine 将永久阻塞——这在超时控制或资源清理场景中极易引发 goroutine 泄漏。
// ❌ 危险:无 default,ch1/ch2 长期无数据 → goroutine 永久挂起
select {
case v := <-ch1:
handle(v)
case <-ch2:
cleanup()
}
逻辑分析:该 select 不含 default,也无超时机制;若 ch1 和 ch2 同时不可读/不可写,当前 goroutine 将陷入不可唤醒的阻塞状态。参数 ch1/ch2 若为无缓冲 channel 或生产端已关闭但未通知,风险陡增。
错误嵌套 cancel() 的级联失效
多次调用同一 cancel() 函数会导致 panic(panic: sync: negative WaitGroup counter 或 context 已取消错误),破坏取消传播链。
| 场景 | 行为 | 后果 |
|---|---|---|
| 正确单次调用 | cancel() 触发 context.Done() 关闭 |
下游 goroutine 及时退出 |
| 重复调用 | 第二次调用 panic 或静默失败 | 上游认为已取消,下游仍在运行 |
graph TD
A[启动 goroutine] --> B{select 是否含 default?}
B -->|否| C[永久阻塞]
B -->|是| D[非阻塞响应]
C --> E[goroutine leak]
3.3 工具链实践:go tool trace 中 context.cancelEvent 的可视化定位
context.CancelEvent 在 Go 运行时 trace 中表现为 GoCtxCancel 类型事件,是诊断 goroutine 泄漏与非预期取消的关键线索。
如何捕获 CancelEvent
启用 trace 需在程序中注入:
import "runtime/trace"
// ...
trace.Start(os.Stderr)
defer trace.Stop()
// 启动含 cancel 的 context
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel() // 此处触发 GoCtxCancel 事件
}()
cancel()调用会向 trace 记录GoCtxCancel事件(含 goroutine ID、时间戳、调用栈),但不会自动记录被取消的 ctx 关联的 goroutine 状态变化——需结合GoBlock,GoUnblock关联分析。
关键字段对照表
| 字段名 | trace 中含义 | 示例值 |
|---|---|---|
cat |
事件类别 | "context" |
name |
事件名称 | "GoCtxCancel" |
args.goid |
触发 cancel 的 goroutine ID | 17 |
可视化定位路径
graph TD
A[启动 trace] --> B[执行 cancel()]
B --> C[生成 GoCtxCancel 事件]
C --> D[在 trace UI 中筛选 “context” 分类]
D --> E[点击事件 → 查看 Goroutine Stack]
第四章:监控盲区三——Timer/Ticker 持有引用未 Stop
4.1 time.Timer 内部 goroutine 调度机制与 runtime.timerBucket 引用陷阱
Go 的 time.Timer 并非独立 goroutine 驱动,而是复用 runtime 全局定时器轮询 goroutine(timerproc),所有 timer 统一注册到哈希分桶 runtime.timers(类型为 []*timerBucket)。
数据同步机制
每个 timerBucket 持有自旋锁 mu 和最小堆 t heap,避免全局锁竞争:
type timerBucket struct {
mu sync.Mutex
t []*timer // 最小堆,按 when 字段排序
}
mu保护堆操作;when是绝对纳秒时间戳,由addtimerLocked插入时计算。并发调用Reset()可能触发delTimer+addTimer,若未正确处理timer.status状态跃迁(如timerModifiedEarlier),将导致桶内堆结构不一致。
关键陷阱:桶指针生命周期
| 场景 | 风险 | 触发条件 |
|---|---|---|
| Timer 跨 goroutine 复用 | timerBucket 被 GC 提前回收 |
*timer 长期持有已释放桶的指针 |
| 高频 NewTimer/Stop | timerBucket 重分配后旧指针悬空 |
runtime 动态扩容 timers 数组 |
graph TD
A[NewTimer] --> B[Hash to bucket index]
B --> C[Lock bucket.mu]
C --> D[heap.Push bucket.t]
D --> E[timer.when = now+duration]
timer 结构体中 tb *timerBucket 字段无引用计数,依赖 timers 全局数组生命周期——这是典型的 隐式强引用陷阱。
4.2 生产案例:定时任务中 Ticker.Stop() 被 defer 延迟执行导致的泄漏链
问题现场还原
某数据同步服务在长期运行后内存持续增长,pprof 显示大量 time.Timer 和 runtime.goroutine 持有未释放的 ticker 实例。
核心缺陷代码
func syncData(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop() // ❌ 错误:defer 在函数返回时才执行,但 goroutine 可能已退出或 ctx 被 cancel
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
doSync()
}
}
}
defer ticker.Stop()在syncData函数返回时才触发,而select中ctx.Done()分支直接return,此时ticker仍在运行——ticker.C通道持续发送时间事件,底层 goroutine 无法退出,形成 goroutine + timer 泄漏链。
修复方案对比
| 方案 | 是否安全 | 说明 |
|---|---|---|
defer ticker.Stop() |
❌ | 仅在函数体结束时调用,无法覆盖早返回路径 |
defer func(){ if ticker != nil { ticker.Stop() } }() |
⚠️ | 仍受 defer 时机限制 |
| 显式 Stop + break | ✅ | 在每个退出分支前调用 ticker.Stop() |
正确模式
func syncData(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer func() { ticker.Stop() }() // 保留 defer,但需确保无早返回漏点
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
doSync()
}
}
}
此处
defer安全,因函数唯一出口是for循环内的return,且defer在return前执行。但更健壮做法是:在ctx.Done()分支显式ticker.Stop()后return。
4.3 检测手段:通过 runtime.ReadMemStats.GC 和 goroutine 数量趋势交叉分析
当 GC 频次陡增而 goroutine 数量持续高位滞留,往往预示着协程泄漏叠加内存压力。
数据采集模式
var m runtime.MemStats
runtime.ReadMemStats(&m)
gCount := runtime.NumGoroutine()
// m.NumGC 是累计 GC 次数(uint32),需差分计算单位时间增量
// gCount 实时反映活跃协程规模,建议每5s采样一次
该采样逻辑避免了 debug.ReadGCStats 的锁开销,且 NumGoroutine() 无内存分配,适合高频监控。
关键指标对照表
| 指标 | 正常波动范围 | 异常信号 |
|---|---|---|
| GC 次数/分钟 | > 10(尤其伴随 PauseNs 增长) | |
| Goroutine 数量 | 稳态±15% | 持续上升且不回落 |
趋势交叉判定逻辑
graph TD
A[每5s采集 MemStats+NumGoroutine] --> B{GC delta > 8/min?}
B -->|是| C{goroutine 数 3min 内↑30%?}
B -->|否| D[暂属正常]
C -->|是| E[触发协程泄漏告警]
C -->|否| F[可能为瞬时负载]
4.4 安全封装:自定义 SafeTicker 与 defer-safe Timer 管理器实现
Go 标准库的 time.Ticker 和 time.Timer 在 goroutine 泄漏与资源未释放场景下存在安全隐患——尤其在 defer 链中误用 Stop() 或忽略返回值时。
数据同步机制
SafeTicker 使用原子布尔标志 + sync.Once 保障 Stop() 幂等性,避免重复关闭已停止的通道:
type SafeTicker struct {
ticker *time.Ticker
stopped atomic.Bool
}
func (st *SafeTicker) Stop() bool {
if st.stopped.Swap(true) { // 原子标记已停,返回 false 表示已停过
return false
}
st.ticker.Stop()
return true // 首次成功停止
}
stopped.Swap(true)确保多 goroutine 并发调用Stop()仅执行一次底层ticker.Stop(),防止 panic(Stop()对 nil ticker 安全,但对已 Stop 的 ticker 多次调用无害却低效)。
defer-safe 资源管理
TimerManager 统一注册/清理,支持 defer mgr.Cleanup(id) 自动 Stop 并移除:
| 方法 | 作用 |
|---|---|
Register() |
返回唯一 ID 与可 Stop Timer |
Cleanup() |
安全 Stop + 从 map 删除 |
graph TD
A[goroutine 启动] --> B[Register Timer]
B --> C[业务逻辑]
C --> D[defer mgr.Cleanup(id)]
D --> E[Stop + map delete]
第五章:Goroutine 泄漏防御体系的终局思考
深度复盘真实线上事故:某支付网关的 72 小时泄漏雪崩
某日早间,某金融级支付网关突发 CPU 持续 98%、内存每小时增长 1.2GB,PProf 抓取 runtime/pprof/goroutine?debug=2 显示活跃 goroutine 数从常规 1200+ 暴增至 47,836。根因定位为一个未设超时的 http.DefaultClient.Do() 调用嵌套在 for-select 循环中,上游服务因 DNS 故障返回空响应,导致该 goroutine 卡死在 readLoop 阶段,且无任何 cancel 信号可穿透——共积累 3.2 万个“幽灵协程”,全部处于 IO wait 状态。
构建三层拦截式防护网
| 防御层级 | 实施手段 | 生效时机 | 典型失效场景 |
|---|---|---|---|
| 编译期拦截 | go vet -shadow + 自定义 staticcheck 规则(检测 go func() { ... }() 无 context 传入) |
make build 阶段 |
闭包捕获外部 context 变量但未显式使用 |
| 运行时熔断 | 启动时注入 runtime.SetMutexProfileFraction(1) + 定时采集 runtime.NumGoroutine(),当 5 分钟内增长率 >15%/min 且绝对值 >5000 时触发 panic dump |
服务运行中 | 高频短生命周期 goroutine(如每毫秒启动 100 个)绕过阈值判断 |
| 观测闭环 | Prometheus 指标 go_goroutines{job="payment-gateway"} + Grafana 告警看板联动 pprof 自动快照(curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > /tmp/goroutines_$(date +%s).txt) |
告警触发后 3 秒内完成现场固化 | 容器被 OOMKilled 导致 pprof server 无法访问 |
关键代码加固范式
// ❌ 危险模式:隐式阻塞,无退出机制
go func() {
resp, _ := http.DefaultClient.Get("https://api.upstream.com/status")
defer resp.Body.Close()
io.Copy(ioutil.Discard, resp.Body)
}()
// ✅ 防御模式:context 控制 + defer 清理 + 错误兜底
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // 确保无论成功失败均释放
go func(ctx context.Context) {
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.upstream.com/status", nil)
client := &http.Client{Transport: &http.Transport{
ResponseHeaderTimeout: 2 * time.Second,
}}
resp, err := client.Do(req)
if err != nil {
log.Warn("upstream call failed", "err", err)
return
}
defer resp.Body.Close()
io.Copy(ioutil.Discard, resp.Body)
}(ctx)
Mermaid 流程图:泄漏事件自动归因链
flowchart TD
A[Prometheus 告警:goroutines > 5000] --> B{是否连续3次告警?}
B -->|是| C[触发 pprof 快照采集]
B -->|否| D[静默观察]
C --> E[解析 goroutine stack trace]
E --> F[正则匹配高频阻塞模式:<br>- \"select { case <-ch:\"<br>- \"runtime.gopark\"<br>- \"net/http.*readLoop\"]
F --> G[定位源文件行号 + 调用链深度]
G --> H[推送至 Slack #infra-alerts 并 @oncall 工程师]
H --> I[自动创建 GitHub Issue 标记 severity/P0]
生产环境强制约束清单
- 所有
go关键字启动的函数必须显式接收context.Context参数,且首行调用ctx.Done()select 判断; - CI 流水线集成
golangci-lint插件govet和errcheck,禁止go func() { ... }()语法糖写法; - 每个微服务 Dockerfile 必须声明
ENV GODEBUG=gctrace=1,GODEBUG=schedtrace=1000,用于灰度环境周期性 GC 调度分析; - 上线前执行
go tool trace分析 60 秒 trace 数据,人工审查是否存在ProcStatus: runnable状态持续超 500ms 的 goroutine; - 每季度执行一次
pprof -proto二进制快照归档,建立历史 goroutine 行为基线模型。
防御失效的边界案例:Context 传递断裂
某团队将 context.WithTimeout 创建的子 context 仅传入 HTTP Client,却在后续 JSON 解析环节调用 json.NewDecoder(r.Body).Decode(&v) —— 此操作不感知 context,当上游返回超长 body 时,Decode 在 io.ReadFull 中永久阻塞,而父 context 的 cancel 已被遗忘。解决方案是改用 io.LimitReader(r.Body, 1<<20) 强制截断,并包裹 http.MaxBytesReader。
