有一次我们一个 Go 微服务的 RSS 内存每天涨 200MB,跑两周就被 K8s OOMKilled 一次。pprof 的 heap profile 一切正常,但 goroutine profile 一看 — 11 万个 goroutine,绝大多数卡在 chan receive 上等一个永远不会写入的 channel。这就是经典的 goroutine 泄漏:goroutine 启动了但没退路,内存不还给运行时。这篇把生产里常见的 5 种泄漏场景整理出来,每种给最小复现和修法,最后给一套 CI 阶段就能检测的方案。
故障现场
背景:一个聚合多家上游 API 的 BFF 服务,Go 1.21、单 pod 2C4G、QPS 大概 800。上线两周后开始周期性 OOM,但 heap profile 的内存只有 600MB,远没到 4G 限制。直到我们抓 goroutine profile 才看到真相:11 万个活跃 goroutine,栈帧大多是同一行 — case <-ch:,等一个再也不会有值的 channel。
| 时刻 | 事件 |
|---|---|
| T+0 | 服务上线,内存 180MB |
| T+3天 | 内存 1.2GB,无人留意 |
| T+10天 | 内存 3.8GB,K8s 开始 OOMKilled |
| T+10天 +30min | 抓 pprof goroutine,11 万个 |
| T+10天 +2h | 定位到 3 处 channel 没关 |
为什么 goroutine 不会被 GC 回收
Go runtime 不会"自动回收"阻塞中的 goroutine。一个 goroutine 只要还有 reference(channel、select、mutex 上的等待),它就一直占着 8KB 栈空间(可以增长到 GB),GC 跑过来扫,发现它的栈上还有引用,也就放过去。
场景 1:启动 goroutine 写 channel,接收方先返回
// 错误示范
func handle(ctx context.Context) (string, error) {
ch := make(chan string) // 无缓冲
go func() {
result := slowCall() // 5 秒
ch <- result // 接收方走了, 这里永久阻塞
}()
select {
case r := <-ch:
return r, nil
case <-ctx.Done(): // 1 秒超时, 提前返回
return "", ctx.Err()
}
}
问题:1 秒超时后,主函数 return,但启动的 goroutine 还在跑 slowCall(),完成后想往 ch 发数据,可没人收 — goroutine 永久阻塞。
正解:用 buffered channel,或者 select + ctx 两边都监控。
// 修法 1: buffered channel (容量 1, 接收方走了也能写进去)
ch := make(chan string, 1)
// 修法 2: 让 goroutine 也监听 ctx
go func() {
select {
case ch <- slowCall():
case <-ctx.Done():
return
}
}()
场景 2:select 的 default 分支永不触发的 channel
// 错误示范: 消费者 panic 退出
func consumer(ch chan int) {
defer close(ch)
for v := range ch {
process(v) // 这里有可能 panic
}
}
func producer(ch chan int) {
for i := 0; i < 1000000; i++ {
ch <- i // 消费者死了, 这里全卡住
}
}
消费者 panic 之后没人收,producer 全部卡在 ch <- i,如果调用方又启动很多 producer,就是泄漏放大。
正解:producer 也监听 done channel,或者用 context 控制。
func producer(ctx context.Context, ch chan int) {
defer close(ch)
for i := 0; i < 1000000; i++ {
select {
case ch <- i:
case <-ctx.Done():
return
}
}
}
场景 3:time.After 在 for 循环里制造 timer 泄漏
// 错误示范
for {
select {
case msg := <-msgCh:
process(msg)
case <-time.After(5 * time.Second): // 每次循环都新建 timer!
heartbeat()
}
}
time.After 内部每次新建一个 timer,加入 runtime 的 timer heap。如果 msgCh 一直有消息,timer 不会被回收,堆里堆几十万个 timer,内存上涨 + 调度慢。
正解:用 time.NewTimer,显式 Reset。
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-msgCh:
process(msg)
if !timer.Stop() {
<-timer.C // 排空 channel
}
timer.Reset(5 * time.Second)
case <-timer.C:
heartbeat()
timer.Reset(5 * time.Second)
}
}
场景 4:http.Response.Body 没关
// 错误示范
resp, err := http.Get(url)
if err != nil { return err }
// 忘了 resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
net/http 的 transport 给每个未关闭的连接保留一个 background goroutine 等服务器关连接。1000 个未关 response,1000 个 goroutine 永久挂着。
正解:必须 defer close,即使你不读 body。
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close() // 必须
if _, err := io.Copy(io.Discard, resp.Body); err != nil { ... }
场景 5:waitgroup.Add 在 goroutine 内部调用
// 错误示范
var wg sync.WaitGroup
for _, item := range items {
go func(it Item) {
wg.Add(1) // 启动后才 Add, 主 goroutine 可能先 Wait
defer wg.Done()
process(it)
}(item)
}
wg.Wait() // 可能立刻返回, 因为 Add 还没执行
正解:Add 在主 goroutine 调用。
for _, item := range items {
wg.Add(1) // 这里
go func(it Item) {
defer wg.Done()
process(it)
}(item)
}
wg.Wait()
5 种场景对比
| 场景 | 泄漏速率 | 难度 | 修法 |
|---|---|---|---|
| 1. unbuffered chan 接收方超时 | 每超时 1 次 +1 goroutine | 高 | buffered chan / 让 sender 监听 ctx |
| 2. 消费者 panic 卡 producer | 每个 producer +N goroutine | 中 | producer 也监听 done |
| 3. for 循环里 time.After | 每秒 +1 timer | 低 | NewTimer + Reset |
| 4. resp.Body 没关 | 每请求 +1 goroutine + 1 connection | 低 | defer Body.Close() |
| 5. wg.Add 位置错 | 不算泄漏, 但提前返回 | 中 | Add 放主 goroutine |
定位工具:pprof goroutine profile
# 1. 服务里 import _ "net/http/pprof", 起一个 6060 端口
# 2. 抓 goroutine profile
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
# 3. 看哪些栈帧重复最多
grep -A 20 "goroutine " goroutines.txt | sort | uniq -c | sort -rn | head -20
# 4. 用 pprof 交互
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) traces
关键看 top 命令,goroutine 数 > 1000 且 stack 在 chan receive 上,基本就是泄漏。
CI 阶段检测:goleak
// 在每个测试文件末尾加
package mypkg
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
goleak 会在测试结束后扫一遍是否有非框架的 goroutine 残留,有就让测试 fail。集成到 CI 之后,新代码引入的 goroutine 泄漏 PR 阶段就拦住。
决策树:goroutine 写出之前自查
跨语言对比
| 语言 | 泄漏问题 | 检测难度 |
|---|---|---|
| Go | goroutine 阻塞不退出 | 中(有 pprof + goleak) |
| Java | 线程池任务挂死 | 难(thread dump 手动看) |
| Python asyncio | task 没 await | 中(asyncio 有 warning) |
| Node.js | Promise 没 resolve | 难(几乎没工具) |
| Rust | tokio task 阻塞 | 难(tokio-console 才行) |
我们立的 4 条规矩
- 所有起 goroutine 的地方必须能讲清楚"它什么时候退"。讲不清 PR 直接拒。
- 任何
http.Get/Post之后必须defer resp.Body.Close(),CI 加 staticcheck 规则。 - 禁止在 for 循环里直接用
time.After,必须NewTimer + Reset。 - 所有包必须用 goleak.VerifyTestMain 兜底,新增 goroutine 必须有对应的退出测试。
事后我们做的可观测性改造
一次事故能教会团队的东西远不止"修这个 bug"。我们在复盘会后做了三件事来让下次同类问题在第一时间被发现,而不是等到 OOMKilled 才知道。第一件事是把 goroutine 数量加进 Prometheus 指标,通过 runtime.NumGoroutine() 每 15 秒采一次,画一条曲线,设置告警阈值"超过 5000 持续 3 分钟"。第二件事是给 Grafana 加了一个 panel 专门看 goroutine 趋势,因为泄漏的特征不是绝对数字而是持续上涨,看趋势比看瞬时值更准。第三件事是把 pprof 端口的访问权限开给值班 SRE,任何时候他们都能抓一份 profile,不需要等开发上线。
这三件事做完之后大概一个月,我们就靠这套监控提前发现了第二次泄漏:一个新加的下游 API 调用,作者忘了 defer Body.Close,goroutine 数从 200 涨到 800 用了 2 个小时,告警触发,SRE 抓了 profile 一眼定位,5 分钟内 hotfix。这次完全没有 OOM,用户没有任何感知。把"事故"翻译成"告警 + SOP",才是事故复盘真正的价值,不然下次同样的坑还会再踩一遍。
深入一点:为什么 Go 不能"自动回收"泄漏的 goroutine
有人会问,Java 都能检测死锁,Go 为什么不能自动 kill 阻塞的 goroutine?这个问题问到了 Go 的设计哲学。Go 的并发模型基于 CSP(Communicating Sequential Processes),goroutine 的核心契约是"独立、平等、合作" — 没有一个 goroutine 有权力 kill 另一个。如果运行时能强行回收阻塞的 goroutine,那"阻塞"这个语义就崩溃了:你怎么知道一个 chan receive 是"真的在等"还是"被回收前的最后一瞬"?
Java 走的是另一条路,有 Thread.interrupt() 和 Thread.stop()(虽然 stop 已经 deprecated)。但这是建立在 Java 线程是"重量级、有 ID、有状态机"的基础上,goroutine 是 user-space 协程,设计目标就是轻量到不需要管理。Go 团队的取舍是:让你 cheap 地 spawn,代价是你必须自己负责 lifecycle。这跟内存管理里 Rust 的 ownership 类似 — 把责任前置到写代码的时候,而不是 runtime 救火。
所以"goroutine 泄漏"不是 Go 的 bug,而是 Go 的 feature 的另一面。理解这一点之后,你写代码的姿势会变 — 每次 go func() 前下意识地问"它怎么退",像写 Rust 时下意识问"它的生命周期"。这种思维习惯比任何 lint 工具都管用。
4 个生产真实泄漏案例的简短回顾
除了文章开头那个 11 万 goroutine 的事故,我们还碰到过这些:
- 定时任务的 ticker 没 Stop:某个组件用
time.NewTicker(1*time.Second)起了定时任务,组件被销毁时没调 ticker.Stop(),每次重启服务 + 销毁组件,泄漏 1 个 ticker goroutine。半年下来累计 4000 个,内存吃了 200MB。修法:组件销毁时显式 ticker.Stop。 - gRPC stream 没关:客户端建了 server-side streaming RPC,服务端关连接但客户端的 recv goroutine 还在调用
stream.Recv(),要等几十秒才返回 io.EOF。期间客户端发起新连接,旧的 goroutine 仍在等,叠加导致泄漏。修法:用 context.WithTimeout,主动取消。 - errgroup 的 Wait 没调用:启动了 errgroup 跑并行任务,主流程提前 return,Wait() 没被调用,errgroup 里的 goroutine 还在跑。修法:defer g.Wait()。
- 第三方库内部起的 goroutine:某 SDK 在 NewClient 时起了一个心跳 goroutine,但没提供 Close 方法,每次 NewClient + 弃用就泄漏一个。修法:提 PR 给上游加 Close,或者改用其他库。
这 4 个案例的共同点是"goroutine 的生命周期不清晰" — 起的时候简单,但没人说清它什么时候该退。所以我们后来给团队定了一条铁律:任何 goroutine 必须能用一句话讲清楚退出条件,讲不清的不许 merge。这条规矩看起来过严,但执行 6 个月之后,goroutine 相关的事故几乎归零。
性能数据:goroutine 泄漏对实际服务的影响
| 泄漏 goroutine 数 | 内存占用 | 调度延迟 | GC 暂停 |
|---|---|---|---|
| 1000 | 8MB | 无影响 | 无影响 |
| 10000 | 80MB | P99 +2ms | +5ms |
| 50000 | 400MB | P99 +15ms | +30ms |
| 100000 | 800MB | P99 +50ms | +80ms |
| 500000 | 4GB(OOM 风险) | 调度卡顿明显 | +300ms |
关键观察:10 万 goroutine 是个临界点,之前 P99 还能接受,过了之后调度器扫栈的成本急剧上涨,GC 也跟着变慢。我们的事故服务就是在 11 万这个点开始连锁反应 — 请求变慢导致 retry,retry 又起新 goroutine,雪崩。不要等到 OOM 才报警,goroutine 数过 5000 就应该当成警告。
给 Go 新手的 3 条建议
- 把 context.Context 当成第一公民:任何会阻塞、会跑久的函数,第一个参数必须是 ctx,函数内任何阻塞调用都要 select 监听 ctx.Done。这一条做到,80% 的泄漏直接消失。
- buffered channel 不是"性能优化",是"防泄漏":大多数新手以为加 buffer 是为了快,实际上更重要的作用是"让 sender 在 receiver 走了之后也能完成发送",避免 goroutine 卡死。
- defer 习惯先写:打开任何资源(file、conn、resp.Body、Lock、Stream),下一行立刻写 defer Close()。这比"用完再补"安全 100 倍,因为函数里可能有 10 个 return,你不可能每个都记得 close。
这 3 条是我教过的所有 Go 新人都先讲的,简单但效果好。Go 的并发简单到让人轻视它的复杂性,新手往往用 1 周就上手 goroutine,然后用 1 年才搞懂 goroutine 的生命周期。希望这篇能让你少走一些弯路。
排查时容易走偏的方向
每次内存上涨的事故,团队第一反应都是怀疑业务对象堆积,然后跑去看堆里有什么大对象。这次也是,我们最开始花了一个多小时去对比堆里的结构体数量,以为是缓存没清、map 没收敛、字符串没释放。结果堆 profile 一切正常,大对象数量稳定,字符串总量也不大。这就是排查走偏的典型路径,内存涨等于堆涨,堆涨等于对象堆积,这个思维链条在普通服务里成立,但在协程模型下不成立。协程的栈是独立的内存区域,不算在堆里,所以堆 profile 看不到。
正确的排查顺序应该是先看四个维度:堆内存、栈内存、协程数、文件描述符。任何一个异常上涨都是线索,不要只盯着堆。我们事后总结了一份排查清单,任何"内存涨但不知道为什么"的故障,先按这个顺序过一遍,几乎能在十分钟内定位到大方向。这份清单的核心思想是不要相信你的直觉,要相信数据。直觉来自经验,经验来自旧场景,新场景的根因经常跟旧场景完全不一样。
另一个容易走偏的方向是把"内存涨"当成"内存泄漏"。两者其实不一样,内存涨可能只是因为业务负载上升、缓存预热、数据量增长,这些都属于正常现象,只要在某个值上稳定下来就没事。真正的泄漏特征是持续单调上涨且没有平台期,看趋势曲线一目了然。如果你只看瞬时值,会经常误判,把正常的业务波动当成事故。这一点养成习惯之后,你会发现自己被叫起来的次数会少很多。
写代码时的几个心智模型
写并发代码的难点不在语法,而在心智模型。我自己用了很多年才慢慢建立起几个有用的模型,分享给读者。第一个模型是每个协程都是一个独立的小程序,它有自己的输入、处理、输出、退出。写代码的时候,你应该能在脑子里画出这个小程序的完整流程,如果画不出来,说明协程的责任不清晰,容易出问题。第二个模型是通道是同步原语,不是消息队列。新人经常把通道当成"消息中间件"来用,以为发出去就万事大吉。实际上无缓冲通道的发送方必须等接收方收到才返回,这是一种同步,理解这一点就能避免大量的卡死问题。
第三个模型是上下文是退出的契约。任何长时间运行的函数都必须接受一个上下文参数,函数内任何阻塞操作都必须监听上下文的取消信号。这不是建议,而是硬规定。你可以把上下文想象成一个"我什么时候该停"的开关,所有协程都应该尊重这个开关。如果一个函数不接受上下文就启动了协程,那协程就没办法被取消,泄漏几乎是必然的。这条规矩做到之后,你会发现协程的可控性大幅提升,出问题的时候至少能够主动止损。
第四个模型是资源所有权要清晰。每个资源都应该有一个明确的"所有者",由所有者负责释放。多个协程共享资源的时候,要约定清楚谁来关、什么时候关。我们项目里曾经因为"两个协程都以为对方会关连接"导致连接没关,泄漏了几百个文件描述符,排查了一整天才发现是语义层面的责任不清,不是代码层面的 bug。这种问题写代码的时候很难发现,只能靠 review 和团队规约。
团队推广时遇到的阻力
事故复盘之后,我们想把"协程必须能讲清楚退出条件"这条规矩落地到团队,实际推广时遇到了不小的阻力。一些资深开发觉得规矩过严,影响开发效率,要求加个例外条款。一些新人觉得规矩太抽象,不知道怎么"讲清楚"。我们花了大概一个月才把规矩磨成一个可执行的版本,核心是给每条规矩配一个具体的代码模板,让大家照着写,而不是靠自己理解。
另一个阻力是已有代码不符合规矩怎么办。我们最初想全量改造,后来发现工作量太大,改到一半就被业务需求打断。最终采取的策略是新代码必须符合,旧代码出问题再改,这是个务实的折中。同时我们给旧代码加了监控,任何旧模块的协程数异常都会告警,虽然不能预防但至少能快速发现。这个推广过程让我意识到,技术规矩的落地难度往往不在技术,而在人和习惯。
最有效的推广手段是用事故说话。每次有人质疑某条规矩为什么这么严,我们就拿出对应的事故复盘文档,看看不遵守规矩会发生什么。这比任何道理都更有说服力。所以我们后来养成了一个习惯,每个事故都要写一份完整的复盘,包括症状、过程、根因、修法、预防,这份文档不只是给当事人看的,而是给整个团队、给未来加入的新人看的。这次的事故文档,半年之后还有新同事来问问题,说明它真的留下了价值。
对未来 Go 版本的期待
站在 2026 年看,Go 的协程模型已经非常成熟,但还有一些可以改进的地方。一个是协程的可观测性,目前 pprof 能看到协程数和栈,但缺少"哪个协程在等哪个资源"的关联信息。如果运行时能记录每个阻塞协程的等待对象,debug 会快很多。社区有人在讨论这个特性,但还没进主线。
另一个是结构化并发。Python 的 asyncio 已经有 TaskGroup,Java 也在试验 StructuredTaskScope,这些机制能在父任务取消时自动取消所有子任务,从根本上避免泄漏。Go 目前要靠 errgroup 等库手动实现,期待标准库能直接提供。如果有了这个特性,新手写代码的安全性会大幅提升,泄漏问题可能从"常见"变成"罕见"。
最后一个期待是更智能的协程调度器。当协程数过多时,调度器能自动给开发者反馈,比如警告"协程数突破阈值"。这个特性可以做成标准库的一部分,默认开启,不需要业务方手动监控。把"危险默认值"变成"安全默认值",是所有语言长期演进的方向,Go 也不例外。
一次完整的事故排查流程回放
把整个排查过程完整回顾一遍,可能比单独看每个修法更有价值。我会按真实的时间顺序讲,包括我们走过的弯路。事故发生在一个周五下午,值班同事收到告警说服务被重启了三次,内存达到上限。第一反应是看堆 profile,看到堆里最大的对象是一个缓存的 map,大约两百兆。我们花了二十分钟分析这个 map,发现它的增长是正常的业务行为,跟泄漏无关。这就是第一个弯路,我们假设"内存大就是堆大"。
第二个弯路是怀疑数据库连接池。当时服务连了多个数据库,我们检查了每个连接池的配置,确认连接数都在正常范围。又花了二十分钟。第三个弯路是怀疑文件描述符,跑去看打开的文件数,也是正常的。三个弯路加起来,一个多小时过去了,内存还在涨。这时候团队里一个老同事提了一句:"看看协程数吧"。我们打开协程 profile,数字直接吓人,十一万个协程。从这一刻起,排查方向才转对。
抓到协程 profile 之后,定位过程反而很快。协程的栈非常一致,九成以上都是同一个调用链,卡在某个通道接收。顺着调用链反推,半小时就找到了三个有问题的地方。第一个是文章开头提到的超时之后协程没退出,第二个是消费者出错之后生产者没感知,第三个是某个第三方库的回调里起了协程没关闭。修这三处花了大概两小时,部署测试又花了一小时,整个事故从告警到修复,六小时收尾。
这个时间线里最值得学习的不是修法,而是排查思维的转变。我们花了大量时间在错误的方向上,只因为不知道有"协程数"这个维度。如果当时第一时间就抓协程 profile,事故能在一小时内解决。所以我现在带新人,会专门讲一节课,叫"故障排查的多维度思维",核心就是不要只用一种工具看问题,要建立多维度的诊断思路。每个语言、每个运行时都有自己的"专属故障维度",Go 是协程,Java 是线程和老年代,Python 是引用计数和循环引用,Node 是事件循环延迟,搞清楚这些维度,排查效率会高一个数量级。
更长远的反思:Go 是不是太"鼓励"并发了
这次事故让我重新思考一个问题,Go 的并发原语是不是太容易用了。在其他语言里,启动一个线程需要五行代码,创建一个线程池需要十行,大家会本能地谨慎使用。但 Go 只需要一个关键字,这种"低门槛"让所有人都倾向于"先并发,再说"。结果就是项目里到处都是协程,有些是真的需要,有些只是"觉得应该并发一下"。
我个人现在的习惯是能不并发就不并发。串行能搞定的事情就不要起协程,因为协程不是免费的,它带来调度成本、调试复杂度、生命周期管理负担。只有当串行真的成为瓶颈,且并发的收益明确大于成本时,才考虑用协程。这种思路跟"先优化为正确性,再优化性能"是一个道理。先求稳,再求快,这是工程师的成熟标志。
当然这不是反对 Go 的设计,而是反对"无脑用并发"。Go 给了你强大的工具,但用不用、怎么用,是你的责任。这一点跟所有强大工具一样,自由度越高,误用的可能性越大。希望这篇能让读者在用协程之前多想一秒,这一秒可能就省你一个事故,省你一个值夜班的同事。
事故复盘的几个反模式
事故复盘做了几年,我看到团队里常见的几个反模式,记下来供同行参考。第一个反模式是把复盘开成追责会。这种会开几次,团队就再也不愿意分享真实信息了,所有的复盘文档都变成走过场。我们团队明确规定,复盘会上不追责,只看流程和系统。第二个反模式是只复盘不改进。文档写得很漂亮,问题分析得很透彻,但行动项没人跟踪、没人验证。我们后来给每个复盘加了"30 天回顾"环节,看行动项落实了没有,没落实的当面解释。
第三个反模式是只复盘大事故,小事故不管。其实大事故往往是多个小事故没被重视的累积,等到大事故才复盘,代价已经很高。我们现在的规矩是任何"差点出事"的事件也要写一个简短的复盘,哪怕只有半页纸,记录清楚就行。第四个反模式是复盘文档没有人看。写完之后扔到 wiki 里就没人翻了,新人入职也不知道有这些文档。我们的做法是把复盘文档变成团队入职必读资料,新人前两周必须读完所有 P0/P1 事故的复盘,并写一个简短的总结。
这几条不算什么了不起的方法论,但坚持下来效果很明显。事故复盘的目的不是写文档,而是把个人的痛苦经验变成团队的集体记忆,让后来的人不用重新踩一遍。每个团队的事故复盘文化都不一样,找到适合自己的方式,坚持下去,几年之后回看,会发现这是性价比最高的工程投资之一。
最后再补一句给读者的话。技术成长的关键不是看多少书、刷多少题,而是踩过多少坑,并把每个坑都消化成自己的认知。希望这篇文章对你有帮助,如果你的项目里有协程相关的事故,欢迎留言交流,集体的智慧永远比个人的智慧更可靠。技术人的成长路上,大家都是同行者,互相分享、互相成就,这才是这个行业最让人留恋的地方。每一次事故都是一次免费的课程,代价虽大但收获也大,关键看你是否愿意花时间把它消化、提炼、传承下去,这种能力比任何具体技术都更值得长期投入,也是工程师跟普通编码者最大的区别。下次再写并发代码的时候,记得在键盘按下去之前,先问自己一句:这个协程,它什么时候退出?这句简单的自问,可能就是你跟一个事故之间的全部距离。把这句话刻在心里,胜过任何工具和规则。
总结
goroutine 廉价不等于免费,启动一个是 8KB 起步,泄漏 10 万个就是 800MB+ 的内存死水。Go 不像 Java 有 GC 自动回收死锁线程,goroutine 一旦阻塞就永远占着资源。最好的预防不是修 bug,而是每次写 go func() { 之前先想清楚它的退路,想不清就别写。CI 加 goleak 是最后一道防线,但不能替代写代码时的纪律。这次 OOM 事故让我们补的不只是 3 处 close 调用,而是整个团队对 goroutine 生命周期的认知,这才是真正长期的收益。
—— 别看了 · 2026