学习目标
学完本章,你应该能够:
- 用自己的话讲清楚什么是 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、哪些场景不适合?
- GORM 的软删除是怎么实现的?为什么普通
Find查不到被删数据?想查被删数据该怎么写? - 什么是"零值不更新"?列举至少两种让"把 Name 改成空串"真正生效的方法。
Save和Updates(User{...})在更新时有啥区别?为什么用结构体Updates时Age: 0不会写进库?Preload("Profile")和Joins("JOIN profiles ...")在语义上有哪两个关键不同点?
动手练习(建议真做一遍):
- 本地起一个 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 复用查询条件,把"工程里真正常用的进阶玩法"补齐。