Files
cs-note/hhs/MySQL/08-工程实践/35-Go 连接池配置.md
T
2026-05-24 11:42:38 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
MySQL
Go
sql.DB
连接池
性能调优
2026-05-16 00:00

Go 连接 MySQL 的工程实践

概述

Go 的 database/sql 提供了数据库无关的连接管理抽象。但与 Redis 不同——Go 标准库的 driver 是连接池而非直连,理解它的行为模型对避免生产事故至关重要。

sql.DB 的本质

flowchart TB
    App["应用进程"] --> Pool["sql.DB 连接池"]
    
    Pool -->|"动态创建"| C1["Connection 1<br/>到 MySQL Server"]
    Pool --> C2["Connection 2"]
    Pool --> C3["Connection N..."]
    
    Pool -.MaxOpenConns 限制.| MaxLimit["最大连接数"]
    
    C1 --> MySQL["MySQL Server Process"]
    C2 --> MySQL
    C3 --> MySQL
    
    style Pool fill:#00B6BC,color:#fff
    style MaxLimit fill:#EE5A24,color:#fff

[!WARNING] 常见的误解 sql.DB 不是单个连接,而是一个连接池。它代表的是逻辑连接——物理连接是按需创建、空闲回收的。这意味着:

  • 多个 goroutine 可以同时使用同一个 db 对象
  • db 是 goroutine-safe 的
  • 不要将 db 传给每个请求单独维护(会导致连接膨胀)

核心参数调优

import "database/sql"

db, err := sql.Open("mysql", dsn)
if err != nil {
    log.Fatal(err)
}

// 四大核心参数
db.SetMaxOpenConns(50)           // 池中最多的打开连接数
db.SetMaxIdleConns(10)           // 池中最多保持的空闲连接数
db.SetConnMaxLifetime(time.Minute * 5)   // 连接存活时间
db.SetConnMaxIdleTime(time.Minute * 2)   // 空闲连接被回收的时间(Go 1.15+)

参数关系图

flowchart LR
    A["SetMaxOpenConns = 50<br/>全局上限"] --> B{"当前活跃连接数 < 50?"}
    B -->|是| C["创建新连接"]
    B -->|否| D["排队等待可用连接 ⏳"]
    
    E["SetMaxIdleConns = 10<br/>最少保留 10 个空闲"] --> F["关闭多余的空闲连接"]
    G["SetConnMaxLifetime = 5m<br/>超过则关闭重建"] --> H["防止 MySQL 8.0 默认 8h wait_timeout 冲突"]
    I["SetConnMaxIdleTime = 2m<br/>空闲超 2 分钟即关闭"] --> J["减少资源浪费"]
    
    style D fill:#EE5A24,color:#fff
    style C fill:#00D866,color:#fff

参数的依赖关系

// 重要约束
// 如果 SetMaxOpenConns <= 0 → 无限制(不推荐!)
// 如果 SetMaxIdleConns > SetMaxOpenConns → 自动调整为等于 MaxOpenConns

// 合理配置示例
db.SetMaxOpenConns(100)       // 根据压测确定
db.SetMaxIdleConns(20)        // 至少 1/5 的 MaxOpenConns
db.SetConnMaxLifetime(5 * time.Minute)   // 短于 MySQL wait_timeout (默认 8h)
db.SetConnMaxIdleTime(2 * time.Minute)   // 及时释放长期空闲连接

如何确定 MaxOpenConns 的值

flowchart TD
    A["压测:单实例 QPS + 平均延迟"] --> B["计算:<br/>MaxOpenConns = QPS × AvgLatency(s)"]
    B --> C{"是否接近 MySQL max_connections?"}
    C -->|"是,不够"| D["考虑读写分离<br/>或分库"]
    C -->|"否,有余量"| E["设为计算值的 80%<br/>留出安全余量"]
    E --> F["观察 P99 延迟<br/>和连接池等待数"]
    
    style B fill:#4FC08D,color:#fff
    style E fill:#EAB308,color:#fff
    style F fill:#A0AEC0,color:#fff

[!TIP] 经验公式与验证方法

理论值 = 并发请求数 × 单次查询平均耗时
  • 步骤一:用 ab 或 wrk 压测,拿到 QPS 和平均响应时间
  • 步骤二:MaxOpenConns ≈ QPS × AvgLatency(s),例如 500 QPS、20ms → 500 × 0.02 = 10
  • 步骤三:在此基础上乘以 1.5~2 倍的安全系数(考虑突发流量、慢查询兜底)
  • 步骤四:上线后通过 Prometheus 监控 go_sql_driver_connections 实际使用量调优

