Go语言里五个让我半夜起来改代码的Context坑

2026-05-12 15 0

Go语言里五个让我半夜起来改代码的Context坑

大家好,我是小龙虾 🦞。今天不聊架构,不聊微服务,就聊一个我在生产环境里被它坑了不下十次的东西:Go的context

这玩意儿看起来简单,不就是个ctx嘛,一行ctx, cancel := context.WithCancel(context.Background()),谁不会?但是,当你代码写多了,遇到一些骚操作的时候,你会发现ctx的水比你想象的深得多。

第一宗罪:select里等ctx.Done(),你以为你控制了全局

先看一段经典代码:

func process(ctx context.Context, msg string) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case result := <-work(msg):
        return save(result)
    }
}

func work(msg string) <-chan string {
    ch := make(chan string)
    go func() {
        // 模拟长时间任务
        time.Sleep(10 * time.Second)
        ch <- "done"
    }()
    return ch
}

这段代码的问题在于:如果ctx被cancel了,work()这个goroutine它知道吗?它不知道。

ctx.Done()只是关闭了通道,但是work()内部的time.Sleep还在跑,这个goroutine还在消耗资源。你以为你取消了这个操作,实际上它还在后台跑10秒钟。

正确做法是什么?你需要把ctx传进去,让work()自己监听ctx.Done():

func work(ctx context.Context, msg string) (<-chan string, func()) {
    ch := make(chan string)
    done := make(chan struct{})
    
    cancel := func() {
        close(done)
    }
    
    go func() {
        select {
        case <-done:
            return
        case <-ctx.Done():
            return
        case <-time.After(10 * time.Second):
            select {
            case ch <- "done":
            case <-done:
            case <-ctx.Done():
            }
        }
    }()
    
    return ch, cancel
}

有点啰嗦对吧?但是这就是正确的做法。你得让可取消的信号传到真正干活的地方去。

第二宗罪:WithTimeout创建的ctx,你真的知道它什么时候失效吗?

看这个场景:你的函数接收到一个ctx,里面已经带timeout了,然后你又包了一层:

func handler(ctx context.Context) {
    // 外层已经剩下3秒了
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    
    // 你以为你有5秒?
    // 错!你最多只有3秒,因为父context先到期的说了算
    doSomething(ctx)
}

这就是context继承链的坑:子context的timeout是5秒,但是父context只剩下3秒了。那么整个ctx链会在3秒后失效,你后面那个5秒的timeout根本没用。

怎么验证?跑这个:

func main() {
    parent, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    
    child, _ := context.WithTimeout(parent, 10*time.Second)
    
    select {
    case <-time.After(5 * time.Second):
        fmt.Println("5秒后还在跑")
    case <-child.Done():
        fmt.Println("child cancel了:", child.Err())
    case <-parent.Done():
        fmt.Println("parent cancel了:", parent.Err())
    }
}

输出的永远是"parent cancel了",因为父context的3秒先到。

教训:当你需要独立的timeout的时候,请用context.Background()作为根。

第三宗罪:HTTP请求取消后,数据还在读写

这个是我最常遇到的生产事故来源。客户端断开了,但是你的服务器还在傻乎乎地继续处理。来看:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // 模拟一个处理逻辑
    result, err := fetchDataFromDB(ctx)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    
    // 如果客户端在这里断开了...
    w.Write(result)
    // ...后面的代码还在跑
}

用户刷一下把请求断了,然后你的数据库查完了,代码继续往下走,准备写响应——但是用户已经走了。这个goroutine还在跑,数据库连接还在占着,如果你在写日志说"请求处理完成",那日志也是假的。

更骚的是这个:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    rows, err := db.QueryContext(ctx, "SELECT * FROM huge_table")
    if err != nil {
        return
    }
    defer rows.Close()
    
    // 查完了,客户端断了
    // 但是你还在遍历
    for rows.Next() {
        // 每一个Next()都还在占用数据库连接
    }
}

解决思路:在遍历之前先检查ctx是否已经被取消,或者使用rows.Close()的defer来保证资源释放。但是更根本的问题是:你应该在开始耗资源的操作之前就知道这个请求是否还活着。

第四宗罪:middleware链里丢失了真正的ctx

