GORM 关联关系与常用 API 完全手册
引言
GORM 是 Go 生态最流行的 ORM 库,提供了完整的关联关系(一对一、一对多、多对多)支持和丰富的查询 API。但很多人只会建表和基础 CRUD,遇到关联关系就开始拼 SQL,或者写出一堆 N+1 查询。
本文系统梳理 GORM 的三种关联关系(HasOne、HasMany、Many2Many)的完整 CRUD 写法,以及日常开发中最常用的 API。可作为速查手册收藏。
表结构设计:三种关联
import "gorm.io/gorm"
// User 主表
type User struct {
gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt
Name string `gorm:"not null;size:50"`
Email string `gorm:"uniqueIndex;not null"`
Profile Profile // 一对一(HasOne)
Posts []Post // 一对多(HasMany)
Roles []Role `gorm:"many2many:user_roles;"` // 多对多
}
// Profile 一对一关联(持有外键)
type Profile struct {
gorm.Model
UserID uint `gorm:"uniqueIndex;not null"` // uniqueIndex 保证一对一
Bio string
Avatar string
}
// Post 一对多关联(持有外键)
type Post struct {
gorm.Model
UserID uint `gorm:"index;not null"` // 普通 index,允许多条
Title string `gorm:"not null"`
Content string
Status int8 `gorm:"default:0"` // 0=草稿 1=发布
}
// Role 多对多关联(中间表 user_roles 由 GORM 自动创建)
type Role struct {
gorm.Model
Name string `gorm:"uniqueIndex;not null"`
Users []User `gorm:"many2many:user_roles;"`
}
建表:
db.AutoMigrate(&User{}, &Profile{}, &Post{}, &Role{})
// GORM 自动创建 users / profiles / posts / roles / user_roles 五张表
一、一对一(HasOne)CRUD
创建
// 嵌套创建:一次性创建 User + Profile
user := User{
Name: "Alice",
Email: "alice@example.com",
Profile: Profile{Bio: "全栈工程师", Avatar: "/a.jpg"},
}
db.Create(&user)
// 自动生成两条 INSERT,Profile.UserID 自动赋值
// 给已有用户补档案
profile := Profile{UserID: user.ID, Bio: "后来填的"}
db.Create(&profile)
查询
// Preload 预加载(推荐)
var u User
db.Preload("Profile").First(&u, userID)
// SQL 1: SELECT * FROM users WHERE id = ?
// SQL 2: SELECT * FROM profiles WHERE user_id IN (?)
// Joins 联查(按关联字段过滤时用)
db.Joins("Profile").Where("Profile.bio LIKE ?", "%工程师%").First(&u)
更新
// 精准更新单字段
db.Model(&Profile{}).Where("user_id = ?", userID).Update("bio", "新简介")
// 整体替换(Save 会更新所有字段)
profile.Bio = "新内容"
db.Save(&profile)
删除
// 删除关联但保留主体
db.Where("user_id = ?", userID).Delete(&Profile{})
// 级联删除(事务保证原子性)
db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&Profile{}).Error; err != nil {
return err
}
return tx.Delete(&User{}, userID).Error
})
二、一对多(HasMany)CRUD
创建
// 嵌套创建
user := User{
Name: "Bob",
Email: "bob@example.com",
Posts: []Post{
{Title: "第一篇", Status: 1},
{Title: "第二篇", Status: 0},
},
}
db.Create(&user)
// Association API 追加(推荐)
db.Model(&user).Association("Posts").Append(&Post{Title: "新文章"})
// 自动设置 UserID
查询
// 加载所有文章
db.Preload("Posts").First(&u, userID)
// 条件预加载:只加载已发布
db.Preload("Posts", "status = ?", 1).First(&u, userID)
// 嵌套预加载:用户 + 档案 + 文章一次拿
db.Preload("Profile").Preload("Posts").First(&u, userID)
// 自定义预加载(排序、限制条数)
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC").Limit(10)
}).First(&u, userID)
// 文章列表分页
var posts []Post
var total int64
db.Model(&Post{}).Where("user_id = ?", userID).Count(&total)
db.Where("user_id = ?", userID).
Order("created_at DESC").
Offset((page-1)*size).Limit(size).
Find(&posts)
更新
// 单条更新
db.Model(&Post{}).Where("id = ?", postID).Update("status", 1)
// 批量更新该用户所有草稿
db.Model(&Post{}).
Where("user_id = ? AND status = 0", userID).
Update("status", 1)
删除
// 删除单篇(软删除)
db.Delete(&Post{}, postID)
// Association API:清空所有
db.Model(&user).Association("Posts").Unscoped().Clear() // 物理删除
// 移除关联但不删数据(UserID 设为 NULL,需字段允许 NULL)
db.Model(&user).Association("Posts").Delete(&post)
三、多对多(Many2Many)CRUD
创建
// 创建 Role
admin := Role{Name: "admin"}
editor := Role{Name: "editor"}
db.Create(&admin)
db.Create(&editor)
// 给用户绑定角色(写中间表)
db.Model(&user).Association("Roles").Append(&admin, &editor)
// 自动 INSERT INTO user_roles (user_id, role_id) VALUES (?, ?), (?, ?)
查询
// 加载用户的所有角色
db.Preload("Roles").First(&u, userID)
// 反向:加载某角色下的所有用户
var role Role
db.Preload("Users").Where("name = ?", "admin").First(&role)
// 检查是否拥有某角色
count := db.Model(&user).
Where("name = ?", "admin").
Association("Roles").Count()
hasAdmin := count > 0
更新关联
// 替换:用户角色完全替换为新列表(旧的全部解除)
db.Model(&user).Association("Roles").Replace(&editor)
// 单条修改 Role 自身字段
db.Model(&admin).Update("name", "super-admin")
删除关联
// 解除某个关联(删中间表行,不删 Role 本体)
db.Model(&user).Association("Roles").Delete(&admin)
// 清空所有角色关联
db.Model(&user).Association("Roles").Clear()
// 删除 Role 本身(同时清理中间表)
db.Select("Users").Delete(&admin)
关键区别:
Association().Delete()只删中间表(解除关联),db.Delete(&role)删的是roles表的记录。
GORM 常用 API 速查
查询类
// 查单条
db.First(&user, 1) // 按主键
db.First(&user, "name = ?", "alice") // 按条件,找不到返回 ErrRecordNotFound
db.Take(&user) // 不排序的第一条
db.Last(&user) // 最后一条
// 查多条
db.Find(&users) // 查全部
db.Where("age > ?", 18).Find(&users) // 条件
db.Where(&User{Name: "alice"}).Find(&users) // struct 条件(注意零值问题)
db.Where(map[string]any{"age": 0}).Find(&users) // map 条件(不忽略零值)
// 高级查询
db.Select("name", "email").Find(&users) // 只查指定字段
db.Order("created_at DESC").Find(&users)
db.Limit(10).Offset(20).Find(&users)
db.Distinct("name").Find(&users)
db.Group("status").Having("count(*) > 1").Find(&results)
// 计数与存在性
var count int64
db.Model(&User{}).Where("status = ?", 1).Count(&count)
// 原生 SQL
db.Raw("SELECT * FROM users WHERE name = ?", name).Scan(&users)
创建类
// 单条
db.Create(&user)
// 批量(一次性 INSERT)
db.Create(&[]User{{Name: "a"}, {Name: "b"}})
// 分批(避免单次 SQL 太大)
db.CreateInBatches(users, 100) // 每批 100 条
// Upsert(存在则更新)
import "gorm.io/gorm/clause"
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "updated_at"}),
}).Create(&user)
// 忽略冲突
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&user)
更新类
// 单字段
db.Model(&user).Update("name", "new")
// 多字段(map:所有字段都会更新;struct:忽略零值)
db.Model(&user).Updates(map[string]any{"name": "new", "age": 0}) // age=0 会更新
db.Model(&user).Updates(User{Name: "new", Age: 0}) // age=0 被忽略!
// 强制更新指定字段(即使零值)
db.Model(&user).Select("name", "age").Updates(User{Name: "new", Age: 0})
// 批量更新
db.Model(&User{}).Where("status = ?", 0).Update("status", 1)
// 表达式更新
db.Model(&user).Update("count", gorm.Expr("count + ?", 1))
删除类
// 软删除(前提:模型有 DeletedAt 字段,gorm.Model 自带)
db.Delete(&user) // 按主键
db.Delete(&User{}, 10) // 按 ID
db.Where("status = 0").Delete(&User{}) // 批量
// 物理删除
db.Unscoped().Delete(&user)
// 查询软删除的记录
db.Unscoped().Where("deleted_at IS NOT NULL").Find(&users)
事务
// 自动事务(推荐)
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err // 自动回滚
}
if err := tx.Create(&profile).Error; err != nil {
return err
}
return nil // 自动提交
})
// 手动事务
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Create(&user).Error; err != nil {
tx.Rollback()
return
}
tx.Commit()
Scopes(可复用的查询片段)
// 定义可复用的查询函数
func ActiveUsers(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", 1)
}
func RecentDays(days int) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
cutoff := time.Now().AddDate(0, 0, -days)
return db.Where("created_at > ?", cutoff)
}
}
// 组合使用
db.Scopes(ActiveUsers, RecentDays(7)).Find(&users)
Hook(生命周期钩子)
// 在模型上定义钩子,自动触发
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.Name == "" {
return errors.New("name required")
}
u.Name = strings.TrimSpace(u.Name)
return nil
}
func (u *User) AfterFind(tx *gorm.DB) error {
// 查询后自动执行
return nil
}
// 支持的钩子:BeforeSave / BeforeCreate / AfterCreate / AfterSave
// BeforeUpdate / AfterUpdate / BeforeDelete / AfterDelete / AfterFind
常见误区
1. Preload vs Joins 用错
// 只展示关联 → Preload(2 条 SQL,IN 查询)
db.Preload("Posts").Find(&users)
// 按关联字段过滤 → Joins(1 条 JOIN)
db.Joins("JOIN posts ON posts.user_id = users.id").
Where("posts.status = 1").Find(&users)
2. struct 条件忽略零值
db.Where(User{Status: 0}).Find(&users) // ❌ Status=0 被忽略
db.Where("status = ?", 0).Find(&users) // ✅
db.Where(map[string]any{"status": 0}).Find(&users) // ✅
3. Updates 用 struct 也忽略零值
db.Model(&user).Updates(User{Status: 0}) // ❌ 不会更新 status
db.Model(&user).Updates(map[string]any{"status": 0}) // ✅
db.Model(&user).Select("status").Updates(User{Status: 0}) // ✅ Select 强制更新
4. AutoMigrate 不删列、不改类型
AutoMigrate 只新增字段和索引,不会删除或修改已有列。生产环境的 schema 变更应使用专门的迁移工具如 golang-migrate 或 Atlas。
5. N+1 查询陷阱
// ❌ 100 个用户 → 101 条 SQL
db.Find(&users)
for _, u := range users {
db.Where("user_id = ?", u.ID).Find(&u.Posts)
}
// ✅ 永远只多 1 条 SQL
db.Preload("Posts").Find(&users)
企业实践
Repository 模式封装
业界常见做法:将 GORM 操作封装在 Repository 层,业务层只调用接口,便于测试和切换 ORM:
type UserRepository struct {
db *gorm.DB
}
func (r *UserRepository) FindByEmail(email string) (*User, error) {
var user User
err := r.db.Preload("Profile").Where("email = ?", email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // 业务层用 nil 表示「不存在」
}
return &user, err
}
func (r *UserRepository) Create(user *User) error {
return r.db.Create(user).Error
}
Query 对象 + Scopes
动态查询条件用指针字段 + Scopes,清晰表达「nil = 不过滤」:
type UserQuery struct {
Name string
Status *int8 // nil 不过滤
}
func (q *UserQuery) Apply(db *gorm.DB) *gorm.DB {
if q.Name != "" {
db = db.Where("name LIKE ?", "%"+q.Name+"%")
}
if q.Status != nil {
db = db.Where("status = ?", *q.Status)
}
return db
}
// 使用
db.Scopes(query.Apply).Find(&users)
关联知识
前置知识:
- 关系型数据库的外键、索引、JOIN
- Go 结构体嵌入与 tag 语法
延伸知识:
gorm.io/plugin/dbresolver:读写分离、多数据源clause.OnConflict:MySQL/PostgreSQL 的 Upsert 实现gorm.Session:避免全局 DB 状态污染- GORM Generator (gen):基于 schema 生成类型安全的 DAO 代码
- 数据库迁移工具:Atlas、golang-migrate
参考文档:
总结
GORM 的三种关联用 tag 区分:HasOne 关联表持唯一外键、HasMany 持普通索引外键、Many2Many 用中间表;查询关联用 Preload(2 条 SQL,避免 N+1),按关联字段过滤用 Joins;写操作多表场景用 db.Transaction 保证原子性;动态查询用 Scopes + 指针字段;记住两个零值陷阱:Where(struct) 和 Updates(struct) 都会忽略零值字段,需要时改用 map 或 Select 强制指定。
