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 里的事务操作,核心就三步:

  1. 开启事务tx, err := db.Begin()
  2. 执行操作:用 tx.Exec 代替 db.Exec
  3. 提交或回滚:成功就 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()
}
避坑指南: 我曾经犯过一个错误——在事务里执行了查询操作,但没有及时关闭 rows。结果事务一直持有连接,导致其他请求排队等待。记住:事务里的操作要快,别在里面做耗时操作,比如调用外部 API、发邮件什么的。事务应该只包含数据库操作。

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.Iserrors.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 操作的核心内容就这些。你可能会觉得,不就是增删改查吗,有什么好讲的?但说实话,很多线上事故都是因为这些「基础操作」没做好。预处理语句防注入、事务保证一致性、错误处理不吞异常——这三板斧练好了,你的数据库操作就稳了八成。

下一章,咱们聊聊连接池的配置和优化。这东西调好了,性能能翻倍;调不好,服务分分钟挂掉。到时候见。