《Go 语言编程入门》14.2 SQL 增删改查与事务

上一节讲了 database/sql 的原语,本节把它们组装成 TaskAPI 的仓储层。实现增删改查五个方法,用 LastInsertId 取自增 ID、用 RowsAffected 判断记录是否存在、把 ErrNoRows 翻译成领域错误,并用 BeginTx/Commit/Rollback 与 defer Rollback 惯用法保证多步写入的原子性。

14.2 SQL 增删改查与事务

14.1 讲的是「零件」:连接池、Exec、QueryRow、Query。本节把它们拼成 TaskAPI 的仓储层(repository)——一个把 SQL 操作封装成 Go 方法、对上层只暴露领域类型的边界。装好这一层,第 15 章的业务层就不必知道任何 SQL。

本节把 TaskAPI 推进到:实现完整的 Repo,覆盖增删改查五个方法,把数据库错误翻译成领域错误,并用事务保证「批量创建」这类多步写入要么全成、要么全废。

14.2.1 仓储层的形状

仓储层就是一个持有 *sql.DB 的结构体,方法一一对应你要暴露的操作:

type Task struct {
	ID    int64
	Title string
	Done  bool
}

type Repo struct{ db *sql.DB }

func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }

注意这里持有接口的最小依赖:Repo 只认 *sql.DB,不认具体的数据库。它把「怎么存」封装起来,上层拿到的是 Task,不是 sql.Row。这正是第 5 章接口思想在持久化上的体现。

14.2.2 Create:拿回自增 ID

插入用 ExecContext,返回值里的 LastInsertId 就是数据库生成的自增主键:

func (r *Repo) Create(ctx context.Context, title string) (Task, error) {
	res, err := r.db.ExecContext(ctx,
		"INSERT INTO tasks(title, done) VALUES(?, ?)", title, false)
	if err != nil {
		return Task{}, err
	}
	id, err := res.LastInsertId()
	if err != nil {
		return Task{}, err
	}
	return Task{ID: id, Title: title}, nil
}

res.RowsAffected() 在这里没用,但在 Update/Delete 里是关键,见 14.2.5。注意 LastInsertId 并非所有数据库都支持(例如某些驱动对批量插入返回 0),真实项目里有时要改用 INSERT ... RETURNING id 配合 QueryRow。

14.2.3 Get:QueryRow 与 ErrNoRows 的翻译

按主键查一条用 QueryRowContext。它查不到时 Scan 返回 sql.ErrNoRows,我们把它翻译成领域错误:

func (r *Repo) Get(ctx context.Context, id int64) (Task, error) {
	var t Task
	err := r.db.QueryRowContext(ctx,
		"SELECT id, title, done FROM tasks WHERE id = ?", id).
		Scan(&t.ID, &t.Title, &t.Done)
	if errors.Is(err, sql.ErrNoRows) {
		return Task{}, fmt.Errorf("task %d: %w", id, ErrNotFound)
	}
	return t, err
}

翻译的动作很小,意义却很大:上层从此不必 import database/sql。业务代码里写 errors.Is(err, ErrNotFound),而不是 errors.Is(err, sql.ErrNoRows)——第 6 章的哨兵错误 + %w 包装在这里发挥了作用,第 15.3 节会把 ErrNotFound 映射成 404。

Scan 的参数顺序必须与 SELECT 的列顺序严格一致,且类型要能对上。列多时容易错位,一个稳妥习惯是把 SELECT 的列写全并和结构体字段一一对应。

14.2.4 List:Query 的 Close 与 Err

列表用 QueryContext,返回游标 *sql.Rows。两个纪律:defer rows.Close() 与迭代后检查 rows.Err():