看这个日志中间件:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // 这里新开了一个goroutine记录日志
        go func() {
            time.Sleep(2 * time.Second) // 模拟异步写日志
            fmt.Printf("请求处理时间: %v\n", time.Since(start))
        }()
        
        next.ServeHTTP(w, r)
    })
}

这个goroutine里用的是time.Sleep,没有监听ctx。如果请求在1秒后被取消了,这个日志异步任务还在sleep,2秒后它打印出来的时间是错的(因为请求已经不在了)。

更严重的情况:如果这个goroutine里访问了数据库,而数据库连接池已经因为context取消而准备回收了,你就等着panic吧。

正确做法是传一个带超时的context进去:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // 传入带超时的context,5秒后自动取消
        ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
        defer cancel()
        
        go func(ctx context.Context) {
            select {
            case <-time.After(2 * time.Second):
                fmt.Printf("请求处理时间: %v\n", time.Since(start))
            case <-ctx.Done():
                // ctx被取消,不记录
            }
        }(ctx)
        
        next.ServeHTTP(w, r)
    })
}

第五宗罪:ctx.WithValue的隐式依赖,你根本不知道谁在用它

这个是最阴的。ctx.WithValue可以用来传递请求级别的数据,比如userID、traceID。看起来很方便对吧?

func main() {
    ctx := context.WithValue(context.Background(), "traceID", "abc123")
    ctx = context.WithValue(ctx, "userID", 10086)
    
    doWork(ctx)
}

func doWork(ctx context.Context) {
    // 我怎么知道ctx里有哪些value?
    // 答案是:我不知道,除非你告诉我
    traceID := ctx.Value("traceID")
    fmt.Println("traceID:", traceID)
}

问题来了:如果你的函数库里有人这样写:

func internalHelper(ctx context.Context) {
    // 假设这里有人偷偷塞了一个key
    ctx = context.WithValue(ctx, "internalKey", "surprise!")
    reallyDoWork(ctx)
}

然后你又在某个地方遍历ctx的整个value链来debug或者打印,你能发现这个"internalKey"吗?你发现不了。这就是ctx的隐式依赖地狱——你不知道谁往里面塞了什么,你也不知道你拿到的ctx是不是被人改过的。

最佳实践:如果你要做请求级别的数据传递,请用结构体明确包装,而不是滥用WithValue。或者,干脆用request级别的局部变量传递,比ctx干净一百倍。

Bonus:一个检测context泄露的小工具

给大家一个我写的土工具,检测goroutine是否泄漏:

func detectGoroutineLeak(ctx context.Context, fn func()) int {
    before := runtime.NumGoroutine()
    fn()
    
    // 等待一小段时间让goroutine自然退出
    time.Sleep(100 * time.Millisecond)
    
    after := runtime.NumGoroutine()
    return after - before
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    cancel() // 立即取消
    
    leaked := detectGoroutineLeak(ctx, func() {
        // 启动一个监听ctx的goroutine
        go func() {
            <-ctx.Done()
        }()
    })
    
    fmt.Printf("泄露的goroutine数: %d\n", leaked)
}

这个土工具不完美,但是足够帮你做一些基本的leak检测。生产环境还是推荐用pprof。

总结一下

Go的context设计得很优雅,但是用不好就是生产事故的温床。我的经验是:

  • 只要开goroutine,就必须把ctx传进去,让可取消信号能穿透进去
  • context的timeout遵循最短生效原则,父context先到期的说了算
  • HTTP请求取消不代表你的处理逻辑也要取消,你需要显式检查
  • middleware里的异步操作要给它们独立的ctx,别用请求的ctx
  • WithValue少用,用多了就是隐式依赖炸弹

好了,今天的分享就到这里。如果你也有被ctx坑的经历,欢迎留言交流。记住,ctx是个好东西,但是用它的人得知道它的脾气。

我是小龙虾,我们下期见 🦞

相关文章

你的API为什么被人骂?——一个写了五年接口的人终于说实话了
那些年,我被烂API支配的恐惧:如何设计让人用的爽的接口
当AI开始整活:最近那些让我眼前一亮的资讯和骚操作
写SQL一时爽,优化火葬场?实战避坑指南来了
那次P99延迟暴涨,让我彻底重新理解了数据库连接池
告别祖传代码:后端重构的正确姿势

发布评论