Go 博客教程 · 第 3 课 / 10
第 3 课:MySQL 与 database/sql
前两课的文章躺在内存 slice 里,进程一重启就没了。这一课把它们搬进 MySQL。
① 本课目标
学完能独立写出:连接池 OpenDB(知道每个参数为什么是那个值)、QueryRowContext + sql.ErrNoRows → 领域错误的转换、QueryContext 的四步完整姿势、ExecContext + LastInsertId 回写、跨三表事务。
并能解释:sql.Open 为什么不连数据库、_ "github.com/go-sql-driver/mysql" 那个下划线干了什么、NULL 该扫进什么类型。
② 前置检查
go version # go version go1.25.0 darwin/arm64
mysql -uroot -p123456 -e "select version();" # 8.4.5
cd ~/go-blog && go run ./cmd/server # 第 2 课的服务还能起来
三条都过了再往下。
③ 核心概念
3.1 建库建表
mysql -uroot -p123456 < ~/go-blog/migrations/schema.sql
mysql -uroot -p123456 blog_dev -e "select id, title, slug, summary, status from posts;"
id title slug summary status
1 Hello Go hello-go Go 的第一课 1
2 database/sql 入门 database-sql-101 NULL 1
3 这是一篇草稿 a-draft 还没写完 0
注意 id=2 的 summary 是 NULL——这不是空字符串,是完全不同的东西(见 3.7)。
3.2 posts 表逐字段拆解
建表语句里每个选择都会直接影响你的 Go 代码怎么写。
id BIGINT UNSIGNED AUTO_INCREMENT —— 为什么不是 INT?INT UNSIGNED 上限 42.9 亿,看着够用一辈子。但自增主键消耗的是发号次数不是存活行数:删掉的行不还号,回滚的事务、INSERT ... ON DUPLICATE KEY UPDATE 也照样吃号。多花 4 字节/行,换的是永远不用做「主键类型迁移」这种要停机的手术。UNSIGNED 白送一倍空间。Go 侧对应 int64 而不是 uint64——Result.LastInsertId() 和 JSON 数字都是 int64,要撞上限得先有 9.2×10¹⁸ 行。
DEFAULT CHARACTER SET utf8mb4 —— MySQL 的 utf8 是 utf8mb3 的历史别名,每字符最多 3 字节,装不下任何 4 字节字符(所有 emoji、「𠮷」这类生僻字)。往 utf8 列写 emoji 报 Error 1366: Incorrect string value: '\xF0\x9F\x98\x80'。记住「MySQL 的 utf8 是假货」。
slug VARCHAR(200) + UNIQUE KEY uk_posts_slug —— 唯一索引不是"校验手段",是并发下的最后一道防线。你在 Go 里先 SELECT 查重、再 INSERT,这两步之间别的请求完全可能插进来(TOCTOU 竞态)。推论:第 4 课 service 层的「先查后插、冲突自动加后缀」是为了给友好的 slug,不是为了保证唯一;真撞了还得靠捕获 1062 错误码兜底。两层都要有。
summary VARCHAR(500) NULL —— 可空是有业务含义的:NULL = 「作者没写摘要」,'' = 「作者写了个空摘要」。SQL 里 NULL 语义是「未知」,所以 summary = '' 是 false、summary != '' 也是 false,只有 IS NULL 是 true。这直接决定 Go 侧不能把它扫进 string。
status TINYINT NOT NULL DEFAULT 0 —— 为什么不用 ENUM('draft','published')?
| 方案 | 加新状态 | 存储 | 排序语义 |
|---|---|---|---|
ENUM |
要 ALTER TABLE(一次发布 + DDL 风险) |
1–2 字节 | 按定义顺序不是字面值,容易出意外 |
VARCHAR(16) |
改代码即可 | 每行多 5–9 字节,索引更胖 | 字典序 |
TINYINT |
改代码即可 | 1 字节,索引最紧凑 | 数值序 |
三种都没有约束力(VARCHAR 照样能写进 'drafr'),约束只能靠应用层。既然如此,选最省、最好加的那个。代价是裸看数据库不知道 status=1 是什么,所以三管齐下:建表 COMMENT 写死对照 + Go 侧用第 1 课的 type Status int8 具名常量 + HTTP 层翻译成 "draft"(第 4 课 DTO 做)。
剩下几个字段:
| 字段 | 选择理由 | 对 Go 代码的影响 |
|---|---|---|
content MEDIUMTEXT |
TEXT 上限 64 KB,长 Markdown 轻松超;MEDIUMTEXT 16 MB。超阈值后存到行外,全表扫描不用读 |
普通 string |
view_count INT UNSIGNED NOT NULL DEFAULT 0 |
计数器绝不能可空:NULL + 1 = NULL,一旦有行是 NULL,SET view_count = view_count + 1 对它永远无效且不报错 |
普通 int |
published_at DATETIME NULL |
用 NULL 表达「还没发布」比 '1970-01-01' 哨兵值干净(哨兵值总有一天被当真数据)。DATETIME 而非 TIMESTAMP:后者 32 位、2038 溢出,且随会话 time_zone 隐式转换 |
*time.Time("有没有发布过"是核心业务状态) |
created_at / updated_at 带 DEFAULT CURRENT_TIMESTAMP / ON UPDATE |
让数据库负责时间戳,不管谁写都有值 | INSERT 后必须回读,否则是零值 0001-01-01T00:00:00Z(4.4 节会踩) |
deleted_at DATETIME NULL |
软删除:误删可恢复、外键不断裂、审计留痕 | *time.Time,且每条查询都要带 AND deleted_at IS NULL |
软删除有两个代价必须知道:① 从此每条查询都要带 AND deleted_at IS NULL,漏一处就是数据泄露且不报错;② 删掉的行还占着 uk_posts_slug,软删 hello-go 之后没法再建同 slug 文章(撞 1062)。这是软删除最常见的坑,留作练习 4。
KEY idx_posts_status_published (status, published_at DESC) —— 为什么是这个顺序?
B+ 树索引可以理解成「把这几列按顺序拼成一个串,整体排好序」。由此推出最左前缀原则:只有从最左列开始、连续使用的列才能走索引。我们的主查询是 WHERE status = 1 ORDER BY published_at DESC LIMIT 10:
status是等值条件 → 放最左,一次定位框到status=1那一段;- 这一段内部已经按
published_at DESC排好序 → 顺序读前 10 条,排序是白拿的。
实测(你现在就能跑):
mysql -uroot -p123456 blog_dev -e \
"explain select id,title from posts where deleted_at is null and status=1 order by published_at desc limit 10\G"
type: ref
key: idx_posts_status_published
Extra: Using where ← 没有 Using filesort
把列顺序写反成 (published_at, status) 呢?我用临时表实测:
type: ALL
key: NULL
Extra: Using where; Using filesort
索引完全没被用上——published_at 上没有等值条件,索引第一列够不着,后面的列也就废了。
两点补充:DESC 关键字在 MySQL 8.0 之前会被静默忽略,8.0 起才是真正的降序索引(我们跑 8.4,有效)。不把 deleted_at 加进索引是因为它区分度极低(99.9% 是 NULL),加进去只让每个索引项变胖。
3.3 标准库只定义接口,驱动是第三方
database/sql 包里没有一行 MySQL 代码。它定义的是抽象:连接池怎么管、Query/Exec 长什么样、Scan 怎么把值塞进 Go 变量。真正会说 MySQL 协议的代码在第三方驱动里。
cd ~/go-blog && go get github.com/go-sql-driver/mysql
# go: added github.com/go-sql-driver/mysql v1.10.1
然后是那行让所有人第一次都愣住的 import:
// internal/repository/db.go —— 片段
import (
"database/sql"
_ "github.com/go-sql-driver/mysql" // ← 这个下划线是什么?
)
下划线叫空白标识符导入(blank import),意思是「我导入这个包但一行都不调用它的函数,编译器别报 unused import」。那导入它干什么?为了副作用——包级 init()。驱动包内部有 func init() { sql.Register("mysql", &MySQLDriver{}) }。
init() 是 Go 的特殊函数:包被导入时自动执行一次,不能手动调用,不能有参数和返回值。它把驱动注册进 database/sql 的全局表,键名就是字符串 "mysql"——于是 sql.Open("mysql", dsn) 的第一个参数才能找到东西。验证:fmt.Println(sql.Drivers()) 输出 [mysql]。
漏掉这行会怎样? 编译能过(sql.Open 第一个参数只是普通字符串),运行时报:
sql: unknown driver "mysql" (forgotten import?)
Go 连报错文案都在提醒你。心智模型:database/sql 是插座,驱动是插头,_ import 是把插头插上,"mysql" 是插座上的标签。
3.4 sql.Open 不建立连接,*sql.DB 是连接池
sql.Open 不会连接数据库,只做两件事:查驱动注册表、让驱动解析 DSN。实测(数据库根本没起):
[C] sql.Open(连不通的地址) err=<nil> db != nil: true
[D] Ping() err=dial tcp 127.0.0.1:13306: connect: connection refused
真正的连接发生在第一次执行查询时。所以要在启动阶段就知道通不通,必须显式 db.PingContext(ctx)。sql.Open 唯一会报错的情况是 DSN 格式本身不合法(MySQL 驱动实现了 DriverContext,在 Open 阶段解析 DSN):invalid DSN: missing the slash separating the database name。
第二件同样重要的事:*sql.DB 不是一个连接,是一个连接池,内部并发安全、自己管理多条底层连接。所以整个进程只 Open 一次,把 *sql.DB 传给需要它的组件;绝不要每个请求 sql.Open 一次——那会瞬间打爆 max_connections,每次还要付 TCP + 认证握手。defer db.Close() 只在 main 里写一次。
3.5 DSN 参数逐个解释
root:123456@tcp(127.0.0.1:3306)/blog_dev?charset=utf8mb4&parseTime=True&loc=Local
└user┘└pass┘ └───address────┘ └dbname┘ └──────────── params ────────────┘
| 参数 | 作用 | 不写会怎样 |
|---|---|---|
charset=utf8mb4 |
连接层字符集 | 中文/emoji 可能变 ? 或乱码 |
parseTime=True |
让驱动把 DATETIME 解析成 time.Time |
见下方真实报错 |
loc=Local |
解析出的 time.Time 挂哪个时区 |
默认 UTC,和库里字面值差 8 小时 |
不加 parseTime=True 的实测报错原文:
sql: Scan error on column index 0, name "created_at":
unsupported Scan, storing driver.Value type []uint8 into type *time.Time
翻译:驱动把 DATETIME 当原始字节 []uint8(即 []byte)交给 Scan,而 Scan 不知道怎么把一坨字节塞进 *time.Time。
loc=Local 的含义:MySQL 的 DATETIME 存的是没有时区信息的字面值(2026-09-06 09:25:04)。loc 告诉驱动「把这个字面值当作哪个时区的时间」。写 Local 就和你在 mysql 命令行看到的一致,最不容易出错。
⚠️ 实测到的坑:DATETIME 不带小数秒,MySQL 写入时是四舍五入到秒,不是截断。Go 侧写 10:00:00.700,读回来是 10:00:01.000。所以生成时间戳时要 time.Now().Truncate(time.Second),否则内存里的值和库里的值差 1 秒——第 4 课的"发布幂等"就是被这个坑到的。
3.6 三个查询 API 的分工
| API | 返回 | 用在 | 必须做的事 | 漏了会怎样 |
|---|---|---|---|---|
QueryRowContext |
*sql.Row(无 error) |
确定 0 或 1 行 | Scan 后判 errors.Is(err, sql.ErrNoRows) |
把"没查到"当系统故障,返回 500 而非 404 |
QueryContext |
(*sql.Rows, error) |
多行 | defer rows.Close() 且 循环后 rows.Err() |
连接泄漏;静默丢数据 |
ExecContext |
(sql.Result, error) |
增删改 | 按需 LastInsertId() / RowsAffected() |
拿不到新 ID;把"没改到行"当成功 |
三条容易记混的规则:
QueryRowContext不返回 error,错误延迟到Scan才吐——这是有意设计,让你能写db.QueryRowContext(...).Scan(&x)一行链式调用。rows.Err()是高频 bug。for rows.Next()结束有两种原因:读完了,或中途出错(网络断、连接被 kill、ctx 超时)。两种情况Next()都返回false,唯一区分方式就是循环后查rows.Err()。漏了它,一次网络抖动就变成"查出来只有 3 条",且无任何日志。LastInsertId()只对有自增列的INSERT有意义。实测在UPDATE上调用返回0, nil(不报错),别当有效 ID。
RowsAffected() 的陷阱(实测):
UPDATE posts SET view_count = view_count + 1 WHERE status=1 → RowsAffected=2
UPDATE posts SET status = status WHERE status=1 → RowsAffected=0
RowsAffected == 0 不等于"没这行",也可能是"新值和旧值完全一样,MySQL 没真写"。所以不能拿它单独判 ErrNotFound。
3.7 SQL 注入与占位符
// ❌ 灾难写法
query := fmt.Sprintf("SELECT COUNT(*) FROM posts WHERE slug = '%s'", userInput)
用户传 hello-go' OR '1'='1,拼出来是 ... WHERE slug = 'hello-go' OR '1'='1'。实测对照:
[L] 拼接 SQL → COUNT=5 ← 整张表都被返回了
[M] 占位符 ? → COUNT=0 ← 正确:没有 slug 叫这个
用 ? 传同样的字符串结果是 0,因为值和语句是分开传给 MySQL 的:驱动把 SQL 文本和参数分两部分发送,MySQL 先把语句结构解析定型,再把参数当纯数据填进去。此时 ' 只是普通字符,根本没有机会被解析成语法。这不是"转义得好"。
规则:SQL 里的任何一个值都必须走 ?。唯一不能用 ? 的是表名、列名、ASC/DESC 这类结构,那些必须走白名单:
// 片段:排序列必须走白名单,绝不能来自用户输入
var orderCols = map[string]string{"time": "published_at", "hot": "view_count"}
col, ok := orderCols[r.URL.Query().Get("sort")]
if !ok { col = "published_at" }
Prepare 什么时候真的需要? db.QueryContext(ctx, sql, args...) 内部已经做了 prepare + execute + close,单次查询不需要手动 Prepare。真正需要的只有一个场景:循环里反复执行同一条语句——Prepare 一次、循环里只 Exec,省掉 N-1 次解析往返。本课 CreateWithTags 就是这个场景。
3.8 NULL 处理
Scan 不会帮你把 NULL 变成零值。扫进 string 的真实报错:
sql: Scan error on column index 0, name "summary": converting NULL to string is unsupported
三条路:
| 方案 | 写法 | 判空 | 适合 | 不适合 |
|---|---|---|---|---|
sql.NullString / NullTime / NullInt64 |
var s sql.NullString |
s.Valid |
Scan 的目标(repository 内部局部变量) |
进领域模型:处处要 .Valid,JSON 序列化出 {"String":"x","Valid":true} |
sql.Null[T](Go 1.22+ 泛型) |
var s sql.Null[string] |
s.Valid,取值 s.V |
同上,少记类型名;冷门类型只能用它 | 同上 |
指针 *string / *time.Time |
var s *string |
s == nil |
领域模型里「有/无」本身有业务含义(published_at) |
到处 deref,容易 nil panic |
零值折叠(NULL → "") |
扫进 NullString 再取 .String |
不判 | 「没写」和「写了空」业务上等价(summary) |
需要区分两者时 |
实测后两种都能工作:sql.Null[string] → Valid=false V="";*string 扫 NULL → np == nil 为 true。
本项目的取舍(第 4 课会依赖):summary 走零值折叠(领域模型里就是普通 string);published_at / deleted_at / author_id 走指针(有没有发布、有没有被删是核心业务状态);sql.NullXxx 只作为 Scan 的中转变量存在于 repository 内部,一行都不外泄。
分界线的原则:sql.NullXxx 是数据库的词汇,不该出现在领域模型里。领域模型只该有 Go 的词汇——普通值,或者指针。
3.9 事务:defer tx.Rollback() 惯用法
// 片段:事务的标准骨架
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() // ← 不是要提交吗,怎么先写回滚?
// ... 一堆 tx.ExecContext ...
return tx.Commit()
关键事实:Commit() 之后再 Rollback() 返回 sql.ErrTxDone,什么都不会发生。实测:
Commit 后 Rollback: err=sql: transaction has already been committed or rolled back
errors.Is(err, sql.ErrTxDone) = true
所以这个 defer 的含义是:「不管我从哪一行 return,只要没走到 Commit 就回滚」。成功路径上它是一次无害空操作。
不用它就会写出 if-else 地狱——每个错误分支都得手动 tx.Rollback(); return err,加一个分支就多一处可能漏。
为什么"忘一个"是重罪:没 Commit 也没 Rollback 的事务会一直持有行锁,直到连接归还池子被复用或超时。期间其他请求更新同一行会卡住,表现是"接口偶发超时",极难查。defer tx.Rollback() 把这件事变成结构性保证。
隔离级别一笔带过:BeginTx 第二个参数是 *sql.TxOptions,可指定 sql.LevelReadCommitted 之类;传 nil 用 MySQL 默认的 REPEATABLE READ,本教程全程够用。
3.10 context 在 DB 层的作用
本课每个方法第一个参数都是 ctx context.Context,两个实打实的原因:
- 超时控制——慢查询不会永远挂着,ctx 超时后驱动会给 MySQL 发
KILL QUERY。实测 100ms 超时执行SELECT SLEEP(2):err=context deadline exceeded,errors.Is(err, context.DeadlineExceeded)=true,100 毫秒就返回了。 - 请求取消——用户关掉浏览器 →
net/http取消r.Context()→ 一路传下来 → 数据库停止这次查询。没有 ctx,客户端早走了你还在为它烧 CPU 和连接。
规矩:ctx 永远是第一个参数,永远从 r.Context() 一路往下传,永远不要在中间层塞 context.Background()(那等于剪断取消链条)。
④ 函数逐个精讲
4.1 OpenDB —— 连接池配置全套
// internal/repository/db.go
package repository
import (
"context"
"database/sql"
"fmt"
"time"
// 空白标识符导入:一行都不调用这个包的函数,只要它的 init() 副作用 ——
// init() 里执行 sql.Register("mysql", ...),把驱动注册进 database/sql 的全局表。
// 删掉这行照样能编译,但运行时报 sql: unknown driver "mysql" (forgotten import?)
_ "github.com/go-sql-driver/mysql"
)
// OpenDB 打开数据库连接池并做一次真实连通性校验。
//
// 签名为什么是 (dsn string) (*sql.DB, error):
// - 只收 dsn 字符串不收 Config 结构体 —— 这个函数只负责"连",
// "配置从哪来"(环境变量/配置文件)是 main 的事,不该渗进来。
// - 返回标准库的 *sql.DB 而不是自定义包装类型 —— 包装一层只会逼所有
// 调用方认识你的类型(第 4 课的 "accept interfaces, return structs")。
func OpenDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
// 走到这里只有一种可能:DSN 格式不合法(驱动在 Open 阶段解析了它)。
return nil, fmt.Errorf("打开数据库: %w", err)
}
// 池子里最多同时有多少条连接(使用中 + 空闲)。
// 0 = 不限制,是危险的默认值:并发一高就打爆 MySQL 的 max_connections,
// 报 Error 1040: Too many connections,此时连 DBA 都登不进去救火。
db.SetMaxOpenConns(25)
// 空闲时最多保留多少条不关掉。设成和 MaxOpenConns 相等,
// 避免"用完就关、下次再建"的连接抖动(每次重建要付 TCP 握手 + MySQL 认证往返)。
// 设得比 MaxOpenConns 小是一个经典的隐形性能坑。
db.SetMaxIdleConns(25)
// 一条连接最长活多久,到点关掉重建。**必须小于 MySQL 的 wait_timeout**
// (本机实测 28800 秒 = 8 小时):否则服务端已单方面关掉连接、客户端还以为它活着,
// 下次拿到它就报 invalid connection / driver: bad connection。
// 云上 LB/代理往往还有更短的空闲超时(常见 5 分钟),所以 5 分钟是安全值。
db.SetConnMaxLifetime(5 * time.Minute)
// 空闲多久就关掉,防止低峰期挂一堆没人用的连接。
db.SetConnMaxIdleTime(2 * time.Minute)
// Ping 是唯一能确认"真的连得上"的方式。加超时是必要的:
// 目标地址若被防火墙 DROP,TCP 握手会挂满默认超时(可能 2 分钟),
// 服务启动就卡死,健康检查会误判成"进程活着但没就绪"。
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // 必须调用,否则 ctx 关联的 timer 要等超时才释放(资源泄漏)
if err := db.PingContext(ctx); err != nil {
db.Close() // 关键:连不上就关掉池子,否则 *sql.DB 会带着后台 goroutine 泄漏
return nil, fmt.Errorf("连接数据库: %w", err)
}
return db, nil
}
| 错误现象 | 根因 | 正确写法 |
|---|---|---|
sql: unknown driver "mysql" (forgotten import?) |
漏了空白导入 | 加 _ "github.com/go-sql-driver/mysql" |
| 启动正常,第一个请求才报连不上 | 只 Open 没 Ping |
启动阶段 PingContext |
偶发 invalid connection / driver: bad connection |
ConnMaxLifetime ≥ wait_timeout |
设成 5 分钟 |
Error 1040: Too many connections |
MaxOpenConns 是默认的 0 |
显式设上限 |
| 压测 QPS 上不去、连接数剧烈波动 | MaxIdleConns 远小于 MaxOpenConns |
两者设相等 |
4.2 GetByID + scanPost —— 单行查询与 NULL
scanPost 被 GetByID 和 List 共用,所以参数类型是个自定义小接口:
// internal/repository/post_repo.go
// rowScanner 把 *sql.Row 和 *sql.Rows 的共同点抽出来。
// 这两个标准库类型都有 Scan(dest ...any) error,但没有共同的公开接口,
// 所以我们自己声明一个 —— 这就是 Go 隐式接口的威力:
// 标准库不需要知道我们的存在,就自动满足了我们的接口(第 4 课细讲)。
type rowScanner interface {
Scan(dest ...any) error
}
// scanPost 把一行结果扫进 model.Post。
// 返回 *model.Post 而不是值:调用方需要用 nil 表达"没扫到"。
func scanPost(s rowScanner) (*model.Post, error) {
// 可空列不能直接扫进 model.Post 的字段,先落到这些中转变量上。
// 它们只活在这个函数里,sql.NullXxx 一个都不外泄。
var (
p model.Post
authorID sql.NullInt64 // author_id 可空
summary sql.NullString // summary 可空
published sql.NullTime // published_at 可空
deleted sql.NullTime // deleted_at 可空
)
// Scan 的参数顺序**必须**和 SELECT 的列顺序严格一致,个数也要相等。
// 这是 database/sql 最脆的地方:改了 SQL 忘改这里,编译器一点忙帮不上。两种报错:
// 个数不对 → sql: expected 12 destination arguments in Scan, not 11
// 类型不对 → converting driver.Value type []uint8 ("Hello Go") to a int: invalid syntax
// 防御手段:把列清单抽成 const postColumns,所有查询共用同一份。
err := s.Scan(
&p.ID, // 全部传指针:Scan 要往里写值
&authorID,
&p.Title,
&p.Slug,
&summary,
&p.Content,
&p.Status, // model.Status 底层是 int8,驱动能直接扫进去
&p.ViewCount,
&published,
&p.CreatedAt, // 靠 DSN 的 parseTime=True 才能扫进 time.Time
&p.UpdatedAt,
&deleted,
)
if err != nil {
return nil, err // 原样返回,让调用方识别 sql.ErrNoRows
}
// ---- 把数据库的词汇翻译成领域模型的词汇 ----
if authorID.Valid {
p.AuthorID = &authorID.Int64
}
// summary 走"零值折叠":Valid=false 时 .String 本来就是 "",直接赋值。
// 业务上"没写摘要"和"写了空摘要"等价,不需要区分。
p.Summary = summary.String
if published.Valid {
t := published.Time // ← 必须先复制到局部变量!
p.PublishedAt = &t
// 为什么不能写 p.PublishedAt = &published.Time?
// 语法上能过,但在 List 的循环里复用同一个 published 变量时,
// 每行拿到的都是**同一个地址**,最后所有元素都指向最后一行的值。
// 这是 Go 里非常经典的一类 bug。复制一份最省心。
}
if deleted.Valid {
t := deleted.Time
p.DeletedAt = &t
}
return &p, nil
}
// internal/repository/post_repo.go
// postColumns 是所有查询共用的列清单。抽成常量的唯一目的:
// 让 SELECT 的列顺序和 scanPost 的 Scan 顺序**只有一处可改**。
const postColumns = `id, author_id, title, slug, summary, content,
status, view_count, published_at, created_at, updated_at, deleted_at`
// GetByID 按主键查一篇文章。
//
// 签名逐项:
// ctx —— 从 HTTP 请求一路传下来,负责超时和取消
// id —— int64,对应 BIGINT UNSIGNED 主键(3.2 讲过为什么不是 uint64)
// 返回 (*model.Post, error) —— 用指针因为"没查到"要返回 nil;
// 返回的是领域模型**不是** *sql.Row,数据库类型不外泄。
func (r *PostRepo) GetByID(ctx context.Context, id int64) (*model.Post, error) {
// 软删除的代价:这个 AND deleted_at IS NULL 每条查询都得写,漏一处就是数据泄露。
const query = `SELECT ` + postColumns + `
FROM posts
WHERE id = ? AND deleted_at IS NULL`
// QueryRowContext 不返回 error —— 错误延迟到 Scan 才吐出来。
p, err := scanPost(r.db.QueryRowContext(ctx, query, id))
if err != nil {
// ⭐ 本课最关键的三行。
// sql.ErrNoRows 是"数据库层的词汇",它一旦流到 handler,
// handler 就得 import database/sql 才能判断它,依赖方向就反了(第 4 课核心)。
// 所以在这里就把它翻译成领域错误。
if errors.Is(err, sql.ErrNoRows) {
// %w 包装(第 1 课学的):errors.Is(返回值, ErrNotFound) 仍为 true,
// 同时 err.Error() 带上了 id,排查时有上下文。
return nil, fmt.Errorf("查询文章 id=%d: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("查询文章 id=%d: %w", id, err)
}
return p, nil
}
// internal/repository/errors.go
package repository
import "errors"
// 定义成包级变量(哨兵错误)而不是每次 errors.New:
// 调用方要能用 errors.Is 判断,那就必须是同一个值。
var (
ErrNotFound = errors.New("repository: 记录不存在")
ErrDuplicate = errors.New("repository: 唯一键冲突")
)
📌 第 4 课预告:这两个错误现在住在 repository 包,第 4 课会搬到 model 包——因为 handler 需要 errors.Is 它们来决定 HTTP 状态码,而 handler 不该认识 repository。这个搬家动作本身就是"依赖方向"最好的例子。
为什么必须显式判 sql.ErrNoRows:「按 ID 没查到」是正常业务结果(该返回 404),不是系统故障(500)。不判的话,用户访问一个不存在的 ID,你的服务会打 ERROR 日志并返回 500,监控半夜把你叫起来。
4.3 List —— 多行查询的完整正确姿势
// internal/repository/post_repo.go
// List 分页查已发布文章。
//
// 参数为什么是 (limit, offset int) 而不是 (page, size int):
// limit/offset 是数据库的词汇,page/size 是业务的词汇。
// repository 只说数据库的话,"第几页"怎么换算成 offset 是 service 的事。
func (r *PostRepo) List(ctx context.Context, limit, offset int) ([]model.Post, error) {
// 防御性钳制:即使上层忘了校验,也不能让 LIMIT 1000000 打到数据库。
// 这不是"业务规则",是"保护数据库",所以放 repository 是合适的。
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
// ORDER BY published_at DESC 配合 idx_posts_status_published(status, published_at DESC),
// EXPLAIN 里不会出现 Using filesort(3.2 节实测过)。
const query = `SELECT ` + postColumns + `
FROM posts
WHERE deleted_at IS NULL AND status = ?
ORDER BY published_at DESC
LIMIT ? OFFSET ?`
// LIMIT / OFFSET 也用 ? —— 它们是**值**不是结构,MySQL 支持参数化。
// 千万别 fmt.Sprintf 拼进去,那就是注入口子。
rows, err := r.db.QueryContext(ctx, query, model.StatusPublished, limit, offset)
if err != nil {
return nil, fmt.Errorf("查询文章列表: %w", err)
}
// ⭐ 必须 defer Close。rows 持有一条池子里的连接,不关就永远还不回去;
// 泄漏 25 次(MaxOpenConns)后整个服务所有 DB 操作挂死等连接。
// 用 defer 而不是末尾调用:下面任何分支提前 return 都能覆盖到。
defer rows.Close()
// 预分配容量避免反复扩容。用 make(..., 0, limit) 而不是 var posts []model.Post
// 还有个好处:结果为空时返回 [](非 nil),json.Marshal 出来是 [] 而不是 null。
posts := make([]model.Post, 0, limit)
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return nil, fmt.Errorf("扫描文章行: %w", err)
}
posts = append(posts, *p) // 解引用存值,切片元素不共享指针
}
// ⭐⭐ 极高频 bug:漏掉这一行。rows.Next() 返回 false 有两种原因 ——
// "读完了" 和 "出错了"(连接断、被 KILL、ctx 超时),两种情况循环都正常退出。
// 不查 rows.Err(),你就把"网络抖动导致只读到 3 行"当成"数据库里就只有 3 行",
// 静默返回不完整数据且没有任何日志。
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历文章结果集: %w", err)
}
return posts, nil
}
四步口诀,一步都不能少:Query → defer Close → for Next { Scan } → Err()。
4.4 Create —— 写入与自增 ID 回写
// internal/repository/post_repo.go
// Create 插入一篇新文章,并把数据库生成的值回写到 p。
//
// 签名为什么是 (p *model.Post) error 而不是 (p model.Post) (int64, error):
// 传指针 + 只返回 error 是 Go 里 repository 的常见约定 ——
// 数据库会生成一批值(自增 ID、created_at、updated_at),
// 与其返回一堆零散返回值,不如直接填回调用方手里那个结构体。
// 代价是这个方法**会修改入参**,所以文档注释里必须写清楚。
func (r *PostRepo) Create(ctx context.Context, p *model.Post) error {
// 不写 id/created_at/updated_at —— 由 AUTO_INCREMENT 和 DEFAULT 生成。
const query = `INSERT INTO posts
(author_id, title, slug, summary, content, status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
res, err := r.db.ExecContext(ctx, query,
p.AuthorID, // *int64:驱动认识指针,nil 会被写成 SQL NULL
p.Title,
p.Slug,
nullString(p.Summary), // string → sql.NullString,"" 写成 NULL
p.Content,
p.Status, // model.Status(int8) 驱动能直接处理
p.PublishedAt, // *time.Time,nil → NULL
)
if err != nil {
// 把 MySQL 错误码翻译成领域错误。1062 = 唯一索引冲突。
// 不翻译的话,上层要么盲目返回 500,要么被迫 import driver 包去解析错误。
if isDuplicateKey(err) {
return fmt.Errorf("创建文章 slug=%q: %w", p.Slug, ErrDuplicate)
}
return fmt.Errorf("创建文章 slug=%q: %w", p.Slug, err)
}
// LastInsertId 只对有自增列的 INSERT 有意义。
// 在 UPDATE 上调用返回 (0, nil) —— 不报错但也没用,别当有效 ID。
id, err := res.LastInsertId()
if err != nil {
return fmt.Errorf("读取自增 ID: %w", err)
}
p.ID = id
// ⭐ 实测中真踩到的坑:created_at / updated_at 是 MySQL 的 DEFAULT CURRENT_TIMESTAMP
// 生成的,Go 这边不回读就永远是零值,API 返回 "0001-01-01T00:00:00Z"。
// 代价是多一次往返;想省掉就得让 Go 自己生成时间戳并显式 INSERT 进去。
if err := r.db.QueryRowContext(ctx,
`SELECT created_at, updated_at FROM posts WHERE id = ?`, id).
Scan(&p.CreatedAt, &p.UpdatedAt); err != nil {
return fmt.Errorf("回读时间戳 id=%d: %w", id, err)
}
return nil
}
// nullString 把 Go 的空串翻译成 SQL NULL。这是"零值折叠"的写入方向:
// 读时 NULL → "",写时 "" → NULL,两边对称。
func nullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: strings.TrimSpace(s) != ""}
}
// isDuplicateKey 判断是不是唯一索引冲突(MySQL 错误码 1062)。
// 用 errors.As 而不是 errors.Is:我们要取出错误里的 Number 字段,不只是比对身份。
// 这是**唯一**允许 repository 认识 driver 包的地方 —— 翻译完就锁在这里。
func isDuplicateKey(err error) bool {
var me *mysql.MySQLError
return errors.As(err, &me) && me.Number == 1062
}
注意 isDuplicateKey 需要非空白导入驱动包(import "github.com/go-sql-driver/mysql",取 mysql.MySQLError 类型)。同一个包在不同文件里可以有不同导入方式,不冲突。
4.5 CreateWithTags —— 事务完整范式
跨 posts / tags / post_tags 三张表,要么全成功要么全失败。
// internal/repository/post_repo.go
// CreateWithTags 在一个事务里同时写 posts / tags / post_tags 三张表。
//
// 返回值写成具名的 (err error) 不是风格问题,是必需的:
// 下面的 defer 要修改这个返回值,匿名返回值改不了。
func (r *PostRepo) CreateWithTags(ctx context.Context, p *model.Post, tagNames []string) (err error) {
// 第二个参数是 *sql.TxOptions(隔离级别、只读标记),nil = MySQL 默认 REPEATABLE READ。
// tx 会**独占**池子里一条连接直到 Commit 或 Rollback,所以事务要尽量短。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开启事务: %w", err)
}
// ⭐ 事务惯用法:不管从哪一行 return,只要没走到 Commit 就回滚。
// Commit 成功后这里的 Rollback 返回 sql.ErrTxDone,是无害空操作,必须忽略。
// 若 Rollback 报了别的错(比如连接已断),用 errors.Join 挂到原错误上,
// 两个信息都不丢 —— 直接覆盖 err 会把真正的失败原因吃掉。
defer func() {
if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("回滚失败: %w", rbErr))
}
}()
// ---- 第一步:插文章 ----
// 注意是 tx.ExecContext 不是 r.db.ExecContext。写错成 r.db 是最常犯的事务 bug:
// 那条语句会走另一条连接、在事务外执行,"回滚"回滚不掉它,
// 数据出现只有一半的诡异状态,而且不报任何错。
res, err := tx.ExecContext(ctx,
`INSERT INTO posts (author_id, title, slug, summary, content, status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
p.AuthorID, p.Title, p.Slug, nullString(p.Summary), p.Content, p.Status, p.PublishedAt)
if err != nil {
if isDuplicateKey(err) {
return fmt.Errorf("创建文章 slug=%q: %w", p.Slug, ErrDuplicate)
}
return fmt.Errorf("插入文章: %w", err)
}
postID, err := res.LastInsertId()
if err != nil {
return fmt.Errorf("读取文章自增 ID: %w", err)
}
// ---- 第二步:批量处理标签,这才是 Prepare 真正有价值的场景 ----
// 循环里执行 N 次同一条语句:Prepare 一次,MySQL 解析一次并缓存执行计划,
// 后面每次 Exec 只传参数,省掉 N-1 次解析往返。
// ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id) 是 MySQL 惯用技巧:标签已存在时
// 不真更新,但让 LAST_INSERT_ID() 返回**已存在那行的 id**,于是新建/命中都能用同一行拿 tagID。
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO tags (name) VALUES (?) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`)
if err != nil {
return fmt.Errorf("预编译 tags 插入语句: %w", err)
}
defer stmt.Close() // prepared statement 占服务端资源,用完要关
for _, name := range tagNames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
tagRes, err := stmt.ExecContext(ctx, name)
if err != nil {
return fmt.Errorf("插入标签 %q: %w", name, err) // ← defer 会回滚整个事务
}
tagID, err := tagRes.LastInsertId()
if err != nil {
return fmt.Errorf("读取标签 ID %q: %w", name, err)
}
// INSERT IGNORE:post_tags 是 (post_id, tag_id) 联合主键,
// 用户传了重复标签名时直接忽略,不报错。
if _, err := tx.ExecContext(ctx,
`INSERT IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)`,
postID, tagID); err != nil {
return fmt.Errorf("绑定标签 %q: %w", name, err)
}
}
// Commit 本身也会失败(网络断、死锁被选中回滚),必须判。
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务: %w", err)
}
// ⭐ 只在 Commit 成功之后才改入参。提前写 p.ID = postID 的话,
// 事务失败时调用方手里会拿到一个"根本不存在的 ID"。
p.ID = postID
return nil
}
回滚是真的吗? 实测:故意传超长标签名让第二步失败,然后查库:
[9] 预期失败 err=插入标签 "很长的标签名…": Error 1406 (22001): Data too long for column 'name' at row 1
[10] 回滚校验:slug=lesson3-rollback-… 在库里有 0 行(应为 0)
第一步插进去的文章被回滚掉了。
⑤ 跑起来验证
新建 cmd/dbtest/main.go(只跑一次的验证程序,不是服务)。核心部分:
// cmd/dbtest/main.go
package main
// import: context, errors, fmt, log, time, blog/internal/model, blog/internal/repository
const dsn = "root:123456@tcp(127.0.0.1:3306)/blog_dev?charset=utf8mb4&parseTime=True&loc=Local"
func main() {
db, err := repository.OpenDB(dsn)
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
defer db.Close() // 整个进程只关一次
log.Println("[1] 连接池就绪,Ping 通过")
repo := repository.NewPostRepo(db)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, _ := repo.GetByID(ctx, 1)
log.Printf("[2] GetByID(1): title=%q summary=%q", p.Title, p.Summary)
p2, _ := repo.GetByID(ctx, 2) // id=2 的 summary 在库里是 NULL
log.Printf("[3] GetByID(2): summary=%q len=%d", p2.Summary, len(p2.Summary))
_, err = repo.GetByID(ctx, 99999)
log.Printf("[4] GetByID(99999): errors.Is(err, ErrNotFound)=%v",
errors.Is(err, repository.ErrNotFound))
posts, _ := repo.List(ctx, 10, 0)
log.Printf("[5] List(10,0) 返回 %d 条", len(posts))
np := &model.Post{
Title: "第三课实测文章",
Slug: fmt.Sprintf("lesson3-%d", time.Now().UnixNano()),
Summary: "", // 空串 → 落库为 NULL
Content: "由 cmd/dbtest 写入。",
Status: model.StatusDraft,
}
if err := repo.Create(ctx, np); err != nil {
log.Fatalf("Create 失败: %v", err)
}
log.Printf("[6] Create 成功 ID=%d created_at=%s", np.ID, np.CreatedAt.Format(time.RFC3339))
// TODO(练习1): 在这里加 CreateWithTags 的调用和回滚验证
}
上面为了聚焦省掉了部分 if err != nil。你自己写的时候每一个都要判——第 1 课的规矩,不吞错。
cd ~/go-blog && go run ./cmd/dbtest
预期输出(时间戳会不同):
2026/09/06 09:26:34 [1] 连接池就绪,Ping 通过
2026/09/06 09:26:34 [2] GetByID(1): title="Hello Go" summary="Go 的第一课"
2026/09/06 09:26:34 [3] GetByID(2): summary="" len=0
2026/09/06 09:26:34 [4] GetByID(99999): errors.Is(err, ErrNotFound)=true
2026/09/06 09:26:34 [5] List(10,0) 返回 2 条
2026/09/06 09:26:34 [6] Create 成功 ID=4 created_at=2026-09-06T09:26:34-07:00
SQL 对照,确认 summary 真的是 NULL 而不是空串:
mysql -uroot -p123456 blog_dev -e \
"select id, slug, summary, summary is null as is_null from posts order by id desc limit 1;"
id slug summary is_null
4 lesson3-1788711994278238000 NULL 1
is_null = 1 就对了。
亲手制造四个报错
亲眼看一遍比读十遍文档有用。改完记得改回来。
| 怎么改坏 | 报错原文 |
|---|---|
注释掉 db.go 里的 _ "github.com/go-sql-driver/mysql" |
sql: unknown driver "mysql" (forgotten import?) |
DSN 去掉 parseTime=True |
unsupported Scan, storing driver.Value type []uint8 into type *time.Time |
scanPost 里 summary sql.NullString 改成 var summary string |
converting NULL to string is unsupported |
Scan 少传一个参数 |
sql: expected 12 destination arguments in Scan, not 11 |
验证 context 超时真的生效
// cmd/dbtest/main.go —— 追加这一段
shortCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
var dummy int
err := db.QueryRowContext(shortCtx, `SELECT SLEEP(2)`).Scan(&dummy)
log.Printf("err=%v; is DeadlineExceeded=%v", err, errors.Is(err, context.DeadlineExceeded))
// err=context deadline exceeded; is DeadlineExceeded=true ← 100ms 就返回,不是等满 2 秒
⑥ TODO 练习
练习 1:补完 CreateWithTags 的验证。 在 cmd/dbtest/main.go 里调用它,并故意让它失败一次(传一个超过 64 字符的标签名),然后用 SQL 确认文章行没被留下。
验收:成功路径 select t.name from tags t join post_tags pt on pt.tag_id=t.id where pt.post_id=<新ID> 返回你传的标签;失败路径 select count(*) from posts where slug='<那个slug>' 返回 0;传重复标签名(["go","go"])不报错且 post_tags 只有一行。
练习 2:GetBySlug
// TODO(练习2): func (r *PostRepo) GetBySlug(ctx context.Context, slug string) (*model.Post, error)
// 提示:和 GetByID 几乎一样,WHERE 换成 slug = ? 并保留 deleted_at IS NULL。
// 思考题:这两个函数 90% 重复,要不要抽成 getOneBy(ctx, whereClause, arg)?
// (建议先别抽。两个还行,第三个出现时再抽 —— 过早抽象比重复更贵。)
验收:GetBySlug(ctx, "hello-go") 返回 id=1;GetBySlug(ctx, "不存在") 满足 errors.Is(err, ErrNotFound)。
练习 3:IncrementViewCount
// TODO(练习3): func (r *PostRepo) IncrementViewCount(ctx context.Context, id int64) error
// 要求:
// 1. 必须用 UPDATE posts SET view_count = view_count + 1 WHERE id = ?
// 而不是"先 SELECT 出来、Go 里加一、再 UPDATE 回去" ——
// 后者在并发下会丢更新(两个请求都读到 10、都写回 11,实际应该是 12)。
// 2. RowsAffected() == 0 时返回 ErrNotFound。
// 想一想:为什么这里可以这么判,而 3.6 节说不能?(提示:view_count+1 的新值永远和旧值不同)
验收:连调 3 次后 select view_count from posts where id=1 比初始值大 3;不存在的 ID 返回 ErrNotFound。
练习 4:修掉软删除的 slug 占位问题
// TODO(练习4): 改造 SoftDelete,让被删文章释放它的 slug
// 提示:同一条 UPDATE 里把 slug 也改掉,例如
// UPDATE posts SET deleted_at = NOW(), slug = CONCAT(slug, '-deleted-', id) WHERE ...
// 注意 slug 列是 VARCHAR(200),拼接后可能超长,想想怎么处理。
验收:软删除 hello-go 之后,能成功 Create 一篇新的 slug 为 hello-go 的文章,不报 1062。
练习 5(进阶):连接池行为观测
// TODO(练习5): 起 50 个 goroutine 同时执行 SELECT SLEEP(1),
// 每 100ms 打印一次 db.Stats() 的 OpenConnections / InUse / Idle / WaitCount。
// 然后把 SetMaxOpenConns 从 25 改成 5,对比两次输出。
验收:能解释 WaitCount 和 WaitDuration 为什么在 MaxOpenConns=5 时显著上升,以及这对接口 P99 延迟意味着什么。
⑦ 自检清单
- 我能解释
_ "github.com/go-sql-driver/mysql"里下划线的作用,以及漏掉它的报错原文 - 我知道
sql.Open不建立连接,必须PingContext才能确认连通 - 我知道
*sql.DB是连接池,全进程只 Open 一次 - 我能说出
SetConnMaxLifetime为什么必须小于 MySQL 的wait_timeout - 我知道不加
parseTime=True会报unsupported Scan, storing driver.Value type []uint8 into type *time.Time - 我能说清三个查询 API 的分工和各自"必须做的事"
- 我每次写
QueryContext都会写全defer rows.Close()和循环后的rows.Err() - 我知道
sql.ErrNoRows必须在 repository 层就翻译成领域错误 - 我能说出
fmt.Sprintf拼 SQL 的危害,并知道表名/列名不能用?(要走白名单) - 我知道
sql.NullString只该做Scan的中转变量,不该进领域模型 - 我能说出什么时候用指针、什么时候用零值折叠来表达 NULL
- 我理解
defer tx.Rollback()在 Commit 之后是无害空操作(sql.ErrTxDone) - 我知道事务里必须用
tx.ExecContext而不是db.ExecContext - 我能解释
(status, published_at DESC)这个列顺序为什么不能反过来 - 我知道
RowsAffected() == 0不一定意味着"没这行"
下一课:现在 repository 能跟数据库说话了,但 handler 还在直接操作内存 slice。第 4 课把它们接起来——用 service 层隔开,用接口解耦,把「依赖箭头只能单向」这条规则立起来。