跳到正文
知识库 / Go 博客教程 / 第 3 课返回主站 ↗

Go 博客教程 · 第 3 课 / 10

第 3 课:MySQL 与 database/sql

前两课的文章躺在内存 slice 里,进程一重启就没了。这一课把它们搬进 MySQL。

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:第 2 课:net/http 与路由下一课:第 4 课:分层架构与 CRUD →

① 本课目标

学完能独立写出:连接池 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 的 summaryNULL——这不是空字符串,是完全不同的东西(见 3.7)。

3.2 posts 表逐字段拆解

建表语句里每个选择都会直接影响你的 Go 代码怎么写。

id BIGINT UNSIGNED AUTO_INCREMENT —— 为什么不是 INTINT 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 的 utf8utf8mb3 的历史别名,每字符最多 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_atDEFAULT 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;把"没改到行"当成功

三条容易记混的规则:

  1. QueryRowContext 不返回 error,错误延迟到 Scan 才吐——这是有意设计,让你能写 db.QueryRowContext(...).Scan(&x) 一行链式调用。
  2. rows.Err() 是高频 bugfor rows.Next() 结束有两种原因:读完了,或中途出错(网络断、连接被 kill、ctx 超时)。两种情况 Next() 都返回 false,唯一区分方式就是循环后查 rows.Err()。漏了它,一次网络抖动就变成"查出来只有 3 条",且无任何日志。
  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,两个实打实的原因:

  1. 超时控制——慢查询不会永远挂着,ctx 超时后驱动会给 MySQL 发 KILL QUERY。实测 100ms 超时执行 SELECT SLEEP(2)err=context deadline exceedederrors.Is(err, context.DeadlineExceeded)=true,100 毫秒就返回了。
  2. 请求取消——用户关掉浏览器 → 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"
启动正常,第一个请求才报连不上 OpenPing 启动阶段 PingContext
偶发 invalid connection / driver: bad connection ConnMaxLifetimewait_timeout 设成 5 分钟
Error 1040: Too many connections MaxOpenConns 是默认的 0 显式设上限
压测 QPS 上不去、连接数剧烈波动 MaxIdleConns 远小于 MaxOpenConns 两者设相等

4.2 GetByID + scanPost —— 单行查询与 NULL

scanPostGetByIDList 共用,所以参数类型是个自定义小接口:

// 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
}

四步口诀,一步都不能少Querydefer Closefor 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
scanPostsummary 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,对比两次输出。

验收:能解释 WaitCountWaitDuration 为什么在 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 层隔开,用接口解耦,把「依赖箭头只能单向」这条规则立起来。