Yasin

Yasin

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-migrateAtlas

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 代码
  • 数据库迁移工具Atlasgolang-migrate

参考文档:

总结

GORM 的三种关联用 tag 区分:HasOne 关联表持唯一外键、HasMany 持普通索引外键、Many2Many 用中间表;查询关联用 Preload(2 条 SQL,避免 N+1),按关联字段过滤用 Joins;写操作多表场景用 db.Transaction 保证原子性;动态查询用 Scopes + 指针字段;记住两个零值陷阱:Where(struct)Updates(struct) 都会忽略零值字段,需要时改用 map 或 Select 强制指定。