--- tags: - 后端 - Go - GORM - ORM - 测试 - 自我考察 create time: 2026-04-29 15:30 --- # GORM 框架自测题 ## 概述 这是一份 **GORM 框架**的自测试卷,覆盖核心机制 → 查询技巧 → 工程实践全链路知识点。通过选择题、填空题、代码补全、主观题四种题型帮助你检验对 GORM 的理解程度,找到知识盲区后回到对应笔记重新学习。 --- ## 正文 ## 一、选择题(每题 3 分,共 30 分) > 每题只有一个正确答案。 ### Q1.【软删除的设计】 为什么 GORM 的软删除使用 `DeletedAt` 时间戳而不是 `IsDeleted BOOLEAN`?以下原因**最全面**的是: A. BOOLEAN 无法区分"未删除"和"误删恢复"两种场景 B. 时间戳记录精确的删除时刻便于审计;NULL 表示未删除语义更清晰 C. BOOLEAN 在 MySQL 中占 1 byte,时间戳占 8 bytes,浪费存储空间 D. BOOLEAN 会导致唯一约束检查变慢 > [!tip]- Q1 答案 > **B — 时间戳提供审计信息 + NULL 语义清晰** > > **解析:** > `gorm.DeletedAt`(本质是 `time.Time`)相比 BOOLEAN 的优势在于: > > 1. **精确时间**:记录了确切的删除时刻,方便后续审计和问题排查 > 2. **NULL = 未删除**:Go 中零值 `time.Time{}` 不等于 nil,GORM 用 `nil` 指针表示"未删除"——这与 BOOLEAN 的 `false = 正常` 相比,语义更不容易混淆 > 3. **时间排序**:可以按删除时间排序,做定时清理策略时更方便 > > **为什么不是其他选项:** > - ❌ A:BOOLEAN 也能通过业务逻辑区分恢复,这不是核心原因 > - ❌ C:这是缺点而非设计理由——软删除本身就比物理删除多占空间 > - ❌ D:BOOLEAN 和时间戳在唯一约束上的行为差异取决于数据库实现,不是 GORM 的考量 > > 💡 **拓展:** `DeletedAt` 的类型是 `*time.Time`(指针),这样就能用 `nil` 表示未删除。如果用的是非指针 `time.Time`,零值也会被误判为已删除。 ### Q2.【Preload vs Joins】 关于 `Preload` 和 `Joins` 的区别,以下说法**正确**的是: A. `Preload` 只执行一条 SQL,`Joins` 执行多条 SQL B. `Preload` 默认对关联数据加软删除过滤,`Joins` 不会自动加 C. `Joins` 返回的数据会自动填充嵌套 struct,`Preload` 需要手动映射 D. `Joins` 会产生行膨胀(笛卡尔积),`Preload` 不会产生 > [!tip]- Q2 答案 > **D — Joins 会产生行膨胀,Preload 不会** > > **解析:** > > | 特性 | Preload | Joins | > |------|---------|-------| > | SQL 数量 | N+1 条(先查主表,再批量 IN 查关联) | 通常 1 条复杂 JOIN SQL | > | 行重复 | ❌ 不会产生 | ✅ 一对多关系会产生重复行(一个用户有多个订单会返回多行 User) | > | 关联过滤 | 容易(WHERE 作为第二参数) | 需写在 JOIN 子句中,控制不够灵活 | > | 内存占用 | 适中 | JOIN 膨胀时较高 | > > **逐项分析:** > - ❌ A:反了——Preload 是多条 SQL,Joins 是一条 > - ❌ B:两者都会自动加软删除过滤,GORM 内部都对关联查询附加 `deleted_at IS NULL` > - ❌ C:两者都能自动填充嵌套 struct,不需要手动映射 > - ✅ D:正确!LEFT JOIN 时若 OneToMany 关系会返回多条相同主记录的行(行膨胀),而 Preload 分两次查询完全避免了这个问题 > > 💡 **经验法则:** "优先 Preload,必要时 Joins"——除非确定关联数据量极小,否则 Preload 更安全可控。 ### Q3.【First vs Take vs Last】 三个方法都要求带条件,关于它们的行为差异,以下说法**正确**的是: A. `First` 和 `Take` 都会按主键排序后取第一条 B. `Take` 是无序的,适合随机抽奖或推荐场景 C. `Last` 不按主键排序,而是按创建时间倒序 D. 三者不带 Where 都能正常工作 > [!tip]- Q3 答案 > **B — Take 无序,适合随机场景** > > **解析:** > > | 方法 | 排序方式 | 结果确定性 | 是否需要 Where | > |------|---------|-----------|---------------| > | `First` | 主键升序(ORDER BY PK ASC LIMIT 1) | ✅ 确定性 | ✅ 必须 | > | `Take` | **不指定任何排序** | ❌ 任意一条 | ✅ 必须 | > | `Last` | 主键降序(ORDER BY PK DESC LIMIT 1) | ✅ 确定性 | ✅ 必须 | > > **逐项分析:** > - ❌ A:`Take` 不带排序,数据库返回哪条就是哪条——这恰恰是关键区别 > - ✅ B:正确。`Take` 等同于 `SELECT * FROM table LIMIT 1`,没有 ORDER BY,所以每次拿到的可能不同,正好适合"随机"语义 > - ❌ C:`Last` 是按主键倒序,不是创建时间 > - ❌ D:文档明确写了三者都需要至少一个 Where 条件 > > 💡 **实际选择:** 要"最新的记录"用 `First`;要"随便来一条"用 `Take`;要"最后一条"用 `Last`。 ### Q4.【Updates 的 zero value 陷阱】 以下代码的输出结果是? ```go user := User{Name: "Alice", Age: 25} db.Model(&user).Updates(User{Name: "", Status: "active"}) ``` A. UPDATE SET name='', status='active' —— Name 被更新为空字符串 B. UPDATE SET status='active' —— Name 跳过了,因为空串是零值 C. UPDATE SET name='', status='active' —— struct 方式的 Updates 不会跳过零值 D. 报错,因为不能传入零值 > [!tip]- Q4 答案 > **B — struct 方式跳过了零值字段** > > **解析:** > GORM 的 `Updates` 有两种传参方式,处理方式截然不同: > > | 方式 | 零值处理 | 生成的 SQL | > |------|---------|-----------| > | `Updates(struct)` | **跳过零值字段**(Zero Value Skipped) | `UPDATE users SET status='active' WHERE ...` | > | `Updates(map[string]any)` | **写入所有 key**(包含零值) | `UPDATE users SET name='', status='active' WHERE ...` | > > 在上面的例子中,`Name: ""` 是 string 类型的零值,所以 `Updates(User{Name: "", Status: "active"})` 只会生成 `SET status='active'`。 > > **真实踩坑案例:** 用户想把昵称清空,调用了 `Updates(User{Nickname: ""})`,结果什么 SQL 都没生成——数据完全没有变化! > > 💡 **解决方案:** 需要更新零值时用 map 方式:`Updates(map[string]any{"name": ""})`。 ### Q5.【事务回调的错误返回值】 ```go err := db.Transaction(func(tx *gorm.DB) error { var account Account if err := tx.First(&account, 1).Error; err != nil { return err } account.Balance -= 100 tx.Save(&account) return nil }) ``` 如果 `tx.First` 找不到账户(返回 `ErrRecordNotFound`),会发生什么? A. 错误被静默吞掉,继续执行 Save B. 函数 return err → GORM 自动 Rollback,最终 `err == ErrRecordNotFound` C. 函数 panic,因为 error 没有包装 D. GORM 自动把错误转换为 HTTP 404 响应 > [!tip]- Q5 答案 > **B — Transaction() 回调返回 error 自动回滚** > > **解析:** > `db.Transaction()` 是 GORM 提供的便捷事务 API,它的核心机制是: > > 1. **内部自动 Begin**:进入回调前开启事务 > 2. **return nil → Commit**:回调返回 nil 说明一切正常,GORM 自动提交 > 3. **return non-nil error → Rollback**:回调返回任何非 nil error,GORM 自动回滚 > 4. **panic → Rollback**:回调内 panic,GORM 捕获后也回滚 > > **在这个例子中:** > - `tx.First` 找不到记录 → 返回 `ErrRecordNotFound` > - `return err` → GORM 检测到非 nil → 自动 `Rollback()` > - 外层 `err` 拿到的是包装后的 `ErrRecordNotFound` > > **为什么要这样设计?** 避免了手动写 `Begin/Rollback/Commit` 的大量样板代码,且天然防止"事务泄漏"——出了闭包就拿不到 tx 实例了。 > > 💡 **限制:** 回调内不能再调用 `Transaction()`,否则 panic——因为此时已经处于事务中了。 ### Q6.【WHERE vs HAVING】 以下 GORM 查询语句的错误原因是: ```go db.Model(&Order{}).Where("COUNT(*) > 5").Group("user_id").Find(&result) ``` A. GORM 不支持 GROUP BY B. WHERE 不能在 GROUP BY 之前执行聚合函数 C. Find 不能和 Group 连用 D. COUNT 必须配合 Select 才能使用 > [!tip]- Q6 答案 > **B — WHERE 在分组前执行,无法使用聚合函数** > > **解析:** > SQL 的标准执行顺序是: > ``` > FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY > ``` > > **关键理解:** `WHERE` 发生在 `GROUP BY` 之前——此时还没有分组,`COUNT(*)` 毫无意义。SQL 引擎会在解析 WHERE 时就报语法错误。 > > **正确写法:** 把聚合条件放到 `HAVING` 中: > ```go > db.Model(&Order{}). > Group("user_id"). > Having("COUNT(*) > ?", 5). // ✅ HAVING 才能用聚合函数 > Find(&result) > ``` > > **如果想先用 WHERE 缩小范围再做聚合:** > ```go > db.Model(&Order{}). > Where("created_at > ?", cutoff). // 先用 WHERE 过滤单行 > Group("user_id"). > Having("COUNT(*) > ?", 5). // 再用 HAVING 过滤聚合结果 > Find(&result) > ``` > > 💡 **记忆口诀:** "WHERE 筛行,HAVING 筛组"。 ### Q7.【钩子与操作的关系】 如果一个模型包含 `DeletedAt` 字段并嵌入了 `gorm.Model`,当调用 `db.Delete(&user)`(软删除)时,哪个钩子**会被调用**? A. `BeforeDelete` 和 `AfterDelete` B. `BeforeUpdate` 和 `AfterUpdate` C. `BeforeDelete` 和 `AfterUpdate` D. 没有任何钩子被调用 > [!tip]- Q7 答案 > **B — BeforeUpdate 和 AfterUpdate** > > **解析:** > 这是软删除最常见的误区之一。虽然名字叫 `Delete()`,但在软删除模式下它发送的是 **UPDATE 语句**: > ```sql > UPDATE users SET deleted_at = NOW() WHERE id = ? > ``` > > **因此触发的是 UPDATE 钩子链:** > ``` > BeforeDelete(❌ 不会被调用) > ↓ > BeforeUpdate (✅ 被调用)→ SQL: UPDATE ... SET deleted_at=... > ↓ > AfterUpdate (✅ 被调用) > ↓ > AfterDelete (❌ 不会被调用) > ``` > > **如果想触发 BeforeDelete / AfterDelete:** 需要用 `Unscoped().Delete()` 执行真正的 DELETE 语句。 > > 💡 **工程影响:** 如果你在 `BeforeDelete` 里做了级联清理(如删除 Redis Session),软删除时这些逻辑**不会执行**——需要额外处理。 ### Q8.【DryRun vs Debug】 关于 DryRun 模式,以下说法**错误**的是: A. DryRun 会构建完整 SQL 但**不执行**,零网络开销 B. DryRun 的参数占位符会保持为 `?`,不会展开为实际值 C. `Debug()` 方法会修改全局 Logger 配置,影响后续所有查询 D. 可以用 DryRun 在 CI Pipeline 中校验 struct tag 变更是否合法 > [!tip]- Q8 答案 > **C — Debug() 不影响全局配置** > > **解析:** > > | 特性 | DryRun | Debug() | > |------|--------|---------| > | 是否执行 SQL | ❌ 不执行 | ✅ 执行 | > | 性能开销 | 零(无网络 IO) | 正常查询开销 | > | 参数占位符 | 保持 `?` | 展开为实际值 | > | 全局影响 | 无 | 无——返回新 `*gorm.DB` 实例 | > > **逐项分析:** > - ✅ A:正确。DryRun 只做 SQL 构建,不走网络 > - ✅ B:正确。预编译语句的参数化就是安全的体现 > - ❌ C:**错误!** `Debug()` 返回一个新的 `*gorm.DB` 实例,原 DB 不受影响。这是函数式编程的思想 > - ✅ D:正确。可以在 CI 中跑 DryRun 验证迁移脚本不会引入破坏性变更 > > 💡 **实战用法:** `db.Session(&gorm.Session{DryRun: true}).First(&user, 1)` 可以用于审计复杂的链式调用生成的最终 SQL。 ### Q9.【Preload 与 N+1 问题】 以下代码中提到了 **N+1 问题**,请问这里的 N 指的是: ```go var users []User db.Preload("Orders").Find(&users) ``` A. 用户的总数 B. Orders 的总行数 C. 预处理阶段执行的次数 D. 数据库连接数 > [!tip]- Q9 答案 > **A — 用户总数** > > **解析:** > Preload 的工作方式是"两步走": > > ``` > SQL 1: SELECT * FROM users; ← 获取 N 个用户 > SQL 2: SELECT * FROM orders WHERE user_id IN (1,2,...); ← 一次性批量查询所有关联订单 > ``` > > 这里的"N+1"名称来源于另一种**懒加载**(手动循环查关联)的模式: > ```go > var users []User > db.Find(&users) // SQL 1: 查用户 > for _, u := range users { > db.Where("user_id = ?", u.ID).Find(&u.Orders) // SQL 2~N+1: 每个用户一条 SQL > } > ``` > > Preload 把 N+1 优化成了**恰好 2 条**(无论用户多少),因为第二条 SQL 用的是 `IN (...)` 批量查询。 > > 💡 **延伸:** Preload 的深度是层级维度——`Preload("Orders.Products")` 变成 3 条 SQL(Users + Orders + Products),每层各一次批量 IN 查询。 ### Q10.【连接池配置】 生产环境中连接池的配置,以下做法**正确**的是: A. `SetMaxOpenConns(0)` 不设上限,让 Go 自己决定最大连接数 B. `SetConnMaxLifetime(8 * time.Hour)` 匹配 MySQL 默认的 wait_timeout C. `SetMaxIdleConns(50)` 设为 MaxOpenConns 的一半以应对突发流量 D. `SetConnMaxLifetime(5 * time.Minute)` 小于数据库侧的 wait_timeout > [!tip]- Q10 答案 > **D — ConnMaxLifetime 必须小于数据库 wait_timeout** > > **解析:** > > | 参数 | 常见错误 | 正确做法 | > |------|---------|---------| > | `MaxOpenConns` | 设为 0(无上限) | 显式设置,不超过 MySQL max_connections | > | `MaxIdleConns` | 设得过大浪费资源 | CPU 核数或 10~20,约为 MaxOpenConns 的 10%~25% | > | `ConnMaxLifetime` | 设为 8h 匹配 MySQL 默认 | **设为 5~10 分钟**,远小于 MySQL 的 8h wait_timeout | > | `ConnMaxIdleTime` | 不设置 | 设为 5 分钟,减少闲置连接占用 | > > **关键原理:** MySQL 默认 `wait_timeout = 8h`——空闲连接超过 8 秒务端主动断开。如果 Go 的连接池不知道这一点(即 `ConnMaxLifetime >= 8h`),它会从池中取出已经断开的旧连接,导致 `server has gone away` 错误。 > > **正确的参数关系:** `IdleTime < Lifetime << wait_timeout` > > 💡 **经验公式:** `MaxOpenConns = CPU核心数 × 2 + 磁盘数`。例如 4 核 2 盘 → 10(I/O 密集型可放宽到 20~50)。 --- ## 二、填空题(每题 3 分,共 24 分) > 根据知识填写空缺的代码或概念。 ### Q11.【建表与表名推导】 ```go type Article struct { ID uint Title string } // GORM 推断的表名是 _________ db.AutoMigrate(&Article{}) // 显式指定表名的方法是实现 _________ 方法 ``` > [!tip]- Q11 答案 > **`articles`** ; **`TableName() string`** > > **解析:** > GORM 的默认命名策略是将 struct 名转为蛇形复数形式: > - `User` → `users` > - `Article` → `articles` > - `OrderItem` → `order_items` > > `TableName()` 是 model 级别的约定,GORM 会优先使用其返回值作为表名。用值接收者 `(Article)` 而非指针 `(Article)` 是因为 GORM 内部先尝试值接收者,找不到再试指针接收者——值接收者兼容性更好。 > > 💡 **注意:** `db.Table("custom_name")` 只在当前链式调用中生效,不会修改模型的 `TableName()` 返回值。 ### Q12.【批量插入参数含义】 ```go users := make([]User, 350) db.CreateInBatches(&users, 100).Error ``` 上面代码总共执行 _____ 批 INSERT,每批最多 _____ 条记录。 > [!tip]- Q12 答案 > **4** ; **100** > > **解析:** > `CreateInBatches(slice, batchSize)` 将 slice 拆分为多个批次: > > ``` > 350 ÷ 100 = 3.5 → 向上取整为 4 批 > 第 1 批:100 条 → INSERT INTO users VALUES (...), (...), ... > 第 2 批:100 条 > 第 3 批:100 条 > 第 4 批:50 条 ← 剩余的不足一批的数据单独执行 > ``` > > 如果 `batchSize = 200`:350 ÷ 200 = 1.75 → 2 批(200 + 150) > > 💡 **底层机制:** GORM 内部的 `Create` 也会自动拆批(约 256 条/批),但不可自定义。需要精确控制时请用 `CreateInBatches`。 ### Q13.【关联查询的外键位置】 ```go type User struct { ID uint Name string } type Order struct { ID uint UserID uint User User `gorm:"_________"` // 空1:声明关联类型 } ``` `Order` **属于** `User`,应使用的关联类型是 ____________(填关联类型名);该关联的外键位于 ____________(填"当前方"或"被关联方")的表中。 > [!tip]- Q13 答案 > **`belongsTo`** ; **当前方** > > **解析:** > GORM 四种关联的核心区别在于**外键在哪张表上**: > > | 关联类型 | 描述 | 外键位置 | 示例 | > |---------|------|---------|------| > | HasOne | 一对一 | 被关联方 | User → Profile | > | HasMany | 一对多 | 被关联方 | User → Orders | > | BelongsTo | 多对一 | **当前方** | Order → User | > | ManyToMany | 多对多 | 中间表 | Order ↔ Product | > > `BelongsTo` 表示"属于"关系——订单属于某个用户。外键 `UserID` 定义在 `Order` 表(当前方),而不是 `User` 表。 > > 💡 **判断口诀:** "谁 belongs to 谁,外键就在谁这边"——Order belongs to User,外键在 Order 表。 ### Q14.【错误判断方式】 GORM 的错误应该通过 _______________ 来判断是否等于 `gorm.ErrRecordNotFound`,而不能直接用 `==` 比较。 > [!tip]- Q14 答案 > **`errors.Is(err, gorm.ErrRecordNotFound)`** > > **解析:** > GORM 的哨兵错误(如 `ErrRecordNotFound`)内部会用 `fmt.Errorf("%w", ...)` 进行包装,形成错误链。标准库的 `errors.Is()` 能逐层识别哨兵错误,而直接 `==` 比较在复杂场景中可能漏判。 > > ```go > result := db.First(&user, 1) > if errors.Is(result.Error, gorm.ErrRecordNotFound) { > // 正确处理 > } > ``` > > 💡 **最佳实践:** 统一使用 `errors.Is()` 做哨兵错误判断,`errors.As()` 做具体错误类型的类型断言。 ### Q15.【Upsert 冲突策略】 MySQL 中 ON DUPLICATE KEY UPDATE 的等效 GORM 写法: ```go db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "email"}}, DoUpdates: clause._________("last_login"), // 空1:填入方法名 }).Create(&users) ``` > [!tip]- Q15 答案 > **`AssignmentColumns`** > > **解析:** > `clause.OnConflict` 支持多种冲突策略: > > | 方法 | 作用 | 生成 SQL | > |------|------|---------| > | `DoNothing` | 冲突时不做任何事 | `ON CONFLICT DO NOTHING` | > | `AssignmentColumns(["col"])` | 冲突时更新指定列 | `ON CONFLICT DO UPDATE SET col = excluded.col` | > | `Assignments(gorm.Expr(...))` | 自定义表达式 | `ON CONFLICT DO UPDATE SET col = expr` | > > 对于 PostgreSQL,同样的写法生成 `ON CONFLICT (email) DO UPDATE SET last_login = excluded.last_login;`。 > > 💡 **进阶:** 冲突时递增计数器: > ```go > DoUpdates: clause.Assignments(gorm.Expr("count = count + 1")) > ``` ### Q16.【游标分页参数计算】 传统 OFFSET 分页中,第 5 页、每页 20 条数据的 Offset 值为 ________;对应的计算公式是 ________________(用变量表达)。 > [!tip]- Q16 答案 > **80** ; **`(page - 1) * pageSize`** > > **解析:** > > | 页码 | 每页数 | Offset | 结果 | > |------|--------|--------|------| > | 第 1 页 | 20 | (1-1)×20 = **0** | 第 1-20 条 | > | 第 3 页 | 20 | (3-1)×20 = **40** | 第 41-60 条 | > | 第 5 页 | 20 | (5-1)×20 = **80** | 第 81-100 条 | > > ```go > offset := (page - 1) * pageSize > db.Limit(pageSize).Offset(offset).Find(&items) > ``` > > 💡 **深度翻页问题:** OFFSET 80000 时数据库仍需扫描并跳过前面 80000 行。百万级数据下应改用游标分页(Keyset Pagination)。 ### Q17.【批量更新字段控制】 ```go // 只更新白名单中的字段(忽略 email) db.Model(&User{}). Where("status = ?", "trial"). ________("status", "plan"). // 空1:白名单控制 Updates(map[string]any{ "status": "active", "plan": "pro", "email": "should_not_change@test.com", }) ``` > [!tip]- Q17 答案 > **`Select`** > > **解析:** > > | 方法 | 类型 | 效果 | > |------|------|------| > | `Select("col1", "col2")` | **白名单** | 只更新指定的字段 | > | `Omit("col1")` | **黑名单** | 除了指定字段外的其他字段全部更新 | > > ```go > // 黑名单方式(排除 password) > db.Model(&User{}). > Omit("password", "secret_key"). > Updates(map[string]any{ > "name": "new name", > "password": "new pass", // 被忽略 > }) > ``` > > 两者可以组合:`Select("a", "b").Omit("b")` = 只更新 a。 > > 💡 **注意:** `Select` 和 `Omit` 只对 `Updates` 有效,对 `Save` 和 `UpdateColumn` 等全量写入方法无效。 ### Q18.【PrepareStmt 缓存机制】 开启 PrepareStmt 缓存后,GORM 使用 ____________ 存储已编译的 SQL 语句。这对 ____________ 类查询有显著的性能提升。 > [!tip]- Q18 答案 > **`sync.Map`** ; **固定模式 / 重复执行**(或"热点查询") > > **解析:** > `PrepareStmt: true` 的工作原理: > > 1. 第一次执行 `SELECT * FROM users WHERE id = ?` → GORM 预编译 SQL,存入 `sync.Map` > 2. 第二次执行相同的 SQL → 直接从缓存取预编译语句,跳过解析和计划编译 > > **适用场景:** 固定的查询模式(如按 ID 查询用户、按状态查询列表) > **不适用场景:** 大量参数不同的动态 SQL → 缓存命中率低,徒增内存消耗 > > ```go > db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{ > PrepareStmt: true, > }) > ``` > > 💡 **重要提醒:** PrepareStmt 的缓存是**独立于连接池**的,但它仍然依赖底层连接。高并发下仍需合理配置连接池大小。 --- ## 三、代码补全(每题 6 分,共 30 分) > 补充代码中空缺的部分。有些题目有多个空。 ### Q19.【级联创建与关联追加】 ```go // 创建用户的同时创建 Profile 并关联商品 user := User{ Name: "Alice", Profile: Profile{AvatarURL: "https://example.com/avatar.png"}, Orders: []Order{ {Amount: 99.9, Products: []Product{{Name: "Book", Price: 29}}}, }, } if err := db._________(&user).Error; err != nil { // 空1:方法名 log.Fatal(err) } // 给已有订单添加商品(追加,不覆盖) product := Product{Name: "New Book", Price: 39} db.Model(&order).Association("Products")._________(&product) // 空2:方法名 ``` > [!tip]- Q19 答案 > **`Create`** ; **`Append`** > > **解析:** > > **空 1:** `db.Create(&user)` 会自动级联写入关联数据——先生成 INSERT INTO users,再 INSERT INTO profiles,最后 INSERT INTO orders。这是 GORM 的"递归保存"特性。 > > **空 2:** Association API 提供了五个核心操作: > > | 方法 | 行为 | 适用场景 | > |------|------|---------| > | `Append` | 追加(新增不删除旧的) | 累积添加 | > | `Replace` | 替换全部(先删旧再加新) | 重新赋值 | > | `Delete` | 删除指定关联 | 移除单项 | > | `Clear` | 清空所有关联 | 批量移除 | > | `Count` | 统计关联数量 | 计数 | > > 💡 **Append 的 SQL 效果:** 只是向中间表 INSERT 新记录,不会影响已有的关联关系。 ### Q20.【事务中的锁行读取】 ```go func TransferMoney(fromID, toID uint, amount float64) error { return db.Transaction(func(tx *gorm.DB) error { // 转出账户需要悲观锁,防止并发扣款超余额 var fromAccount Account if err := tx._______("gorm:query_option", "FOR UPDATE"). First(&fromAccount, fromID).Error; err != nil { // 空1:设置查询选项的方法 return fmt.Errorf("查询转出账户失败: %w", err) } if fromAccount.Balance < amount { return errors.New("余额不足") } fromAccount.Balance -= amount if err := tx.________(&fromAccount).Error; err != nil { // 空2:更新账户的方法 return fmt.Errorf("扣款失败: %w", err) } var toAccount Account if err := tx.First(&toAccount, toID).Error; err != nil { return fmt.Errorf("查询入账账户失败: %w", err) } toAccount.Balance += amount if err := tx.Save(&toAccount).Error; err != nil { return fmt.Errorf("入账失败: %w", err) } return nil // 返回 nil → 自动 Commit }) } ``` > [!tip]- Q20 答案 > **`Set`** ; **`Save`** > > **解析:** > > **空 1:** `tx.Set("key", value)` 用于在查询中携带额外的 session 级选项。`"gorm:query_option"` 是一个内置键名,GORM 会将其原样拼接到 SQL 末尾——`SELECT ... FOR UPDATE` 就是在数据库层面加排他锁,防止其他事务同时读取并修改同一行。 > > **空 2:** `Save` 是全量更新,无视零值全部写回。这里用 `Save` 是因为需要确保 `Balance` 字段的最新值被持久化。 > > ```go > // FOR UPDATE 的效果对比: > // ❌ 无锁:两个并发请求同时读到 Balance=100,都扣 60,结果 Balance=-20(超卖) > // ✅ 有锁:第二个请求必须等待第一个事务结束后才能读,保证原子性 > ``` > > 💡 **延伸:** PostgreSQL 同样使用 `SELECT ... FOR UPDATE`;SQLite 默认串行执行事务,不需要显式加锁。 ### Q21.【条件预加载 + 排序】 ```go // 加载用户及其订单,但只加载金额大于 100 的订单,并按金额降序排列 db.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db._______("amount > ?", 100). _________("amount DESC") }).Find(&users) ``` > [!tip]- Q21 答案 > **`Where`** ; **`Order`** > > **解析:** > `Preload` 的第二个参数可以是函数形式的条件构造器: > > ```go > db.Preload("AssociationName", func(db *gorm.DB) *gorm.DB { > return db.Where("condition").Order("field") > }).Find(&parent) > ``` > > 这个函数的签名固定为 `func(*gorm.DB) *gorm.DB`——接收 DB 实例,返回修饰后的 DB 实例。可以在里面自由链式调用 `Where`、`Order`、`Limit`、`Select` 等方法。 > > 💡 **注意:** 函数形式的条件只适用于 Preload,不适用于 Joins。Joins 的条件需要写在 `Joins("Table", "condition")` 的第二个参数中。 ### Q22.【JSON 自定义类型的 Scanner】 ```go type JSONMap map[string]interface{} // Scan 方法负责从数据库读出数据(DB → Go) func (j *JSONMap) Scan(value interface{}) error { if value == nil { *j = nil return nil } str, ok := value._________ // 空1:类型断言 if !ok { str = []byte(fmt.Sprintf("%v", value)) } return json.Unmarshal(str, j) // 空2:反序列化的目标 } ``` > [!tip]- Q22 答案 > **`([]byte)`** ; **`j`**(指针接收者本身) > > **解析:** > `sql.Scanner` 接口定义了 `Scan(value interface{}) error` 方法,由 GORM 在查询后调用,将数据库读出的原始值转换为 Go 类型。 > > ```go > type Scanner interface { > Scan(value interface{}) error > } > ``` > > **流程分解:** > 1. `value` 是从数据库来的原始字节数组 `[]byte` > 2. 类型断言 `value.([]byte)` 提取字节切片 > 3. `json.Unmarshal(str, j)` 将 JSON 反序列化到 `*JSONMap`(指针接收者,这样才能修改外部结构体) > > 💡 **为什么用指针接收者?** `Scan` 需要将反序列化后的内容**写回**目标对象。如果用值接收者 `(j JSONMap)`,修改的只是副本,原始结构体不会被改变。同理 `Value()` 用值接收者 `(j JSONMap)` 是因为只需读不需写。 ### Q23.【事务内的条件检查与日志输出】 ```go func ExecTx(db *gorm.DB, fn func(tx *gorm.DB) error) error { tx := db.Begin() defer func() { if r := recover(); r != nil { tx._________ // 空1:出错时回滚 panic(r) } }() if err := fn(tx); err != nil { tx.Rollback() return err } return tx.__________.Error // 空2:提交事务的方法 } ``` > [!tip]- Q23 答案 > **`Rollback()`** ; **`Commit()`** > > **解析:** > 这是一个通用的事务封装函数 `ExecTx`,消除了每个业务函数里重复的 Begin/Rollback 样板代码。 > > ```go > // 使用方式 > err := ExecTx(db, func(tx *gorm.DB) error { > // 业务逻辑... > return nil // 成功 → ExecTx 自动 Commit > }) // 失败 → ExecTx 自动 Rollback > ``` > > **关键点:** > 1. `defer` 只在 panic 分支中执行 `Rollback()`,正常路径不调用——避免 Commit 后再 Rollback 报错 > 2. `tx.Commit()` 返回 `*gorm.DB`(和大多数 GORM 方法一样),`.Error` 获取最终结果 > > 💡 **对比:** 简单场景直接用 `db.Transaction(callback)` 即可,代码更简洁。复杂业务(如跨包传递 tx)才需要 `ExecTx` 这样的封装。 --- ## 四、主观题(每题 4 分,共 16 分) > 简要回答即可,不需要长篇大论。关键是说出你的理解。 ### Q24.【何时不该用事务?】 以下场景中哪些**不需要**用事务?请给出理由。 ① 单条 `INSERT` 记录 ② 转账:扣 A 加 B ③ COUNT / SUM 统计查询 ④ 下单:减库存 + 创建订单 + 生成流水 > [!tip]- Q24 参考答案 > > ① **不需要**——单条 INSERT 本身就是原子操作,数据库层面保证了要么全成功要么全失败,无需额外的事务包裹。 > > ② **必须用事务**——涉及两笔以上的资金变动,如果没有事务,可能出现"A 扣了钱但 B 没到账"的数据不一致。 > > ③ **不需要**——只读查询不涉及数据修改,不存在一致性问题。可以使用普通查询 + 缓存来减轻数据库压力。 > > ④ **必须用事务**——三步操作具有业务原子性要求:如果减库存成功但创建订单失败,会导致库存少了但没有对应订单。 > > **总结:** 涉及多步数据写入且要求原子性的场景才需要事务。单条写入、只读查询都可以免去事务开销。 ### Q25.【软删除 + 唯一索引冲突】 某电商系统中 `Product.SKU` 设置了 `uniqueIndex`。现有记录 SKU="ABC-123" 已被软删除。此时还能再次创建 SKU="ABC-123" 的新产品吗?MySQL 和 PostgreSQL 的行为是否一致?说明原因和你的解决方案。 > [!tip]- Q25 参考答案 > > **不一致!** > > - **MySQL(InnoDB):** ✅ 可以插入。InnoDB 在处理软删除行的唯一约束时有历史缺陷——已软删除的行不参与唯一约束检查,所以允许两条 SKU 相同的记录存在(一条被删,一条未删)。但这意味着可能出现"同一 SKU 多条有效记录"的数据不一致。 > - **PostgreSQL:** ❌ 不能插入。PG 的正确行为是唯一约束包含软删除行,会报 duplicate key 错误。 > > **解决方案:** > 1. **方案一(PG):** 用部分唯一索引 `CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL`,只约束未删除行 > 2. **方案二(通用):** 加版本字段 `SlugVersion uint`,保证同一 SKU 不会同时出现在两条未删除记录中 > 3. **方案三:** 应用层校验——插入前先检查是否有未删除的同 SKU 记录 > > 💡 **核心教训:** 不要依赖单一数据库的行为一致性,应在应用层做好校验。 ### Q26.【BeforeUpdate Changed 的最佳实践】 ```go func (u *User) BeforeUpdate(tx *gorm.DB) error { if tx.Statement.Changed("Password") { hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) u.Password = string(hashed) } return nil } ``` 为什么要用 `Changed("Password")` 判断,而不直接在 `BeforeUpdate` 里无条件哈希?这样做有什么优势? > [!tip]- Q26 参考答案 > > 核心原因是**避免重复哈希**。 > > 如果无条件哈希,每次 UPDATE 操作(即使改的是名字或邮箱)都会重新对密码做 bcrypt 哈希。bcrypt 故意很慢(计算密集型),频繁调用会带来不必要的性能开销。 > > 此外,如果用户修改了其他字段但未修改密码,数据库执行的是 `Updates(struct)`——`Password` 字段不在 struct 中参与更新,但前面的哈希操作仍然白白浪费了 CPU。 > > `Changed("Password")` 的作用:只有当 `Updates(map)` 的 map 中确实包含了 `password` 键时才执行哈希。这样既保证了安全性(密码改了就加密),又避免了不必要的计算。 > > 💡 **注意:** `Changed` 只对 `Updates` 方法有效,`Save` 是全量写入不会追踪变化。 ### Q27.【AutoMigrate 的局限性与替代方案】 为什么生产环境不推荐仅靠 `AutoMigrate` 管理数据库迁移?请至少列举三条原因,并简述推荐的替代方案。 > [!tip]- Q27 参考答案 > > **三条原因:** > > 1. **不可回滚:** AutoMigrate 只能"加"不能"减"。当代码回退到旧版本时,它无法撤销已添加的列或索引——只会跳过已存在的部分。版本化迁移方案通过 Down 脚本可以轻松应对回退场景。 > > 2. **不可预测:** GORM 内部决定迁移的执行顺序和具体 ALTER 语句,不同数据库方言的行为可能不一致。版本化迁移用手写 SQL,完全可控。 > > 3. **团队协作困难:** 每个人改了 model 就 auto migrate,容易产生冲突和不一致。版本化迁移通过文件合并可以更好地管理多人协作。 > > **推荐替代方案:** 本地开发用 `AutoMigrate + DryRun` 快速迭代,生产环境用专门的迁移工具(如 Goose 或 golang-migrate),手写版本化的 Up/Down SQL 脚本。这样既享受了开发便利,又保证了生产环境的可控性和可回滚能力。 --- ## 评分参考 | 题目类型 | 满分 | 权重 | |---------|------|------| | 选择题(Q1-Q10) | 30 分 | 30% | | 填空题(Q11-Q18) | 24 分 | 24% | | 代码补全(Q19-Q23) | 30 分 | 30% | | 主观题(Q24-Q27) | 16 分 | 16% | | **总计** | **100 分** | **100%** | **及格线:** ≥70 分 **优秀线:** ≥85 分