备忘用笔记。围绕本项目 server 的启动流程(main.go + database.Init)展开,把 context 从「不敢用」讲到「会用」。忘了直接翻这一篇。

这篇的起点是一个真实问题:程序启动时要连数据库,连不上不能卡死,退出时资源要能干净释放。 解决它需要 context。所以我们顺着「为什么要用 ctx → ctx 是什么 → 怎么落到代码」走一遍。


一、context 是什么

1.1 一句话本质

ctx 不是一个数据容器,而是一个「取消信号广播器」。

你把同一个 ctx 传给 A,A 传给 B,B 传给 C……当这个 ctx 被取消时,所有持有它的函数都能同时收到「别干了,停」的信号。

类比:办公室里的总电闸。ctx 是那根线,谁的工作插在这根线上,拉闸时所有人一起停。

1.2 它只有两个核心能力

  1. 携带取消信号:<-ctx.Done() 阻塞,直到被取消;取消后这个 channel 被关闭,读操作立刻返回。
  2. 携带截止时间:ctx.Deadline(),到点自动取消。

注意:它不存业务数据(虽然有个 WithValue,但官方不推荐滥用)。

1.3 为什么不用一个全局 bool 标志?

因为调用链是嵌套且并发的:

text
1main → run → database.Init → PingContext → 底层网络读

PingContext 可能正卡在网络 I/O 里。用一个 bool 变量,没法唤醒一个已经阻塞的系统调用。而 Go 的网络/数据库底层函数都接受 ctx,内部会监听取消信号并提前返回。这就是为什么是 PingContext(ctx) 而不是 Ping()。

1.4 派生树

ctx 是树状派生的,context.Background() 是根:

text
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 接口的方法

go
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()。
go
1func DoSomething(ctx context.Context, id int) error { ... } // ✅
2func (s *Svc) DoSomething(id int) error { s.ctx ... }        // ❌ 不要存 ctx

三、关键认知:ctx 是「配置」,它自己不阻塞

这是最容易误解的一点,务必记牢:

WithTimeout / WithCancel / WithDeadline / NotifyContext 全部「秒返回」,它们不等任何东西。

真正会「等」的,是拿着 ctx 去执行的那个操作。

go
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 用法

go
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 它到底做了什么

  1. 向操作系统注册信号处理器:告诉系统「这些信号先别杀我,交给我处理」。
  2. 返回一个 ctx(内部按需提供 Done() channel)。

关键点:它「截胡」了默认的当场杀死行为。于是 Ctrl+C 变成:

text
1Ctrl+C → ctx 取消 → <-ctx.Done() 返回 → 打印退出日志 → defer 清理 → run 返回 → main 返回 → 进程退出

正因为截胡了默认行为,才「偷」到了执行清理逻辑的机会。

4.4 stop() 干嘛的

stop 是清理函数,作用:

  • 注销信号监听、释放相关资源。
  • 顺带取消 ctx。

defer stop() 在 main 返回时执行。不加通常也不影响退出,但属于规范做法,lint 可能会提醒。

⚠️ 这不是「设置结束时间」。NotifyContext 没有时间概念;带时间的是 WithTimeout。准确说法是「设置取消条件 = 收到信号」。


五、落地一:main.go 的优雅退出

5.1 代码

go
 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() 卡住主流程

go
1<-ctx.Done()
  • ctx.Done() 返回一个 channel。
  • <-chan 阻塞,直到 channel 关闭。
  • 收到信号 → ctx 取消 → channel 关闭 → 读操作立刻返回 → 往下走。

这就是优雅退出的全部秘密:主线程不主动退出,静静等信号,收到后有序关资源,最后自然返回。

5.3 资源释放靠 defer 的 LIFO

db.Close() 用 defer 注册,在 run 返回时按后进先出执行。以后再加 Redis、HTTP server,同一套 defer 顺序即可。

text
1defer A()   // 先注册
2defer B()   // 后注册
3...
4return      // 执行顺序:B 先、A 后(LIFO)

5.4 接 HTTP server 时的大坑(提前记)

go
1<-ctx.Done()          // 此刻 ctx 已经被取消
2srv.Shutdown(ctx)     // ❌ 错!用已取消的 ctx,Shutdown 立即返回,等于没优雅

正确做法:新开一个带超时的 ctx:

go
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 代码

go
 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

源码一看就懂:

go
 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 之后能不能直接操作数据库?

能! 这是重点:

go
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 里几乎每个方法都有这样的配对:

text
1Ping       ↔ PingContext
2Query      ↔ QueryContext
3Exec       ↔ ExecContext
4QueryRow   ↔ QueryRowContext
5Prepare    ↔ PrepareContext
6Begin      ↔ BeginTx

一般 Xxx() 内部约等于 XxxContext(context.Background(), ...),而 Background() 永不取消,所以那版就等于「没法叫停」。

6.7 WithTimeout + PingContext 的协作

回到那两行:

go
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 时序图

text
 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:

go
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.NotifyContextcontext.WithTimeout
取消触发收到 OS 信号(事件)时间到 / 父取消
有时间概念吗❌ 没有✅ 有
用来干嘛优雅退出给操作设上限
会阻塞吗❌ 秒返回❌ 秒返回

两者可以叠加:WithTimeout 从信号 ctx 派生,于是子 ctx 同时具备「能被 Ctrl+C 打断」和「最多等 5 秒」两个性质。


八、常见坑清单

  1. 用已取消的 ctx 调 Shutdown:srv.Shutdown(ctx) 传了刚被取消的 ctx,Shutdown 立即返回,在途请求被砍。要用新的 WithTimeout(context.Background(), ...)。
  2. 忘记 defer cancel():WithTimeout/WithCancel 的 timer 泄漏,go vet 报 lostcancel。规则:只要调了就紧跟 defer cancel()。
  3. 把 ctx 存进 struct 长期持有:应作为方法第一个参数传递,随调用链走。
  4. 用 WithValue 传业务参数:WithValue 只适合传请求域的元数据(trace id、认证信息),且 key 用自定义类型避免碰撞。
  5. 传 nil 当 ctx:很多函数会 panic,不知道用啥就传 context.TODO()。
  6. 无 ctx 的 Ping / Query 卡死:网络异常时无超时,启动或请求可能长期挂起。有 ctx 版本就用 ctx 版本。
  7. Open 后以为「没连接就是没配好」:空池是正常的,Open 成功不代表能连上,要 Ping 才知道。
  8. Ping 失败忘了关池子:返回 nil, err 时调用方不会 Close,必须 Init 内部 db.Close()。
  9. 只 Notify 不 Done:信号被吞了但没人处理,Ctrl+C 按了没反应,更难查。
  10. ctx.Done() 没人在读:取消了也没用,退出流程跑不起来。

九、速查表

构造函数

go
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

常用方法

go
1ctx.Done()                     // <-chan struct{}
2ctx.Err()                      // nil / Canceled / DeadlineExceeded
3ctx.Deadline()                 // (time.Time, bool)
4<-ctx.Done()                   // 阻塞等待取消
5context.Cause(ctx)             // 取具体取消原因

信号

go
1ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
2defer stop()

database/sql 的 ctx 配对

go
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)

本项目关键片段

go
 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。