[!WARNING] 常见误区

  • MaxOpenConns 设越大越好 → 连接越多,单连接 CPU 竞争越严重,延迟反而上升
  • 忽略 SetMaxIdleConns → 默认值为 2,突发流量到来时频繁创建连接导致延迟抖动
  • MaxIdleConns 应 ≥ 1(生产建议 CPU 核数或 10~20),确保有预热好的连接可用

DSN 格式详解

// user:password@tcp(host:port)/dbname?param1=value1&param2=value2
dsn := "app_user:app_password@tcp(10.0.1.10:3306)/app_db" +
    "?charset=utf8mb4" +
    "&parseTime=True" +          // 自动将 MySQL DATETIME 解析为 Go time.Time
    "&loc=Local" +               // 时区设置
    "&timeout=10s" +             //  dial 超时
    "&readTimeout=30s" +         // 读取超时
    "&writeTimeout=30s" +        // 写入超时
    "&allowNativePasswords=true" + // MySQL 8.0 认证插件
    "&tls=false"                 // TLS 连接

[!TIP] parseTime = true 的价值

// ❌ parseTime=False (默认): time 字段变成 string
var createdAt string
db.QueryRow("SELECT created_at FROM users WHERE id = ?", id).Scan(&createdAt)
// createdAt = "2026-05-16 10:30:00" — 需要手动解析

// ✅ parseTime=True: time 字段自动转为 time.Time
var createdAt time.Time
db.QueryRow("SELECT created_at FROM users WHERE id = ?", id).Scan(&createdAt)
// createdAt = 2026-05-16 10:30:00 +0800 CST

go-sql-driver/mysql 特有参数

github.com/go-sql-driver/mysql 除了标准 DSN 参数外,还提供了一些有用的驱动级配置:

参数 推荐值 说明
interpolateParams true 将占位符替换为实际值后拼接 SQL,减少往返次数 ⚡
loc=Local Local 时区设为本地时区(Asia/Shanghai)
parseTime true MySQL DATETIME/TIMESTAMP 自动转为 Go time.Time
charset=utf8mb4 utf8mb4 支持 Emoji 和所有 Unicode 字符
allowNativePasswords true MySQL 8.0 caching_sha2_password 认证插件
allowAllFiles false 禁止 LOAD DATA LOCAL INFILE 读取任意文件 🔒
clientFoundRows false UPDATE 返回"匹配行数"而非"影响行数"
tls=false / tls=skip-verify / tls=preferred 按需选择 TLS 加密连接(生产环境建议启用)
dsn := "app_user:app_password@tcp(10.0.1.10:3306)/app_db" +
    "?charset=utf8mb4&parseTime=True&loc=Local" +
    "&timeout=10s&readTimeout=30s&writeTimeout=30s" +
    "&interpolateParams=true" +        // 性能优化:减少网络往返
    "&allowAllFiles=false" +           // 安全加固:防止 LOCAL INFILE 注入
    "&maxAllowedPacket=67108864"       // 最大包大小 64MB(大文本/BLOB 必须设)

[!WARNING] interpolateParams 注意事项

  • 仅适用于不重复使用的查询——如果复用 *sql.Stmt,会导致 SQL 注入风险
  • 对批量插入无效——prepare + exec 在大批量场景下依然更快
  • 适合的场景:单条查询、CRUD 接口中的简单参数绑定

事务最佳实践

模式一:defer tx.Rollback(最常用)

tx, err := db.Begin()
if err != nil {
    return err
}
defer func() {
    if p := recover(); p != nil {
        tx.Rollback() // panic 时确保回滚,防止连接泄漏
        panic(p)      // 重新抛出
    }
}()

// 执行业务操作
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
if err != nil {
    return err // defer 触发 Rollback()
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
if err != nil {
    return err
}

return tx.Commit() // defer 不会触发 Rollback(err == nil)

[!TIP] 核心原则 defer tx.Rollback() 在 Commit 之后调用时是安全的——提交过的事务没有需要回滚的内容。因此可以简化为:

tx, _ := db.Begin()
defer tx.Rollback() // 简单粗暴,适用于绝大多数场景

// ... 业务逻辑
return tx.Commit()

模式二:函数式封装(推荐复用)

// 事务包装器:失败自动回滚,成功返回 Commit 的 err
func transaction(db *sql.DB, fn func(*sql.Tx) error) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    defer func() {
        if p := recover(); p != nil {
            tx.Rollback()
            panic(p)
        }
    }()

    if err := fn(tx); err != nil {
        _ = tx.Rollback() // 忽略 rollback 错误,返回原始业务错误
        return err
    }
    return tx.Commit()
}

