🔖 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 执行成功"。
✏️ 十、练习题
- 🖥️ 动手实践 :用
database/sql查询用户列表,正确处理rows.Close和rows.Err。 - 🔧 事务实践:实现两个账户之间的转账,确保任一步失败都会回滚。
- 🤔 思考题 :为什么
*sql.DB应该复用,而不是每次请求重新创建? - 📚 ORM 对比:用 GORM 完成一次查询,再思考哪些场景需要直接使用 SQL。
💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~
关注我,一起学编程
如果本文对你有帮助,欢迎 点赞、在看、转发、关注。