Go 操作 MySQL:常用写法与最佳实践

Go 操作 MySQL:常用写法与最佳实践

2026年6月,Go + MySQL 依然是后端开发的黄金组合。 但能连上和写好,是两回事。


一、先搞清一个核心:database/sql + 驱动,不是 ORM

Go 官方不提供 MySQL 原生驱动。标准做法是:

组件 作用 包路径
database/sql 通用数据库接口抽象层 标准库
go-sql-driver/mysql MySQL 驱动实现 github.com/go-sql-driver/mysql

记住:必须空白导入驱动,否则报 sql: unknown driver "mysql"

复制代码

go

复制代码
`1import (
2    "database/sql"
3    _ "github.com/go-sql-driver/mysql"  // ← 这个下划线不能省
4)
5`

二、连接:sql.Open 不等于真的连上了

这是新手最大的坑。sql.Open 只初始化连接池,不建立连接 。真正连通靠 Ping

复制代码

go

复制代码
`1dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
2db, err := sql.Open("mysql", dsn)
3if err != nil {
4    log.Fatal(err)
5}
6defer db.Close()
7
8// 必须显式 Ping,否则第一次查询才会真正连接
9if err := db.Ping(); err != nil {
10    log.Fatal(err)
11}
12`

DSN 参数说明

参数 作用 不加会怎样
parseTime=True DATETIME 自动转 time.Time 查出来是 []byte
loc=Local 按时区解析时间 时间差8小时
charset=utf8mb4 字符集 中文乱码

三、连接池:不配置等于裸奔

默认 MaxOpenConns=0(无上限)、MaxIdleConns=2。并发一上来,MySQL 直接被打满。

推荐配置(Web 应用)

复制代码

go

复制代码
`1db.SetMaxOpenConns(25)      // 最大并发连接数
2db.SetMaxIdleConns(25)      // 最大空闲连接数(建议与 MaxOpenConns 相同)
3db.SetConnMaxLifetime(5 * time.Minute)  // 连接最长存活时间
4db.SetConnMaxIdleTime(30 * time.Minute) // 空闲连接回收时间
5`
参数 推荐值 为什么
MaxOpenConns 10~50 超过 MySQL 承载能力会雪崩
MaxIdleConns 5~10 太少频繁建连,太多浪费资源
ConnMaxLifetime 30分钟 必须小于 MySQL 的 wait_timeout(云数据库常设300秒)

实测数据:max=20, minIdle=5 时 QPS 达 1420,延迟 63ms;max=10, minIdle=2 时 QPS 仅 850,延迟 118ms。


四、CRUD:四种写法,一次讲透

4.1 插入(INSERT)

复制代码

go

复制代码
`1// 单条插入
2res, err := db.Exec("INSERT INTO users(name, age) VALUES (?, ?)", "Alice", 25)
3if err != nil {
4    log.Fatal(err)
5}
6id, _ := res.LastInsertId()  // 自增ID
7
8// 批量插入(比循环 Exec 快一个数量级)
9res, err := db.Exec(
10    "INSERT INTO users(name, age) VALUES (?, ?), (?, ?), (?, ?)",
11    "Bob", 30, "Carol", 28, "Dave", 35,
12)
13`

4.2 查询单行(QueryRow)

复制代码

go

复制代码
`1var name string
2var age int
3err := db.QueryRow("SELECT name, age FROM users WHERE id = ?", 1).Scan(&name, &age)
4if err != nil {
5    if err == sql.ErrNoRows {
6        // 记录不存在,这是唯一可预期的"无结果"错误
7        return nil, fmt.Errorf("user not found")
8    }
9    log.Fatal(err)
10}
11`

关键点:QueryRow 返回的 *sql.Row 必须调用 Scan,否则游标不释放,连接泄漏。

4.3 查询多行(Query)

复制代码

go

复制代码
`1rows, err := db.Query("SELECT id, name, age FROM users WHERE age > ?", 18)
2if err != nil {
3    log.Fatal(err)
4}
5defer rows.Close()  // ← 不 defer 就泄漏
6
7var users []User
8for rows.Next() {
9    var u User
10    if err := rows.Scan(&u.ID, &u.Name, &u.Age); err != nil {
11        log.Fatal(err)
12    }
13    users = append(users, u)
14}
15if err := rows.Err(); err != nil {  // 检查迭代过程中的错误
16    log.Fatal(err)
17}
18`

4.4 更新(UPDATE)

复制代码

go

复制代码
`1res, err := db.Exec("UPDATE users SET age = ? WHERE name = ?", 30, "Alice")
2if err != nil {
3    log.Fatal(err)
4}
5affected, _ := res.RowsAffected()  // 受影响行数
6`

