362 lines
12 KiB
Markdown
362 lines
12 KiB
Markdown
---
|
||
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¶m2=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)
|