今天进入Day 4,总算到了后端绕不开的核心话题——数据层。前面三天把环境、HTTP服务、路由这些地基打好了,但一个只会在内存里读写数据的后端,跑不了几天就得重构。今天这篇,我们全力解决“数据怎么落库、怎么查出来、怎么改回去”的问题,主角就是Go标准库里的database/sql,以及基于它实现的增删改查(CRUD)。
database/sql是Go官方提供的数据库操作统一入口,它本身不是某个具体数据库的驱动,而是一套标准接口。你只需要按它的规则写代码,再配合对应的驱动(我们用的是MySQL驱动),就能完成建表后的所有读写操作。这篇文章适合刚入门Go后端、已经会写基础HTTP接口、但对数据库操作还比较陌生的同学。看完之后,你能独立写出一套完整的用户表增删改查接口,也知道连接池、事务、防SQL注入这些“高级话题”到底是怎么回事。
1. 数据层入门前:先搞清楚它到底在管什么
1.1 数据层不是“连上数据库”,而是“数据的守门人”
很多新手容易有个误解,觉得数据层就是把数据库连上,然后写几句SQL,完事。其实数据层远不止“连接”这一件事,它是整条请求链路里最靠近存储的关卡。
先说清楚一个请求在后端大致走的路:
code复制HTTP Handler → Service/业务逻辑 → 数据层(Data Access) → 数据库
前端发来一个“创建用户”的请求,Handler负责解析参数和校验格式,Service负责处理业务规则(比如密码加密、用户名是否重复),真正跟数据库打交道的是数据层。数据层要做的事情包括:构造SQL语句、绑定参数、执行查询、把结果映射成Go结构体、处理查询过程中的异常。
我用一个日常类比帮大家理解:如果你把数据库比作一个巨大的仓库,那么数据层就是仓库门口的“管理员”。管理员负责登记谁要拿什么、拿多少、拿完放回哪里。它不关心货物到了前台怎么摆(那是业务层的事),但所有进出仓库的动作都必须在它这里走一遍。没有这层管理,代码里到处直接写SQL,今天一个写法、明天一个写法,后期改表结构、加缓存、切数据库,全都变成灾难。
这正好解释了为什么很多Go项目即使很小,也会单独把数据访问抽出来。Day 4我们不搞复杂分层,但至少要在脑子里有这个概念:数据层是独立的、被业务代码调用的、统一管理数据库会话的一层。
1.2 为什么不直接上GORM?标准库的价值不可替代
市面上Go的ORM库不少,GORM是最出名的一个。很多教程会直接教GORM,天天User.Find(1)、db.Create(&user),写起来确实爽。那为什么我建议Day 4先老老实实把database/sql过一遍?
先说结论:不是GORM不好,而是新手直接学ORM,很容易变成“只会调用API,不懂底层SQL”。比如GORM的db.Where("email = ?", email).First(&user),底层生成的SQL是什么?如果查询条件拼错了,GORM报的错提示的是它自己封装后的错误,你能不能一眼看出是SQL语法问题还是参数类型问题?我见过不少同学,在GORM里遇到“record not found”就懵了,不知道这是数据库查不到数据还是连接断了。根源就是不懂database/sql层的错误模型。
再往深一层说,ORM解决的痛点是“对象-关系映射”,但Go的标准库database/sql已经帮你处理了连接管理、预编译、结果扫描这些最繁琐的部分,剩下的其实就那么几个方法:Exec、Query、QueryRow。这个学习曲线比直接跳进GORM要平滑得多。等你把database/sql的CRUD写顺了,再回头看GORM的文档,一眼就能看懂它封装了什么、省了什么、哪些地方可能埋了坑。
我用个表格把两者差异列出来:
| 维度 | database/sql | GORM |
|---|---|---|
| 上手成本 | 需要自己写SQL,但模式固定 | 方法调用简单,但概念多 |
| SQL可控性 | 完全可控,SQL写什么就是什么 | 通过链式调用生成,复杂SQL需要Raw |
| 错误模型 | 直观,sql.ErrNoRows等清晰 | 包装后的错误,需熟悉ErrRecordNotFound |
| 性能开销 | 最直接,几乎无额外损耗 | 有反射和中间层开销 |
| 适用场景 | 一切需要精细控制SQL的场景 | 快速CRUD、模型关系简单、不太在意底层 |
当然,项目做大了之后,直接写SQL的重复劳动会增多,这时候按需引入GORM或者sqlc这类工具是合理的。但那是“选型”的问题,不是“学习路径”的问题。先把标准库吃透,后面不管换什么工具,你都知道它底层在干什么,不会被框架绑死。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手准备:从建库建表到连接池调优
2.1 建库建表:一个用户表就够了
开始写代码之前,先去本地把数据库准备好。我用的是MySQL,如果你用的是PostgreSQL或者SQLite,思路完全一致,只是驱动和占位符略有差别(PostgreSQL用$1、$2,MySQL和SQLite用?)。
先建一个库,再建一张用户表:
sql复制CREATE DATABASE IF NOT EXISTS demo
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE demo;
CREATE TABLE IF NOT EXISTS users (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
name VARCHAR(64) NOT NULL COMMENT '用户名',
email VARCHAR(128) NOT NULL COMMENT '邮箱',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
这张表结构很简单,但有几个细节值得说道说道。
第一,字符集用utf8mb4而不是utf8。MySQL的utf8其实是utf8mb3,只能存基本多语言平面字符,遇到emoji或者某些生僻字就存不进去、查不出来。utf8mb4才是完整的UTF-8,这是老生常谈,但真的有人踩坑。
第二,id用BIGINT自增,主键尽量别用VARCHAR。字符串做主键在InnoDB里面会导致聚簇索引存储效率偏低,尤其数据量上来之后,插入顺序和排序都会受影响。用自增BIGINT是大多数场景下稳妥的选择。
第三,created_at和updated_at这两个字段,看起来是“顺带加的”,但实际写接口时几乎一定会用到。比如列表排序按创建时间倒序、更新用户时要确认updated_at有没有变化。建表时顺手把默认值写好,比后面手工维护省事得多。
2.2 空导入不是注释:驱动注册到底是怎么回事
表建好了,现在要写Go代码。第一步是引入依赖。
bash复制go get github.com/go-sql-driver/mysql
然后新建一个db.go文件,写一个初始化数据层的函数:
go复制package db
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
)
func Open(cfg Config) (*sql.DB, error) {
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User,
cfg.Password,
cfg.Host,
cfg.Port,
cfg.DBName,
)
conn, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(20)
conn.SetConnMaxLifetime(30 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.PingContext(ctx); err != nil {
return nil, err
}
return conn, nil
}
很多新手看到import块里那个下划线就懵了:“_”不是用来忽略返回值的吗?怎么放在import里?这里要解释清楚。
go复制import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
下划线导入的意思是“只执行这个包的init函数,但我并不直接使用它的导出符号”。go-sql-driver/mysql这个包在init函数里会自动调用sql.Register("mysql", &MySQLDriver{}),把自己注册到database/sql的驱动列表里。如果你不空导入它,sql.Open("mysql", ...)的时候database/sql就会提示“unknown driver 'mysql'”,因为驱动根本没注册进来。
这个设计是Go数据库生态的一个特点,你换数据库只需要改驱动 import 和 DSN 字符串,业务代码可以做到基本不动。
2.3 连接池三个参数:调好了是药,调差了是毒
连接池,是database/sql里最值得花时间理解的机制。sql.Open之后,Go会替你维护一组数据库连接,谁用谁借,用完还回池里。这比每次操作都新建连接、用完马上销毁要高效得多。
有三个参数直接影响连接池行为:
- SetMaxOpenConns:连接池最多能打开的连接数。超出这个数之后,新的数据库请求会排队等别人还连接。
- SetMaxIdleConns:连接池中最多保留多少空闲连接。空闲连接太多会占用数据库端资源,太少则高并发时来不及建连。
- SetConnMaxLifetime:连接的最长存活时间。超过这个时间的连接会被丢弃,下次使用时重新建立。
我见过最典型的翻车案例是这样的:有人把SetMaxOpenConns设成20,但接口代码里忘记关闭rows(rows.Close()没调用),结果每个查询都“借走”一个连接不还,连接池很快被占满,后面的请求全部卡在等待连接上,表现就是接口越来越慢,直到超时。把连接池参数调大解决不了这个问题,反而会把数据库打崩。正确的做法是先排查代码里有没有连接泄漏,再谈参数调优。
还有一点要注意:SetMaxIdleConns如果设得比SetMaxOpenConns还大,实际生效的上限会被强制限制为SetMaxOpenConns的值,这个不必纠结,但别反向调。
参数设置多少合理?没有统一答案,我的经验是:单机小项目50/20/30分钟起步,压测后观察数据库端的连接数和响应时间再微调。不要一上来就设500,本地开发环境根本扛不住这种资源占用。
3. 核心 CRUD:每个操作都有讲究
3.1 插入数据:Exec、占位符与LastInsertId
建好表、连上池子,我们开始写第一个真实操作:插入用户。
先说一个反面教材。很多初级程序员会写出这样的SQL拼接:
go复制name := "' OR '1'='1" // 恶意输入
query := "INSERT INTO users(name, email) VALUES('" + name + "', 'xxx')"
这样的结果是SQL语句变成了:
sql复制INSERT INTO users(name, email) VALUES('' OR '1'='1', 'xxx')
且不说逻辑对不对,一旦用户输入里包含单引号、分号、注释符,整个SQL都可能被改写。这就是传说中的SQL注入。
database/sql的标准做法是用占位符?,让数据库驱动帮我们把参数安全地绑定进去:
go复制func CreateUser(ctx context.Context, u User) (int64, error) {
result, err := db.ExecContext(
ctx,
"INSERT INTO users(name, email) VALUES(?, ?)",
u.Name,
u.Email,
)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
if err != nil {
return 0, err
}
return id, nil
}
这里有几个细节值得展开。
第一,ExecContext返回的sql.Result里,LastInsertId()能拿到自增主键的值。注意,这个能力依赖驱动,MySQL支持,PostgreSQL的写法是RETURNING id,需要用到QueryRowContext而不是ExecContext。也就是说,CRUD的写法和你选的数据库是强相关的,这也是为什么我说要了解底层而不是无脑学API。
第二,占位符?不是简单地“把参数转成字符串再拼进去”,而是走预编译(prepare)或者协议层的参数绑定。数据库会先解析SQL结构,再把参数作为数据传进去。这样一来,即使用户输入里包含“' OR '1'='1”这种字符串,它也只被当成一个普通文本参数,不会被当成SQL代码执行。这是防注入的第一道闸门,必须形成肌肉记忆。
第三,注意参数顺序和数量一定要和SQL里的?一一对应。OrderBy这种动态拼接的场景不能直接用占位符拼列名和排序方向,因为列名是结构,不是数据,占位符绑定不了。遇到这种情况,要么用白名单校验,要么用反引号包列名后慎重拼接。
3.2 查询数据:QueryRow、Query 与 rows.Close 的玄机
插入搞定,接着是查询。查询分两种:只查一条,和查多条。
查单条记录:QueryRowContext
go复制func GetUserByID(ctx context.Context, id int64) (*User, error) {
var u User
err := db.QueryRowContext(
ctx,
"SELECT id, name, email, created_at FROM users WHERE id = ?",
id,
).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, err
}
return nil, err
}
return &u, nil
}
QueryRow只查询一行,然后直接把结果扫描到变量里。这里最需要关注的是sql.ErrNoRows——它是“没查到数据”信号。新手最容易犯的错是拿到这个错误后不判断,直接返回500,导致前端收到“服务器内部错误”,但其实只是因为id不存在。正确的做法是先判断是否ErrNoRows,再决定返回404还是别的业务错误。
Scan方法很挑剔:它要求SELECT出来的列数量和类型,跟传入变量的数量、类型完全匹配。比如created_at在MySQL里是DATETIME,默认会返回time.Time,但前提是DSN里设置了parseTime=true,否则得到的是[]byte,Scan到time.Time就会报错。这也是为什么我在连接配置里特意写了parseTime=true。
查多条记录:QueryContext + Next 循环
go复制func ListUsers(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, "SELECT id, name, email, created_at FROM users ORDER BY id DESC")
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
这段代码最容易被忽略的就是defer rows.Close()。很多教程让你“记得Close”,但没讲清楚为什么。rows不是普通的查询结果集,它底层持有数据库连接。在rows遍历结束或主动Close之前,这个连接一直“借”在函数手里。如果你在循环里提前return,但没有Close,这个连接就回不到池子里,久而久之连接池就被消耗殆尽。defer的好处是函数退出时无论走哪个分支都会执行Close。
不过要注意defer有个小坑:如果你的函数后面还要做耗时操作,比如把结果发给另一个服务,连接会在defer触发前一直占用。所以正确的姿势是“尽快遍历、尽快Close”。如果数据量大,分批读取比一次性全载到内存更稳,但这属于查询优化的话题了。
还有,别忘了遍历结束后检查rows.Err()。因为Next()走到末尾返回false时,不代表一定成功结束了,还可能是网络中断、MySQL服务端出错。只检查Scan的错误而不检查rows.Err(),某些隐蔽错误会被悄悄吞掉。
3.3 更新与删除:影响行数比函数返回值更重要
更新和删除在写法上都走Exec,但它们的关键点在“怎么知道自己有没有更新成功”。
go复制func UpdateUserName(ctx context.Context, id int64, newName string) error {
result, err := db.ExecContext(
ctx,
"UPDATE users SET name = ? WHERE id = ?",
newName,
id,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrUserNotFound
}
return nil
}
这段代码里,RowsAffected()返回的是这条UPDATE实际影响的行数。如果id不存在,或者这一行本来就叫这个名字,影响行数就是0。把握住这个信号,才能对外返回“用户不存在”这种语义明确的错误,而不是笼统地报成功。
删除也是一样的套路:
go复制func DeleteUser(ctx context.Context, id int64) error {
result, err := db.ExecContext(
ctx,
"DELETE FROM users WHERE id = ?",
id,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrUserNotFound
}
return nil
}
这里想提醒一点:数据库里执行UPDATE和DELETE的时候,WHERE条件一定不能省,或者至少要在业务上确保“不带WHERE是合法的”。很多安全事故就是手滑执行了UPDATE users SET name = 'x',结果全表都被改了。生产环境最好加上SQL审计或者在数据库层限制无WHERE的UPDATE/DELETE。Go代码层面能做的,是写一个公共方法,统一校验WHERE条件存在,但这属于工程规范的范畴,今天先点个题。
另外,UPDATE和DELETE的“先查后改”也是一个良好的防御性编程习惯。比如更新用户信息之前,可以先GetUserByID确认用户存在,再执行UPDATE。虽然多了一次查询,但换来的是更清晰的控制流和更友好的错误提示。
4. 事务:一堆操作要么全成,要么全不成
4.1 转账场景:没有事务会怎样
CRUD学完了,来看一个必须用事务的场景:转账。
假设A要给B转100块钱。数据层要做的事情大致是:A的balance减100,B的balance加100。如果这两条UPDATE中间某个环节出了错——比如B的账户不存在、数据库连接断了——会发生什么?A的钱已经扣了,B的钱没到账。这种事故纯粹是数据一致性问题,任何业务都接受不了。
事务就是来解决这个问题的。事务把一组数据库操作包成一个原子单元:要么全部成功提交(COMMIT),要么全部回滚(ROLLBACK)。像转账这种场景,如果把“A扣钱”和“B加钱”放在同一个事务里,任何一步失败都会把前面的操作撤销,数据库回到事务开始前的状态。
这个道理,用生活类比特别好懂:点外卖,你下了单、付了款、商家备了餐,这三步任何一步失败,整个订单要么被取消,要么被标记为异常,而不是“钱已经付了但商家根本没收到单”。事务就是那套“要么全成、要么当什么都没发生”的机制。
4.2 事务的写法:Begin、Commit、Rollback与连接的独占性
Go标准库的事务操作非常直白:
go复制func Transfer(ctx context.Context, fromID, toID int64, amount float64) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // 如果commit了,rollback是无害操作
if _, err := tx.ExecContext(
ctx,
"UPDATE users SET balance = balance - ? WHERE id = ?",
amount, fromID,
); err != nil {
return err
}
if _, err := tx.ExecContext(
ctx,
"UPDATE users SET balance = balance + ? WHERE id = ?",
amount, toID,
); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
重点在于那个defer tx.Rollback()。很多人第一反应是“这不是跟Commit重复了吗?”其实不冲突。如果中途任何一步出错提前return,defer会执行Rollback,把未提交的操作全部撤销。如果函数正常走到Commit,事务已经提交,再执行Rollback就是一个无害的空操作,返回ErrTxDone而已。这个模式几乎是Go事务代码的标配,能用一行defer解决忘记回滚的问题,省心且安全。
还有一个更深层次的原理需要知道:事务期间的连接是独占的。BeginTx会从连接池里“借”一条连接出来,事务内所有Exec都在这条连接上执行,直到Commit或Rollback后才归还。这意味着你不能在事务里发起一个耗时的HTTP请求等5秒——那5秒里,这条连接被占着,池子里少一条可用连接。如果并发高且每个事务都干这种耗时操作,连接池很快就空了,后面的请求全部排队等连接。
更隐蔽的坑是嵌套事务。标准库没有嵌套事务概念,tx.Exec再开一个tx是不行的。如果你的业务确实需要“一组操作中某一步失败了只回退一部分”,那就要么拆成多个独立事务并用补偿逻辑,要么引入saga模式的框架。新手阶段,先记住“事务要短、要快、里面别做外部I/O”就够了。
5. 避坑指南:我从Go数据层一路踩过来的坑
5.1 问题速查表
写数据层的代码,踩坑几乎是必然的。我把自己这几年见过、踩过的高频问题整理成一张速查表,可以当作参考手册用。
| 现象 | 可能原因 | 排查/解决思路 |
|---|---|---|
| 连接池打满,接口越来越慢 | rows未Close,或事务未提交/回滚 | 检查遍历后是否defer rows.Close();检查所有BeginTx路径是否有Commit或Rollback |
| Scan报错:converting NULL to string is unsupported | 数据库字段为NULL,但扫描目标类型是string | 用sql.NullString、sql.NullInt64等包装类型;或者改表结构避免NULL |
| 查询结果为空时,代码返回500 | 把sql.ErrNoRows当成普通错误直接抛了 | 先errors.Is(err, sql.ErrNoRows)判断,再返回404或业务空结果 |
| 插入数据后LastInsertId为0 | 驱动不支持,或表没有自增主键 | 确认表主键为AUTO_INCREMENT;PostgreSQL改用RETURNING id查询 |
| 中文乱码 | 表字符集或连接字符集不是utf8mb4 | DSN加charset=utf8mb4;建表时指定utf8mb4;检查MySQL默认字符集 |
| 代码里看不出错误,但SQL执行很慢 | 缺少索引,或WHERE条件里用了函数导致索引失效 | EXPLAIN SELECT语句,看是否走全表扫描;对高频过滤字段加索引 |
| 事务里一段逻辑耗时很久 | 事务内做了外部API调用或大查询 | 重构:把外部I/O挪到事务外,事务只保留必要数据库操作 |
| 偶发的invalid connection错误 | 连接空闲时间超过MySQL wait_timeout被服务端杀死 | 用SetConnMaxLifetime小于MySQL wait_timeout;或检查连接池生命周期设置 |
5.2 排查思路与调试技巧
最后分享几个调试技巧,这些都是平时看日志就能派上用场的。
第一,把执行的SQL和参数打出来。不要只在出错时打,正常执行也可以打。我习惯在数据层写一个简单的日志函数,Exec和Query之前把最终SQL和参数格式化打印出来(注意不要打印密码和密钥)。这样前端报错时,我能快速知道当时到底执行了什么SQL、传了什么参数,很多“我以为”的问题一看日志就破案了。
第二,遇到慢SQL,先去数据库里跑EXPLAIN。看是不是全表扫描、有没有用上索引、有没有临时文件排序。Go代码本身很少是慢SQL的根源,问题通常出在表结构设计或索引缺失上。EXPLAIN的输出结果不复杂,学会看type列和key列就够用一阵子了。
第三,确认连接池状态。database/sql在运行时,可以通过sql.DB的Stats()拿到当前打开的连接数、空闲连接数、等待中的连接数。压测时把这些指标打到日志或监控里,能非常直观地判断连接池配置是否合理。一旦看到wait_count一直在涨,基本就是连接不够用或者连接泄漏。
我觉得在数据层这个环节,最核心的能力不是会写某个API,而是能在一堆看似莫名其妙的错误面前,快速定位问题是出在SQL语法、参数类型、连接管理,还是数据库状态。有了这套排查思路,database/sql就算真正入门了。
今天的内容看起来只是简单CRUD,但底层把连接池、SQL注入防御、错误模型、事务边界都过了一遍。这些东西后面学GORM、学sqlc、学分布式事务时,全都会用到。Day 4的任务是把demo跑起来,再自己写完一整套用户增删改查的接口,不要跳步。
