Files
cs-note/hhs/GORM/03-CRUD 操作.md
T
2026-05-24 11:42:38 +08:00

492 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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()<br/>指定数据模型"] --> Choice{选择方法}
Choice --> |单条| Single[First / Take / Last]
Choice --> |批量| Batch[Find]
Single --> SingleRet["返回 *User<br/>单条指针"]
Batch --> BatchRet["返回 []User<br/>切片"]
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-错误处理]]