3、CRUD操作实战:增删改查基本操作、预处理语句、事务管理、错误处理最佳实践
好,咱们直接进入正题。CRUD 这四个字母,说白了就是后端开发吃饭的家伙。增删改查,哪个项目离得开?今天我就带你把这四个操作在 Go 里彻底玩明白。
我个人习惯,学任何数据库操作,先别急着上 ORM。先把原生的 database/sql 摸透,后面用 GORM、XORM 这些框架时,你才知道底层在干什么。嗯,咱们就从最基础的开始。
3.1 增删改查基本操作
先看一个完整的例子。假设我们有个用户表,结构很简单:
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
age INT DEFAULT 0,
email VARCHAR(100)
);
对应的 Go 结构体:
type User struct {
ID int
Name string
Age int
Email string
}
3.1.1 插入数据
插入操作,我建议你养成一个好习惯:永远使用参数化查询。别偷懒去拼接 SQL 字符串,那是给自己挖坑。
func InsertUser(db *sql.DB, user *User) (int64, error) {
sqlStr := "INSERT INTO users(name, age, email) VALUES(?, ?, ?)"
result, err := db.Exec(sqlStr, user.Name, user.Age, user.Email)
if err != nil {
return 0, fmt.Errorf("插入用户失败: %w", err)
}
return result.LastInsertId()
}
LastInsertId() 返回的是自增主键的值。但要注意,这个操作依赖数据库驱动支持。MySQL 没问题,PostgreSQL 的话你得用 RETURNING 子句。
3.1.2 查询数据
查询分两种:单行查询和多行查询。先看单行:
func GetUserByID(db *sql.DB, id int) (*User, error) {
sqlStr := "SELECT id, name, age, email FROM users WHERE id = ?"
user := &User{}
err := db.QueryRow(sqlStr, id).Scan(&user.ID, &user.Name, &user.Age, &user.Email)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // 没找到,返回 nil
}
return nil, fmt.Errorf("查询用户失败: %w", err)
}
return user, nil
}
多行查询呢?用 Query 方法,然后遍历结果集:
func ListUsers(db *sql.DB, minAge int) ([]*User, error) {
sqlStr := "SELECT id, name, age, email FROM users WHERE age >= ?"
rows, err := db.Query(sqlStr, minAge)
if err != nil {
return nil, fmt.Errorf("查询用户列表失败: %w", err)
}
defer rows.Close() // 千万别忘了关闭!
var users []*User
for rows.Next() {
user := &User{}
err := rows.Scan(&user.ID, &user.Name, &user.Age, &user.Email)
if err != nil {
return nil, fmt.Errorf("扫描行数据失败: %w", err)
}
users = append(users, user)
}
// 检查遍历过程中是否有错误
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("遍历结果集出错: %w", err)
}
return users, nil
}
defer rows.Close() 一定要写。我曾经见过一个线上事故,就是因为忘记关闭 rows,导致数据库连接池被耗尽,整个服务挂了。你想想看,一个连接泄漏可能不明显,但并发一上来,几分钟就炸了。
3.1.3 更新和删除
更新和删除,套路跟插入差不多:
func UpdateUser(db *sql.DB, user *User) (int64, error) {
sqlStr := "UPDATE users SET name=?, age=?, email=? WHERE id=?"
result, err := db.Exec(sqlStr, user.Name, user.Age, user.Email, user.ID)
if err != nil {
return 0, fmt.Errorf("更新用户失败: %w", err)
}
return result.RowsAffected()
}
func DeleteUser(db *sql.DB, id int) (int64, error) {
sqlStr := "DELETE FROM users WHERE id=?"
result, err := db.Exec(sqlStr, id)
if err != nil {
return 0, fmt.Errorf("删除用户失败: %w", err)
}
return result.RowsAffected()
}
这里有个细节:RowsAffected() 返回的是受影响的行数。你可以用它来判断操作是否真的生效了。比如删除一个不存在的 ID,返回 0,你就可以给客户端返回「记录不存在」的提示。
3.2 预处理语句
预处理语句,英文叫 Prepared Statement。这东西有啥用?说白了就两点:性能提升 和 SQL 注入防护。
你想想看,如果一条 SQL 要执行很多次,每次都让数据库去解析、编译、生成执行计划,多浪费啊。预处理语句就是先把 SQL 模板发给数据库编译好,后面只传参数就行。
// 预编译 SQL 语句
stmt, err := db.Prepare("INSERT INTO users(name, age, email) VALUES(?, ?, ?)")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
// 批量插入,每次只传参数
users := []User{
{Name: "张三", Age: 25, Email: "zhangsan@example.com"},
{Name: "李四", Age: 30, Email: "lisi@example.com"},
{Name: "王五", Age: 28, Email: "wangwu@example.com"},
}
for _, u := range users {
_, err := stmt.Exec(u.Name, u.Age, u.Email)
if err != nil {
log.Printf("插入用户 %s 失败: %v", u.Name, err)
}
}
? 占位符,驱动会自动帮你做转义。你传什么值,它就当什么值处理,绝对不会被解释成 SQL 关键字。这就是防注入的原理。
我在项目中遇到过一个问题:有个同事用 fmt.Sprintf 拼接 SQL,把用户输入的 ID 直接拼进去了。结果被黑客注入了 1 OR 1=1,整个表的数据全查出来了。嗯,从那以后,我要求团队里所有 SQL 操作必须用预处理语句,谁用字符串拼接谁请全组喝咖啡。
3.3 事务管理
事务,说白了就是「要么全做,要么全不做」。转账场景最典型:A 扣钱,B 加钱,两个操作必须同时成功或同时失败。
Go 里的事务操作,核心就三步:
- 开启事务:
tx, err := db.Begin() - 执行操作:用
tx.Exec代替db.Exec - 提交或回滚:成功就
tx.Commit(),失败就tx.Rollback()
看个完整的例子:
func TransferMoney(db *sql.DB, fromID, toID int, amount float64) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开启事务失败: %w", err)
}
// 注意:这里用 defer 保证发生 panic 时也能回滚
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // 重新抛出 panic
}
}()
// 扣钱
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
if err != nil {
tx.Rollback()
return fmt.Errorf("扣款失败: %w", err)
}
// 加钱
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
if err != nil {
tx.Rollback()
return fmt.Errorf("加款失败: %w", err)
}
// 提交事务
return tx.Commit()
}
3.4 错误处理最佳实践
错误处理这块,我见过太多「差不多先生」了。要么吞掉错误,要么打印一下就继续往下走。嗯,这里我分享几个原则:
3.4.1 永远不要吞掉错误
// ❌ 错误做法
rows, _ := db.Query("SELECT ...")
// ✅ 正确做法
rows, err := db.Query("SELECT ...")
if err != nil {
// 处理错误
}
3.4.2 使用 %w 包装错误
Go 1.13 之后,推荐用 %w 来包装错误,这样上层可以调用 errors.Is 或 errors.As 来判断错误类型:
func GetUser(db *sql.DB, id int) (*User, error) {
// ...
if err != nil {
return nil, fmt.Errorf("查询用户 %d 失败: %w", id, err)
}
}
// 调用方可以这样判断
user, err := GetUser(db, 1)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 处理记录不存在的情况
} else {
// 处理其他错误
}
}
3.4.3 区分业务错误和系统错误
我个人习惯,把错误分成两类:
| 错误类型 | 示例 | 处理方式 |
|---|---|---|
| 业务错误 | 记录不存在、余额不足 | 返回给客户端,提示用户 |
| 系统错误 | 数据库连接失败、SQL 语法错误 | 记录日志,返回 500,报警 |
// 自定义业务错误
var ErrInsufficientBalance = errors.New("余额不足")
func DeductBalance(db *sql.DB, userID int, amount float64) error {
// ... 检查余额
if balance < amount {
return ErrInsufficientBalance // 业务错误,不包装
}
// ... 执行扣款
if err != nil {
return fmt.Errorf("扣款失败: %w", err) // 系统错误,包装
}
return nil
}
sql.ErrNoRows 转换成自定义的 ErrNotFound,这样上层业务代码就不用关心底层用的是 MySQL 还是 PostgreSQL 了。
好了,CRUD 操作的核心内容就这些。你可能会觉得,不就是增删改查吗,有什么好讲的?但说实话,很多线上事故都是因为这些「基础操作」没做好。预处理语句防注入、事务保证一致性、错误处理不吞异常——这三板斧练好了,你的数据库操作就稳了八成。
下一章,咱们聊聊连接池的配置和优化。这东西调好了,性能能翻倍;调不好,服务分分钟挂掉。到时候见。