func (r *Repo) List(ctx context.Context) ([]Task, error) {
	rows, err := r.db.QueryContext(ctx,
		"SELECT id, title, done FROM tasks ORDER BY id")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []Task
	for rows.Next() {
		var t Task
		if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

为什么 defer rows.Close() 不能省?因为 Rows 持有连接,不 Close 连接就不归还池子,几次之后池子就被耗干,后续请求全部阻塞。为什么 return out, rows.Err()?因为 Next() 返回 false 有两种原因:正常读完,或读的过程中网络出错。只有 rows.Err() 能区分——这是最容易被漏掉的一行。

14.2.5 Update/Delete:用 RowsAffected 判断存在性

更新和删除时,如果目标行不存在,Exec 不会报错——它只是影响 0 行。所以「记录不存在」要靠 RowsAffected 判断:

func (r *Repo) Update(ctx context.Context, t Task) error {
	res, err := r.db.ExecContext(ctx,
		"UPDATE tasks SET title = ?, done = ? WHERE id = ?", t.Title, t.Done, t.ID)
	if err != nil {
		return err
	}
	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return fmt.Errorf("task %d: %w", t.ID, ErrNotFound)
	}
	return nil
}

删除同理,n == 0 就回 ErrNotFound。实测确认:Update 一个不存在的 ID 返回的错误满足 errors.Is(err, ErrNotFound)。这个模式在真实数据库上有一个细微陷阱:若更新前后的值完全相同,某些数据库也报告 0 行受影响,此时你会误判成「不存在」。需要严格区分时,得先 SELECT 一次或用数据库特有的返回子句。

14.2.6 事务:BeginTx / Commit / Rollback

单个语句本身就是原子的,但「创建一批任务,任一失败就全部撤销」这种跨多步的操作必须包进事务:

func (r *Repo) CreateAll(ctx context.Context, titles []string) error {
	tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
	if err != nil {
		return err
	}
	defer tx.Rollback() // 已 Commit 时是 no-op

	stmt, err := tx.PrepareContext(ctx,
		"INSERT INTO tasks(title, done) VALUES(?, ?)")
	if err != nil {
		return err
	}
	defer stmt.Close()

	for _, title := range titles {
		if strings.TrimSpace(title) == "" {
			return fmt.Errorf("empty title")
		}
		if _, err := stmt.ExecContext(ctx, title, false); err != nil {
			return err
		}
	}
	return tx.Commit()
}

这段代码里有一个 Go 事务的经典惯用法:defer tx.Rollback() 紧跟 BeginTx。它的精妙之处在于:

  • 如果函数中途 return err,deferred Rollback 执行,事务被撤销。
  • 如果顺利走到 tx.Commit(),事务已提交,此时 Rollback 是一个安全的 no-op(返回 ErrTxDone,被忽略)。

这样无论从哪条路径退出,事务都不会悬着不释放连接。千万别只在错误分支写 Rollback——一旦将来有人加了新的 return 忘了补,就会泄漏一个挂着的事务。

三条退出路径的行为可以列成一张表:

退出路径defer tx.Rollback() 的效果
中途 return err撤销此前所有已执行的语句
走到 tx.Commit()no-op(返回 ErrTxDone,忽略即可)
函数内 panic撤销,连接随 defer 链归还池子

实测验证:对 ["会被回滚", ""] 调用 CreateAll,第二条标题为空触发错误,defer Rollback 把第一条也撤销了,任务总数保持不变。

14.2.7 事务内也要用预处理

注意上面在事务里用的是 tx.PrepareContext,而不是 db.PrepareContext。这很重要:*sql.Stmt 绑定在创建它的连接上,事务占用的是一条特定连接,所以事务内的语句必须从 tx 上准备。用 db.Prepare 得到的语句可能落到另一条连接,就脱离了事务的原子性。

若你手上已经有一个 *sql.Stmt,想把它「绑」进某个事务,用 tx.Stmt(stmt):

stmt, _ := db.PrepareContext(ctx, "INSERT INTO tasks(title, done) VALUES(?, ?)")
defer stmt.Close()
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
txStmt := tx.StmtContext(ctx, stmt) // 绑定到本事务的连接
_, _ = txStmt.ExecContext(ctx, "事务内", false)
_ = tx.Commit()

14.2.8 TxOptions:隔离级别与只读

BeginTx 的第二个参数可以指定隔离级别与只读:

tx, _ := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelSerializable, // 最严格,防幻读
	ReadOnly:  true,                  // 只读事务,可用于一致性快照
})

隔离级别是常量(LevelDefault / LevelReadUncommitted / LevelReadCommitted / LevelRepeatableRead / LevelSerializable),而不是字符串。关键提醒:标准库只负责把级别传给驱动,它本身不实现任何隔离语义——真正的行为由数据库决定,有些数据库甚至不支持某个级别,此时 BeginTx 会报错。别把「我写了 LevelSerializable」当成「一定序列化了」。

只读事务适合「一次查询里读多张表、要求看到同一时间点的快照」的场景。实测一个只读事务里读取任务,正常工作。

14.2.9 完整实测

把本节所有方法放进 main,配合 14.1 的内存驱动(本机无数据库,驱动同一份代码),实测输出:

created: 1 2
get: {ID:1 Title:写 Go 书 Done:false} err=<nil>
missing is ErrNotFound: true
after update: {ID:1 Title:写 Go 书(改) Done:true}
update missing is ErrNotFound: true
after delete len: 1
after CreateAll ok: <nil> len: 3
after CreateAll fail: true len: 3
readonly tx read: {1 写 Go 书(改) true} err: <nil>

逐行对照:创建两条、按 ID 查到、查不存在的返回 ErrNotFound、更新生效、更新不存在的返回 ErrNotFound、删除后剩 1 条、事务成功加 2 条到 3 条、事务失败后仍是 3 条(回滚生效)、只读事务读到正确数据。这些 database/sql 的语义与真实数据库完全一致,只是驱动是内存实现。

14.2.10 小结

  • 仓储层持有 *sql.DB,对上层只暴露领域类型。
  • Create 用 LastInsertId 拿主键;Get 把 sql.ErrNoRows 翻译成 ErrNotFound。
  • List 必须 defer rows.Close() 且返回 rows.Err()。
  • Update/Delete 用 RowsAffected == 0 判断记录不存在。
  • 事务用 BeginTx + defer tx.Rollback() + tx.Commit(),三件套缺一不可。
  • 事务内的语句用 tx.PrepareContext(或 tx.Stmt),别用 db.Prepare。
  • 隔离级别是常量,但语义由数据库决定,标准库不替你保证。

仓储层写完了,但表结构从哪来?总不能每次手动建表。下一节讲 schema 迁移,以及用 sqlc 把 SQL 自动生成类型安全的 Go 代码。

阅读导航:上一节:14.1 database/sql 与驱动 · 下一节:14.3 schema 迁移与 sqlc 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练