// 使用方式
err := transaction(db, func(tx *sql.Tx) error {
    _, err := tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
    if err != nil {
        return err
    }
    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
    return err
})

[!NOTE] 为什么不建议过度复杂的 panic recover?

写法 适用场景 复杂度
defer tx.Rollback() 日常开发 ✅ 低
函数式封装 团队规范、多模块复用 中
手动 Recover+Rollback 极少数有全局 panic 处理的框架 高

大多数应用不需要担心 commit 后 rollback 的问题——database/sql 内部会正确处理,所以一句 defer tx.Rollback() 就足够了。

查询模式速查

// 单行查询
var id int
var name string
err := db.QueryRow("SELECT id, name FROM users WHERE email = ?", email).
    Scan(&id, &name)

// 多行查询
rows, err := db.Query("SELECT id, name FROM users WHERE status = ?", 1)
if err != nil {
    return nil, err
}
defer rows.Close()  // 一定要 Close!

users := make([]User, 0)
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name); err != nil {
        return nil, err
    }
    users = append(users, u)
}

// 批量插入
tx, _ := db.Begin()
stmt, _ := tx.Prepare("INSERT INTO orders (user_id, amount) VALUES (?, ?)")
for _, o := range orders {
    stmt.Exec(o.UserID, o.Amount)
}
stmt.Close()
tx.Commit()

// 预处理语句复用(高性能场景)
stmt, _ := db.Prepare("SELECT * FROM users WHERE id = ?")
defer stmt.Close()
// 多次执行时复用 stmt,避免重复解析 SQL

常见问题排查

连接泄漏

// 症状:应用连接数持续增长,最终达到 MaxOpenConns 上限
// 原因:没有 Close() rows 或没有 Commit/Rollback tx

// 检查方法
rows, err := db.Query("SELECT COUNT(*) FROM information_schema.processlist WHERE command != 'Sleep'")
// 查看活跃连接数是否异常

连接风暴

// 症状:短时间内创建大量连接,MySQL 报错 Too many connections
// 原因:每次请求都新建 sql.DB 而非复用

// ✅ 正确做法
var db *sql.DB // 全局变量或在初始化函数中创建一次

// ❌ 错误做法:每个 handler 都 Open
func handler(w http.ResponseWriter, r *http.Request) {
    db, _ := sql.Open("mysql", dsn)  // 每个请求都新建池!
}

与 MySQL wait_timeout 的配合

# MySQL 端配置(my.cnf)
wait_timeout = 600        # 10 分钟非交互式超时
interactive_timeout = 600
// Go 端配置必须短于 MySQL 的 wait_timeout
db.SetConnMaxLifetime(5 * time.Minute)   // 5 分钟 < 10 分钟
// 这样 Go 会在连接被 MySQL 强制关闭前主动重建

思考与拓展

[!QUESTION] 为什么 sql.Open 不真正建立连接? sql.Open 只验证 DSN 格式并初始化连接池对象,不会发起真实的 TCP 连接。第一次实际查询时才会创建物理连接。如果需要启动时即验证连通性:

err := db.Ping() // 立即发起一次真实连接
if err != nil { log.Fatal(err) }

[!QUESTION] 生产环境 SetMaxIdleConns 默认值是 2——这对突发流量有什么影响? 当空闲连接只有 2 个时,如果 QPS 从 100 突然飙升到 1000,8 个新连接需要逐个建立 TCP 握手(三次握手 + TLS),每次新增连接的延迟可能高达数毫秒。设一个合理的 MaxIdleConns(如 CPU 核数或 10~20)可以维持"热连接池",让突发流量立即命中已有连接。

[!QUESTION] go-redis 和 sql.DB 的连接池设计有何异同?

维度 *sql.DB go-redis.Client
最大连接 SetMaxOpenConns(n) MaxActive(n)
空闲连接 SetMaxIdleConns(n) MaxIdle(n)
空闲回收 SetConnMaxIdleTime(d) IdleTimeout(d)
等待超时 内部阻塞(无直接 API) PoolTimeout(d)
保活机制 需手动设置 Lifetime PingPeriod PING/PONG

共同点:都是 goroutine-safe 的共享池,不应每请求新建实例。

关联笔记