一、Context 到底是干嘛的?
一句话:
用来在 Goroutine 之间传递:取消信号、超时信号、请求级数据。
核心目的:控制协程生命周期,防止泄漏、卡死、资源浪费。
二、Context 四大核心能力
1. 取消信号(WithCancel)
作用:手动发通知,让所有子协程安全退出。
go
ctx, cancel := context.WithCancel(parent)
cancel() // 发信号
使用场景:
- 程序优雅退出
- 手动停止任务
- 主协程控制子协程
2. 超时自动取消(WithTimeout)
作用:一定时间后自动发取消信号,防止卡死、慢查询、死锁。
go
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
使用场景:
- HTTP 请求
- DB 查询
- Redis / RPC 调用
- 定时任务单次执行
你那个刷缓存就必须用这个!
3. 截止时间取消(WithDeadline)
和 Timeout 几乎一样,只是指定具体时间点:
go
ctx, cancel := context.WithDeadline(parent, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
4. 传递请求级数据(WithValue)
作用:不跨请求、不跨业务,只在当前请求链路传值。
go
ctx := context.WithValue(parent, "traceID", "123456")
哪些数据能放入ctx
核心就是:能全链路透传才放ctx,断链传不动、只能局部用的,放进去纯纯浪费,还添乱
1. 为什么全链路从头串到尾的才适合放
- 链路:网关→路由→中间件→handler→logic→dao→db/redis
- traceID/requestID/登录用户标识、客户端IP
整条链路每一层都要打日志、排错、鉴权,每层都要用
放ctx里一路跟着走,不用每层函数手动额外传参,省事统一
2. 为啥不能从头串到底的坚决不放
这类就是纯业务入参:订单ID、手机号、分页参数、商品ID等
- 特点:只有某几层用到,上下游很多层级完全用不上
- 放ctx弊端:
- 浪费存储:整条链路拖着无用数据白白传递
- 可读性崩盘:别人看函数不知道藏了啥隐性参数
- 调试麻烦:参数藏上下文里,排查流程看不到
- 类型断言繁琐,还容易panic
简单的比喻,如果ctx是一辆公车,就是不允许半路下车的乘客上车,他只允许那些坐到终点站的人上车
全程乘客(允许上车)
traceID、requestID、登录用户标识、客户端 IP、链路日志标签
从请求入口一路跟着走到最底层 DAO / 第三方调用,全链路每一站都要用,直达终点才下车
短途乘客(禁止上车)
订单 ID、商品 ID、分页参数、临时业务字段、接口专属入参
走两三站就不用了,半路就要下车,不配占公交位置,老老实实走函数显性入参自己打车
3. 本质结论
不是单纯嫌浪费资源,是违背设计初衷
- Context设计初衷:链路级通用上下文标识、生命周期控制
- 函数入参设计初衷:当前业务流程必需显性参数
非技术不能实现,而是违反原则
-
功能层面:完全能用,一点不报错
context.WithValue本身就是原生传参能力,你随便塞订单ID、商品ID、各种业务结构体、数字字符串全都能塞,全链路也能取到,程序正常跑、逻辑正常执行,不存在语法错误、运行报错、功能失效。 -
现实层面:不影响业务运行,只是纯违反编码原则与工程规范
小项目、单人写、短期维护:随便塞业务数据没人管,怎么写都能跑
团队协作、分布式项目、长期迭代、规范严谨项目:严禁这么干
-
核心区分
能用 ≠ 该用
功能支持 ≠ 工程允许
为啥明明能用,还要禁止塞业务数据
-
隐性传参,代码可读性崩盘
函数入参明明白白写出来,一眼知道依赖什么;
业务数据藏ctx里,看函数签名完全看不出依赖,阅读、重构、重构全靠猜。
-
类型无约束,断言繁琐易panic
ctx取值全是
interface{},每次都要类型断言,写错直接崩;正规入参强类型校验,编译期就拦截错误。
-
污染上下文,权责混乱
ctx本职:管控生命周期(取消/超时)+ 全链路通用元信息
强行塞满零散业务参数,把生命周期控制器当成全局临时参数容器,职责彻底乱掉。
-
链路污染,层级复用性变差
同一个底层函数,被不同业务调用,ctx里塞的业务数据五花八门,极易出现取值冲突、数据覆盖问题。
-
调试、排查、单元测试极度麻烦
单元测试造ctx要塞一堆无关业务数据;线上排查看不到隐式参数,定位问题效率暴跌。
最终定论
- 技术上**:上下文具备完整传参能力,存放任何业务数据都能正常运行,无任何功能阻碍。
- 规范上**:属于滥用API、违背设计初衷,属于写法不优雅、工程不规范,不属于代码错误。
- 最简定论
私下写测试、练手随便塞业务数据无所谓;
正式业务、团队开发、线上项目,只透传全链路通用元数据(traceId、requestId、登录身份标识、日志标签),纯业务参数老老实实走函数入参。
三、Context 最核心的 3 个方法
go
/
/ 1. 获取取消信号通道
<-ctx.Done()
// 2. 获取取消原因(超时/手动关闭)
ctx.Err()
// 3. 检查是否已经取消
if ctx.Err() != nil {
return
}
四、Context 继承树规则(最重要!)
根 ctx (Background/TODO)
├─ 子 ctx1(取消/超时)
│ ├─ 孙 ctx1
│ └─ 孙 ctx2
└─ 子 ctx2
铁律:
- 父取消 → 所有子孙全部取消
- 子取消 → 不影响父和兄弟
- 超时是子节点行为,不污染上层
- 上层永远不依赖下层
你之前纠结的:
- 全局退出 ctx → 爹
- 独立任务 ctx → 儿子
- 单次执行业务 ctx → 孙子(带超时)
完全符合这套规则!
五、最标准使用姿势(全场景模板)
模板 1:常驻后台协程
go
func StartTask(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return // 安全退出
case <-ticker.C:
// 必须用超时ctx
taskCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
doWork(taskCtx)
cancel()
}
}
}()
}
模板 2:HTTP 请求(必须用 r.Context())
go
func handler(w http.ResponseWriter, r *http.Request) {
// 只用这个ctx!
ctx := r.Context()
db.Query(ctx)
redis.Get(ctx)
rpc.Call(ctx)
}
模板 3:RPC / DB / 定时任务
go
func doWork(ctx context.Context) {
if err := ctx.Err(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// ... 业务逻辑
}
六、Context 使用铁律(生产级)
✅ 必须遵守
- ctx 必须作为第一个参数
- 变量名必须叫 ctx
- 不要用结构体存 ctx
- 不要传 nil ctx
- 每次业务操作必须派生超时 ctx
- 父 ctx 只用来继承,不污染业务
- 用完 cancel 必须调用(defer)
只有你自己调用 WithCancel / WithTimeout / WithDeadline 时,才需要 defer cancel ()!
别人传给你的 ctx,绝对不要 cancel ()!更不要 defer cancel ()!
谁创建,谁取消;谁派生,谁释放。
对应场景对照
main 里派生任务子 ctx → 新建了 → 加defer cancel
cron 循环里每次刷新建超时 ctx → 新建了 → 加defer cancel
HTTP Shutdown 建超时 ctx → 新建了 → 加defer cancel
logic 业务函数接收上层 ctx → 没新建 → 啥都不加
handler 里r.Context() → 框架建好的 → 只用不建、不写 cancel
严禁
- 全局 ctx 用来做业务超时
- 跨请求共用 ctx
- 用 WithValue 传业务参数
- 底层函数自己创建根 ctx
- 无限循环不监听 ctx.Done()
七、 示例代码
main rootCtx(取消)
├─ aTaskCtx(子取消)
│ └─ refreshCtx(10s超时)
└─ bTaskCtx(子取消)
└─ refreshCtx(10s超时)
- 全局退出:rootCancel()
- 任务隔离:各自子 ctx
- 防卡死:每次刷新都有超时
- 无泄漏:所有 cancel 都 defer
- 无卡死:所有业务都检查 ctx
八、终极总结
Context = 协程生命周期控制器 + 超时熔断 + 请求链路传值
父管子,子不干扰父,兄弟互不干扰
长任务用取消,短任务用超时
HTTP 用自带 ctx,后台任务用全局 ctx