不能用 context.background() 传入下游服务调用,因为它无取消信号,上游中断或超时后下游仍执行,导致幽灵请求和资源泄漏;正确做法是始终继承入参 context(如 r.context() 或 req.context())并逐层传递。

为什么不能用 context.Background() 传入下游服务调用
因为 context.Background() 没有取消信号来源,一旦上游 HTTP 连接中断或超时,这个 context 不会关闭,下游 gRPC/HTTP 请求仍会继续执行,造成“幽灵请求”和资源泄漏。真实场景中,一个用户刷新页面,后端却还在往支付服务发重复扣款请求,根源常在这里。
正确做法是始终从入参继承:HTTP handler 中用 r.Context(),gRPC server 中用 req.Context(),再逐层向下传递。不要在任意一层重新生成 context.Background() 或 context.TODO()。
- 所有中间件、业务逻辑、DB 查询、缓存操作、远程调用,都必须接收
ctx context.Context作为第一个参数 - 禁止在函数内部调用
context.WithTimeout(ctx, ...)后忘记defer cancel()—— 泄漏的 goroutine 会累积成内存毛刺 - 若需延长某段子操作的超时(如重试),应基于原
ctx派生新 context,而非丢弃原 ctx
http.NewRequestWithContext() 和 grpc.CallOption 必须显式设超时
Go 标准库的 http.Get()、http.Post() 等便捷函数默认不带 context,也不设超时,极易拖垮整条调用链。gRPC 客户端同理:不传 grpc.Timeout 时,底层连接可能卡死数分钟。
示例对比:
❌ 错误写法
resp, err := http.Get("http://user-svc/profile/123")
✅ 正确写法
req, _ := http.NewRequestWithContext(ctx, "GET", "http://user-svc/profile/123", nil)
resp, err := httpClient.Do(req)
❌ gRPC 错误写法
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
✅ 正确写法
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"}, grpc.WaitForReady(false), grpc.Timeout(3*time.Second))
-
http.Client.Transport需预设IdleConnTimeout和MaxIdleConnsPerHost,否则连接池失控 - gRPC 的
grpc.Timeout是 per-RPC 超时,不是连接建立超时;连接级超时需配grpc.DialOption中的grpc.WithTimeout - 避免在重试逻辑里反复调用
context.WithTimeout()创建新 cancel —— 每次都要 defer,否则 goroutine 泄漏风险翻倍
多级依赖中如何让取消信号穿透到最深层
Context 的取消是树状传播:父 ctx 取消 → 所有派生子 ctx 的 Done() 通道立即关闭 → 所有监听该通道的操作(如 select、http.Do、db.QueryContext)收到通知并退出。但前提是每一层都真正用了那个 ctx。
常见断点位置:
- 调用第三方 SDK(如 AWS SDK、Redis client)时,没把
ctx传进去,SDK 内部用的是自己的默认 timeout 或无限等待 - 数据库查询用了
db.Query()而非db.QueryContext(ctx, ...),导致 SQL 卡住时无法响应上游取消 - 自定义 channel 操作未监听
ctx.Done(),比如for range ch循环没有 select 分支兜底 - 日志、metric 上报等“尽力而为”操作,也应加
select { case 防止阻塞
哪些地方最容易忽略 context 传递
最容易被跳过的不是主干调用,而是旁路逻辑:健康检查、异步回调、后台任务触发、错误上报、审计日志。这些看似不关键的路径,一旦没接 ctx,就会成为取消链路上的“绝缘体”。
典型反例:
- HTTP handler 中发起 DB 查询后,顺手调用
sendAuditLog(user.ID),而该函数内部硬编码了新 goroutine +http.Post(),没带 ctx - gRPC server 在返回前触发消息队列投递,用
go func() { mq.Publish(...) }(),没传 ctx,也没做 Done() 监听 - 重试中间件捕获到 503 后,直接
time.Sleep()然后重试,没判断ctx.Err() != nil就提前退出
真正难的不是写对第一层 context,而是确保它像电流一样流过整个调用树——任何一处裸奔的 goroutine 或阻塞 IO,都会让整棵树失去响应能力。











