2026 年 4 月底某个周一上午 10:47,我们的核心订单聚合服务 order-agg 触发了一个让人摸不着头脑的告警:"数据库连接池使用率 95%,QPS 仅 1200"。这两个数字放在一起完全不科学——我们的 PostgreSQL 连接池配的是每 Pod 50 个,12 个 Pod 共 600 个连接,按平均查询 10ms 算,理论能撑 6 万 QPS。现在 QPS 才 1200,连接池却几乎打满。一定是有连接被"占着不用"。
接下来 3 天我们带着两个 Go 后端把 order-agg 的整个调用链翻了一遍,定位到的根因是一个 Go 圈子里反复被提醒但仍然广泛存在的反模式:HTTP handler 收到请求时拿到了带超时的 ctx,但在内部某层调用里,有人为了"防止上游 cancel 影响后台任务",把 ctx 替换成了 context.Background()——这条断链让 handler 已经返回 500 给客户端后,后台 goroutine 仍在继续跑数据库长查询,持有连接直到查询自然结束(5 分钟超时)。这篇是完整复盘,涵盖 Go context 的取消传播机制、goroutine 泄漏的几种典型形态、pprof 实战、以及落地的《Go context 传播纪律》。
服务背景:这个聚合多个下游的查询服务
| 维度 | 数值 |
|---|---|
| 业务 | 订单聚合服务——并发查询 5 个下游(订单/支付/物流/库存/客户)再合并返回 |
| 规模 | 日均请求 1.2 亿,P99 80ms,峰值 QPS 4500 |
| 技术栈 | Go 1.22 + Gin + pgx 5.5 + Redis 7 |
| 部署 | K8s,12 Pod,每 Pod 2 vCPU + 1GB |
| 数据库 | PostgreSQL 15 主从,每 Pod pool_max_conns=50 |
| HTTP 超时配置 | 客户端层 3s,Gin handler context 3s,数据库 statement_timeout 5min |
| 事故前现象 | QPS 上涨到 1200+ 时数据库连接池打满,P99 飙升,QPS 回落后又恢复 |
这个服务从 2024 年初上线,一直跑得很稳。直到 3 月底某次例行迭代加了"异步推送审计日志"的功能,4 月开始出现 QPS 中等水平时连接池就告警的现象。当时被归因为"业务量上涨",顺手把 pool 从 30 调到 50。这次再次触发,我们意识到这是真有问题,不是简单扩容能解决的。
事故时间线:从打满告警到根因的 3 天
| 时刻 | 事件 |
|---|---|
| 04-27 10:47 | order-agg 数据库连接池告警,使用率 95%,QPS 仅 1200 |
| 04-27 11:00 | 我从 pg_stat_activity 看到大量"active"状态的查询,query 内容是 SELECT * FROM audit_log_history WHERE ...,持续时间 1 ~ 4 分钟 |
| 04-27 11:15 | 对照 order-agg 代码,这个 audit_log_history 查询是新加的"审计日志归档"路径,本该是异步的 |
| 04-27 下午 | 抓 pprof goroutine profile,看到 1800+ 个 goroutine 卡在 pgx.Query 调用,栈往上是 auditService.ArchiveAsync |
| 04-28 | 读 auditService 代码,看到内部用 ctx := context.Background() 启动 goroutine——这是"防止上游 cancel 中断归档"的好心办了坏事 |
| 04-29 上午 | 设计修复:用 context.WithoutCancel(Go 1.21+)继承 value 但解除取消;同时给"独立生命周期任务"加显式超时 |
| 04-29 下午 | 预发跑混沌测试,连接池稳定 < 40% |
| 04-30 | 分批灰度上线,事后扫公司所有 Go 服务的 context.Background() 用法,发现 23 处类似隐患 |
第一反应:"是不是某个慢查询没加索引"
看到 pg_stat_activity 里有"持续 1 ~ 4 分钟"的 SELECT,所有 Go 工程师的本能反应都是"漏加索引了"。我们花了半个上午做 EXPLAIN ANALYZE,发现:
- audit_log_history 表 1.2 亿行,按 user_id + created_at 分区
- 查询是 SELECT * FROM audit_log_history WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1000
- EXPLAIN 显示走了 (user_id, created_at) 索引,只扫了 ~1500 行,执行时间 12ms
查询本身只要 12ms,但 pg_stat_activity 里它"活"了 1 分钟以上——这说明查询早已执行完,只是结果集还没被客户端消费完。PG 在 active 状态期间,会一直持有连接,直到客户端读完所有结果或主动 close。Go 服务端的 pgx 应该负责拉取结果并 close,但显然没干这件事。
方向锁定:连接没被释放,不是因为查询慢,是因为客户端层(我们的 Go 服务)拿了结果集后没 close。
真凶 1:pprof goroutine profile 揭穿的 1800 个僵尸
Go 排查这类问题的杀手锏是 pprof 的 goroutine profile。我们的服务一直接入着 net/http/pprof,生产环境只对内网开放。直接拉一份:
curl http://order-agg-pod-1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
# 看总数
grep "^goroutine " goroutines.txt | wc -l
# 输出: 2347
# 看常见栈
grep -A 20 "^goroutine " goroutines.txt | sort | uniq -c | sort -rn | head
2347 个 goroutine!对比正常时 200 个左右(每请求 1 个 handler + 5 个并发下游 + 后台心跳)。多出来的 ~2000 个,栈基本都长成这样:
goroutine 38421 [chan receive, 3 minutes]:
internal/poll.runtime_pollWait(0x7f8a..., 0x72)
internal/poll.(*pollDesc).wait(0xc0001..., 0x72, 0x0)
internal/poll.(*pollDesc).waitRead(...)
internal/poll.(*FD).Read(0xc0001..., {0xc0010..., 0x1000, 0x1000})
net.(*netFD).Read(0xc0001..., {0xc0010..., 0x1000, 0x1000})
net.(*conn).Read(0xc0000..., {0xc0010..., 0x1000, 0x1000})
github.com/jackc/pgx/v5/pgconn.(*PgConn).receiveMessage(0xc0001..., ...)
github.com/jackc/pgx/v5/pgconn.(*PgConn).Exec(0xc0001..., ...)
github.com/jackc/pgx/v5.(*Conn).Query(0xc0000..., ...)
order-agg/internal/audit.(*Service).ArchiveAsync.func1()
/workspace/internal/audit/service.go:142 +0x123
created by order-agg/internal/audit.(*Service).ArchiveAsync at /workspace/internal/audit/service.go:138 +0x89
关键信息:
- 2000+ goroutine 卡在 pgx 的网络读(等数据库返回数据)
- 状态是 "chan receive, 3 minutes"——已经等了 3 分钟
- 调用栈源头是 audit.(*Service).ArchiveAsync
- 这些 goroutine 是被 ArchiveAsync 创建的(看 "created by" 行)
到这里基本可以盖棺定论:auditService.ArchiveAsync 启动的后台 goroutine 在做一个 pg 查询,但因为某种原因这个查询永远不会自然结束——它们是僵尸 goroutine,各自占着一个 DB 连接。
真凶 2:好心办坏事的 context.Background()
翻 internal/audit/service.go,找到 ArchiveAsync 的实现:
// service.go
func (s *Service) ArchiveAsync(ctx context.Context, userID int64) {
// 注意: 这里用 context.Background, 不是传入的 ctx
// 注释写: "防止上游 cancel 导致归档失败"
bgCtx := context.Background()
go func() {
rows, err := s.db.Query(bgCtx, `
SELECT * FROM audit_log_history
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 1000
`, userID)
if err != nil {
log.Printf("archive query failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
// 处理每行...
s.processArchiveRow(rows)
}
// 写到 S3
s.uploadToS3(bgCtx, results)
}()
}
这段代码犯了三个相互叠加的错误,任何一个单独都不致命,合起来就是灾难:
| 错误 | 意图 | 实际后果 |
|---|---|---|
| 1. 用 context.Background 替代 ctx | "防止上游 cancel 影响归档" | 整条链路丧失任何超时控制 |
| 2. 没给 bgCtx 加任何 timeout | "归档不应该有时间限制" | 查询可能跑 1 小时甚至更久 |
| 3. for rows.Next() 内部 processArchiveRow 卡住 | "按行处理就行" | processArchiveRow 卡 -> rows 没读完 -> pg 连接持续 active |
第 3 个错误是真正的"卡死"源头。我们查了 processArchiveRow,它内部有一个调用是同步调外部审计 SDK——这个 SDK 在某些边缘情况(比如 SDK 那边的 token 过期 + 重新签发流程卡住)会无限挂起。结果就是:
- ArchiveAsync 开 goroutine 执行查询
- 查询返回结果集,for rows.Next() 开始消费
- 处理某一行时,审计 SDK 调用卡住
- goroutine 永远在 SDK 调用里等
- rows 没消费完,pgx 不能 close 连接
- 这个 DB 连接永远是 active 状态
- 如果 SDK 一天卡 200 次,一天就泄漏 200 个连接
而且因为 ctx 是 Background,哪怕 HTTP handler 早就因为客户端断开而 cancel 了,这个后台 goroutine 完全不知道——它和上游的生死已经断了连。
下面这张图把"链路断在哪里"画得很清楚。绿色是正常 context 传播链,红色是被 Background 一刀切断的节点:
红色节点 A 是事故的"绝缘体":上游的生死信号到这里全部消失。Go context 之所以叫"context"而不是"timeout",就是因为它本质上承载的是"一次请求的所有相关上下文 + 生命周期共识",一旦中间被 Background 替换,等于把整条链路一分为二——上半段还知道 deadline,下半段以为自己活在永恒里。这是事故里最关键的认知。
真凶 3:为什么作者会这么写
事后我跟写这段代码的同事聊了下,他的理由其实非常普遍,也是 Go 圈子里一个常见的误解:
"HTTP handler 拿到的 ctx,会在 handler 返回后被 cancel。如果我把这个 ctx 传到后台 goroutine,handler 一返回 goroutine 就 cancel 了,异步任务就废了。所以我用 Background 启动新 goroutine,保证它能跑完。"
这个理由听起来很合理,但有两个细节被忽略了:
- "上游 cancel 时后台任务被打断"是个真实问题,但解法不是 Background,而是给后台 goroutine 一个独立的、有上限的 context(比如 context.WithTimeout(context.Background(), 30*time.Second))
- Go 1.21 加了 context.WithoutCancel(parent),可以继承 parent 的所有 value(如 trace ID、auth token)但解除取消信号——这才是"既能继承上下文又能脱离生命周期"的正确姿势
正确的写法应该是:
func (s *Service) ArchiveAsync(ctx context.Context, userID int64) {
// 继承 ctx 的 value(trace ID 等), 但解除 cancel 传播
// 然后给一个独立的超时(必须有上限!)
detachedCtx := context.WithoutCancel(ctx)
bgCtx, cancel := context.WithTimeout(detachedCtx, 30*time.Second)
go func() {
defer cancel() // 关键: 不管成功失败, 都要释放
s.runArchive(bgCtx, userID)
}()
}
修法:三层组合拳
修法 1:context 传播规则修正
把所有 context.Background() 在内部函数里的使用都列出来 review,90% 应该改成 context.WithoutCancel(ctx) + context.WithTimeout:
// 通用模板: "异步执行 + 不被上游 cancel 影响 + 有自己的超时"
func startDetachedTask(parent context.Context, timeout time.Duration, fn func(context.Context)) {
ctx := context.WithoutCancel(parent)
ctx, cancel := context.WithTimeout(ctx, timeout)
go func() {
defer cancel()
defer func() {
if r := recover(); r != nil {
log.Printf("detached task panic: %v\n%s", r, debug.Stack())
}
}()
fn(ctx)
}()
}
// 调用方
startDetachedTask(ctx, 30*time.Second, func(ctx context.Context) {
if err := auditService.Archive(ctx, userID); err != nil {
log.Printf("archive failed: %v", err)
}
})
这个 helper 后来被加进了我们的内部 util 包,所有"启动后台 goroutine"都强制走它,linter 禁止直接 go func() { ... }。
修法 2:数据库查询必须设 statement_timeout
就算应用层 context 没设超时,数据库自己也应该有兜底。PG 提供了几层超时:
| 参数 | 作用 | 建议值 |
|---|---|---|
| statement_timeout | 单条 SQL 执行最长时间 | OLTP 5s,后台 30s |
| idle_in_transaction_session_timeout | 事务空闲最长时间 | 30s |
| lock_timeout | 等锁最长时间 | 3s |
| tcp_keepalives_idle | TCP keepalive 探测间隔 | 60s |
我们之前 statement_timeout 设的是 5min(写归档任务为了"灵活"调大),改成按 connection 维度区分——OLTP 连接池 5s,后台任务专用连接池 30s:
// OLTP 连接池
configOLTP, _ := pgxpool.ParseConfig(dsn)
configOLTP.ConnConfig.RuntimeParams["statement_timeout"] = "5000" // ms
configOLTP.MaxConns = 50
// 后台任务连接池(独立)
configBatch, _ := pgxpool.ParseConfig(dsn)
configBatch.ConnConfig.RuntimeParams["statement_timeout"] = "30000"
configBatch.MaxConns = 5 // 限死, 防止后台任务把主链路饿死
把"后台批处理"和"在线请求"用不同连接池物理隔离——后台任务最多用 5 个连接,就算泄漏也不会影响主链路的 50 个连接。这是个非常划算的隔离,推荐每个 Go 服务都做。
这里再补一条配套实践:给后台任务专用连接池设独立的 application_name。pgx 支持通过 RuntimeParams 传 application_name=order-agg-batch,这样 PG 端 pg_stat_activity 一查就能区分"这条慢查询来自 OLTP 还是 batch"——事故现场定位效率立刻上一个量级。这次事故里我们因为没分,光"这条 SELECT 是谁发的" 就猜了 20 分钟。
// 给后台任务连接池打专属 application_name
configBatch.ConnConfig.RuntimeParams["application_name"] = "order-agg-batch-v2"
// PG 端排查
// SELECT application_name, count(*), max(now() - query_start)
// FROM pg_stat_activity
// WHERE state = 'active'
// GROUP BY application_name;
同样地,Redis 客户端有 CLIENT SETNAME,Kafka producer/consumer 有 client.id,所有这些"客户端身份"字段都应该填,而不是用默认。事故响应时间能不能压到 5 分钟之内,很大程度取决于这些"平时看着没用" 的命名是不是到位。
修法 3:goroutine 数量告警
除了"事后用 pprof 排查",我们加了"事前监控":定期拉 goroutine 总数推到 Prometheus,异常增长报警:
// 启动时挂一个后台采集
go func() {
for {
time.Sleep(15 * time.Second)
gcount := runtime.NumGoroutine()
goroutineGauge.Set(float64(gcount))
}
}()
// Prometheus 告警规则
- alert: GoroutineLeak
expr: increase(go_goroutines[10m]) > 500 and go_goroutines > 1000
for: 5m
labels:
severity: warning
annotations:
description: "Goroutine count grew by > 500 in 10min, total > 1000. Possible leak."
4 天里被否决的"看似合理"方案
修法定下来之前,我们试过或者认真讨论过另外几个方向,记下每条没用的原因,免得后人重复绕弯。
| 方案 | 提出理由 | 为什么没用 |
|---|---|---|
| 把 PG 连接池从 50 调到 100 | "加资源最快" | 泄漏速率是恒定的,只是把 OOM 推迟一天,根因不解决 |
| 把 ArchiveAsync 改成同步 | "没了 goroutine 就没泄漏" | 同步会让 handler 多 2~5 秒延迟,业务 SLO 不接受 |
| 用 Redis 队列 + 独立 worker 进程处理归档 | 架构最干净 | 需要部署新组件 + 改 4 个上游, 工作量 3 周, 不在事故修复范围 |
| 把审计 SDK 换掉 | "卡死的根源是 SDK" | SDK 是公司基础设施, 替换涉及合规, 周期 2 个月起 |
| 给 SDK 调用包一层超时 | "在 SDK 外面套个 ctx WithTimeout" | SDK 内部不响应 ctx, 套外面没用——必须用 select + 单独 goroutine 强行 reap |
| 关掉归档功能 | "先关掉再说" | 归档是合规硬要求, 不能关 |
最后留下的"修代码 + 立纪律 + 加监控"是 ROI 最高的——一周内见效、不影响业务、可解释性最强。这种事故复盘里最容易犯的错是"抓住事故的机会推一个大重构",但真到执行就会发现资源 / 排期 / 合规 / 风险全部凑不齐,最后什么都没做成,根因还在。先把最小可用修法做完上线,再谈架构演进,这是几次事故后的肌肉记忆。
验证:混沌测试
预发跑的混沌测试故意触发了 ArchiveAsync 的失败路径:
| 测试场景 | 修复前 | 修复后 |
|---|---|---|
| 1000 个 ArchiveAsync 调用,内部 SDK 100% 卡住 | 1000 个僵尸 goroutine,1000 个 DB 连接占满 | 30 秒后全部超时退出,无残留 |
| 客户端 50% 断开请求 | handler 已返回但 goroutine 仍跑 | handler 返回,detached goroutine 30s 内自己结束 |
| 持续 1 小时 200 QPS | goroutine 数线性上涨 | 稳定在 ~ 220 个 |
| OLTP + 后台任务并发 | 后台任务挤占 OLTP 连接 | OLTP 连接池稳定 < 30%,后台任务在 5 个连接池内排队 |
决策树:启动后台 goroutine 时应该用哪种 context
这次事故后我们给团队画了一张"启动 goroutine 时怎么选 context"的决策树,作为代码 review 时的判断标准:
关键提醒:无论选哪种,都必须有超时上限——这是事故给我们最深刻的教训。不要相信"这个任务一般几秒就完",分布式系统里"一般"两个字是事故的同义词。给一个保守的硬上限(比如 30s、5min),哪怕你 95% 的时候用不到,剩下 5% 的边缘情况它能救你。
横向对比:其他语言的"上下文传播"是怎么做的
| 语言 / 生态 | 对应概念 | 取消机制 | 常见陷阱 |
|---|---|---|---|
| Go | context.Context | cancel func + Done chan | 本文 - Background 断链 |
| Java(虚拟线程前) | ThreadLocal + Future.cancel | interrupt 信号 | 很多 IO 库不响应 interrupt |
| Java(Loom 虚拟线程) | ScopedValue + StructuredTaskScope | scope 自动取消 | 新 API, 团队还在适应 |
| Rust(tokio) | CancellationToken + select! | 显式 token.cancel() | 没传 token 导致永跑 |
| Python(asyncio) | Task + asyncio.shield / wait_for | Task.cancel() | shield 包裹后取消不传递 |
| JavaScript | AbortController / AbortSignal | signal.abort() | 很多旧库不接受 signal |
从对比看,每种语言都有自己的"取消信号链",每种都有等价的"断链"陷阱。Go 的 context 算是设计得相对清晰的,但仍然挡不住"我用 Background 让它跑完"这种朴素直觉。这不是语言问题,是"分布式系统里资源管理"这件事本身的复杂度。任何语言里你都需要回答同一个问题:这个计算的生命周期边界在哪里?谁来负责终止它?
顺手扫到的另外几类 Go 并发反模式
| 反模式 | 问题 | 修法 |
|---|---|---|
| 启动 goroutine 不带超时 / 取消 | 泄漏的根源 | 必须有 ctx + WithTimeout |
| chan 没人接收 | 发送方永久阻塞 | buffered chan / select default |
| WaitGroup.Add 在 goroutine 内部调用 | race condition,可能漏计 | Add 必须在 go 语句外 |
| defer 在循环里没释放 | 循环结束才执行,资源占用累积 | 提取到独立函数 |
| map 并发读写 | fatal: concurrent map read and map write | sync.Map 或加锁 |
| time.After 在 select 循环里 | 每次循环都创建新 timer,GC 不及时回收 | 用 time.NewTimer 复用 |
| http.Client 没设 Timeout | 请求可能挂起几小时 | 必须显式 Timeout |
| 用 select 监听 ctx.Done() 但忘了在子函数也监听 | 子函数继续执行 | 每层都要响应 ctx |
立的《Go context 与 goroutine 纪律》
- 禁止裸用
go func() { ... },所有 goroutine 启动必须经统一 helper,helper 内部强制 ctx + timeout + recover。 - 禁止裸用 context.Background()(在非 main / 非测试代码),代码 review 时见到必须解释清楚为什么不用传入的 ctx。
- 需要"脱离上游生命周期"的场景,用 context.WithoutCancel(ctx),而不是 Background;同时必须配 WithTimeout 给独立超时上限。
- 所有数据库连接必须配 statement_timeout,OLTP 5s,后台任务 30s,绝不用默认无限。
- OLTP 和后台任务用独立连接池物理隔离,后台 pool 容量 ≤ OLTP pool 的 20%。
- goroutine 数量必须接监控:正常基线 + 10 分钟内增长告警 + 总数上限熔断。
- pprof endpoint 必须在生产环境开(只对内网或 sidecar),并定期拉 baseline goroutine profile 存档,异常时直接对比 diff。
- 所有 HTTP client / gRPC client 必须显式配 Timeout,从不接受默认。
团队 Go 工程能力的更新
除了具体修法,这次复盘后我们在内部组织了一次 90 分钟的 brown bag,把"context 真正在干什么" 这个话题从头讲了一遍。事后我做了一次小问卷,18 个一线 Go 工程师分别在分享前后回答 5 个问题——结果有点意外。
| 问题 | 分享前正确率 | 分享后正确率 |
|---|---|---|
| context cancel 是同步还是异步传播 | 33% | 89% |
| context.WithoutCancel 的存在与适用场景 | 11% | 83% |
| HTTP handler ctx 在何时被 cancel | 61% | 100% |
| defer cancel 的必要性 | 50% | 94% |
| statement_timeout 和应用层 ctx 谁先生效 | 17% | 78% |
这个数据再次说明一件事:大多数一线 Go 工程师对 context 的实际行为是没有完整心智模型的。这跟工龄关系不大,我们团队里 4 年 Go 经验的同事在 "WithoutCancel 适用场景" 这题也答错——平时谁也没需要,关键时刻就想不起来。语言层面的 API 教程谁都看过,但是"在生产里、在跨服务调用里、在 goroutine 边界上,这些 API 实际怎么行为"很少有人系统地想过。事故是个被动学习的契机,但更好的做法是事故之前就把这些主题列入团队入职培训和定期复习,把"被动踩坑学习"变成"主动建立心智模型"。
给读者的几条自查清单
- 项目里 grep
context.Background(),看出现在哪些地方。除了 main.go / 测试 / 启动时的根 ctx,其他地方基本都应该改。 - 项目里 grep
go func(),看有多少裸启动 goroutine 的地方,有没有传 ctx、有没有 recover。 - 访问
http://your-service:port/debug/pprof/goroutine?debug=1,看 goroutine 总数和 top 栈,正常服务 goroutine 数应该和 QPS 相关、上线后稳定。 - 跑一个简单负载测试,看 goroutine 数曲线——如果是单调上涨而非锯齿状,有泄漏。
- 数据库
SHOW statement_timeout;,如果是 0(无限制),改成合理值。 - 检查所有
http.Client{}实例,确认有Timeout字段。http.DefaultClient的 Timeout 是 0(无限),禁用! - 用
go vet/staticcheck/govet --shadow跑一遍代码,会捕获一些 context 相关的明显错误。 - 引入
contextchecklinter,自动检查 context 是否正确传播(它能识别"接收了 ctx 但传给下游用了 Background"的反模式)。
这次事故让我重新理解 Go context 的设计哲学:context 不只是一个超时机制,它是一个"生命周期信号传播链"。一个请求的所有相关计算应该共享同一条生命周期链,任何一个节点出问题(超时、客户端断开、调用方放弃),信号都能传达到所有下游。这条链一旦断了(被 Background 替换),你的系统就失去了"知道何时停止"的能力——而无法停止的计算,就是无法回收的资源,迟早会成为事故。把 context 当成"水管的连续性",而不是"一个可有可无的超时参数",看待方式不同,写出来的代码品质会有质的差异。
另一个感悟是"为了让任务跑完所以用 Background"是种错觉——任务不会因为没有取消信号就永远成功,它只会因为没有取消信号就永远不被取消。在分布式系统里,"永远不被取消"比"被早早取消"危险得多。Go 1.21 的 context.WithoutCancel + WithTimeout 组合,才是这个场景的正确答案。你的服务在过去几年里如果遇到过"连接池缓慢耗尽"、"goroutine 数量缓慢上涨"、"重启就好一阵子"这类亚临床症状,大概率背后都藏着一个或多个 Background 启动的"永生 goroutine",值得花半天集中扫一遍代码,提前把这些定时炸弹拆掉。
下次见到代码里写 ctx := context.Background() 然后启动 goroutine,直接拍肩膀:"这里是定时炸弹,改一下。"
事故后这 3 个月的长期收益
5 月初上线修复,到现在已经 3 个月。复一下账,看看这件事的真实工程价值。
| 指标 | 事故前 | 3 个月后 |
|---|---|---|
| order-agg 连接池峰值占用 | 95% | 32% |
| order-agg 单 Pod 平均 goroutine 数 | 1100(异常) | 240(稳态) |
| 类似 "Background + 后台任务" 隐患 | 全公司 23 处 | 全部修完, linter 拦新增 |
| 每周一次混沌测试覆盖率 | 仅基础设施层 | 新增 "下游慢响应 + SDK 卡死" 应用层场景 |
| 类似根因事故复发次数 | 过去 6 个月 2 次 | 3 个月 0 次 |
有几个意外收益值得记一下:
- 因为我们把 "OLTP 池 + batch 池物理隔离" 做了, 后来另一次"误删慢查询索引" 的事故影响范围被天然限制在 batch 池 5 个连接里,主链路毫发无损,故障等级从 P1 直降到 P3。这是事故隔离设计的复利。
- 新加的 goroutine 数监控告警在 6 月触发过 1 次,提前发现一个新写的 webhook 重试逻辑也有类似泄漏,这次是在预发就拦住,没等到生产暴露。
- 统一的 startDetachedTask helper 推广后,新人写后台任务的代码 review 周期从平均 2 轮降到 1 轮——因为模板限定死了,大家不再"自由发挥",讨论焦点回到业务逻辑本身。
这种"事故 → 工程纪律 → 长期收益"的循环,是高质量后端团队和普通团队的最大差异。普通团队事故后忙着写复盘 PPT、追责、补告警,做完三件事就翻篇;高质量团队会把事故拆成可执行的纪律条款 + 可强制的工具链 + 可复用的代码模板 + 可定期演练的混沌场景,让同样的雷在工程化层面变得不可能再炸。事故本身是负面事件,但事故后能不能产出可执行、可强制、可复用的纪律,决定了同样的雷会不会再炸第二次第三次。我们这次产出了 8 条 context 纪律 + 1 个 helper 包 + 1 个 linter 规则 + 1 套混沌测试用例,投入大概 5 人日,挡掉的潜在事故按过去频次估算每年至少 2 次,每次直接业务损失百万级——这是后端工程里少数 ROI 能算得出来的明确正收益的事情之一。
—— 别看了 · 2026