Go context 超时取消怎么一层层传下去

小程序后台有个查历史订单的接口,前端那边设了超时,接口自己没设。下游一个统计查询卡住了,goroutine 就一直挂着,前端早断了它还在跑。修完之后我定了个规矩:Go context 超时和取消怎么传下去,答案是每个请求进来第一件事就是带 context,只从最外层发一次,往下全靠透传。

一、Go context 超时和取消怎么传下去:只往下传,不往下造

一句话——入口造 context,中间层只接和传,最底层负责响应 Done。

入口指的是 HTTP handler、定时任务、命令行启动点。这些地方你知道这次调用最多能花多久,超时值在这里定。

中间层(service、repository)不该自己 context.WithTimeout 再包一层,除非它要调一个明显更慢的外部依赖。层层包超时,最后你算不清哪个值生效。

二、分三步改

第一步,把 ctx 提成每个函数的第一个参数。

这是 Go 里少有的、全社区都认的约定。函数签名长这样:第一个参数 ctx context.Context,放在最前面。main 或者 handler 里用 context.Background() 起头。

别把 ctx 塞进结构体里存着。context 是跟着一次调用走的,不是跟着一个对象活着的。存进结构体之后,取消的时机就跟调用对不上了。

第二步,在入口做超时,并调 cancel。

入口那里 context.WithTimeout 会返回一个 cancel 函数,这个 cancel 一定要调,通常紧跟一行 defer cancel()。不调的话,即使超时时间设了,计时器资源也要等父 context 结束才回收。

这条在长驻服务里尤其重要,短命进程感知不明显。

第三步,在最底层响应 Done。

真正干活的地方——数据库查询、HTTP 客户端、循环里——要 select 一下 ctx.Done()。用 database/sql 的话,query 方法本身就接 ctx,把 ctx 传进去就行;自己写的循环,得手动判。

我那次卡住的统计查询就是自己写的循环,里面没判 Done,所以上游早取消了它还在跑。

三、最容易出问题的三个点

第一个是 context 传着传着就断了。 中间某层图省事用了新造的空 context。表现是接口超时了,下游还在跑,排查方法是看链路里有没有不该出现的 context.Background()。

第二个是不调 cancel。 上面说过,一行 defer 的事,漏了就白设超时。

第三个是把 ctx 存起来复用。 有人为了少传参数,把 ctx 存到 struct 字段里,服务启动时创建一次。这样所有请求共用一个 context,一个请求超时,其他请求跟着取消。

这种 bug 最难查,因为现象跟超时本身没关系。

四、取消之后报什么错

下游收到取消信号后,通常会拿到一个 context 相关的错误。我的处理原则是:不要把它当成服务端错误往上抛。

前端已经断了,你返回 500 只会在日志里刷一堆假告警。我一般判断一下是不是取消导致的,是就直接返回,不记 error 级别。

判断方式各版本写法略有不同,以你用的 Go 版本官方文档为准,别照抄老博客里的写法。

五、几个不用 context 的地方

不是所有函数都要加 ctx。纯计算函数、字符串处理、结构体方法,加了反而是噪音。

判断标准很简单:这个函数会不会阻塞、会不会调外部依赖、会不会跑很久。三样都不沾,就不加。

另外,别用 context 传业务参数。它设计上是传取消信号和请求范围的值,拿它当通用参数包用,过半年自己都看不懂谁放的。

所以 Go context 超时和取消怎么传下去,说到底就是一传到底这一件事。

常见问题

Q:Go context 超时和取消怎么传下去,中间层要不要再设一次超时?

A:一般不用。超时在最外层入口设一次,中间层只负责把 ctx 往下透传。只有调一个明显更慢的外部依赖时,才值得在那一层单独收窄。

Q:每个函数都要加 context 参数吗?

A:不用。可能阻塞、可能调外部依赖、可能跑很久的函数才加,纯计算函数加了只是噪音。

Q:Go context 超时和取消怎么传下去,接口超时了下游还在跑是什么原因?

A:多半是中间某层断了链路,比如重新造了个空 context,或者最底层的循环没判 Done。先查有没有多余的 context.Background()。

相关阅读


本文作者西米,37 岁程序员,一个人加五个 AI 员工在跑一家公司。踩过的坑和真实账本都写在公众号「西米分享」。

完整代码、踩坑清单与最新进展,都在公众号。

📱 关注公众号「西米分享」