---
tags: [GORM, Go, ORM, CRUD, 增删改查, First, Find, Create, Update, Delete, Select, Omit, Count, Distinct, Raw SQL, Or]
create time: 2026-04-28 00:00
---
# CRUD 操作
## 概述
CRUD(Create / Read / Update / Delete)是最基础的数据库操作。GORM 将 SQL 操作封装成了链式调用的 Go API,让你可以用「写 Go 代码」的方式与数据库交互。
想象一个电商场景:用户浏览商品 → 加入购物车 → 下单支付 → 查看订单历史 —— 这一连串动作背后就是 CRUD 在支撑。掌握 GORM 的查询、创建、更新和删除方法,就能覆盖日常开发中 **80% 的数据交互需求**。
> [!question] 读到这里你想过吗?
> 为什么 GORM 要设计 `First`、`Take`、`Last` 三个看起来很相似的方法?它们在什么场景下该用哪个?带着这个问题往下看。
## 五大查询方法
```mermaid
flowchart LR
Start["db.Model()
指定数据模型"] --> Choice{选择方法}
Choice --> |单条| Single[First / Take / Last]
Choice --> |批量| Batch[Find]
Single --> SingleRet["返回 *User
单条指针"]
Batch --> BatchRet["返回 []User
切片"]
style Start fill:#4FC08D,color:#fff
style Choice fill:#3B82F6,color:#fff
style SingleRet fill:#10B981,color:#fff
style BatchRet fill:#F59E0B,color:#fff
```
### 方法对比
| 方法 | 返回类型 | 行为 | 是否需要 Where |
|------|----------|------|----------------|
| `First` | 单条指针 | 按主键排序取第一条,**必须有条件** | ✅ 必须 |
| `Take` | 单条指针 | 随机取一条(ORDER BY 随机),**必须有条件** | ✅ 必须 |
| `Last` | 单条指针 | 按主键倒序取最后一条 | ✅ 必须 |
| `Find` | 切片 | 查询所有匹配行 | ❌ 可选 |
| `Get` | 单条指针 | `First` 的封装,自动处理空结果 | 同 First |
### First vs Take 的区别
> [!tip] 核心差异一句话总结
> `First` 是**有序的**(按主键升序),结果可预期;`Take` 是**无序的**(不指定排序),拿到的是任意一条。
```go
// First: 按主键升序取第一条(确定性结果)
db.First(&user) // SELECT * FROM users ORDER BY primary_key ASC LIMIT 1; WHERE ?
// 每次执行,只要数据没变,拿到的永远是同一条记录
// Take: 不按任何顺序,数据库返回哪条就是哪条
db.Take(&user) // SELECT * FROM users LIMIT 1;
// 适合随机抽奖、每日推荐等场景 —— 你不在乎是哪条,只在乎「随机」这个动作
```
> [!question] 思考题
> 如果表中只有一条记录,`First` 和 `Take` 的结果会一样吗?
>
> > **答案**:是的。当数据只有一条时,无论有无排序,结果都是它。区别在于多行数据时的行为。
### 实际用法示例
```go
// 1. First - 根据主键或条件查找单条记录
var user User
db.First(&user, 10) // WHERE id = 10 —— 按主键查询
db.First(&user, "name = ?", "john") // WHERE name = 'john' —— 自定义条件
// 2. Take - 随机取一条(抽奖、推荐场景常用)
var product Product
db.Take(&product) // 从全表中随机拿一条商品
// 3. Find - 批量查询返回切片
var users []User
db.Where("age > ?", 18).Find(&users) // WHERE age > 18 —— 成年用户列表
// 4. Get - 实际上是 First + 错误检查的组合写法
err := db.First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// 记录不存在 —— 需要给用户返回友好提示
}
```
**执行流程图解**:`First` → GORM 自动生成 `ORDER BY 主键 ASC LIMIT 1 WHERE ...` → 返回指针。如果找不到记录,会在 `.Error` 中设置 `gorm.ErrRecordNotFound` 而不是直接抛 panic。
> [!danger] 常见误区
> `Find` 不带 `Where` 会查询全表!数据量大时会导致 OOM。始终记得加过滤条件,或用 `Limit` 兜底。
## 补充查询方法
除了前面提到的五大核心方法,GORM 还有两个非常实用的查询 API:
### Scan — 将结果扫描到任意结构体
当你不需要完整模型,只想提取部分字段时,`Scan` 可以把查询结果直接映射到你自定义的结构体上,节省内存和带宽。
```go
// 只查用户名和邮箱,扫描到轻量结构体
type UserSummary struct {
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
}
var summaries []UserSummary
db.Table("users").Select("name, email").Scan(&summaries)
```
> [!tip] 使用场景
> - **报表统计**:只需几个聚合字段时,避免加载整个模型
> - **API 响应**:防止泄露敏感字段(如密码、内部标记)
### Pluck — 提取单一列
想快速获取某一列的所有值?`Pluck` 直接返回切片,无需额外处理。
```go
// 获取所有用户名
var names []string
db.Model(&User{}).Pluck("name", &names)
// SELECT name FROM users; -> []string{"Alice", "Bob", ...}
// 配合 Distinct 去重
var roles []string
db.Model(&User{}).Pluck("DISTINCT role", &roles)
```
### Or —— 条件"或"组合
`Where` 默认是"与"关系。当需要多个条件**只要满足其一即可**时,使用链式 `Or`:
```go
// 查找名字为 "Alice" 或者角色为 "admin" 的用户
db.Where("name = ?", "Alice").Or("role = ?", "admin").Find(&users)
// SELECT * FROM users WHERE name = 'Alice' OR role = 'admin';
// 多组"或"嵌套 —— 用括号包裹
db.Where("role = ?", "admin").
Where(db.Or("status = ?", "active").Or("status = ?", "super")).
Find(&users)
// SELECT * FROM users WHERE role = 'admin' AND (status = 'active' OR status = 'super');
```
> [!warning] Or 和 Where 的顺序很重要
> GORM 将 `Or` 附加到**前一个 Where 所在的逻辑组**。先写 `Where + Or` 再套 `Where`,得到的是 `A OR B AND C` 而非 `(A OR B) AND C`。如果你期望后面的括号效果,请用 `db.Or()` 显式包裹。
### Raw SQL —— 原生 SQL 查询与执行
有些场景 GORM 无法优雅表达(如复杂子查询、存储过程调用),这时可以退回到原生 SQL。GORM 提供了两类 API:
| 方法 | 用途 | 返回 |
|------|------|------|
| `Raw(sql, args...)` | 构造原生查询,仍可链式调 Select/Where 等 | `*gorm.DB` |
| `Exec(sql, args...)` | 直接执行 DML/DDL,不返回数据行 | `sql.Result` |
```go
// 1. Raw — 原生 SQL + GORM 链式方法混搭
var results []User
db.Raw("SELECT * FROM users WHERE created_at > ?", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)).
Order("id DESC").
Find(&results)
// 用原生 SQL 写初始过滤条件,再用 GORM 链做排序和映射
// 2. Exec — 执行无返回结果的语句(INSERT/UPDATE/DELETE/DDL)
db.Exec("DELETE FROM expired_sessions")
db.Exec("ALTER TABLE orders ADD COLUMN remark TEXT")
// 3. 带参数绑定的原生查询(防 SQL 注入)
var count int
db.Raw("SELECT COUNT(*) FROM users WHERE status = ?", "active").Scan(&count)
fmt.Println("活跃用户数:", count)
```
> [!tip] 为什么要用参数绑定而不是字符串拼接?
> ```go
> // ❌ 危险 — 字符串拼接可被 SQL 注入攻击
> db.Raw("SELECT * FROM users WHERE name = '" + input + "'")
>
> // ✅ 安全 — 参数化查询由驱动层转义
> db.Raw("SELECT * FROM users WHERE name = ?", input)
> ```
### Rows —— 游标式逐行处理
对于超大数据集,一次性加载所有结果到内存会导致 OOM。此时可以用 `Rows()` 返回标准库的 `*sql.Rows`,逐行迭代:
```go
rows, err := db.Table("logs").Where("level = ?", "error").Rows()
if err != nil { ... }
defer rows.Close()
for rows.Next() {
var id uint
var msg string
rows.Scan(&id, &msg) // 手动扫描字段
processError(id, msg) // 自定义处理逻辑
}
```
> [!question] 什么时候该用 Rows 而不是 Find?
> - **数据量极大**(百万级以上)→ `Rows()` 逐行消费,内存可控
> - **每行处理成本高** → 避免全部加载后再逐个处理
> - **普通场景(几千条以内)** → `Find()` 更简洁,不用手动管理 `Close()`
## 字段选择查询
掌握了如何查数据后,接下来思考一个问题:**每次全量读取整张表真的是最高效的方式吗?**
显然不是——只需要的字段才去读,既能节省网络带宽,也能减少序列化的开销。
### Select — 指定要查哪些列
```go
// 查 name 和 email 两个字段,扫描到完整模型(仅加载部分字段)
type UserLite struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:64"`
Email string `gorm:"size:128"`
}
var users []UserLite
db.Model(&User{}).Select("id, name, email").Find(&users)
// SELECT id, name, email FROM users;
// 结合 Pluck 提取单一列
var emails []string
db.Model(&User{}).Pluck("email", &emails)
// SELECT email FROM users; -> []string{"alice@example.com", ...}
```
### Omit — 排除某些列
与 `Select` 相反,`Omit` 告诉 GORM **不要读取**这些字段,适合大文本字段(如 `content`、`bio`)不需要频繁读取的场景:
```go
// 查除了 content 和 password 之外的所有字段
db.Model(&Article{}).Omit("content", "password").Find(&articles)
// SELECT id, title, author_id, created_at FROM articles;
```
> [!tip] `Omit` vs `Select` 的选择策略
> - **要查询的列很少**(例如 3/20)→ `Select` 更简洁,明确列出所需字段
> - **要排除的列很少**(例如 3 个不要 / 17 个保留)→ `Omit` 更省事
> - **原则**:谁的参数少就用谁,代码更直观
> - **敏感字段**(密码、密钥等)永远用 `Omit` 排除——白名单式防护,防止重构或新增字段时意外暴露
### Distinct — 去重查询
```go
// 获取所有不重复的角色
var roles []string
db.Model(&User{}).Distinct("role").Pluck("role", &roles)
// SELECT DISTINCT role FROM users; -> ["admin", "user", "editor"]
// 组合使用 — 去重 + 排序
db.Model(&Order{}).Distinct().Order("created_at DESC").Find(&orders)
// SELECT DISTINCT * FROM orders ORDER BY created_at DESC;
```
### Count — 计数查询
```go
// 统计总行数
var total int64
db.Model(&User{}).Count(&total)
// SELECT COUNT(*) FROM users; -> total = 1523
// 配合条件统计
db.Model(&User{}).Where("status = ?", "active").Count(&total)
// SELECT COUNT(*) FROM users WHERE status = 'active';
```
> [!tip] Count 为什么返回值是 `int64`?
> MySQL 的 `COUNT()` 函数在结果超过 2^31 时可能超出 `int32` 范围,Go 中 `int` 在 32 位系统是 32 位,所以 GORM 统一返回 `int64` 保证安全。
## 创建(Create)
> [!tip] 理解 GORM 的插入逻辑
> `db.Create(&user)` 背后执行的是标准的 `INSERT INTO ...` SQL。GORM 会自动把结构体字段映射到数据库列 —— 前提是字段上配有 `gorm:"column:name"` tag,或者遵循驼峰转下划线的命名约定。
```go
// 单条插入
user := User{Name: "Alice", Age: 30}
result := db.Create(&user)
// result.RowsAffected — 影响行数(通常返回 1)
// result.Error — 错误信息(如唯一约束冲突时会报错)
// 批量插入(传入 slice,GORM 自动生成 VALUES (...), (...), (...))
users := []User{
{Name: "Bob", Age: 25},
{Name: "Charlie", Age: 35},
}
db.Create(&users) // INSERT INTO users (name, age) VALUES ('Bob', 25), ('Charlie', 35)
```
> [!question] 为什么 GORM 要自动回填 ID?
>
> 因为在 RESTful API 中,你经常需要在创建资源后立即返回新资源的 URL 或 ID。GORM 在 INSERT 完成后会立即查询最后插入的 ID 并赋值给结构体,省去了二次查询的麻烦。
> [!tip] 批量插入上限
> GORM 内部会将批量 insert 拆分为每批约 256 条,避免单次 SQL 过大。如需调整,可通过 `db.Session(&gorm.Session{FullSaveRecords: true})` 控制行为。
### 插入后获取自增 ID
```go
db.Create(&user)
fmt.Println(user.ID) // 插入后 ID 自动回填到结构体
```
## 更新(Update)
> [!tip] 理解三种更新策略的差异
> 在实际业务中,你可能只想改一个字段、更新部分字段、或者全量覆盖。GORM 为此提供了不同粒度的 API —— 选错了会导致「该更新的没更新」或「不该更新的被覆盖」。
### 方法速查表
| 方法 | 签名 | 行为 |
|------|------|------|
| `Update` | `(col string, value any)` | 更新单个字段 |
| `Updates` | `(value Model / map[string]any)` | 更新一个或多个字段 |
| `Save` | `(value Model)` | 全量更新所有字段 |
| `UpdateColumn` | `(col string, value any)` | 同 `Update` 但不触发 Hooks |
| `UpdateColumns` | `(value Model)` | 同 `Updates` 但不触发 Hooks |
### 详细示例
```go
// 1. Update - 单字段更新(最简洁的改法)
db.Model(&user).Update("name", "Bob")
// UPDATE users SET name='Bob', updated_at=... WHERE id=...
// ⚡ 只生成一个 SET 子句,SQL 最小化
// 2. Updates - 多字段(map 方式:零值也会被写入数据库)
db.Model(&user).Updates(map[string]any{
"name": "Alice",
"age": 0, // ✅ age 会被更新为 0
"role": "admin",
})
// 适合前端传来的完整表单数据 —— 你希望任何字段都原样写入
// 3. Updates - 多字段(struct 方式:跳过零值字段!)
db.Model(&user).Updates(User{Name: "Alice", Role: "admin"})
// 只有 Name 和 Role 被更新,Age、Status 等未填写的字段保持不变
// ⚡ GORM 会自动比对哪些字段「非零」才生成 SET 语句
// 4. Save - 全量覆盖(无视零值,全部字段写回数据库)
db.Save(&user)
// UPDATE users SET name='Alice', age=0, status='', ... WHERE id=...
// 谨慎使用 —— 会把所有字段重新写一遍,包括可能被意外覆盖的敏感字段
```
> [!warning] Zero Values 陷阱 —— struct vs map 的关键区别
| 传入方式 | 零值处理 | 典型场景 |
|----------|---------|---------|
| `Updates(struct)` | 跳过零值字段 | 局部修改(如只改昵称) |
| `Updates(map)` | 正常写入零值 | 前端完整表单提交 |
> **真实案例**:用户把年龄从 `25` 改为 `0`,如果用 struct 方式调用 `Updates(User{Age: 0})`,Age 根本不会出现在 SQL 中,导致数据没有被更新!这就是为什么上表里说「需要根据场景选 API」。
### 批量更新
```go
// 批量更新满足条件的行
db.Model(&User{}).Where("status = ?", "active").Update("role", "vip")
// UPDATE users SET role='vip' WHERE status='active';
```
## 删除(Delete)
> [!tip] GORM 的删除不是「删了就完事」
> 理解软删除和物理删除的区别,是安全操作数据库的第一步。在生产环境中,**默认倾向软删除**是更稳妥的做法。
### 软删除原理
当模型包含 `DeletedAt` 字段时,GORM 会自动开启软删除功能。此时调用 `db.Delete(&user)` **不会执行 DELETE 语句**,而是执行:
```sql
UPDATE users SET deleted_at = NOW() WHERE id = ?;
```
同时,后续所有查询(Find、First 等)**自动附加** `WHERE deleted_at IS NULL` 条件,被「删除」的记录对常规查询不可见。
> [!question] 软删除有什么优缺点?
| 优点 | 缺点 |
|------|------|
| 可恢复误删数据 | 占用存储空间,表越来越大 |
| 保留审计追踪(谁在什么时候删的) | 查询需要额外过滤,性能下降 |
| 避免外键约束问题 | 需要手动处理已删除记录的关联数据 |
```go
// 1. 根据主键物理删除
db.Delete(&user, 10) // DELETE FROM users WHERE id = 10 —— 不可恢复!
// 2. 条件批量删除(删除所有未成年用户)
db.Where("age < ?", 18).Delete(&User{})
// DELETE FROM users WHERE age < 18;
// 3. 软删除(模型含 DeletedAt 时生效)
db.Delete(&user) // UPDATE users SET deleted_at=NOW() WHERE id=?
// 数据还在库里,只是查询时自动被过滤掉了
```
> [!info] 物理删除 — 彻底擦除
> 当你确认需要永久移除数据时(如合规要求),使用 `Unscoped`:
> ```go
> db.Unscoped().Delete(&user, 10) // 真正的 DELETE FROM users WHERE id=10
> ```
> [!warning] 执行前三思
> 物理删除是不可逆操作。建议在业务层先做「逻辑标记」(如加 `status='deleted'` 字段),保留至少 30 天的恢复窗口。
## 错误处理
> [!tip] GORM 的错误处理哲学
> GORM **从不 panic**,所有错误都通过返回值或 `.Error` 字段返回。这意味着你可以在业务层优雅地处理异常,而不是让程序崩溃。
```go
result := db.First(&user, 100)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
// 用户不存在 —— 可以返回 404 或默认值
fmt.Println("用户不存在")
} else if result.Error != nil {
// 数据库层面的错误(连接失败、SQL 语法错误等)
fmt.Println("数据库错误:", result.Error)
}
```
| 错误常量 | 含义 | 典型处理策略 |
|----------|------|-------------|
| `gorm.ErrRecordNotFound` | 查询结果为空 | 返回 404 或默认值 |
| `gorm.ErrDuplicatedKey` | 唯一约束冲突 | 提示用户「用户名已存在」 |
| `gorm.ErrInvalidData` | 插入/更新的数据无效 | 校验前端输入后重试 |
| `gorm.ErrTxAlreadyCommitted` | 事务已提交 | 检查事务逻辑是否有重复调用 |
> [!warning] 永远不要忽略 Error
> ```go
> // ❌ 危险写法 —— 错误被静默吞掉了
> db.Create(&user)
>
> // ✅ 正确写法
> if err := db.Create(&user).Error; err != nil {
> log.Printf("创建失败: %v", err)
> }
> ```
## CRUD 决策流程图
```mermaid
flowchart TD
Start[收到数据操作请求] --> Type{操作类型_}
Type --> |读取| ReadQ{要几条_}
ReadQ --> |1条| OneQ{需要确定性顺序_}
OneQ --> |是| FirstNode[First_按主键升序]
OneQ --> |否_随机| TakeNode[Take_随机一条]
ReadQ --> |N条| FindNode[Find_加Where条件]
Type --> |新增| CreateQ{单条还是批量_}
CreateQ --> |单条| CreateSingle[Create]
CreateQ --> |批量| CreateBatch[Create_slice]
Type --> |修改| UpdateQ{改多少字段_}
UpdateQ --> |1个| UpdateOne[Update_col_val]
UpdateQ --> |部分| ZeroQ{需要更新零值_}
ZeroQ --> |是| UpdateMap[Updates_map]
ZeroQ --> |否| UpdateStruct[Updates_struct]
UpdateQ --> |全部| SaveNode[Save]
Type --> |删除| SoftQ{启用SoftDelete_}
SoftQ --> |是| SoftDel[Delete_软删除]
SoftQ --> |否| HardDel[Delete_物理删除]
style Start fill:#4FC08D,color:#fff
style FirstNode fill:#3B82F6,color:#fff
style CreateSingle fill:#8B5CF6,color:#fff
style SaveNode fill:#EC4899,color:#fff
style SoftDel fill:#F59E0B,color:#000
```
## 关联笔记
- [[01-安装与初始化]]
- [[02-模型定义]]
- [[04-条件查询]]
- [[10-软删除]]
- [[14-错误处理]]