24-数据库操作:database/sql、连接池与事务

🔖 Go 语言学习教程 · 第 24 篇

📌 本系列基于 Go 1.23 编写,代码兼容 Go 1.21+

全文约 4500 字 | 阅读约 15 分钟


欢迎来到第二十四篇 👋

数据库代码最容易出现的问题,往往不是 SQL 不会写,而是连接没有释放、查询结果没有关闭、事务没有回滚、并发连接数没有控制。

Go 标准库的 database/sql 提供了统一的数据库访问抽象,具体数据库通过驱动实现。本文先掌握标准库的核心用法,再介绍 GORM 的适用边界和事务设计。


✅ 上一篇练习题答案

以下是上一篇练习题的参考答案,欢迎在评论区分享你的解法~

1. 使用 ServeMux 注册路由

go 复制代码
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", health)
mux.HandleFunc("GET /users/{id}", getUser)

在 getUser 中使用 r.PathValue("id") 读取路径参数。若项目需要兼容 Go 1.21,需要换用兼容版本的路由写法。

2. 带 Context 和超时的 GET

创建 context.WithTimeout,用 http.NewRequestWithContext 绑定请求,再通过带超时的 http.Client 发出请求,并在成功后关闭响应体。

3. 为什么必须关闭响应体

关闭响应体可以释放响应资源,并帮助 HTTP 客户端复用底层连接。长期忽略关闭会造成连接和文件描述符逐渐耗尽。

4. 日志和优雅关闭

日志中间件包装主 Handler;服务启动后在 goroutine 中监听;主流程接收停止信号,创建带超时的 Context 调用 server.Shutdown。


🧐 一、database/sql 的角色

database/sql 不是具体数据库驱动,而是一套统一接口:

go 复制代码
db, err := sql.Open("postgres", dsn)
if err != nil {
	return err
}
defer db.Close()

sql.Open 通常不会立刻建立数据库连接,它会创建一个可管理连接池的 *sql.DB。可以使用 PingContext 尽早验证连接和配置:

go 复制代码
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

if err := db.PingContext(ctx); err != nil {
	return err
}

*sql.DB 应作为长生命周期对象复用,不要每次请求都 Open 和 Close。


📖 二、连接池配置

连接池参数需要结合数据库容量和业务并发设置:

go 复制代码
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
配置 作用
MaxOpenConns 同时打开的最大连接数
MaxIdleConns 空闲连接池上限
ConnMaxLifetime 单个连接最多存活时间
ConnMaxIdleTime 空闲连接最多保留时间

连接数不是越大越好。数据库连接通常消耗服务端内存、锁和调度资源,应用实例数量增加后还要计算总连接数。

可以读取连接池统计信息:

go 复制代码
stats := db.Stats()
fmt.Println("打开连接:", stats.OpenConnections)
fmt.Println("等待连接:", stats.WaitCount)
fmt.Println("等待时间:", stats.WaitDuration)

💡 三、查询单行和多行

查询单行:

go 复制代码
type User struct {
	ID    int64
	Name  string
	Email string
}

func findUser(ctx context.Context, db *sql.DB, id int64) (User, error) {
	var user User
	err := db.QueryRowContext(
		ctx,
		`SELECT id, name, email FROM users WHERE id = $1`,
		id,
	).Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		return User{}, err
	}
	return user, nil
}

查询不到数据时,通常返回 sql.ErrNoRows:

go 复制代码
if errors.Is(err, sql.ErrNoRows) {
	// 转换为业务层的"用户不存在"
}

查询多行:

go 复制代码
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
if err != nil {
	return err
}
defer rows.Close()

for rows.Next() {
	var user User
	if err := rows.Scan(&user.ID, &user.Name); err != nil {
		return err
	}
	fmt.Println(user)
}
if err := rows.Err(); err != nil {
	return err
}

rows.Close() 和 rows.Err() 都不能省略。循环中断、网络错误或数据库错误可能在最后一次 Scan 之后才体现。


🛠️ 四、插入、更新与删除

写操作使用 ExecContext:

go 复制代码
result, err := db.ExecContext(
	ctx,
	`INSERT INTO users (name, email) VALUES ($1, $2)`,
	"小明",
	"xiaoming@example.com",
)
if err != nil {
	return err
}

id, err := result.LastInsertId()
rowsAffected, err := result.RowsAffected()

不同数据库对 LastInsertId 的支持不同。PostgreSQL 常使用 RETURNING 配合 QueryRowContext:

go 复制代码
var id int64
err := db.QueryRowContext(
	ctx,
	`INSERT INTO users (name) VALUES ($1) RETURNING id`,
	"小明",
).Scan(&id)

