--- 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-错误处理]]