3. CRUD 操作:Create、First/Find/Take、Updates/Save、Delete 与软删除

GORM 的 CRUD 操作是构建 Web 服务数据层的基石。本章深入剖析每个操作的执行语义、边界条件与常见陷阱,帮助你在实际项目中精准控制数据流。

3.1 Create:插入与批量插入

Create 方法用于向数据库插入一条或多条记录。GORM 在插入时会自动处理主键、时间戳和关联字段。

单条记录创建

user := User{Name: "Alice", Age: 30}
result := db.Create(&user)

// 插入后 user.ID 会被自动回填
fmt.Println(user.ID)        // 1
fmt.Println(result.Error)   // nil
fmt.Println(result.RowsAffected) // 1

批量插入

传递切片即可实现批量插入,GORM 会合并为一条 SQL 语句执行,大幅提升性能。

users := []User{
    {Name: "Bob", Age: 25},
    {Name: "Carol", Age: 28},
    {Name: "Dave", Age: 32},
}
result := db.Create(&users)

fmt.Println(len(users))     // 3
fmt.Println(result.RowsAffected) // 3
性能提示:批量插入时,GORM 默认每 100 条记录拆分一次 SQL。可通过 db.CreateInBatches(&users, 50) 自定义批次大小。

指定字段插入

使用 Select 或 Omit 控制插入字段,避免零值或敏感字段被意外写入。

// 仅插入 Name 和 Age 字段
db.Select("Name", "Age").Create(&user)

// 跳过 Password 字段
db.Omit("Password").Create(&user)

3.2 查询:First / Find / Take

GORM 提供三种主要查询方法,各自适用于不同场景。理解它们的差异是避免“记录未找到”异常的关键。

方法 行为 未找到时 典型用途
First 按主键升序取第一条 返回 ErrRecordNotFound 获取单条记录,期望必须存在
Take 不排序取第一条 返回 ErrRecordNotFound 获取任意一条记录
Find 返回所有匹配记录 返回空切片,不报错 列表查询、条件筛选
// First:按主键升序
var user User
err := db.First(&user, 10).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
    // 处理未找到
}

// Take:不排序
db.Take(&user, "name = ?", "Alice")

// Find:返回切片,未找到时为空
var users []User
db.Where("age > ?", 20).Find(&users)
// users 为 [],不会返回错误
常见错误:使用 First 或 Take 时未检查 ErrRecordNotFound,直接访问返回的结构体字段会导致 panic。建议始终使用 errors.Is(err, gorm.ErrRecordNotFound) 进行判断。

3.3 Updates / Save:更新策略

GORM 提供两种更新方式:Save 全字段更新,Updates 选择性更新。选择错误可能导致零值覆盖或性能问题。

Save:全字段保存

Save 会更新所有字段,包括零值。适用于从数据库查询后修改再写回的场景。

// 先查询
var user User
db.First(&user, 1)

// 修改字段
user.Name = "Alice Updated"
user.Age = 0  // 零值

// Save 会更新所有字段,包括 Age=0
db.Save(&user)
// UPDATE users SET name='Alice Updated', age=0, ... WHERE id=1

Updates:选择性更新

Updates 只更新非零值字段(除非使用 Select 指定)。推荐用于部分更新 API。

// 使用结构体:零值字段会被忽略
db.Model(&user).Updates(User{Name: "Bob", Age: 0})
// 只更新 name,age 不变

// 使用 map:零值也会被更新
db.Model(&user).Updates(map[string]interface{}{
    "name": "Bob",
    "age":  0,
})
// name 和 age 都会被更新
最佳实践:RESTful API 的 PATCH 请求推荐使用 Updates + map,避免结构体零值问题。PUT 请求可使用 Save 或 Updates + Select 全字段更新。

3.4 Delete 与软删除

GORM 默认使用软删除:当模型包含 DeletedAt 字段时,Delete 不会真正删除记录,而是设置删除时间戳。

软删除模型定义

import "gorm.io/plugin/soft_delete"

type User struct {
    ID        uint
    Name      string
    DeletedAt soft_delete.DeletedAt `gorm:"index"`
}

软删除操作

// 软删除:设置 deleted_at 时间戳
db.Delete(&user, 1)
// UPDATE users SET deleted_at='2024-01-15 10:00:00' WHERE id=1

// 查询时自动忽略软删除记录
var users []User
db.Find(&users)
// SELECT * FROM users WHERE deleted_at IS NULL

// 查询包含软删除记录
db.Unscoped().Find(&users)
// SELECT * FROM users

物理删除

使用 Unscoped 可永久删除记录。

// 物理删除
db.Unscoped().Delete(&user, 1)
// DELETE FROM users WHERE id=1
事务边界注意:软删除字段 deleted_at 是唯一索引的一部分时,重复软删除再创建相同唯一键的记录会导致冲突。建议唯一索引组合 deleted_at 或使用唯一键 + 软删除时间戳的复合索引。

3.5 CRUD 操作流程总览

GORM CRUD 操作流程与边界 Create First / Find / Take Updates / Save Delete 含 DeletedAt? 软删除(设时间戳) 物理删除(DELETE) 说明:软删除通过 Unscoped() 可恢复或物理删除;Updates 配合 map 可避免零值覆盖问题

3.6 常见陷阱与避坑指南

  • 零值覆盖:使用结构体进行 Updates 时,零值字段(0、""、false)不会被更新。如需更新零值,使用 map[string]interface{} 或 Select 显式指定。
  • 软删除唯一索引冲突:软删除记录仍占用唯一索引。解决方案:组合唯一索引包含 deleted_at 字段,或使用 gorm.DeletedAt 的 uniq 标签。
  • 批量操作事务:批量 Create/Updates/Delete 默认在单个事务中执行。如需手动控制,使用 db.Transaction 包裹。
  • Find 与 First 的性能差异:Find 返回切片,First 返回单条。在只需要一条记录时使用 First 或 Take,避免不必要的数据传输。
核心原则:CRUD 操作的选择直接影响数据一致性和 API 行为。Create 关注字段白名单,查询关注未找到处理,更新关注零值策略,删除关注软/硬选择。每个操作都应在事务边界内明确其语义。