参数使用占位符,不要通过字符串拼接 SQL:

go 复制代码
// 错误:可能导致 SQL 注入
query := "SELECT * FROM users WHERE name = '" + name + "'"

🔄 五、事务处理

事务通常包含"开始、执行、提交或回滚":

go 复制代码
tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

if _, err := tx.ExecContext(ctx, `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, 100, fromID); err != nil {
	return err
}
if _, err := tx.ExecContext(ctx, `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, 100, toID); err != nil {
	return err
}

return tx.Commit()

defer tx.Rollback() 即使提交成功也不会产生影响,但能覆盖中途返回的所有失败路径。事务提交后不要继续使用 tx。

事务边界应该覆盖一个完整的业务不变量,不要把网络调用、长时间计算或用户交互放进数据库事务里。


⚠️ 六、事务和 Context 取消

如果请求 Context 被取消,数据库操作应尽快返回:

go 复制代码
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

tx, err := db.BeginTx(ctx, nil)

不要在 Context 已经取消后继续重试数据库写入,否则可能让已经结束的请求继续修改数据。重试要区分:

  • 网络暂时失败;
  • 唯一键冲突;
  • 超时和取消;
  • 业务校验失败。

只有明确可重试且具备幂等设计的操作,才适合自动重试。


🧩 七、SQL Null 值

数据库中的 NULL 不能直接扫描到普通 string、int64 等类型。可以使用 sql.NullString:

go 复制代码
var email sql.NullString
err := row.Scan(&email)
if err != nil {
	return err
}
if email.Valid {
	fmt.Println(email.String)
}

也可以在领域层使用指针或自定义类型,把数据库层的 NULL 转换成业务更容易理解的表示。


📦 八、GORM 适合什么场景?

GORM 等 ORM 可以减少重复 CRUD 代码:

go 复制代码
type User struct {
	ID    uint
	Name  string
	Email string
}

var user User
result := db.First(&user, 1)
if result.Error != nil {
	return result.Error
}

ORM 的优点是开发效率高、模型映射方便、事务和关联查询有统一 API;缺点是复杂 SQL、性能诊断和数据库特性可能需要回到底层 SQL。

无论使用 ORM 还是 database/sql,都要关注:

  • 查询是否带分页和索引;
  • 是否预加载过多关联数据;
  • 事务是否过大;
  • 是否把数据库错误直接暴露给用户;
  • 是否正确设置连接池。

📝 九、本篇小结

要点 总结
*sql.DB 长生命周期的数据库连接池
PingContext 启动时验证连接配置
连接池 根据数据库容量配置上限和生命周期
查询 及时关闭 rows,检查 rows.Err
参数绑定 使用占位符,避免 SQL 注入
事务 失败回滚,成功提交,边界保持短小
Null 值 使用 sql.Null* 或领域类型转换
ORM 提高 CRUD 效率,但复杂场景仍需理解 SQL

💬 一句话总结:

数据库代码的可靠性来自连接池、Context、资源关闭、参数绑定和清晰的事务边界,而不只是"把 SQL 执行成功"。


✏️ 十、练习题

  1. 🖥️ 动手实践 :用 database/sql 查询用户列表,正确处理 rows.Close 和 rows.Err。
  2. 🔧 事务实践:实现两个账户之间的转账,确保任一步失败都会回滚。
  3. 🤔 思考题 :为什么 *sql.DB 应该复用,而不是每次请求重新创建?
  4. 📚 ORM 对比:用 GORM 完成一次查询,再思考哪些场景需要直接使用 SQL。

💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~


关注我,一起学编程

如果本文对你有帮助,欢迎 点赞、在看、转发、关注。

相关推荐
敲代码的玉米C2 小时前
设置导进来了,界面却没变:配置不能只管写文件
go
敲代码的玉米C2 小时前
从终端敲一行命令,把文件送进已经打开的砚
go
敲代码的玉米C2 小时前
两个提交到底差在哪:给砚补上比较和补丁导出
go
敲代码的玉米C2 小时前
终端里的工具改了文件,砚不能还停在旧内容上
go
敲代码的玉米C2 小时前
功能藏得有点深了,给砚加一个能跟着做的教程
go
敲代码的玉米C2 小时前
一个目录里放了几个仓库,提交按钮到底该操作谁
go
敲代码的玉米C2 小时前
想在 Markdown 预览里改两个字,最后补了一套块编辑
go
敲代码的玉米C2 小时前
不想每次都手动换 DMG,我给砚接了自动更新
go
敲代码的玉米C2 小时前
看完代码先留几条批注,再一起交给终端里的工具
go