Go context 实战:从优雅退出到数据库连接超时
目录
备忘用笔记。围绕本项目
server的启动流程(main.go+database.Init)展开,把context从「不敢用」讲到「会用」。忘了直接翻这一篇。
这篇的起点是一个真实问题:程序启动时要连数据库,连不上不能卡死,退出时资源要能干净释放。 解决它需要 context。所以我们顺着「为什么要用 ctx → ctx 是什么 → 怎么落到代码」走一遍。
一、context 是什么
1.1 一句话本质
ctx 不是一个数据容器,而是一个「取消信号广播器」。
你把同一个 ctx 传给 A,A 传给 B,B 传给 C……当这个 ctx 被取消时,所有持有它的函数都能同时收到「别干了,停」的信号。
类比:办公室里的总电闸。ctx 是那根线,谁的工作插在这根线上,拉闸时所有人一起停。
1.2 它只有两个核心能力
- 携带取消信号:
<-ctx.Done()阻塞,直到被取消;取消后这个 channel 被关闭,读操作立刻返回。 - 携带截止时间:
ctx.Deadline(),到点自动取消。
注意:它不存业务数据(虽然有个
WithValue,但官方不推荐滥用)。
1.3 为什么不用一个全局 bool 标志?
因为调用链是嵌套且并发的:
1main → run → database.Init → PingContext → 底层网络读PingContext 可能正卡在网络 I/O 里。用一个 bool 变量,没法唤醒一个已经阻塞的系统调用。而 Go 的网络/数据库底层函数都接受 ctx,内部会监听取消信号并提前返回。这就是为什么是 PingContext(ctx) 而不是 Ping()。
1.4 派生树
ctx 是树状派生的,context.Background() 是根:
1context.Background() ← 根,永不取消
2 │
3 └─(NotifyContext)──► ctx ← 收到 Ctrl+C / SIGTERM 时取消
4 │
5 └─(WithTimeout)──► pingCtx ← 父取消 或 5s 到点,先到先取消取消是向下传播的:父取消,所有子跟着取消;反之不成立。
二、核心 API 速览
2.1 四个「造 ctx」的函数
| 函数 | 作用 | 取消条件 |
|---|---|---|
context.Background() | 根 ctx,永不取消 | 永不 |
context.TODO() | 占位,同上;语义是「这里还没想好该用哪个」 | 永不 |
context.WithCancel(parent) | 派生一个可手动取消的 ctx | 调 cancel() 或父取消 |
context.WithTimeout(parent, d) | 派生一个带超时的 ctx | 到 d 或父取消或 cancel() |
context.WithDeadline(parent, t) | 派生一个到绝对时间点取消的 ctx | 到时刻 t 或父取消或 cancel() |
context.WithValue(parent, k, v) | 派生一个携带键值对的 ctx | 随父 |
WithTimeout其实就是WithDeadline(now+d)的语法糖。
Go 1.20/1.21 还补了几个实用函数:
| 函数 | 作用 |
|---|---|
context.Cause(ctx) | 拿到「到底是谁取消的」具体原因 |
context.WithoutCancel(ctx) | 复制一个不随父取消、但保留 value 的 ctx |
context.AfterFunc(ctx, f) | ctx 取消时执行 f |
2.2 Context 接口的方法
1type Context interface {
2 Deadline() (deadline time.Time, ok bool) // 有截止时间吗
3 Done() <-chan struct{} // 取消信号 channel
4 Err() error // 取消原因:Canceled / DeadlineExceeded / nil
5 Value(key any) any // 取携带的值
6}Done():被取消后 channel 关闭,<-Done()立刻返回。Err():还没取消时返回nil;取消后返回context.Canceled或context.DeadlineExceeded。Deadline():没有截止时间时ok == false。
2.3 只读不改:ctx 永远是第一个参数
惯例(被 go vet 强制):
ctx作为第一个参数,命名为ctx。- 不要把
ctx存进 struct 字段长期持有。 - 不要传
nil,不知道用啥就传context.TODO()。
1func DoSomething(ctx context.Context, id int) error { ... } // ✅
2func (s *Svc) DoSomething(id int) error { s.ctx ... } // ❌ 不要存 ctx三、关键认知:ctx 是「配置」,它自己不阻塞
这是最容易误解的一点,务必记牢:
WithTimeout/WithCancel/WithDeadline/NotifyContext全部「秒返回」,它们不等任何东西。真正会「等」的,是拿着 ctx 去执行的那个操作。
1ctx, cancel := context.WithTimeout(parent, 5*time.Second)
2// ↑ 执行时间 ≈ 0,只是「设了个闹钟」,马上返回
3
4err := db.PingContext(ctx)
5// ↑ 这里才真正阻塞,才可能等到 5 秒上限一句话比喻:WithTimeout 是「设闹钟」,PingContext 是「睡觉等人叫醒」,闹钟本身不等你。
推论:
- 那 5 秒定时器在后台独立跑。即使你从没调用任何使用它的操作,到点它照样会取消 ctx(只是没人在听)。
- 早返回时(比如 20ms 就成功),后台定时器还在,靠
defer cancel()拆掉,否则泄漏。
四、signal.NotifyContext:优雅退出的开关
4.1 场景
默认情况下按 Ctrl+C,操作系统会直接杀死进程,你的 defer db.Close() 根本没机会执行。要做到「收到退出信号 → 有序释放资源 → 才退出」,就需要接管信号。
4.2 用法
1ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
2defer stop()os.Interrupt:Ctrl+C。syscall.SIGTERM:kill/ docker stop / k8s 发的终止信号。- 两个信号的任意一个到达,ctx 就被取消。
4.3 它到底做了什么
- 向操作系统注册信号处理器:告诉系统「这些信号先别杀我,交给我处理」。
- 返回一个 ctx(内部按需提供
Done()channel)。
关键点:它「截胡」了默认的当场杀死行为。于是 Ctrl+C 变成:
1Ctrl+C → ctx 取消 → <-ctx.Done() 返回 → 打印退出日志 → defer 清理 → run 返回 → main 返回 → 进程退出正因为截胡了默认行为,才「偷」到了执行清理逻辑的机会。
4.4 stop() 干嘛的
stop 是清理函数,作用:
- 注销信号监听、释放相关资源。
- 顺带取消 ctx。
defer stop() 在 main 返回时执行。不加通常也不影响退出,但属于规范做法,lint 可能会提醒。
⚠️ 这不是「设置结束时间」。
NotifyContext没有时间概念;带时间的是WithTimeout。准确说法是「设置取消条件 = 收到信号」。
五、落地一:main.go 的优雅退出
5.1 代码
1func main() {
2 // 结构化日志统一走 JSON,便于日志采集(stdout)。
3 // level 用 LevelVar,方便读到配置后按 mode 动态调整。
4 level := new(slog.LevelVar)
5 level.Set(slog.LevelInfo)
6 slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
7 Level: level,
8 })))
9
10 // 监听 Ctrl+C(os.Interrupt)和 kill(SIGTERM),
11 // 收到信号后 ctx 被取消,作为整个进程优雅退出的触发点。
12 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
13 defer stop()
14
15 if err := run(ctx, level); err != nil {
16 // slog 没有内置 Fatal,这里手动 Error + os.Exit。
17 slog.Error("启动失败", "err", err)
18 os.Exit(1)
19 }
20}
21
22func run(ctx context.Context, level *slog.LevelVar) error {
23 // ... 读配置(flag/config.Load)...
24
25 db, err := database.Init(ctx, &cfg.Database) // ctx 一路透传
26 if err != nil {
27 return err
28 }
29 defer db.Close()
30
31 slog.Info("数据库连接成功",
32 "dbname", cfg.Database.DBName,
33 "port", cfg.Database.Port,
34 )
35
36 slog.Info("服务启动成功",
37 "app_name", cfg.Server.AppName,
38 "port", cfg.Server.Port,
39 )
40
41 // TODO: 这里后续启动 HTTP server 等服务。
42 // 目前先阻塞等待退出信号,保证优雅退出流程可用。
43 <-ctx.Done()
44
45 slog.Info("收到退出信号,开始优雅退出")
46 // TODO: 后续接入 HTTP 时,在此用带超时的 context 调用 server.Shutdown,
47 // 例如:
48 // shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
49 // defer cancel()
50 // if err := srv.Shutdown(shutdownCtx); err != nil { ... }
51 // 注意:不能用已取消的 ctx,否则 Shutdown 会立即返回。
52
53 // 下方 defer(db.Close 等)会在这里按 LIFO 顺序执行。
54 return nil
55}5.2 <-ctx.Done() 卡住主流程
1<-ctx.Done()ctx.Done()返回一个 channel。<-chan阻塞,直到 channel 关闭。- 收到信号 → ctx 取消 → channel 关闭 → 读操作立刻返回 → 往下走。
这就是优雅退出的全部秘密:主线程不主动退出,静静等信号,收到后有序关资源,最后自然返回。
5.3 资源释放靠 defer 的 LIFO
db.Close() 用 defer 注册,在 run 返回时按后进先出执行。以后再加 Redis、HTTP server,同一套 defer 顺序即可。
1defer A() // 先注册
2defer B() // 后注册
3...
4return // 执行顺序:B 先、A 后(LIFO)5.4 接 HTTP server 时的大坑(提前记)
1<-ctx.Done() // 此刻 ctx 已经被取消
2srv.Shutdown(ctx) // ❌ 错!用已取消的 ctx,Shutdown 立即返回,等于没优雅正确做法:新开一个带超时的 ctx:
1<-ctx.Done()
2
3shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
4defer cancel()
5if err := srv.Shutdown(shutdownCtx); err != nil { // ✅ 给在途请求最多 10 秒处理完
6 slog.Error("HTTP 优雅关闭失败", "err", err)
7}原因:旧 ctx 已经拉闸了,你需要的是一根新的、有独立倒计时的线。
六、落地二:database.Init 的建连超时
6.1 代码
1const connTimeout = 5 * time.Second
2
3// Init 初始化数据库连接池并做一次连通性探测。
4// ctx 用于控制建连阶段的取消/超时(例如进程启动时收到退出信号)。
5func Init(ctx context.Context, cfg *config.Database) (*sqlx.DB, error) {
6 // Open 只创建连接池、不会真正建连,因此这里不会返回连接错误。
7 db, err := sqlx.Open("mysql", cfg.DSN())
8 if err != nil {
9 return nil, fmt.Errorf("database: 打开数据库 %s 失败: %w", cfg.DBName, err)
10 }
11
12 db.SetMaxOpenConns(cfg.MaxOpenConns)
13 db.SetMaxIdleConns(cfg.MaxIdleConns)
14 db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeMin) * time.Minute)
15
16 // 真正的建连发生在 Ping,用带超时的 ctx 兜底。
17 pingCtx, cancel := context.WithTimeout(ctx, connTimeout)
18 defer cancel()
19 if err := db.PingContext(pingCtx); err != nil {
20 // Ping 失败时关闭连接池,避免资源泄漏(返回值是 nil,调用方不会 Close)。
21 _ = db.Close()
22 return nil, fmt.Errorf("database: 数据库 %s 连接失败: %w", cfg.DBName, err)
23 }
24
25 return db, nil
26}6.2 sqlx.Open vs sqlx.Connect
源码一看就懂:
1// Connect = Open + Ping
2func Connect(driverName, dataSourceName string) (*DB, error) {
3 db, err := Open(driverName, dataSourceName) // 第一步
4 if err != nil {
5 return nil, err
6 }
7 err = db.Ping() // 第二步:试连(无 ctx)
8 if err != nil {
9 db.Close()
10 return nil, err
11 }
12 return db, nil
13}Connect 就是 Open 之后又帮你 Ping 了一下。 唯一问题是它内部的 Ping() 没有 ctx,没法设超时。
6.3 「空池不连接」:*sqlx.DB 是连接池,不是连接
*sqlx.DB是一个连接池对象,并发安全,全局只建一个、到处复用。sqlx.Open只做:记住 DSN、创建空池结构。不发起任何 TCP 连接,不碰网络。- 真正的连接是第一次执行查询 / Ping 时,池子懒加载地去建。
Open也可能返回 error:驱动名没注册、DSN 解析失败(mysql 驱动会ParseDSN)。但不会有网络错误。
6.4 Open 之后能不能直接操作数据库?
能! 这是重点:
1db, _ := sqlx.Open("mysql", dsn) // 此刻还没连
2db.Exec("INSERT ...") // 这一行执行时,池子才自动去连数据库Open之后直接db.Query/db.Exec都没问题。- 只是第一次使用时,池子发现没连接,会懒加载地建一条(可能几百毫秒),然后执行你的 SQL。
- 后续查询复用池里的连接。
所以 Ping 不是「操作数据库的必要前提」,它只是「提前试连一次」。
6.5 那为什么还要 Ping?
为了尽早发现问题:
| 策略 | 行为 |
|---|---|
只 Open 不 Ping | 启动「看起来成功」,第一条真实请求才暴露「连不上」,用户已经打到接口了才发现 |
Open + Ping | 启动阶段就试连,连不上启动就失败退出,问题在部署时立刻暴露 |
Ping 语义是「池子你去连一下,连上/连不上都告诉我」,相当于发车前先打火试一下。
6.6 Ping vs PingContext
一对函数,干的事完全一样,唯一区别是能不能带 ctx:
| 立即执行 | 能否中途叫停 | 最坏情况 | |
|---|---|---|---|
Ping() | ✅ | ❌ | 一直卡到操作系统 TCP 超时(可能几十秒) |
PingContext(ctx) | ✅ | ✅ | 由 ctx 决定,比如最多 5 秒 |
可以认为 PingContext = Ping + 一个「随时叫停」的能力。
database/sql 里几乎每个方法都有这样的配对:
1Ping ↔ PingContext
2Query ↔ QueryContext
3Exec ↔ ExecContext
4QueryRow ↔ QueryRowContext
5Prepare ↔ PrepareContext
6Begin ↔ BeginTx一般 Xxx() 内部约等于 XxxContext(context.Background(), ...),而 Background() 永不取消,所以那版就等于「没法叫停」。
6.7 WithTimeout + PingContext 的协作
回到那两行:
1pingCtx, cancel := context.WithTimeout(ctx, connTimeout) // 设闹钟,秒返回
2defer cancel()
3err := db.PingContext(pingCtx) // 睡觉等人叫醒WithTimeout起一个 5 秒后台定时器,派生pingCtx。取消条件有两个:父 ctx 取消(你 Ctrl+C)或 5 秒到点,先到先算。PingContext拿着pingCtx真正去连库,内部监听pingCtx.Done(),一旦取消就强制返回。- 5 秒是上限,不是固定等待。正常几毫秒就返回了;5 秒是数据库卡死时的兜底。
- 成败结果不在
pingCtx里,而在PingContext的返回值err里。ctx只负责「随时能叫停」。
6.8 时序图
1main: ctx = 信号ctx(无超时)
2 │
3 └─► Init(ctx, cfg)
4 │
5 ├─ Open(DSN) ← 只建空池,不连接
6 ├─ 设置连接池参数
7 ├─ pingCtx = WithTimeout(ctx,5s) ← 子ctx:信号 或 5秒,先到先取消
8 ├─ PingContext(pingCtx) ← 这里才真正 TCP 连接
9 │ ├─ 成功 ──────► return db ✅(main 随后 defer db.Close())
10 │ ├─ 5s 超时 ───► err=DeadlineExceeded ─► db.Close() ─► return err ❌
11 │ └─ Ctrl+C ───► err=Canceled ─► db.Close() ─► return err ❌
12 └─ defer cancel() ← 无论成败,拆掉定时器6.9 为什么失败时要 db.Close()
看返回值是 nil, err。调用方(main)看到 err 就直接 return:
1db, err := database.Init(ctx, &cfg.Database)
2if err != nil {
3 return err // ← 这里拿不到 db,不会 defer db.Close()
4}
5defer db.Close() // ← 只有成功才注册所以失败路径必须由 Init 自己收尾。db.Close() 会关掉池里所有已开连接、回收资源。这步是从「Connect 一步到位」改成「手动 Open + Ping」后必须自己补的——否则每次启动失败都泄漏一个连接池(内部还有后台 goroutine)。
七、两种「取消」的维度:事件 vs 时间
这篇里出现了两个 ctx,容易混,对比记住:
signal.NotifyContext | context.WithTimeout | |
|---|---|---|
| 取消触发 | 收到 OS 信号(事件) | 时间到 / 父取消 |
| 有时间概念吗 | ❌ 没有 | ✅ 有 |
| 用来干嘛 | 优雅退出 | 给操作设上限 |
| 会阻塞吗 | ❌ 秒返回 | ❌ 秒返回 |
两者可以叠加:WithTimeout 从信号 ctx 派生,于是子 ctx 同时具备「能被 Ctrl+C 打断」和「最多等 5 秒」两个性质。
八、常见坑清单
- 用已取消的 ctx 调
Shutdown:srv.Shutdown(ctx)传了刚被取消的 ctx,Shutdown 立即返回,在途请求被砍。要用新的WithTimeout(context.Background(), ...)。 - 忘记
defer cancel():WithTimeout/WithCancel的 timer 泄漏,go vet报lostcancel。规则:只要调了就紧跟defer cancel()。 - 把 ctx 存进 struct 长期持有:应作为方法第一个参数传递,随调用链走。
- 用
WithValue传业务参数:WithValue只适合传请求域的元数据(trace id、认证信息),且 key 用自定义类型避免碰撞。 - 传
nil当 ctx:很多函数会 panic,不知道用啥就传context.TODO()。 - 无 ctx 的
Ping/Query卡死:网络异常时无超时,启动或请求可能长期挂起。有 ctx 版本就用 ctx 版本。 Open后以为「没连接就是没配好」:空池是正常的,Open成功不代表能连上,要Ping才知道。- Ping 失败忘了关池子:返回
nil, err时调用方不会 Close,必须Init内部db.Close()。 - 只
Notify不Done:信号被吞了但没人处理,Ctrl+C 按了没反应,更难查。 ctx.Done()没人在读:取消了也没用,退出流程跑不起来。
九、速查表
构造函数
1context.Background() // 根,永不取消
2context.TODO() // 占位
3context.WithCancel(parent) // (ctx, cancel)
4context.WithTimeout(parent, 5*time.Second) // (ctx, cancel) 常用
5context.WithDeadline(parent, t) // (ctx, cancel)
6context.WithValue(parent, key, val) // ctx
7context.WithoutCancel(parent) // 不随父取消,保留 value常用方法
1ctx.Done() // <-chan struct{}
2ctx.Err() // nil / Canceled / DeadlineExceeded
3ctx.Deadline() // (time.Time, bool)
4<-ctx.Done() // 阻塞等待取消
5context.Cause(ctx) // 取具体取消原因信号
1ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
2defer stop()database/sql 的 ctx 配对
1db.Ping() ↔ db.PingContext(ctx)
2db.Query(...) ↔ db.QueryContext(ctx, ...)
3db.Exec(...) ↔ db.ExecContext(ctx, ...)
4db.QueryRow(...) ↔ db.QueryRowContext(ctx, ...)
5db.Prepare(...) ↔ db.PrepareContext(ctx, ...)
6db.Begin() ↔ db.BeginTx(ctx, opts)本项目关键片段
1// main:优雅退出触发点
2ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
3defer stop()
4if err := run(ctx, level); err != nil { slog.Error("启动失败", "err", err); os.Exit(1) }
5
6// run:阻塞等信号,然后 defer 清理
7<-ctx.Done()
8
9// database.Init:带超时的建连探测
10pingCtx, cancel := context.WithTimeout(ctx, connTimeout)
11defer cancel()
12if err := db.PingContext(pingCtx); err != nil { _ = db.Close(); return nil, err }十、一句话总结
- 本质:
ctx是取消信号广播器,不是数据容器;它让阻塞中的系统调用能被「叫停」。 - 不阻塞:
WithTimeout/NotifyContext只是「设闹钟」,秒返回;真正等待的是拿 ctx 去执行的操作。 - 优雅退出:
signal.NotifyContext截胡默认杀进程行为,用<-ctx.Done()等信号,defer按 LIFO 关资源。 - 两个维度:
NotifyContext管「事件取消」,WithTimeout管「时间上限」,可叠加。 - 连接池:
Open只建空池不连接,第一次用才懒加载;Ping是提前试连,PingContext是可中断版;失败务必自己Close。 - 死规矩:
WithTimeout下一行必defer cancel();别用已取消的 ctx 调Shutdown。