4.5 删除(DELETE)

复制代码

go

复制代码
`1res, err := db.Exec("DELETE FROM users WHERE id = ?", 123)
2if err != nil {
3    log.Fatal(err)
4}
5affected, _ := res.RowsAffected()
6`

五、事务:Go 不会自动回滚

这是最容易翻车的地方。一旦 tx, err := db.Begin() 成功,必须显式 CommitRollback,否则连接一直被占用直到超时。

复制代码

go

复制代码
`1tx, err := db.Begin()
2if err != nil {
3    return err
4}
5
6// 关键:用 defer 确保 Rollback 只执行一次
7defer func() {
8    if p := recover(); p != nil {
9        tx.Rollback()
10        panic(p)
11    }
12}()
13
14_, err = tx.Exec("INSERT INTO logs(msg) VALUES (?)", "start")
15if err != nil {
16    tx.Rollback()
17    return err
18}
19
20_, err = tx.Exec("UPDATE accounts SET balance = balance - 100 WHERE id = ?", 1)
21if err != nil {
22    tx.Rollback()
23    return err
24}
25
26// 全部成功才提交
27if err := tx.Commit(); err != nil {
28    return err
29}
30`

三条铁律

  1. 事务内所有 SQL 必须用 tx.Query / tx.Exec绝不能用 db.Query
  2. defer tx.Rollback() 写在最前面,成功路径末尾 tx.Commit()
  3. context 控制超时:db.BeginTx(ctx, nil)

六、预编译语句:高频场景的性能利器

复制代码

go

复制代码
`1stmt, err := db.Prepare("INSERT INTO users(name, age) VALUES (?, ?)")
2if err != nil {
3    log.Fatal(err)
4}
5defer stmt.Close()
6
7// 复用 stmt,避免重复解析 SQL
8_, err = stmt.Exec("John", 25)
9_, err = stmt.Exec("Jane", 28)
10`

好处: SQL 只解析一次,防止 SQL 注入,批量执行时性能提升明显。


七、Scan 踩坑清单

现象 解决方案
字段数不匹配 sql: expected 3 destination arguments in Scan 用具体字段名,不用 SELECT *
NULL 值 Scan panic 或结果为零值 sql.NullString / sql.NullInt64 接收
TINYINT(1) 当 bool Go 里读出来是 0/1 不是 true/false 明确用 int8 接收
时区错乱 时间差8小时 DSN 加 loc=Local

八、Web 应用中的依赖注入

别用全局变量,别搞自定义 Context 封装。函数式依赖注入才是正解:

复制代码

go

复制代码
`1// main.go
2func main() {
3    db, _ := initDB()  // 全局唯一实例
4    http.HandleFunc("/users", handlers.UsersHandler(db))
5    http.ListenAndServe(":8000", nil)
6}
7
8// handlers/users.go
9func UsersHandler(db *sql.DB) http.HandlerFunc {
10    return func(w http.ResponseWriter, r *http.Request) {
11        rows, err := db.Query("SELECT id, name FROM users")
12        if err != nil {
13            http.Error(w, "DB error", 500)
14            return
15        }
16        defer rows.Close()
17        // ...
18    }
19}
20`

好处 : 依赖清晰可见,测试时用 sqlmock 轻松替换,避免 init() 顺序陷阱。


九、一张图总结最优架构

复制代码
复制代码
`1HTTP 请求
2    │
3    ▼
4Handler(函数式注入 *sql.DB)
5    │
6    ├── Query / Exec(普通操作)
7    ├── db.Prepare(预编译,高频复用)
8    └── db.BeginTx(事务,必须 Commit/Rollback)
9    │
10    ▼
11连接池(MaxOpenConns=25, MaxIdleConns=25, Lifetime=5min)
12    │
13    ▼
14go-sql-driver/mysql → MySQL
15`

十、2026年的新变量

MySQL 9.6.0(2026 Linux 创新版)已将外键约束上移至 SQL 层,所有变更完整记录至 Binlog,CDC 和主从复制的数据一致性问题基本解决。如果你在用云数据库,SetConnMaxLifetime 务必小于云厂商的连接超时(通常 300 秒),否则复用连接时会遭遇 invalid connection


写在最后

Go 操作 MySQL 的核心就一句话:连接池管好,事务别忘回滚,Scan 字段对齐。

能做到这三点,你已经超过 80% 的 Go 开发者了。剩下 20% 的差距,在错误处理和可测试性上------而那恰恰是工程能力的分水岭。