880 lines
34 KiB
Markdown
880 lines
34 KiB
Markdown
---
|
||
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 分
|