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

362 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, Go, sql.DB, 连接池, 性能调优]
create time: 2026-05-16 00:00
---
# Go 连接 MySQL 的工程实践
## 概述
Go 的 `database/sql` 提供了数据库无关的连接管理抽象。但与 Redis 不同——Go 标准库的 driver 是**连接池**而非直连,理解它的行为模型对避免生产事故至关重要。
## sql.DB 的本质
```mermaid
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` 传给每个请求单独维护(会导致连接膨胀)
## 核心参数调优
```go
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+)
```
### 参数关系图
```mermaid
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
```
### 参数的依赖关系
```go
// 重要约束
// 如果 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 的值
```mermaid
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 格式详解
```go
// 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 的价值
> ```go
> // ❌ 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 加密连接(生产环境建议启用) |
```go
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(最常用)
```go
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 **之后**调用时是安全的——提交过的事务没有需要回滚的内容。因此可以简化为:
> ```go
> tx, _ := db.Begin()
> defer tx.Rollback() // 简单粗暴,适用于绝大多数场景
>
> // ... 业务逻辑
> return tx.Commit()
> ```
### 模式二:函数式封装(推荐复用)
```go
// 事务包装器:失败自动回滚,成功返回 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()` 就足够了。
## 查询模式速查
```go
// 单行查询
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
```
## 常见问题排查
### 连接泄漏
```go
// 症状:应用连接数持续增长,最终达到 MaxOpenConns 上限
// 原因:没有 Close() rows 或没有 Commit/Rollback tx
// 检查方法
rows, err := db.Query("SELECT COUNT(*) FROM information_schema.processlist WHERE command != 'Sleep'")
// 查看活跃连接数是否异常
```
### 连接风暴
```go
// 症状:短时间内创建大量连接,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 的配合
```ini
# MySQL 端配置(my.cnf)
wait_timeout = 600 # 10 分钟非交互式超时
interactive_timeout = 600
```
```go
// Go 端配置必须短于 MySQL 的 wait_timeout
db.SetConnMaxLifetime(5 * time.Minute) // 5 分钟 < 10 分钟
// 这样 Go 会在连接被 MySQL 强制关闭前主动重建
```
## 思考与拓展
> [!QUESTION] 为什么 `sql.Open` 不真正建立连接?
> `sql.Open` 只验证 DSN 格式并初始化连接池对象,**不会发起真实的 TCP 连接**。第一次实际查询时才会创建物理连接。如果需要启动时即验证连通性:
> ```go
> 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 的共享池**,不应每请求新建实例。
## 关联笔记
- [[hhs/GORM/01-安装与初始化]] — GORM 底层调用 `*sql.DB` 进行连接池调优
- [[hhs/Redis/11-运维与性能调优]] — go-redis 连接池参数对比(MaxActive/MaxIdle vs SetMaxOpenConns/SetMaxIdleConns)