1. 项目背景与整体思路
1.1 这个实战到底在做什么
标题里带着“go-mysql”“数据查询”“输入数据”“实战2”几个词,核心其实就一件事:用 Go 操作 MySQL,把数据写进去,再按条件查出来。这套流程我从数据库连接、单条插入、批量写入、事务控制、单行查询、多行遍历、动态条件搜索一路跑下来,发现真正卡人的不是 SQL 语法,而是 database/sql 里的各种边界情况。
本文对应一个代码级的练习项目,我给它起的内部代号叫“模拟项目 X”。这个项目的需求很简单:一张用户表,支持录入新用户、按姓名和年龄搜索用户、批量导入数据。功能不多,但刚好能覆盖“输入数据”和“数据查询”两大核心主题。适合正在学 Go 数据库编程的开发者,也适合准备写内部管理后台、运维工具、接口聚合服务的同学参考。项目整体不依赖任何 ORM,全部基于 Go 标准库 database/sql,把最底层的操作逻辑先弄明白,后面再上框架心里才有底。
这套流程里,最值得花时间研究的不是“怎么写 SQL”,而是连接生命周期、占位符参数、NULL 值扫描、事务边界和连接池调优。这些内容在官方文档里都有,但往往分散在各处,没有人告诉你它们会以什么顺序坑你。这篇我按照实操顺序,把代码、报错和规避方法一次性整理清楚。
1.2 为什么用原生 database/sql 而不是 ORM
我在选择方案时对比过两条路线:一条是直接用 ORM,把结构体和表映射起来,增删改查几行代码搞定;另一条是用 database/sql 写原生 SQL。初期觉得 ORM 很省事,但真跑起来后,一旦涉及动态条件、批量插入、事务回滚,框架会把细节包得很严实,出了问题反而难排查。
database/sql 虽然被很多人说“太底层”,但它的核心概念其实很少:Open、Exec、Query、QueryRow、Begin,掌握这几个方法就能覆盖绝大多数场景。它本质是一个统一的 SQL 接口层,具体协议交给第三方驱动处理,MySQL 就用 go-sql-driver/mysql。这样做的好处是,每一次网络往返、每一条 SQL 的走向都透明可见,程序做了什么完全在掌控范围内。
另一个理由是可迁移性。用 database/sql 写的代码,换到 PostgreSQL、SQLite 或者其他支持数据库时,业务代码基本不用大改,只需要换驱动和 DSN。而如果绑死在某个 ORM 上,迁移成本往往会高出很多。
2. 环境准备与数据表初始化
2.1 Go 环境与 MySQL 驱动安装
先确认基础环境:Go 1.20 以上版本,MySQL 8.0 社区版,本地能通过命令行连上 MySQL。项目目录我用的是普通 Go Module 结构,提前执行 go mod init 初始化模块。
安装 MySQL 驱动只需要一条命令:
go get github.com/go-sql-driver/mysql导入方式有个细节值得说明。代码里写的是:
import ( "database/sql" _ "github.com/go-sql-driver/mysql" )这里使用匿名导入,也就是包名前面的下划线。原因是 database/sql 只定义接口,不会自动加载任何第三方驱动。MySQL 驱动在 init 函数里通过 sql.Register 完成注册,匿名导入就是为了触发这个注册副作用。如果你只导入而不用它,Go 编译器不会报错,但如果你用了普通导入又不引用,编译直接失败。这个规律在接其他驱动时也适用。
2.2 设计一张能覆盖多种场景的业务表
项目里要跑通输入数据和数据查询,表结构不能太简单,也不能太复杂。我最终设计了一张 users 表,包含自增主键、用户名、邮箱、年龄、状态、创建时间六个字段:
CREATE DATABASE IF NOT EXISTS demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE demo; CREATE TABLE users ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, email VARCHAR(128) NOT NULL, age TINYINT UNSIGNED NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_email (email) ) ENGINE=InnoDB;这张表每个字段都有用处:id 自增,用来测试 LastInsertId;email 唯一,用来验证重复写入时的报错;age 允许为 NULL,用来练习 Scan 阶段的 NULL 处理;status 是常见查询条件;created_at 是时间字段,用来验证 parseTime 配置。存储引擎选择 InnoDB,因为后续要用事务,MyISAM 不支持事务,一开始就选错的话后面全白做。
2.3 基础连接代码与连接池参数初始化
数据库连接的初始化代码是整个项目的骨架。我把它封装成一个 initDB 函数,在 main 函数里调用:
package main import ( "database/sql" "fmt" "log" "time" _ "github.com/go-sql-driver/mysql" ) var db *sql.DB func initDB() error { dsn := "root:你的密码@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local" var err error db, err = sql.Open("mysql", dsn) if err != nil { return err } db.SetMaxOpenConns(20) db.SetMaxIdleConns(10) db.SetConnMaxLifetime(30 * time.Minute) if err = db.Ping(); err != nil { return err } return nil } func main() { if err := initDB(); err != nil { log.Fatalf("初始化数据库失败: %v", err) } defer db.Close() fmt.Println("数据库连接成功") }DSN 是数据库连接串,里面每个参数都有讲究。charset=utf8mb4 解决中文乱码问题,以前很多人还在用 utf8,但在 MySQL 里 utf8 并不完整支持四字节字符,统一用 utf8mb4 最稳妥。parseTime=True 的作用是让驱动把 DATETIME、TIMESTAMP 扫描成 time.Time,而不是字符串。loc=Local 用来指定时区,避免插入和查询时间出现 8 小时偏移。
这里要强调一个初学者最容易误解的地方:sql.Open 并不会立刻创建连接,它只是初始化连接池配置,真正和数据库建立连接的是 Ping。因此 Open 返回的 error 几乎总是 nil,即使 DSN 里的密码是错的也一样,必须通过 Ping 才能提前发现。
3. 数据输入:从单条插入到批量写入
3.1 单条记录插入与自增ID获取
“输入数据”的第一步是单条插入。这段代码看起来简单,但里面藏着 Exec、Query 方法选型的问题:
func insertUser(username, email string, age int) (int64, error) { result, err := db.Exec( `INSERT INTO users (username, email, age) VALUES (?, ?, ?)`, username, email, age, ) if err != nil { return 0, err } return result.LastInsertId() }Exec 方法专门用来执行不返回结果集的 SQL,INSERT、UPDATE、DELETE 都属于这一类。它返回的 sql.Result 里有两个常用方法:LastInsertId 获取自增 ID,RowsAffected 获取影响行数。为什么不用 Query 执行 INSERT?Query 会把 SQL 当作可能返回结果集的语句来处理,多走一步结果集相关逻辑,而且如果没有正确关闭结果集,连接状态会被弄乱。实战里我见过有人用 Query 执行 UPDATE,然后忘记 Close,最后连接池耗尽,这就是方法选型的代价。
关于 LastInsertId,它只在 INSERT 语句成功且表里有 AUTO_INCREMENT 字段时才有意义。如果表是 UUID 主键或者业务主键,它返回的数值没有参考价值。另一个注意点是,如果执行的是 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE,且最终因为唯一键冲突没有插入新行,LastInsertId 的行为会不同,需要按实际情况判断。
3.2 批量写入的三种实现方式
单条插入测试通过之后,我开始批量导入用户数据。批量写入有三种常见写法,执行效率和稳定性差别很大。
第一种是循环 Exec,代码最简单,但是效率最低:
func batchInsertLoop(users []User) error { for _, u := range users { _, err := db.Exec( `INSERT INTO users (username, email, age) VALUES (?, ?, ?)`, u.Username, u.Email, u.Age, ) if err != nil { return err } } return nil }这种写法的本质是 N 次网络往返。每条 INSERT 都要经历一次连接获取、SQL 发送、执行、结果返回的过程。小批量数据感觉不到,但导入 10000 条时,耗时会明显拉长。
第二种是拼接多值 SQL,一次把所有数据塞进一条语句:
func batchInsertValues(users []User) error { valueStrings := make([]string, 0, len(users)) valueArgs := make([]interface{}, 0, len(users)*3) for _, u := range users { valueStrings = append(valueStrings, "(?, ?, ?)") valueArgs = append(valueArgs, u.Username, u.Email, u.Age) } sqlStr := "INSERT INTO users (username, email, age) VALUES " + strings.Join(valueStrings, ",") _, err := db.Exec(sqlStr, valueArgs...) return err }优势是一次网络往返就能完成所有插入。代价是 SQL 语句可能非常长,受到 max_allowed_packet 参数限制,默认值在 64MB 左右。实测中我一次插 500 条没问题,但不建议盲目加大,尤其是单条记录里还有大文本字段时,拼接出来的 SQL 会迅速膨胀。另一个风险是,如果一条语句里某条记录触发错误,整个批次都会失败,定位问题时不容易确定是哪条数据出了问题。
第三种是 Prepared Statement 预编译后循环执行:
func batchInsertStmt(users []User) error { stmt, err := db.Prepare( `INSERT INTO users (username, email, age) VALUES (?, ?, ?)`, ) if err != nil { return err } defer stmt.Close() for _, u := range users { if _, err := stmt.Exec(u.Username, u.Email, u.Age); err != nil { return err } } return nil }这条路线是我最终推荐的方案。SQL 在 MySQL 端预编译一次,后面每次 Exec 只需要传参数,网络开销比循环 Exec 小,又避开了多值 SQL 的长度隐患。几千条数据的场景下,相比循环 Exec 能明显感觉到速度提升,而且代码可读性高,错误处理也更自然。
如果你要导入的是上万条数据,建议把 Prepared Statement 和事务结合起来,每 500 到 1000 条提交一次事务,避免一次事务持有锁太久。
3.3 事务保护下的一致性插入
批量导入场景有一个业务上的硬要求:要么全部成功,要么全部回滚。如果前 999 条插进去了,最后 1 条邮箱重复,业务上不能留下 999 条半成品。这时必须使用事务。
func batchInsertInTx(users []User) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() stmt, err := tx.Prepare( `INSERT INTO users (username, email, age) VALUES (?, ?, ?)`, ) if err != nil { return err } defer stmt.Close() for _, u := range users { if _, err := stmt.Exec(u.Username, u.Email, u.Age); err != nil { return err } } return tx.Commit() }事务操作里有几个关键点,必须写清楚。
第一,事务里的 Prepare 和 db 级别的 Prepare 不共享。db.Prepare 得到的语句句柄不会自动绑定到某个事务上,所以事务内必须用 tx.Prepare。
第二,defer tx.Rollback() 这种写法不会造成二次回滚的问题。如果事务已经 Commit 成功,再调用 Rollback 会返回 sql.ErrTxDone,但通常我们不会去关心这个返回值,它只是提示“事务已经结束”。这样写的好处是,中间任何一步出错 return 时,连接都会回到正确的状态,不会留下未提交的事务。
第三,Commit 本身也可能失败,比如连接断开、锁等待超时。Commit 失败后要立刻返回错误,让上层决定重试还是放弃。不要忽略 Commit 的返回值。
第四,事务要短。事务内不要发起 HTTP 请求、等待用户输入、执行耗时的外部调用。InnoDB 的行锁会一直持有到事务提交或回滚,长事务会让大量写请求排队,数据库 CPU 都有可能被拖高。
3.4 插入数据的字段校验与去重策略
输入数据时,除了基本的类型检查,还要处理“重复数据”的问题。假设邮箱必须在库里唯一,业务上有两种写法,效果差别很大。
第一种是先 SELECT 再 INSERT,比如先查邮箱是否存在,不存在才插入。这种写法在高并发下有问题:两个请求同时查到“不存在”,然后同时插入,其中一个必然报唯一键冲突错误。它不是原子操作。
第二种是直接用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE,把去重逻辑交给数据库唯一索引:
_, err := db.Exec( `INSERT INTO users (username, email, age) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), age = VALUES(age)`, username, email, age, )邮箱冲突时,这条语句会把原来的 username 和 age 更新成新值,看起来像“存在就覆盖”。需要注意 VALUES() 函数在 MySQL 8.0.20 以后被标记为已弃用,很多版本还能用,但如果你希望彻底避开,可以写成:
INSERT INTO users (username, email, age) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE username = ?, age = ?后面的参数需要把 username 和 age 再传一遍。
在 Go 程序里读取唯一键冲突的报错时,可以这样判断:
if err != nil { var mysqlErr *mysql.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { log.Println("邮箱重复,执行更新逻辑") } }这段逻辑依赖 errors.As 拿到驱动层返回的 MySQLError,然后用错误码判断。直接字符串匹配错误信息在英文环境能用,但如果你改了语言或字符集,字符串判对比错误码脆弱得多。
4. 数据查询:从单行查询到多条件组合
4.1 单行查询与对应的错误处理
数据查询里最常见的是按主键或唯一条件查单条记录。database/sql 里对应的是 QueryRow + Scan:
var ( id int username string email string age int ) err := db.QueryRow( `SELECT id, username, email, age FROM users WHERE id = ?`, 1, ).Scan(&id, &username, &email, &age) if errors.Is(err, sql.ErrNoRows) { log.Println("记录不存在") } else if err != nil { log.Fatal(err) }QueryRow 最多返回一行结果,Scan 负责把数据库返回的字段按位置填充到变量里。它和普通 Query 最大的区别是错误处理方式:如果没查到记录,QueryRow 不会直接返回 err,而是等到 Scan 时才返回 sql.ErrNoRows。所以判断“数据是否存在”用的是 errors.Is,而不是 err != nil。
Scan 是这段代码里最容易翻车的地方。数据库字段的返回顺序固定为 SELECT 后面写的字段顺序,所以 Scan 参数的顺序必须和 SELECT 字段顺序一一对应。变量名叫什么不关键,关键是位置。
4.2 多行查询与结果集遍历
多行查询使用的是 db.Query,返回一个 rows 句柄。遍历方式和 Scan 类似,但多了几个明显的坑:
rows, err := db.Query( `SELECT id, username, email, age FROM users WHERE age >= ? ORDER BY id DESC LIMIT 20`, 25, ) if err != nil { log.Fatal(err) } defer rows.Close() for rows.Next() { var ( id int username string email string age int ) if err := rows.Scan(&id, &username, &email, &age); err != nil { log.Fatal(err) } fmt.Printf("id=%d username=%s email=%s age=%d\n", id, username, email, age) } if err := rows.Err(); err != nil { log.Fatal(err) }第一坑是忘记 Close。rows 打开时占着数据库连接,如果只遍历到一半就 return,或者把你完全忘记,连接不会自动归还。连接池里连接数量本来就有限,泄漏到一定量后,新的查询只能排队。
第二坑是遍历结束后没有检查 rows.Err()。严格来说,遍历过程中可能发生网络中断、数据截断等错误,这些错误不会在 Next 里直接暴露,而是累积在 rows 内部。必须用 rows.Err() 检查一遍,否则可能拿到不完整的结果还以为查询很成功。
第三坑是不要在遍历期间发起新的查询。rows 未关闭期间,底层连接被占用,同一时刻用 db.Query 发起新请求确实会找连接池里的其他连接,这在连接池空闲时通常没问题,但如果连接池已经见底,会出现死锁或长时排队。一个安全的惯例是:先把 rows 里的数据全部 Scan 到内存切片,Close 之后再处理后续逻辑。
4.3 多条件动态搜索的实现
业务查询很少是固定一条 SQL,更多场景是搜索框里选了多个条件,有就加,没有就跳过。下面的代码我调试过很多次,可以直接当模板用:
func searchUsers(keyword string, minAge int, status int, limit int) ([]User, error) { baseSQL := `SELECT id, username, email, age, status FROM users WHERE 1 = 1` var args []interface{} if keyword != "" { baseSQL += ` AND username LIKE ?` args = append(args, "%"+keyword+"%") } if minAge > 0 { baseSQL += ` AND age >= ?` args = append(args, minAge) } if status >= 0 { baseSQL += ` AND status = ?` args = append(args, status) } baseSQL += ` ORDER BY id DESC LIMIT ?` args = append(args, limit) rows, err := db.Query(baseSQL, args...) if err != nil { return nil, err } defer rows.Close() var result []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Age, &u.Status); err != nil { return nil, err } result = append(result, u) } return result, rows.Err() }WHERE 1 = 1 是很多人争议的写法。它在性能上确实没有太大帮助,除非你用的优化器极端到连这个恒真条件都要重新评估。但它有巨大的维护优势:所有条件都可以统一用 AND 开头,不需要单独记录“这是第几个条件,要不要加 WHERE”。在条件数量持续变动的查询里,这个写法让代码清晰很多。如果追求极致,完全可以用一个布尔变量做同样的事,但最终可读性不一定比得上 1 = 1。
这里有一个绝对红线:所有条件值都必须放入 args 切片,再通过占位符传给 Query。不能用 fmt.Sprintf 把字符串拼进 SQL。后面我会专门展开讲。
4.4 Scan 阶段常见坑:NULL、类型与时间字段
users 表的 age 字段允许 NULL。如果某条记录的 age 是 NULL,Scan 到一个 int 变量会直接报错,错误内容是 converting NULL to int is unsupported。因为数据库的 NULL 不是 0,它表示“未知”,Go 语言的 int 没有表达“未知”的能力。这时必须用 sql.NullInt64:
var age sql.NullInt64 err := rows.Scan(&id, &username, &email, &age) if age.Valid { fmt.Println("年龄:", age.Int64) } else { fmt.Println("年龄未填写") }sql.NullInt64 有两个字段:Int64 保存实际数值,Valid 表示是否为 NULL。Valid 为 true 时读 Int64,为 false 时说明数据库里就是 NULL。类似的还有 sql.NullString、sql.NullFloat64、sql.NullTime。
时间字段的坑在 DSN 的 parseTime 参数上。如果 parseTime=False,驱动会把 DATETIME 返回成字符串,Scan 到 time.Time 会报错;如果 parseTime=True,就可以直接用 time.Time 接收。另一个常见异常是时区偏移,插入和查出来的时间差了 8 小时,这往往不是代码问题,而是 DSN 里没有指定 loc=Local,服务器和客户端时区不一致导致的。
5. 实战中提炼的性能与安全细节
5.1 Prepared Statement 为何是默认选择
前面批量插入时用过 Prepared Statement,这里把它的价值归纳一下。
第一,MySQL 端预编译节省语法解析时间。一条 SQL 第一次 Prepare 时完成编译,后面再执行就只是传入参数,执行计划可以复用。单条查询收益不明显,但批量场景收益很大。
第二,参数走二进制协议传输。Prepared Statement 的参数和 SQL 结构是分开传输的,参数内容永远不会被当作 SQL 语法解析,这从原理上杜绝了拼接注入。
第三,database/sql 内部的连接池会自动管理语句句柄和连接的关系。如果某个连接被回收,语句句柄下次使用时会通过重新绑定机制保证可用,开发者不需要手工干预。
但也要说清楚,不是所有情况都必须手动 Prepare。如果只是一次性查询一条记录,直接 db.Query 带占位符,driver 内部大概率也会走 prepare 路径,差别不大。需要主动 Prepare 的核心场景是“一条 SQL 反复执行、参数不同”。
5.2 参数占位符与 SQL 注入防护
Go 的 database/sql 使用问号 ? 作为占位符,这是 MySQL 驱动支持的风格。任何一个从外部进来的值,都应该通过占位符传参。
有一种特殊情况:表名、字段名、排序关键字不能使用占位符。因为参数编译阶段会被当作值,而不是标识符,你写 ORDER BY ? 时 MySQL 会把参数当成字符串常量解析,结果完全不对。这种场景下,必须做白名单映射:
orderMap := map[string]string{ "id": "id", "age": "age", "time": "created_at", } orderCol, ok := orderMap[req.Order] if !ok { orderCol = "id" } orderDir := "ASC" if req.Desc { orderDir = "DESC" } sql += " ORDER BY " + orderCol + " " + orderDir排序方向只用三元判断,排序字段只从固定映射表里取值。无论前端传进来什么,都只能命中预设的选项,这是一条很实用的边界安全实践。
5.3 连接池参数调优的几条经验
初始化代码里的三个连接池参数,值得单独拉出来讲清楚。
SetMaxOpenConns 是连接池允许打开的最大连接数。不设置时默认不限制,高并发瞬间可能把数据库连接数打爆。设置太小则并发请求会排队等待空闲连接。具体值要根据业务并发量来压测,不要拍脑袋。
SetMaxIdleConns 是连接池保留的空闲连接数。建议大于零,否则每个请求都要新建连接,连接建立本身有 TCP 握手开销。但也不要设得特别大,因为每个空闲连接都占用 MySQL 端内存。
SetConnMaxLifetime 是连接的最大存活时间。MySQL 服务端默认 wait_timeout 是 8 小时,如果连接被服务器断开而连接池不知道,下次拿这条连接执行查询时会报 invalid connection。把它设成 30 分钟或者更短,可以让连接池定期换新连接,有效规避这个问题。
还有一个容易忽视的点:SetConnMaxLifetime 是生存时间,不代表到点立刻关闭,而是在空闲或归还连接时替换。如果你发现某个时间点数据库连接数异常上升,先检查这三个参数是不是没设过。
6. 常见报错与排查速查表
6.1 连接类报错
连接问题是最早出现的,也很容易识别。我整理了一个速查表格:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| dial tcp 127.0.0.1:3306: connect: connection refused | MySQL 服务未启动或端口不对 | 检查 MySQL 进程和端口监听状态 |
| Access denied for user 'root'@'localhost' | 用户名密码或权限错误 | 核对账号密码、执行授权并刷新权限 |
| dial tcp: i/o timeout | 网络不通或防火墙拦截 | 检查防火墙规则、安全组、ping 和 telnet 连通性 |
| Error 1040: Too many connections | 连接数达到 MySQL 上限 | 控制应用连接池大小,或调大 MySQL 的 max_connections |
DSN 编码问题也经常导致连接失败。如果密码里有 @、#、:、/ 这类特殊字符,必须做 URL 编码,否则 DSN 解析会错位,提示奇怪的认证失败。最稳妥的办法是先写一个最小 DSN 测试连接,通了再往上加参数。
6.2 SQL 执行类报错
SQL 执行时报错的原因比较集中,基本围绕语法、参数、约束三方面。
参数数量不匹配是最常见的。SQL 里写了 3 个 ?,但传参时只传了 2 个参数,驱动会报参数数量不匹配。排查方法是数清楚占位符数量,再数 args 切片长度。动态拼接条件时尤其容易漏掉。
唯一键冲突会返回 Error 1062 duplicate entry。如果业务允许覆盖更新,改用 ON DUPLICATE KEY UPDATE;如果不允许,就用 errors.As 抓 MySQLError 错误码做分支处理。
表不存在、字段不存在属于开发期问题,检查库名、表名、字段名。注意 MySQL 里的标识符在 Linux 下区分大小写,表名大小写不一致也会报错。
慢插入、锁等待超时在大批量写入时比较明显。如果插入时长时间卡住,最后报 lock wait timeout exceeded,多半是另一个事务占用行锁没提交。敲 SHOW PROCESSLIST 看当前会话,找出持锁的线程。
6.3 数据读取类报错
读取阶段最常见的错误集中在 Scan。
Scan NULL 到基础类型是不允许的。解决方式是字段可空时使用 sql.NullInt64、sql.NullString 等包装类型。
字段顺序不匹配也很好排查。SELECT id, username, email,Scan 就必须按 id、username、email 的顺序给变量。如果 SELECT 的列在前面,Scan 的变量在后面,位置错位不会报编译错误,但数据会错得离谱。
类型不匹配时报错也很直接。比如 VARCHAR 字段 Scan 到 int,驱动转换失败。最常见的是时间字段:parseTime=False 时 Scan 到 time.Time 的类型不对;parseTime=True 时再 Scan 到 string 也有问题。把 DSN 的 parseTime 参数和字段类型都检查一遍。
还有一点容易遇到:rows 遍历中再发起新查询,可能导致连接池等待,如果 MaxOpenConns 设置得小,甚至会卡死。需要先收集数据、关闭 rows,再执行后续查询。
6.4 数据写入后的即时查询异常
“INSERT 完立刻 SELECT 却查不到”是高频问题。先检查是否在同一事务里:事务中 INSERT 未提交之前,其他连接默认看不到变化。如果确定要立刻读到,就直接在事务里查询,或者拿到 LastInsertId 后开新查询。
另一个原因是查询条件错误。比如自增 ID 是数字,但程序把 ID 传成字符串,可能会因为隐式类型转换查不到数据。还有 LIKE 查询没有处理百分号,业务输入里如果包含 % 或 _,会被当作通配符,导致结果超出预期。这时候需要转义或者限制关键词长度。
MySQL 默认的隔离级别是 REPEATABLE READ,一个事务内反复查询,即使其他事务已提交,也看不到新变化。如果业务上要求同一事务读到最新提交数据,需要重新评估隔离级别的选择,但建议默认保持默认,不要轻易改动。
7. 写在最后的实操心得
所有代码逻辑跑通之后,真正影响项目稳定性的往往是那些不起眼的配置细节。
第一个体会是 DSN 里的 parseTime 和 loc 必须从一开始就设置。很多项目做了一半才发现时间字段读出来是字符串,或者插入时间差 8 个小时。这时候再改代码,涉及字段类型、比较逻辑、报表展示,改动面会迅速扩大。宁可最开始多花一分钟配置,也不要后期补坑。
第二个体会是批量导入一定要配合事务和分批提交。我实测过,普通配置下,单批 500 条用户数据在事务内执行耗时大约几百毫秒,2000 条以上时耗时明显上升,锁等待概率也会提高。如果导入几十万条,一个事务到底是不可取的,建议每 500 到 1000 条一个事务,分片提交。
第三个体会是排查问题时把 SQL 打印出来。我在调试时会在代码里临时加日志,把最终 SQL 和 args 打印到控制台,然后在 mysql 客户端里手动执行同样的 SQL,对比执行计划。很多时候问题出在索引缺失,而不是代码逻辑。比如 age、status 这类经常作为查询条件的字段,建一个联合索引,性能提升立竿见影。
第四个体会是,database/sql 不是 ORM,它只是把数据库连接和结果集暴露给你。用它的最大收获不是减少代码量,而是逼你把 SQL、连接、事务这些概念彻底弄明白。后续如果项目继续做第三期,我大概率会把重心放在查询性能优化上,比如 explain 分析、覆盖索引、大偏移分页的优化。这篇先写到这里,希望能给正在写 Go 和 MySQL 实战项目的朋友带来一些实际帮助。