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
go
1import (
2 "database/sql"
3 _ "github.com/go-sql-driver/mysql" // ← 这个下划线不能省
4)
5
二、连接:sql.Open 不等于真的连上了
这是新手最大的坑。sql.Open 只初始化连接池,不建立连接 。真正连通靠 Ping。
go
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 应用) :
scss
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
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
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
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
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
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() 成功,必须显式 Commit 或 Rollback,否则连接一直被占用直到超时。
go
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
三条铁律:
- 事务内所有 SQL 必须用
tx.Query/tx.Exec,绝不能用db.Query defer tx.Rollback()写在最前面,成功路径末尾tx.Commit()- 用
context控制超时:db.BeginTx(ctx, nil)
六、预编译语句:高频场景的性能利器
go
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
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() 顺序陷阱。
九、一张图总结最优架构
sql
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% 的差距,在错误处理和可测试性上------而那恰恰是工程能力的分水岭。