Go 的并发能力很强,但'能开很多协程'不等于'系统就会跑得稳'。线上真正出问题的,往往不是语法,而是 Goroutine 没退、Channel 卡住、Context 没传对。量一上来,这些小毛病就会变成内存飙升、请求堆积,最后拖垮整个服务。
这篇内容不讲概念堆砌,直接围绕几个常见坑来拆:怎么定位 Goroutine 泄漏,Channel 为什么会阻塞,Context 应该怎么传,连接池该怎么设,最后再看一组能复现问题的调试场景。顺序不复杂,基本都是线上会碰到的事。
一、Goroutine 泄漏:先看是不是没退出
Goroutine 泄漏是 Go 服务里最容易被忽略的问题之一。协程启动后一直挂着,短时间看不出毛病,时间一长,内存和调度开销都会慢慢堆上去。
常见的几个入口
- Channel 没人消费:生产者还在写,消费者已经走了;
- Context 取消了,但子协程没监听;
- for 循环没有退出条件,一直跑到服务重启。
用 net/http/pprof 先把栈抓出来
在服务里打开 pprof:
import _ "net/http/pprof"
然后访问 /debug/pprof/goroutine?debug=2,先看 goroutine 栈;如果要进一步分析,也可以走 go tool pprof:
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) list YourLeakingFunc
我一般会先盯 goroutine 数量的走势。它应该跟负载一起上下波动,而不是一路往上爬。稳态下如果已经超过 1000,还继续涨,基本就该查了。
二、Channel 阻塞:别把同步和异步混在一起
Channel 是 goroutine 之间传递数据的通道,但它不是'放进去就完事'的队列。写法不对,阻塞和死锁都很常见。
1. 无缓冲 Channel:两边必须同时在线
ch := make(chan int) // 无缓冲
go func() {
ch <- 1
}() // 如果没有 receiver,这个 Goroutine 会一直卡住
这种写法适合明确的一对一同步场景。优点是简单,问题也很直接:收发双方只要有一边晚到,就会卡住。
2. 缓冲 Channel:只是缓一口气
ch := make(chan int, 10) // 缓冲 10
缓冲能做削峰,适合生产速度和消费速度不一致的场景。但它不是无限队列,写满之后照样阻塞。这个坑很常见,尤其是在高峰流量下,很多人会误以为'加了缓冲就安全了'。
3. 用 select 给阻塞留出口
select {
case result := <-ch:
// 成功处理
case <-time.After(100 * time.Millisecond):
return errors.New("timeout")
default:
// 非阻塞尝试
}
如果这类 Channel 操作会出现在请求链路里,我更倾向于给它加超时,而不是指望它永远顺滑。没有退出口的协程,迟早会把系统拖慢。
三、Context 传递:取消信号要一路带下去
context.Context 的作用很单纯:把超时、取消、截止时间这些控制信号往下传。它不是装饰品,很多并发问题其实都是因为这里断了。
该守的几个原则
- 函数入参里带上 Context:
func Process(ctx context.Context, id string) error { ... }
- 向下游传原始 ctx,不要随手新建一个;
- 协程里监听
ctx.Done():
select {
case <-ctx.Done():
return ctx.Err() // context canceled or deadline exceeded
case result := <-ch:
// handle
}
HTTP 调用和数据库查询里尤其要注意
func HandleRequest(w http.ResponseWriter, r *http.Request) {
// r.Context() 自带 server 侧的超时和取消信号
result, err := callExternalService(r.Context())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Write(result)
}
func callExternalService(ctx context.Context) ([]byte, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil)
client := &http.Client{}
resp, err := client.Do(req)
// ...
}
Context 是树状传播的,父节点一取消,下面整棵子树都会收到信号。这个特性很有用,但前提是你真的把它传下去了。
四、连接池:数据库和 HTTP Client 都别反复新建
高并发场景里,频繁创建连接通常不是什么'灵活',而是性能负担。数据库和 HTTP Client 都应该复用。
1. database/sql 的连接池
db, _ := sql.Open("mysql", dsn)
db.SetMaxOpenConns(100) // 最大连接数
db.SetMaxIdleConns(10) // 最大空闲数
db.SetConnMaxLifetime(30 * time.Minute) // 连接最大生命周期
设置这几个参数时,别只盯着应用侧。数据库能扛多少、连接是否会堆积,才是更实在的边界。MaxOpenConns 通常要结合 DB 的承载能力来定,MaxIdleConns 也别放太大,空闲连接不是越多越好。
2. HTTP Client 复用
// 全局复用 client
var httpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
Timeout: 30 * time.Second,
}
这块最容易踩的坑就是:每次请求都 new 一个 http.Client。短时间看不出什么,流量一高,端口和连接状态就会开始给你颜色看。复用客户端,通常是更省事的做法。
五、怎么设计可复现的并发问题
调优不是只靠'感觉像有问题'。能复现,才好查,才好验证修复是否真的生效。下面这些场景不复杂,但很适合拿来做排查和回归。
1. Goroutine 死锁
// 两个 Goroutine 互相等待
ch1, ch2 := make(chan bool), make(chan bool)
go func() {
<-ch1
ch2 <- true
}()
go func() {
<-ch2
ch1 <- true
}()
// 无任何写入,永久阻塞
2. Channel 阻塞
ch := make(chan int) // 无缓冲
go func() {
ch <- 1
}() // 无 receiver,Goroutine 挂起
time.Sleep(time.Second) // pprof 可见一个 Goroutine 卡在 ch <- 1
3. CGO 内存异常
- 调用 C 函数后没有正确释放内存;
- Go 的 GC 处理不到 C 分配的内存;
- 可以结合
runtime.MemStats或pprof/heap看内存是不是在持续增长。
如果要做评估,我会看三件事:能不能用 pprof 找到卡住的 goroutine,能不能用 select + timeout 或 Context 把问题收回来,以及能不能用不打扰业务的方式把异常暴露出来,比如定期 dump goroutine 数量。
六、Go 并发控制流程图
下面这张图把 HTTP 请求里的 Context、Channel、Goroutine 关系画得比较清楚:

图里最核心的意思其实就两个:Context 负责统一控制超时和取消;多个 goroutine 并行干活,结果通过 Channel 汇总,select 再把超时和正常返回接住。这样资源该回收的时候能回收,不会一直挂在那儿。
结语
Go 的并发模型确实顺手,但顺手不等于可以随便用。Goroutine 不是线程,Channel 也不是万能队列,Context 更不是可有可无的参数。
高并发微服务真正要做的,不是把协程开得更多,而是在压力上来时还能稳住、还能看清、还能收回来。最实用的几个点其实也不复杂:每个并发操作都要有退出机制,资源尽量复用,异常要能定位和复现。做到这些,服务一般就不会太差。

