后端开发做久了你会发现,代码里最绕不开的那块不是 HTTP 路由,也不是框架 API,而是数据层。接口写得再漂亮,最后还是要落库、查库、改库、删库。Day 4 这个位置刚好是整个系列的关键转折点:前面几天你把 Go 的语法、HTTP 服务、路由和中间件都打通了,从今天开始,项目开始真正“有数据”了。这一篇我会用 Go 标准库 database/sql 把一张 user 表从建表到增删改查完整走一遍,顺带把连接池、事务、常见炸点全部讲清楚。适合刚入门 Go 后端、知道怎么启动 HTTP 服务但还没系统碰过数据库的读者。
1. 数据层在后端项目里的位置,以及为什么先碰 database/sql
1.1 一个请求从接口到数据库,到底走了几层
很多新手对“后端提供接口”这件事的理解停留在路由和函数上:前端请求 /api/users,后端某个 handler 收到请求,返回一段 JSON。但项目一旦真实跑起来,你会发现这个链路在代码层面至少要拆成好几层。
以我这个系列的教学项目为例,目前就是这样分层的:
handler层:负责解析 HTTP 请求、读取参数、校验输入、组装响应。service层:负责业务规则,比如“用户名不能为空”“邮箱不允许重复”,这层不一定每个项目都有,但上了规模基本都会加。store层:也有人叫dao或repository,它只做一件事,就是和数据库打交道,把数据读出来、写进去。db层:真正的数据库连接与连接池,由 store 层使用。
Day 4 的核心就在后面的两层。你前端界面上看到的用户列表、详情、删除结果,最终都落在几条 SQL 语句上。把数据层从 HTTP 逻辑里拆出来,最大的好处是职责清晰:handler 不用关心数据存哪,store 不用关心请求从哪来,两边各自好测、好改。
从学习顺序上,我建议你先把 store 层练熟,再回去调整 handler。因为数据操作是后端最实在的硬功夫,一旦你掌握了 CRUD,后面的接口基本就是“把 store 的返回值包成 JSON”而已。
1.2 不急着上 ORM,先把 database/sql 吃透
现在的 Go 生态里,GORM、sqlx 这类库用起来确实爽,结构体一标,增删改查自动生成,代码量能少一大半。但我在这套 7 天实战里故意不用 ORM,开头几天就老老实实写标准库,原因有三个。
第一个原因,标准库帮你把数据库驱动和连接池这些脏活累活都干了,你只需要关心 SQL 本身,这反而能让你把 SQL 写明白。很多用了两年 ORM 的开发者,遇到复杂查询还是得回头补原生 SQL,早学早省事。
第二个原因,理解 database/sql 的工作方式,你才能理解 ORM 底层到底替你做了什么,以后排查问题不至于两眼一抹黑。比如 GORM 报 sql: database is closed 时,你能瞬间反应过来是连接池或者 db 实例生命周期出了问题,而不是对着报错瞎猜。
第三个原因,标准库足够稳定。database/sql 从 Go 1.1 开始就有了,接口设计这么多年没大改,你学会这套写法,换数据库、换框架都不会过时。后面真要引入 GORM 或者 sqlx,再看它们的文档会轻松很多,因为核心概念是相通的:连接、查询、扫描、释放。
我这么说不是在贬低 ORM,生产项目里我也用 GORM。但 Day 4 这个阶段,老老实实写几天原生 SQL,你会比别人少踩很多隐形的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与数据库连接:先把地基打牢
2.1 数据库选型与建库:我为什么先用 MySQL
Go 的 database/sql 是标准接口,理论上支持所有关系型数据库,但不同数据库驱动、语法细节有差异。为了贴近真实后端项目场景,我这里选 MySQL,原因很直接:国内后端岗位最常见的就是 MySQL,网上资料多,部署方便,你以后工作大概率也绕不开。
如果手头暂时没有 MySQL 服务,两个选择:一是装一个 Docker 容器,docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=你的密码 mysql:8,几秒钟就能跑起来;二是本地直接装 MySQL 8.x,Windows 和 macOS 都有安装包。实在不想装 MySQL,用 SQLite 也能跑通下面的代码,驱动换成 github.com/mattn/go-sqlite3,SQL 语法大部分兼容,但为了不偏题,这篇统一按 MySQL 讲。
建库这一步很简单,我一般会单独建一个库给当前项目用,避免和本地其他项目的数据混在一起。顺手把字符集指定成 utf8mb4,不然以后存 emoji 或者生僻字会报错:
sql复制CREATE DATABASE IF NOT EXISTS go_crud
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_general_ci;
USE go_crud;
这里有个小经验:建库的时候把排序规则也一起定好。utf8mb4 只解决字符存储问题,排序规则决定字符串比较的行为,比如邮箱查询是否区分大小写。一般项目用 utf8mb4_general_ci 就够了,追求更精确的 Unicode 排序可以选 utf8mb4_unicode_ci,性能差异在这个量级几乎感觉不到。
2.2 驱动引入与连接串配置
Go 语言的 database/sql 只是一层抽象接口,真正和 MySQL 通信的是驱动。最常用的是 github.com/go-sql-driver/mysql,这个驱动质量很高,也是官方文档推荐的。安装命令:
bash复制go get -u github.com/go-sql-driver/mysql
引入方式有个特点,必须是匿名导入:
go复制import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
那个空的 _ 就是为了让驱动在 init() 里把自己注册到 database/sql,这样 sql.Open("mysql", dsn) 才能识别。如果你忘了这行匿名导入,运行时不会报编译错误,但一执行就会看到 panic: sql: unknown driver "mysql",这个我在第 4 节还会专门提。
DSN(Data Source Name)是连接串的核心,格式如下:
go复制dsn := "root:你自己的密码@tcp(127.0.0.1:3306)/go_crud?charset=utf8mb4&parseTime=True&loc=Local"
参数拆开看:
root:密码:用户名和密码。@tcp(127.0.0.1:3306):网络协议和地址,注意是tcp,不是unix,远程连接也同样写法。/go_crud:要连的数据库名。charset=utf8mb4:客户端字符集,必须和库一致。parseTime=True:这个很重要,不加的话 MySQL 的DATETIME类型扫描到 Go 结构体时会变成[]byte或string,而不是time.Time,你后面日期处理会非常痛苦。loc=Local:时间使用本地时区,不加默认按 UTC 处理,容易出现“时间差了 8 小时”的诡异问题。
我一般会把 DSN 放进环境变量或者配置文件里,而不是硬编码在代码中。教学项目图省事可以写死,但心里要清楚:生产环境密码绝不允许出现在源码仓库里。
2.3 sql.DB 的初始化逻辑:你拿到的是一个连接池
很多新手第一次写连接逻辑时会有一个误解:sql.Open 返回的 *sql.DB 是不是代表“一个数据库连接”?不是的,它其实是一个连接池的句柄。你在代码里只创建一个 sql.DB 对象,但底层可以同时维护多个网络连接,供并发请求复用。
sql.Open 本身也不会真的去连接数据库,它只是做校验和结构体初始化,真正的连接建立发生在第一次查询时。所以初始化代码里建议紧跟一个 Ping,确认数据库到底通不通:
go复制db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("open db failed: %v", err)
}
if err := db.Ping(); err != nil {
log.Fatalf("ping db failed: %v", err)
}
这里要注意:sql.Open 返回的 err 只代表 DSN 格式是否合法,不代表连接成功。只有 Ping 成功,才说明你的账号、密码、网络、权限都没问题。
*sql.DB 是并发安全的,整个程序生命周期里保持一个全局实例就够了,不要每次操作都重新 Open。频繁创建连接是非常典型的性能反模式,既浪费握手时间,又会让数据库端堆积大量 TIME_WAIT 的连接。
2.4 连接池参数怎么调,新手最容易忽略
连接池不是越大越好,也不是用默认值就万事大吉。database/sql 提供了三个最常用的配置方法:
go复制db.SetMaxOpenConns(50) // 最大同时打开的连接数
db.SetMaxIdleConns(10) // 最大空闲连接数
db.SetConnMaxLifetime(30 * time.Minute) // 连接最长存活时间
我见过很多入门项目不配置任何参数,默认情况下 MaxOpenConns 是无限大,MaxIdleConns 默认也有一个保底值。等到某个高峰期数据库瞬间被打满,或者线上出现 connection pool exhausted,再回头调参就晚了。
三个参数怎么定:
MaxOpenConns需要参考你的数据库性能。MySQL 默认最大连接数一般 151,你的应用实例多,就得算总账。如果两个实例各开 100,已经超过数据库上限。一般保守一点,单实例 50-100 是个常见区间。MaxIdleConns就是“保留多少个空闲连接随时待命”。设太小时并发一上来就得不停建连,延迟会高;设太大,数据库缓存和内存就被没实际工作的连接白白占着。常见比值大约是 MaxOpenConns 的 1/5 到 1/10。ConnMaxLifetime防止连接长时间不被回收。MySQL 的wait_timeout默认 8 小时,如果某个连接空闲太久被服务端断开,客户端还不知道,下次使用这条“死连接”就会报错。让客户端定期做连接回收,能规避这类问题。
这三项配置在 Day 4 阶段未必能感受到差异,但建议你一开始就把它们写进初始化函数,后面项目上量不用返工。
3. CRUD 五件套实操:建表、Create、Read、Update、Delete
3.1 建表设计与结构体定义
先建一张最简单的用户表,字段刻意控制在业务范围内,方便把注意力集中在增删改查上:
sql复制CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '',
`email` VARCHAR(128) NOT NULL DEFAULT '',
`age` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
对应的 Go 结构体:
go复制type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
这个结构体我们会反复用来承载查询和插入的数据。有一点需要提前说:created_at 和 updated_at 这类由数据库自动维护的字段,插入时不需要手动赋值,MySQL 会自动填当前时间;但查询时我们一定要 SELECT 出来,因为接口只要返回数据,前端几乎都会要时间。
为了代码整洁,我习惯把数据库操作封装在一个 UserStore 结构体里,它持有连接池句柄:
go复制type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
后面所有 CRUD 方法都挂在这个 UserStore 上。这样 handler 层拿到的就是一个只包含 CreateUser、GetUser、ListUsers、UpdateUser、DeleteUser 方法的对象,完全不感知 SQL。
3.2 Create:插入数据并拿回自增 ID
插入操作使用 ExecContext,它适用于不需要返回结果集的情况,比如 INSERT、UPDATE、DELETE。示例:
go复制func (s *UserStore) Create(ctx context.Context, u User) (int64, error) {
result, err := s.db.ExecContext(ctx,
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
u.Name, u.Email, u.Age,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
这里的操作可以拆成三层理解:
ExecContext返回一个sql.Result,它包含LastInsertId和RowsAffected两个方法。自增主键场景下,LastInsertId能拿到数据库生成的新 ID,之后可以直接拿来查详情,或者回传给前端。- 参数里的
?是 MySQL 驱动里的占位符。直接把变量拼进 SQL 字符串是绝对禁止的,有 SQL 注入风险。占位符的作用是先让数据库编译 SQL,再绑定参数执行,用户输入永远只是数据,永远不会变成 SQL 命令的一部分。 - 我把
ctx作为第一个参数传下去,这是 Go 标准库推荐的用法。上层请求超时或取消时,可以用 context 终止底层数据库操作,避免 goroutine 无意义地空等。
LastInsertId 的返回值是 int64,正好能塞回 User.ID。如果表没有自增主键,这个方法返回 0,那是正常的。
3.3 Read:单条查询和列表查询的差异点
查询是 CRUD 里最常用的,也是新手最容易写错的地方。先看单条查询,用 QueryRowContext:
go复制func (s *UserStore) GetByID(ctx context.Context, id int64) (User, error) {
var u User
err := s.db.QueryRowContext(ctx,
"SELECT id, name, email, age, created_at, updated_at FROM users WHERE id = ?",
id,
).Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return User{}, err
}
return u, nil
}
注意两个关键点。
第一,SELECT 的字段顺序必须和 Scan 的参数顺序一一对应。Scan 是按位置把数据库返回的每一列扫进对应变量里的,一旦字段顺序错乱,轻则扫描到错误的值,重则报 Scan error on column index,这是个非常常见的低级错误。
第二,QueryRowContext 最多只返回一行,所以不需要 rows.Close,但这也意味着如果没有查到记录,它会返回 sql.ErrNoRows,这个错误需要单独处理,不能当作普通错误直接打到日志里。具体怎么处理,放到第 4 节讲。
列表查询用 QueryContext,它会返回一个 *sql.Rows 结果集,需要循环读取:
go复制func (s *UserStore) List(ctx context.Context, limit, offset int) ([]User, error) {
rows, err := s.db.QueryContext(ctx,
"SELECT id, name, email, age, created_at, updated_at FROM users ORDER BY id DESC LIMIT ? OFFSET ?",
limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
这里有两处是我踩过坑后特别强调的:
defer rows.Close()必须在检查err之后立刻执行。很多人会忘记 Close,或者把它放在函数末尾,但这会让连接一直被占用,长时间运行后连接池被耗尽。- 循环结束后要检查
rows.Err()。Next()不断遍历时,底层可能因为网络中断、SQL 执行错误等原因提前结束,如果只在循环里处理 Scan 错误,循环外的这个错误很容易被忽略。
我把 make([]User, 0) 而不是 var users []User,是为了保证接口返回的是空数组 [],而不是 null。前端处理 JSON 时,空数组比 null 友好得多,这个细节很影响联调体验。
3.4 Update:带条件更新与影响行数
更新的核心不是 SQL 难写,而是“更新了几行”这个信息我们经常需要知道。看代码:
go复制func (s *UserStore) Update(ctx context.Context, id int64, name string, age int) error {
result, err := s.db.ExecContext(ctx,
"UPDATE users SET name = ?, age = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
name, age, id,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrUserNotFound
}
return nil
}
虽然数据库有 ON UPDATE CURRENT_TIMESTAMP 自动维护 updated_at,但在 UPDATE 语句里显式写一次 updated_at = CURRENT_TIMESTAMP 并不会出错,反而能保证即使未来 MySQL 版本行为有变化,咱们的更新逻辑依然符合预期。
RowsAffected() 返回的是本次 UPDATE 语句实际影响的行数。利用这个返回值,我可以判断“要更新的用户是否存在”:如果 ID 不存在,影响行数就是 0,可以直接返回一个业务错误 ErrUserNotFound,不用再额外写一条 SELECT。
还有一种情况要注意:UPDATE 时如果字段内容本来就和新值一样,MySQL 默认会返回 0 行受影响。比如把 age 从 18 改成 18,RowsAffected 可能是 0,即使记录是存在的。所以不要只凭 affected == 0 就断定记录不存在,如果你需要区分“记录不存在”和“值没变化”,可以换一种做法:先用 SELECT 判断记录是否存在,再 UPDATE,或者用 WHERE 条件加上记录存在性的断言。入门阶段,我的建议是先接受这个语义,心里有数即可。
3.5 Delete:常规删除与逻辑删除的取舍
删除是 CRUD 里最“危险”的操作。从技术上讲,它和 UPDATE 一样,用 ExecContext:
go复制func (s *UserStore) Delete(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM users WHERE id = ?",
id,
)
return err
}
物理删除就是直接 DELETE,记录彻底没了。这个操作简洁高效,但有个很大的副作用:如果你日后需要做数据统计分析、追溯操作记录,或者只是不小心删错了,数据就再也找不回来了。
所以现在很多后端项目,尤其是用户、订单这类核心数据,都采用逻辑删除:加一个 deleted_at 字段,删除时不是真的 DELETE,而是把这个字段更新为当前时间。查询时统一带上 WHERE deleted_at IS NULL 过滤。逻辑删除的代价是每次查询都要记得过滤,忘一次,脏数据就漏出来了。
Day 4 阶段怎么选?我的建议是:个人练习项目可以物理删除,图省事;真实项目里,用户表这类敏感数据用逻辑删除更稳妥。把这条记在心里,将来面试被问到“逻辑删除和物理删除的区别”时,你就能讲出实际场景而不是背书。
3.6 事务:把多个写操作绑成同一个原子操作
CRUD 做到后面一定会遇到“要么都成功,要么都失败”的场景。比如注册一个用户,如果你还要给他初始化一张购物车表,两步 SQL 之间任何一步失败,数据库都会处于半完成状态,这就必须用事务了。
Go 里的事务写法很固定,先 BeginTx,然后所有操作都用 tx 而不是 db,最后 Commit:
go复制func (s *UserStore) CreateUserWithProfile(ctx context.Context, u User, profile string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
// 这里用 defer 做兜底:如果最终没有 Commit,就回滚
defer tx.Rollback()
result, err := tx.ExecContext(ctx,
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
u.Name, u.Email, u.Age,
)
if err != nil {
return err
}
userID, err := result.LastInsertId()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx,
"INSERT INTO user_profiles (user_id, profile) VALUES (?, ?)",
userID, profile,
)
if err != nil {
return err
}
// 所有操作都成功,才提交
return tx.Commit()
}
这个模式里的 defer tx.Rollback() 是精华。它保证事务在任何返回路径上都能被清理:如果中途出错了,Rollback 会释放连接;如果 Commit 成功了,Rollback 再调用也不会报错,只是变成一个无操作。这样你就不用绞尽脑汁在每个错误分支手动回滚。
事务本质上占用的是一个连接,而不是连接池里的自由连接。因此事务过程不要夹杂慢查询、网络等待,事务时间越长,连接池被占用的就越久,并发一高,其他人就拿不到连接了。
4. 常见问题与排查技巧实录
4.1 连接类问题的排查套路
先给一个速查表,这几类错误我几乎每周都能在论坛或群里看到:
| 错误表现 | 可能原因 | 排查方向 |
|---|---|---|
panic: sql: unknown driver "mysql" |
驱动未注册 | 检查是否匿名导入 _ "github.com/go-sql-driver/mysql" |
dial tcp 127.0.0.1:3306: connect: connection refused |
数据库没启动、端口不对、防火墙拦截 | 检查 MySQL 进程、端口监听、DSN 地址 |
Access denied for user 'root'@'localhost' |
用户名密码错误或授权不足 | 检查 DSN 账号密码,确认远程/本地访问权限 |
Error 1049: Unknown database |
数据库名拼错 | 确认 go_crud 库已创建 |
invalid connection |
连接被服务端断开,客户端还在用 | 设置 ConnMaxLifetime,让连接定期回收 |
连接类问题有个通用排查法:先用命令行工具直接测数据库能不能连上。比如 mysql -uroot -p -h127.0.0.1 -P3306,如果命令行都连不上,那问题肯定不在 Go 代码,而在 MySQL 服务或网络本身。这一步能把问题范围缩小一半。
还有一次我遇到一个奇怪现象:项目跑了几小时后突然报 invalid connection,重启就好了,过几小时又复发。后来发现是 MySQL 的 wait_timeout 把空闲连接断掉了,客户端还傻傻地复用这条连接。解决方案就是 SetConnMaxLifetime,让客户端在连接超时之前主动换新连。这个坑很典型,遇到稀奇古怪的连接问题,先想想连接池配置。
4.2 sql.ErrNoRows 与空值扫描
sql.ErrNoRows 是新手最容易处理错的一个错误。它在使用 QueryRowContext 查询不到数据时返回。很多人第一次遇到时,会直接把它当作普通错误打日志,然后把 stack 发到群里问“为什么查不到就报错”。
正确做法是用 errors.Is 判断:
go复制user, err := store.GetByID(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
// 走业务上的“未找到”逻辑,比如返回 404
return nil, ErrUserNotFound
}
if err != nil {
return nil, err
}
注意不能用 err == sql.ErrNoRows 直接比较,因为 driver 可能会对错误做包装。Go 1.13 之后的 errors.Is 就是专门为这种场景设计的,能穿透包装层做判断。这个习惯,从入门第一天就要养成。
另一个和“空”相关的坑是 NULL 值。如果表里某个字段允许 NULL,查询结果扫描到 Go 的普通变量上就会报错,提示 converting NULL to string is unsupported。这时需要引入 sql.NullString、sql.NullInt64、sql.NullTime 这些包装类型:
go复制var nickname sql.NullString
err = row.Scan(&nickname)
// nickname.Valid 为 false 表示数据库中是 NULL
不过从设计角度看,我建表时尽量把字段都设成 NOT NULL DEFAULT 某个值,从源头上减少 NULL 的出现。能用默认值表达的,就别让它空着,这样后端代码能少写很多判断。
4.3 连接泄漏和资源释放
连接泄漏是 Go 后端另一个高频问题,症状是:程序运行越久越慢,最终报错 sql: database connection pool exhausted,但数据库本身负载很低,CPU 内存都正常。
问题几乎都出在资源释放上。最常见的三个场景:
- 查询后忘了
rows.Close(),Rows底层占用的连接一直不归还。 - 明明只要一条记录,却用
QueryContext而不Close,连接被一直占着。 - 事务里
Commit或Rollback漏调用,事务持有的连接永久挂起。
排查方法其实不难:把连接池上限调小(比如 5),然后用压测工具打一点并发请求,观察是否很快报连接耗尽。如果复现了,再用 goroutine 堆栈分析,看到卡在 database/sql.(*Rows).Next 或者 database/sql.(*Tx).awaitDone 基本就能定位。
我的习惯是写完每个查询函数都自查三遍:
- 是不是用了
defer rows.Close()? - 是不是在错误分支上也保证能走到 Close?
- 事务函数是否覆盖了
defer tx.Rollback()?
这三条做到位,连接泄漏基本和你无缘。
4.4 我踩过的占位符和参数坑
最后分享几个占位符相关的坑,这些细节文档里写得很简单,但实际踩到才会印象深刻。
MySQL 的占位符是 ?,PostgreSQL 是 $1、$2,SQLite 也是 ?。所以同一个 SQL 模板在这三个数据库之间迁移时,参数占位符必须改,别指望无缝替换。我在一个项目里从 SQLite 切到 PostgreSQL 时,第一轮报错全是因为 $1 没改回来。
LIMIT ? OFFSET ? 里的参数也是用占位符,不能直接拼数字,但要注意 MySQL 驱动默认情况下 LIMIT 后的参数如果类型不对,会出现奇怪的报错。Go 里传入 int 类型一般没问题,但如果你传入字符串,比如从 URL query 解析出来的值忘记 strconv.Atoi,数据库端就报语法错误。所以参数类型要提前转换好。
还有一种隐蔽的错误:一次传多个参数时,顺序搞反。比如 UPDATE users SET name = ? WHERE id = ?,传入的顺序必须是 name 在前、id 在后,和 SQL 模板里的 ? 出现顺序对应。这看起来简单,实际代码改多了,字段位置一挪,特别容易错。我的经验是写 UPDATE 语句时,先写 SET 部分的所有 ?,再写 WHERE 的 ?,参数列表也同样先放 SET 的值,再放 WHERE 的值,结构上保持一一对应,然后用注释标明每个位置的含义,能省不少排查时间。
还有一次我在生产环境排查过一个诡异问题:某个函数偶尔报 sql: database is closed,但服务刚启动时完全正常。后来发现是有个协程误调用了 db.Close(),导致整个连接池被关闭,所有后续查询全部失败。生产环境千万别随便调 Close,*sql.DB 设计出来就是给你长时期共享的,关闭它的时机只应该是应用退出。
个人经验里还有一个我反复强调的原则:写 SQL 时一律使用占位符,绝不拼字符串。这不仅是安全问题,还是可维护性问题。项目里如果哪天要求支持某类复杂动态查询,拼 SQL 的方式会迅速变成一场灾难,而占位符方式始终留着一个干净可控的边界。
最后再分享一个很小的技巧:开发环境可以把 db.SetMaxOpenConns(1) 临时限制一下,这能帮你快速暴露连接泄漏问题。如果并发请求数一高就报连接耗尽,说明你代码里肯定有连接没释放。调回正常值之前,把每个查询函数过一遍资源释放,后面的坑会少踩很多。
