学习目标
学完本章,你应该能够:
- 用自己的话讲清楚什么是 ORM,以及它在什么场景能帮你省事、什么场景会拖后腿。
- 独立完成 GORM 的建连、连接池配置、AutoMigrate、CRUD、关联查询一整条基本链路。
- 解释 GORM 的软删除机制,以及它为什么会在查询时自动追加
WHERE deleted_at IS NULL。 - 说清并规避 GORM 最经典的"零值不更新“坑,知道用指针 /
sql.NullXXX/Select三种解法。 - 面试时能讲清楚
Preload与Joins、Save与Updates、以及AutoMigrate的边界——这些是八股高频题。
前置知识:
- Go 基础语法(结构体、tag、指针、
map)。 - 至少用过一种关系型数据库,知道表、主键、外键、索引是什么。
- 了解
database/sql的基本用法更佳,但不是必须。
本章你会动手做的事:
- 用 GORM 连上本地 MySQL,建一张
users表并读写几条数据。 - 故意触发一次"零值不更新"现象,再用指针字段把它修好。
- 给
User加一个Profile一对一关系,分别用Preload和Joins把数据捞出来对比。
复习提示:类比:ORM 就像"翻译官"。你只会说 Go(面向对象),数据库只会说 SQL(关系型)。ORM 站在中间,把你写的
user.Name = "Alice" 翻译成数据库能懂的 UPDATE users SET name='Alice'。好处是你不用背 SQL 语法,坏处是翻译有时不够精准,复杂句子还是得自己上手写。什么是ORM
ORM概念
ORM(Object-Relational Mapping)即对象关系映射,是一种将面向对象编程语言中的对象与关系型数据库中的表进行映射的技术。
核心思想:通过操作对象来操作数据库,而不需要编写原生SQL语句。
复习提示:白话:ORM 让你"像操作内存里的结构体一样操作数据库"。你改结构体的字段,框架负责把改动同步到表里,你基本不用手写
INSERT/UPDATE。这张图说明了映射方向:
flowchart LR
A[Go 结构体 User] -->|GORM 翻译成 SQL| B[(MySQL 表 users)]
C[代码 user.Name = Alice] -->|生成| D[UPDATE users SET name='Alice']
B -->|查询结果映射回| AORM的优点
- 简化开发:无需编写复杂的SQL语句
- 类型安全:编译时检查类型错误
- 数据库无关:切换数据库只需修改配置
- 提高可维护性:代码更易理解和维护
ORM的缺点
- 性能开销:相比原生SQL有一定性能损失
- 学习成本:需要学习框架的API和规则
- 灵活性受限:复杂查询可能难以实现
如何正确看待ORM
- 适合场景:常规CRUD操作、快速开发、团队协作
- 不适合场景:高性能要求、复杂查询优化、数据库特定功能
复习提示:⚠️ 新手必踩的坑:别把 ORM 当银弹。GORM 在简单 CRUD 上很香,但遇到复杂统计、窗口函数、多表大 join,它生成的 SQL 往往又长又慢。生产里常见的做法是"简单操作用 GORM,复杂报表直接
db.Raw() 写原生 SQL"——GORM 完全支持混用,不要强迫自己只用一种写法。GORM连接数据库
安装GORM
go get gorm.io/gorm
go get gorm.io/driver/mysql
基本连接
package main
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
}
DSN格式说明
user:password@tcp(host:port)/dbname?params
| 参数 | 说明 |
|---|---|
| user | 数据库用户名 |
| password | 数据库密码 |
| host | 数据库地址 |
| port | 数据库端口 |
| dbname | 数据库名称 |
| charset | 字符集,推荐utf8mb4 |
| parseTime | 是否解析时间 |
| loc | 时区 |
连接池配置
// 步骤 1:从 GORM 句柄里取出底层的 *sql.DB(连接池就藏在它里面)
sqlDB, err := db.DB()
if err != nil {
panic("failed to get DB")
}
// 步骤 2:设置最大空闲连接数(闲着也不释放的连接上限)
sqlDB.SetMaxIdleConns(10)
// 步骤 3:设置最大打开连接数(含正在用的 + 空闲的,超过就排队)
sqlDB.SetMaxOpenConns(100)
// 步骤 4:设置连接最大存活时间,到期回收,避免用到被数据库单方面断开的"死连接"
sqlDB.SetConnMaxLifetime(time.Hour)
复习提示:⚠️ 新手必踩的坑:不配置连接池。GORM 默认连接数很小(空闲 2、打开 10 左右),高并发下会瞬间打满,请求全部阻塞。线上务必根据压测结果调大
SetMaxOpenConns,并设 SetConnMaxLifetime——否则运行一段时间后偶发 driver: bad connection,多半是 MySQL 主动断了长连接你还在用。Auto Migrate功能
什么是Auto Migrate
Auto Migrate 可以自动根据 Model 定义创建或更新数据库表结构。
使用示例
type User struct {
ID uint
Name string
Age int
}
func main() {
// 步骤 1:定义好结构体后,调用 AutoMigrate
db.AutoMigrate(&User{})
}
字段类型映射
| Go类型 | MySQL类型 |
|---|---|
| int, int64 | bigint |
| uint, uint64 | bigint unsigned |
| float32, float64 | float |
| string | longtext |
| bool | tinyint(1) |
| time.Time | datetime |
注意事项
- 创建表:如果表不存在,自动创建
- 更新表:只添加新字段,不会删除已有字段
- 不会修改:字段类型、字段名、约束条件
复习提示:⚠️ 新手必踩的坑:AutoMigrate 只加不减。它不会删字段、不会改字段类型(比如把
varchar(50) 改 varchar(100) 它无动于衷,把 string 改成 int 它直接报错或不动)。生产环境千万别把 AutoMigrate 当迁移工具用——结构变更请用专门的迁移工具(如 golang-migrate、Flyway)管理,AutoMigrate 只适合本地开发快速建表。GORM的Model逻辑删除
软删除概念
软删除不是真正删除记录,而是通过标记字段表示记录已删除。
复习提示:类比:软删除就像把文件丢进"回收站"。文件还在磁盘上,只是打了个删除标记;真要彻底消失得"清空回收站"(硬删除)。好处是误删能恢复,坏处是查询时得记得"只看回收站外的"。
启用软删除
type User struct {
gorm.Model
Name string
}
gorm.Model 包含以下字段:
type Model struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
查询时自动过滤
var users []User
db.Find(&users)
上述查询会自动添加 WHERE deleted_at IS NULL 条件。
下面这张图说明了为什么"普通查询"看不到被删数据:
flowchart TD
A["db.Find &users"] --> B[GORM 自动追加过滤条件]
B --> C[WHERE deleted_at IS NULL]
C --> D[只返回未删除的记录]
E["db.Unscoped.Find &users"] --> F[不加 deleted_at 过滤]
F --> G[返回含已软删除的记录]查找已删除的记录
var users []User
db.Unscoped().Find(&users)
永久删除
db.Unscoped().Delete(&user)
通过NullString解决不能更新零值问题
问题描述
在 GORM 中,零值(如空字符串、0、false)不会被更新到数据库。
user.Name = ""
db.Save(&user)
上述代码中,Name 字段不会被更新为空字符串。
复习提示:白话:GORM 分不清你"故意把名字改成空"还是"压根没动这个字段"。它偷懒地把所有零值当成"没设置",于是
Save 时直接跳过。这在"我想清空某个字段"时就会翻车。解决方案
使用 sql.NullString 或 GORM 的 gorm.NullString:
import (
"gorm.io/gorm"
"database/sql"
)
type User struct {
gorm.Model
Name sql.NullString
Age sql.NullInt64
}
user := User{
Name: sql.NullString{String: "", Valid: true},
}
db.Save(&user)
复习提示:关键点:
sql.NullString 用 Valid: true 显式告诉 GORM"这个空值是我认真的",于是它才会把空串写进库。Valid: false 则表示"不更新这个字段"。使用 GORM 的 Scanner
type User struct {
gorm.Model
Name *string
Age *int
}
name := ""
user := User{
Name: &name,
}
db.Save(&user)
复习提示:⚠️ 新手必踩的坑:零值的三套解法怎么选。① 指针类型(
*string)——最常用,零值也能写,但每次赋值要取地址有点啰嗦;② sql.NullXXX——语义清晰但要 Valid 标记;③ db.Model(&u).Select("name").Updates(...)——只更新指定字段,强制把 name 纳入更新列表,连零值一起写。实战推荐:要么模型字段用指针,要么用 Select 精确控制,别混着用以免心智负担。表结构定义细节
字段标签
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:255;not null;unique"`
Age int `gorm:"default:0"`
Email string `gorm:"column:email_address"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
常用标签
| 标签 | 说明 |
|---|---|
| primaryKey | 主键 |
| size | 字段大小 |
| not null | 非空约束 |
| unique | 唯一约束 |
| default | 默认值 |
| column | 列名 |
| autoCreateTime | 自动创建时间 |
| autoUpdateTime | 自动更新时间 |
| index | 创建索引 |
| uniqueIndex | 创建唯一索引 |
嵌入结构体
type Address struct {
City string
State string
}
type User struct {
gorm.Model
Name string
Address Address `gorm:"embedded"`
}
通过create方法插入记录
基本插入
user := User{Name: "Alice", Age: 25}
result := db.Create(&user)
// 返回插入记录的主键
fmt.Println(user.ID)
// 返回错误
fmt.Println(result.Error)
// 返回插入的记录数
fmt.Println(result.RowsAffected)
批量插入
users := []User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
db.Create(&users)
通过Map插入
db.Create(map[string]interface{}{
"Name": "Charlie",
"Age": 35,
})
选择字段插入
db.Select("Name").Create(&User{Name: "Dave", Age: 40})
忽略字段
db.Omit("Age").Create(&User{Name: "Eve", Age: 45})
批量插入和通过map插入记录
批量插入优化
users := make([]User, 100)
for i := range users {
users[i] = User{Name: fmt.Sprintf("User%d", i)}
}
db.CreateInBatches(users, 100)
使用Upsert
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"age"}),
}).Create(&users)
通过Map批量插入
db.Model(&User{}).Create([]map[string]interface{}{
{"Name": "Alice", "Age": 25},
{"Name": "Bob", "Age": 30},
})
通过take, first、last获取数据
获取第一条记录
var user User
db.First(&user)
获取最后一条记录
var user User
db.Last(&user)
获取任意一条记录
var user User
db.Take(&user)
根据条件获取
var user User
db.Where("name = ?", "Alice").First(&user)
处理不存在的情况
var user User
result := db.First(&user)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
fmt.Println("记录不存在")
}
复习提示:⚠️ 新手必踩的坑:
First 找不到会返回 ErrRecordNotFound,但 Find 不会。很多人用 db.Find(&user) 查单条,结果查不到时 user 是零值,error 还是 nil,代码误以为"查到了个空用户"。要查单条请用 First/Take 并显式判断 gorm.ErrRecordNotFound。GORM的基本查询
查询所有记录
var users []User
db.Find(&users)
带条件查询
var users []User
db.Where("age > ?", 18).Find(&users)
多个条件
db.Where("name = ? AND age > ?", "Alice", 18).Find(&users)
IN查询
db.Where("name IN ?", []string{"Alice", "Bob"}).Find(&users)
LIKE查询
db.Where("name LIKE ?", "%li%").Find(&users)
排序
db.Order("age DESC").Find(&users)
分页
var users []User
db.Limit(10).Offset(0).Find(&users)
计数
var count int64
db.Model(&User{}).Count(&count)
GORM的更新操作
保存所有字段
user := User{ID: 1, Name: "Alice", Age: 26}
db.Save(&user)
复习提示:⚠️ 新手必踩的坑:
Save 是全量更新。它会把结构体里所有字段都写进 UPDATE,包括你没改的零值字段(又回到零值问题)。只改某几个字段请用 Update/Updates + Select,更安全也更省。更新指定字段
db.Model(&user).Update("name", "Alice Updated")
更新多个字段
db.Model(&user).Updates(map[string]interface{}{
"name": "Alice Updated",
"age": 26,
})
使用结构体更新
db.Model(&user).Updates(User{Name: "Alice Updated", Age: 26})
复习提示:⚠️ 新手必踩的坑:用结构体
Updates 时零值会被忽略。上面 Updates(User{...}) 里如果 Age: 0,GORM 不会把 age 更新成 0。要更新零值,要么用 map[string]interface{},要么 Select 指定字段。更新选中字段
db.Model(&user).Select("name").Updates(User{Name: "Alice", Age: 27})
更新忽略字段
db.Model(&user).Omit("age").Updates(User{Name: "Alice", Age: 27})
批量更新
db.Model(&User{}).Where("age > ?", 18).Update("age", 19)
GORM的软删除细节
查询未删除记录
var users []User
db.Find(&users)
查询已删除记录
var users []User
db.Unscoped().Find(&users)
恢复已删除记录
db.Unscoped().Model(&user).Update("deleted_at", nil)
永久删除
db.Unscoped().Delete(&user)
软删除条件
var users []User
db.Where("deleted_at IS NOT NULL").Find(&users)
表的关联插入
一对一关系
type User struct {
gorm.Model
Name string
Profile Profile
}
type Profile struct {
gorm.Model
UserID uint
Bio string
}
user := User{
Name: "Alice",
Profile: Profile{
Bio: "Software Engineer",
},
}
db.Create(&user)
保存关联
user := User{Name: "Alice"}
db.Create(&user)
user.Profile = Profile{Bio: "Engineer"}
db.Model(&user).Association("Profile").Append(&user.Profile)
下面这张图概括了 GORM 四种典型关联在数据库层面的形态:
erDiagram
USER ||--o| PROFILE : "拥有 一对一"
USER ||--o{ ORDER : "下 一对多"
USER }|..|{ ROLE : "拥有 多对多"复习提示:白话:
||--o| 是一夫一妻(一个 User 配一个 Profile);||--o{ 是一夫多妻(一个 User 有多个 Order);}|..|{ 是群舞(User 和 Role 互相多对多,中间靠一张 user_roles 连接表)。} 和 | 表示"可选/必填",这里不必死记,看箭头数量就行。通过preload和joins查询多表
Preload预加载
var users []User
db.Preload("Profile").Find(&users)
多级预加载
db.Preload("Orders").Preload("Orders.Items").Find(&users)
带条件的预加载
db.Preload("Orders", "status = ?", "paid").Find(&users)
Joins连接查询
db.Joins("JOIN profiles ON profiles.user_id = users.id").
Where("profiles.bio LIKE ?", "%Engineer%").
Find(&users)
复习提示:⚠️ 新手必踩的坑:
Preload 是"两条 SQL",Joins 是"一条 SQL",语义不同。Preload 先查 User 再查 Profile(默认可能触发 N+1,需用 Preload 或 Joins 解决);Joins 用 SQL 的 JOIN 一次查出来,但它是"内连接",主表记录若没有关联子记录就会被过滤掉。要"左连接保留主表全部"得写 Joins("LEFT JOIN ...")。另外 Preload 不会按关联字段过滤主表,要过滤主表请用 Joins。Has Many关系
定义关系
type User struct {
gorm.Model
Name string
Orders []Order
}
type Order struct {
gorm.Model
UserID uint
Amount float64
}
查询关联
var user User
db.Preload("Orders").First(&user)
添加关联
order := Order{Amount: 100}
db.Model(&user).Association("Orders").Append(&order)
删除关联
db.Model(&user).Association("Orders").Delete(&order)
替换关联
orders := []Order{{Amount: 200}, {Amount: 300}}
db.Model(&user).Association("Orders").Replace(&orders)
清空关联
db.Model(&user).Association("Orders").Clear()
GORM处理多对多的关系
定义关系
type User struct {
gorm.Model
Name string
Roles []Role `gorm:"many2many:user_roles;"`
}
type Role struct {
gorm.Model
Name string
Users []User `gorm:"many2many:user_roles;"`
}
创建关联表
db.AutoMigrate(&User{}, &Role{})
添加关联
user := User{Name: "Alice"}
role := Role{Name: "Admin"}
db.Create(&user)
db.Create(&role)
db.Model(&user).Association("Roles").Append(&role)
查询关联
var user User
db.Preload("Roles").First(&user)
删除关联
db.Model(&user).Association("Roles").Delete(&role)
替换关联
roles := []Role{{Name: "User"}, {Name: "Editor"}}
db.Model(&user).Association("Roles").Replace(&roles)
清空关联
db.Model(&user).Association("Roles").Clear()
获取关联计数
var count int64
db.Model(&user).Association("Roles").Count(&count)
复习提示:⚠️ 新手必踩的坑:多对多一定要指定连接表名
many2many:user_roles。两侧都写上同一个表名,GORM 才知道用哪张中间表。漏写的话 GORM 会用默认命名(通常是 user_roles 拼接),一旦两侧不一致或想自定义字段就抓瞎。另外 Association().Append 对切片要传 *Role 的地址或切片,传值容易编译不过。GORM的表名自定义、自定义beforecreate逻辑
表名自定义
func (u User) TableName() string {
return "my_users"
}
全局禁用复数
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
})
复习提示:白话:GORM 默认把结构体名变复数当表名(
User → users),这是英文习惯。国内很多老表是单数命名,用 SingularTable: true 关掉复数化最省事。更精准的做法是给每个模型实现 TableName(),优先级最高。BeforeCreate钩子
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
u.UUID = uuid.New().String()
return
}
AfterCreate钩子
func (u *User) AfterCreate(tx *gorm.DB) (err error) {
tx.Create(&Profile{UserID: u.ID})
return
}
其他钩子
func (u *User) BeforeUpdate(tx *gorm.DB) (err error) {
// 更新前逻辑
return
}
func (u *User) AfterUpdate(tx *gorm.DB) (err error) {
// 更新后逻辑
return
}
func (u *User) BeforeDelete(tx *gorm.DB) (err error) {
// 删除前逻辑
return
}
func (u *User) AfterDelete(tx *gorm.DB) (err error) {
// 删除后逻辑
return
}
事务中的钩子
钩子会在事务中执行,可以访问当前事务对象 tx。
下面这张图说明了 Create 操作里钩子与数据库的交互顺序:
sequenceDiagram
participant App as 业务代码
participant BC as BeforeCreate 钩子
participant DB as 数据库
participant AC as AfterCreate 钩子
App->>BC: 调用 Create 触发
BC->>BC: 生成 UUID 等预处理
BC->>DB: 执行 INSERT
DB-->>AC: 插入成功
AC->>DB: 例如再建一条 Profile
AC-->>App: 返回结果自测题与动手练习
自测题(合上书能答出来,才算懂):
面试官
ORM 的核心思想是什么?哪些场景适合用 ORM、哪些场景不适合?
候选人
ORM 的核心是"对象-关系映射":把 Go 结构体当成表、把对象的增删改查翻译成 SQL,让你用面向对象的方式操作数据库,省去手写大量样板 SQL。适合:常规 CRUD、表结构和对象一一对应、想快速开发的中后台。不适合:超复杂报表/分析查询、需要精细控制执行计划的性能敏感场景、存储过程/批量 ETL——这些该直接写原生 SQL(
db.Raw)或用专门的 SQL 构建器。面试官
GORM 的软删除是怎么实现的?为什么普通
Find 查不到被删数据?想查被删数据该怎么写?候选人
给模型加一个
gorm.DeletedAt 字段(或 deleted_at 列),调用 Delete 时 GORM 不会真删行,而是把 deleted_at 填上当前时间,并把它当成"已删除"。之后所有普通查询(如 Find)GORM 都会自动追加 WHERE deleted_at IS NULL,于是被删数据自然查不出来。想看全量(含已删)数据,用 Unscoped().Find(...) 跳过这个过滤条件。面试官
什么是"零值不更新"?列举至少两种让"把 Name 改成空串"真正生效的方法。
候选人
用结构体
Updates(User{Name: ""}) 时,GORM 会忽略零值字段(空串、0、false、nil),因为它分不清"你想设成零值"还是"你只是没传这个字段",结果是 Name 不会被改成空串。让零值生效的方法:①用指针字段 *string,非 nil 的空串指针会被识别为"明确要设";②用 sql.NullString 这类可空类型;③用 Updates 配合 Select("name") 显式指定要更新的列,强制带上该字段。面试官
Save 和 Updates(User{...}) 在更新时有啥区别?为什么用结构体 Updates 时 Age: 0 不会写进库?候选人
Save 是"全量保存":按主键匹配,把结构体的所有字段(含零值)都 UPDATE 一遍,常用于"我手里就是完整对象"。Updates 用结构体时只更新非零值字段,所以 Age: 0 会被当成"没提供"而跳过;想更新零值得用 map 形式 Updates(map[string]interface{}{"age": 0}) 或配合 Select。简单记:Save=全字段覆盖,Updates(结构体)=非零值增量更新。面试官
Preload("Profile") 和 Joins("JOIN profiles ...") 在语义上有哪两个关键不同点?候选人
两个关键不同:①SQL 条数——
Preload 是"先查主表,再额外发一条 SQL 把关联表捞出来在内存组装"(多条 SQL,可能 N+1);Joins 是用一条 JOIN 在数据库侧把数据连出来(单条 SQL)。②连接类型——Preload 用 LEFT JOIN 语义,主表记录即使没有关联也会保留;Joins 默认是 INNER JOIN,没有关联的记录会被过滤掉。所以"要保证主记录都在"用 Preload,“要按关联条件筛选"且追求单 SQL 用 Joins。动手练习(建议真做一遍):
- 本地起一个 MySQL,用 GORM 建
users表并插入 3 条数据;随后把某个用户的Name改成空串,分别用Save和"指针字段 + Save"两种方式验证结果差异。 - 给
User加Profile(一对一)和Orders(一对多),用Preload查出来,再故意制造 N+1(循环里逐个查 Order),用Preload优化后对比 SQL 条数。 - 写一个复杂统计需求(比如"按年龄分组统计用户数”),先用 GORM 链式写法实现,再用
db.Raw()写原生 SQL 实现,对比两者生成的语句与可读性。
本章小结
复习提示:
- ORM 是翻译官:把 Go 对象操作翻译成 SQL,常规 CRUD 省心,复杂查询该写原生就写原生(
db.Raw)。 - 连接池必须配:
SetMaxOpenConns+SetConnMaxLifetime,否则高并发会阻塞或踩到"死连接"。 - AutoMigrate 只加不减:仅适合开发建表,生产结构变更请用专门迁移工具。
- 零值与软删除是两大坑:零值用指针 /
Select/NullXXX解决;软删除靠DeletedAt字段,查询自动过滤,想看全量加Unscoped()。 - 关联查选用对姿势:
Preload多条 SQL、Joins单条 SQL 且是内连接,多对多别忘了指定many2many连接表。
下一篇可以接着看 GORM 的事务、原生 SQL 与 Scopes 复用查询条件,把"工程里真正常用的进阶玩法"补齐。