GORM 开发指南

写给 MyBatis / JPA 开发者的完整 GORM 实战手册 —— 从模型定义到关联查询,从事务到性能

GORM v2 · Go 1.22+ MyBatis + JPA 双视角对比 21 个核心主题 80+ 代码对比

🎯01 · 设计哲学对比

要理解 GORM,先要理解它与 MyBatis、JPA 的根本差异。三者代表了三种不同的数据库交互哲学。

维度 MyBatis JPA / Hibernate GORM
定位SQL Mapper(手写 SQL,映射结果)完整 ORM(对象关系映射)ORM(偏轻量,可控性强)
SQL 控制权完全你写,灵活度最高框架生成,你几乎不碰 SQL默认生成,随时可接管
模型驱动POJO + XML 映射文件@Entity 注解,对象即表struct + tag,结构体即表
关联查询手写 JOIN 或嵌套查询自动 JOIN / Fetch / N+1 问题重Preload 显式预加载,可控
缓存无(需自配)一级缓存 + 二级缓存无(需自配)
延迟加载有(代理对象,常见坑)无(Preload 显式加载)
事务Spring @TransactionalSpring @Transactionaldb.Transaction(func)
动态 SQL<if> <choose> <foreach>Specification / Criteria API链式 .Where().Or() 构建
Schema 迁移无(配合 Flyway)hibernate.hbm2ddl(危险)AutoMigrate(温和)
学习曲线低(会 SQL 就会)高(N+1、懒加载、会话陷阱多)中(链式 API 直观)
💡 一句话理解三者

MyBatis:"SQL 是我的核心资产,框架只帮我映射结果" —— 适合复杂报表、性能敏感场景。
JPA:"忘掉 SQL,操作对象就好" —— 适合 CRUD 为主的业务系统,但 N+1 和懒加载是噩梦。
GORM:"默认帮你生成 SQL,想自己写随时可以" —— 介于两者之间,既享受 ORM 便利,又不放弃 SQL 控制权

一段相同业务,三种写法

需求:查询年龄大于 18 的用户,按注册时间倒序,取前 10 条。

📄 MyBatis · XML
<select id="findAdults"
        resultType="User">
  SELECT * FROM users
  WHERE age > #{age}
  ORDER BY created_at DESC
  LIMIT #{limit}
</select>

// Mapper 接口
List<User> findAdults(
    @Param("age") int age,
    @Param("limit") int limit);
☕ JPA · 方法命名/Spec
// 方式1:方法命名约定
List<User> findByAgeGreaterThanOrderByCreatedAtDesc(
    int age, Pageable pageable);

// 方式2:Specification
Specification.where((root, q, cb) ->
    cb.gt(root.get("age"), 18))
  .and((root, q, cb) -> ...)
  .orderBy(...);
🐹 GORM · 链式
var users []User
db.Where("age > ?", 18).
   Order("created_at desc").
   Limit(10).
   Find(&users)

可以看到:MyBatis 要写 XML + 接口两个文件;JPA 方法名长到离谱或 Specification 啰嗦;GORM 一行链式调用清晰直观。这就是 GORM 的魅力。

⚙️02 · 安装与连接

安装依赖

Shell
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql    # MySQL 驱动
go get -u gorm.io/driver/postgres  # PostgreSQL
go get -u gorm.io/driver/sqlite    # SQLite(本地开发)

连接数据库

Java 用 Spring Boot 的 application.yml 配置 + DataSource 自动注入;Go 用 gorm.Open 显式建立连接。

☕ JPA/MyBatis · Spring Boot
# application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb
    username: root
    password: secret
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
      connection-timeout: 30000

mybatis:
  mapper-locations: classpath:mapper/*.xml

jpa:
  hibernate:
    ddl-auto: none
  show-sql: true

// 直接注入即用,无需手动建连
@Autowired
private UserMapper userMapper;
🐹 GORM
package main

import (
    "gorm.io/gorm"
    "gorm.io/driver/mysql"
)

func initDB() *gorm.DB {
    dsn := "root:secret@tcp(127.0.0.1:3306)/" +
           "mydb?charset=utf8mb4&parseTime=True&loc=Local"
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Warn),
    })
    if err != nil {
        panic("连接数据库失败")
    }
    return db
}

连接池配置

Spring Boot 用 HikariCP 配置项;GORM 底层用 database/sql 的连接池,通过 sql.DB 配置。

🐹 GORM · 连接池
sqlDB, _ := db.DB()

// 相当于 Hikari 的 maximum-pool-size
sqlDB.SetMaxOpenConns(100)

// 相当于 minimum-idle
sqlDB.SetMaxIdleConns(10)

// 连接最大存活时间(重要!避免长连接被 DB 断开)
sqlDB.SetConnMaxLifetime(time.Hour)

// 连接最大空闲时间
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
⚠️ 必须设置 ConnMaxLifetime

这是 MyBatis/JPA 开发者最容易忽略的点。Spring Boot 的 HikariCP 默认 30 分钟回收连接;GORM 默认不设,长连接会被 MySQL 的 wait_timeout(默认 8 小时)断开,导致 "connection reset" 错误。生产环境务必设置 SetConnMaxLifetime 小于 DB 的 wait_timeout

全局配置项

GORM 配置JPA 对应说明
Logger.LogModeshow-sql / logging.level日志级别:Silent/Error/Warn/Info
NamingStrategy表名/列名命名策略
FullSaveAssociationscascade保存时是否完整保存关联
DryRun生成 SQL 但不执行(调试用)
PrepareStmt预编译缓存缓存预编译语句,提升性能
DisableForeignKeyConstraintWhenMigrating迁移时不创建外键
🐹 GORM · 完整配置示例
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
    NamingStrategy: schema.NamingStrategy{
        TablePrefix:   "t_",   // 表名加前缀 t_user
        SingularTable: true,    // 禁用复数表名
    },
    Logger: logger.Default.LogMode(logger.Info),
    PrepareStmt: true,              // 启用预编译缓存
})

🏗️03 · 模型定义

JPA 用 @Entity + @Table + @Column 注解;MyBatis 用 POJO + XML 的 <resultMap>;GORM 用 struct + 反引号 tag。

📄 MyBatis
// User.java(纯 POJO)
public class User {
    private Long id;
    private String name;
    private Integer age;
    private Date createdAt;
    // getter/setter...
}

// UserMapper.xml
<resultMap id="userMap"
    type="User">
  <id property="id" column="id"/>
  <result property="name"
      column="user_name"/>
</resultMap>
☕ JPA · 注解
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(name = "user_name",
             nullable = false, length = 50)
    private String name;

    @Column
    private Integer age;

    @Column(updatable = false)
    private LocalDateTime createdAt;
}
🐹 GORM · struct + tag
type User struct {
    gorm.Model                // 内嵌:ID/Created/Updated/Deleted
    Name      string    `gorm:column:user_name;size:50;not null`
    Age       int       `gorm:default:18`
    Email     string    `gorm:uniqueIndex`
   CreatedAt time.Time `gorm:autoCreateTime`
}

// 自定义表名
func (User) TableName() string {
    return "users"
}

gorm.Model 内嵌模型

GORM 提供了一个 gorm.Model 结构体,包含 4 个通用字段。JPA 通常用 @MappedSuperclass 抽象基类实现同样的事。

☕ JPA · @MappedSuperclass
@MappedSuperclass
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(updatable = false)
    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;

    @LogicDelete
    private Integer deleted;
}

@Entity
public class User extends BaseEntity {
    ...
}
🐹 GORM · gorm.Model
// gorm.Model 的定义:
type Model struct {
    ID        uint           `gorm:primarykey`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:index`
}

// 嵌入即可获得这 4 个字段
type User struct {
    gorm.Model   // ID + CreatedAt + UpdatedAt + DeletedAt
    Name string
    Age  int
}

// 不想用默认的?自定义即可
type User struct {
    ID int `gorm:primarykey`
    Name string
    ...
}
💡 软删除(Soft Delete)

gorm.DeletedAt 字段实现软删除——Delete 时不真正 DELETE,而是 SET deleted_at 时间戳,查询时自动加 WHERE deleted_at IS NULL。这相当于 MyBatis-Plus 的 @TableLogic 或 JPA 的 @SQLDelete + @Where(clause = "deleted = false"),但 GORM 是原生内置的,无需额外配置。

🏷️04 · GORM 标签详解

GORM 的 tag 用分号分隔多个选项(注意:JSON tag 用逗号,GORM tag 用分号),选项内部用冒号传参。

GORM 标签JPA 等价说明
column:xxx@Column(name="xxx")列名
type:varchar(50)@Column(columnDefinition=...)列类型
size:50@Column(length=50)长度(字符串类型)
primarykey@Id主键
autoIncrement@GeneratedValue(IDENTITY)自增
not null@Column(nullable=false)非空
unique@Column(unique=true)唯一
uniqueIndex@Table(uniqueConstraints=...)唯一索引
index@Index普通索引
index:idx_name@Index(name="idx_name")命名索引
default:18@Column(columnDefinition="default 18")默认值
autoCreateTime@CreationTimestamp插入时自动填当前时间
autoUpdateTime@UpdateTimestamp更新时自动刷新时间
-@Transient忽略该字段(不映射)
-:all忽略读写
-:migration迁移时忽略(保留字段不删)
comment:用户名列注释
precision:2@Column(precision=2)精度(decimal)
check:age > 0检查约束
embedded@Embedded内嵌结构体
embeddedPrefix:addr_@AttributeOverrides内嵌字段前缀
⚠️ 分号 vs 逗号 —— 最常踩的坑

GORM tag 用分号 ; 分隔JSON tag 用逗号 , 分隔。两者混用时务必注意:

# ✅ 正确
Name string `json:"name" gorm:column:name;size:50;not null`

# ❌ 错误:GORM 选项用了逗号
Name string `gorm:column:name,size:50,not null`

复合索引

🐹 GORM · 复合索引
type User struct {
    ID       uint
    FirstName string `gorm:index:idx_name,priority:1`
    LastName  string `gorm:index:idx_name,priority:2`
    Age      int    `gorm:index:idx_age,,composite:age_group`
}
// 生成复合索引 idx_name(first_name, last_name)

05 · 插入数据

📄 MyBatis
<insert id="insert"
        parameterType="User"
        useGeneratedKeys="true"
        keyProperty="id">
  INSERT INTO users
    (name, age)
  VALUES
    (#{name}, #{age})
</insert>

userMapper.insert(user);
// user.id 自动回填
☕ JPA
// save 方法,自动判断新增/更新
userRepository.save(user);

// 批量
userRepository.saveAll(users);

// 或 EntityManager.persist
em.persist(user);
🐹 GORM
user := User{Name: "Alice", Age: 18}

// 单条插入
db.Create(&user)
// user.ID 自动回填

// 批量插入
users := []User{
    {Name: "Bob"},
    {Name: "Carol"},
}
db.Create(&users)

// 用 map 插入
db.Model(&User{}).Create(map[string]any{
    "name": "Dan", "age": 20,
})

指定字段插入

🐹 GORM
// 只插入指定字段
db.Select("Name", "Age").Create(&user)
// INSERT INTO users (name, age) VALUES ('Alice', 18)

// 排除字段(插入时不包含)
db.Omit("Age").Create(&user)
// INSERT INTO users (name) VALUES ('Alice')

// 批量插入指定条数一批
var users = []User{...}  // 10000 条
db.CreateInBatches(users, 100)  // 每批 100 条
⚠️ Create 会填充零值字段

默认情况下,db.Create(&user) 会把结构体的所有字段(含零值)都写入。如果你只想插入非零字段,用 db.Select("Name").Create(&user)。这与 JPA 的 save() 行为一致,但与 MyBatis 的 <insert>(只插入 SQL 里写的字段)不同。

🔍06 · 查询数据

主键查询

📄 MyBatis
<select id="selectById"
        resultType="User">
  SELECT * FROM users
  WHERE id = #{id}
</select>

User u = mapper.selectById(1L);
☕ JPA
Optional<User> opt =
    repo.findById(1L);

if (opt.isPresent()) {
    User u = opt.get();
}

// 或 MyBatis-Plus
User u = mapper.selectById(1);
🐹 GORM
var user User

// 按主键
db.First(&user, 1)
// SELECT * FROM users WHERE id = 1 LIMIT 1

// 按条件取第一条
db.First(&user, "name = ?", "Alice")

// 取第一条(无排序)
db.Take(&user)

// 取最后一条
db.Last(&user)

First / Take / Last 的区别

方法SQL找不到时
FirstORDER BY id LIMIT 1返回 ErrRecordNotFound
TakeLIMIT 1(无 ORDER BY)返回 ErrRecordNotFound
LastORDER BY id DESC LIMIT 1返回 ErrRecordNotFound
Find查多条不报错,返回空切片
⚠️ First 报错 vs Find 不报错

这是 JPA/MyBatis 开发者最容易困惑的点:First / Take / Last 找不到记录时会返回 ErrRecordNotFound(类似 JPA 的 EmptyResultDataAccessException);但 Find 查多条时找不到不报错,返回空切片。所以:查单条用 First 要判 err;查列表用 Find 不用判 NotFound

查询多条

☕ JPA / MyBatis-Plus
// JPA
List<User> list = repo.findAll();
List<User> adults =
    repo.findByAgeGreaterThan(18);

// MyBatis-Plus
LambdaQueryWrapper<User> w =
    new LambdaQueryWrapper<>();
w.gt(User::getAge, 18);
List<User> list = mapper.selectList(w);
🐹 GORM
var users []User

// 全部
db.Find(&users)
// SELECT * FROM users

// 条件
db.Where("age > ?", 18).Find(&users)
// SELECT * FROM users WHERE age > 18

// 主键列表
db.Find(&users, []int{1, 2, 3})
// SELECT * FROM users WHERE id IN (1,2,3)

// 结构体条件(非零字段)
db.Where(&User{Age: 18}).Find(&users)
// SELECT * FROM users WHERE age = 18
⚠️ 结构体查询只认非零值

db.Where(&User{Age: 0}).Find(...) 不会查 age = 0——因为 0 是零值,被忽略。这是 JPA 没有的"坑",与 Go 的零值机制有关。想查零值,用字符串条件:db.Where("age = ?", 0)

✏️07 · 更新数据

Save vs Update

☕ JPA · save
User u = repo.findById(1L).get();
u.setAge(20);
repo.save(u);
// UPDATE users SET ... WHERE id=1
// save 会更新所有字段(含未改的)

// MyBatis-Plus
User u = new User();
u.setId(1L);
u.setAge(20);
mapper.updateById(u);
// 只更新非 null 字段
🐹 GORM
// Save:更新所有字段(类似 JPA save)
user.Name = "NewName"
db.Save(&user)
// UPDATE users SET name,age,... WHERE id=1

// Update:更新单个字段
db.Model(&user).Update("age", 20)
// UPDATE users SET age=20 WHERE id=1

// Updates:更新多个字段(只更新非零值)
db.Model(&user).Updates(User{
    Name: "Bob", Age: 25,
})
// UPDATE users SET name='Bob',age=25 WHERE id=1

// 用 map 可更新零值字段
db.Model(&user).Updates(map[string]any{
    "age": 0, "active": false,
})
方法更新范围零值处理JPA 类比
Save所有字段更新零值save()(全量)
Update(field, val)单字段更新零值
Updates(struct)多字段忽略零值MyBatis-Plus updateById
Updates(map)多字段更新零值JPA save + @DynamicUpdate
✅ 想更新零值,用 map 不用 struct

Updates(User{Age: 0, Active: false}) 不会更新 age 和 active(零值被忽略)。改用 Updates(map[string]any{"age": 0, "active": false}) 就能更新。这是与 JPA/MyBatis 最大的行为差异。

批量更新

🐹 GORM
// 条件批量更新
db.Model(User{}).Where("age > ?", 100).Update("active", false)
// UPDATE users SET active=false WHERE age > 100

// 表达式更新(基于当前值)
db.Model(&user).Update("age", gorm.Expr("age + ?", 1))
// UPDATE users SET age=age+1 WHERE id=1

🗑️08 · 删除数据

📄 MyBatis
<delete id="deleteById">
  DELETE FROM users
  WHERE id = #{id}
</delete>

mapper.deleteById(1L);

// 批量
mapper.deleteBatchIds(
    Arrays.asList(1L, 2L));
☕ JPA
// 先查再删
User u = repo.findById(1L).get();
repo.delete(u);

repo.deleteById(1L);

repo.deleteAllById(
    List.of(1L, 2L));
🐹 GORM
// 按主键删(需有主键值)
db.Delete(&User{}, 1)
// DELETE FROM users WHERE id=1

// 按条件删
db.Where("age > ?", 100).Delete(User{})

// 批量按主键
db.Delete(&User{}, []int{1, 2, 3})

// 永久删除(绕过软删除)
db.Unscoped().Delete(&user)

软删除 vs 硬删除

如果模型包含 gorm.DeletedAt 字段,Delete 默认是软删除(UPDATE deleted_at)。这是与 MyBatis/JPA 默认硬删除的重要差异。

🐹 GORM · 软删除机制
type User struct {
    gorm.Model   // 含 DeletedAt
    Name string
}

db.Delete(&user)
// UPDATE users SET deleted_at='2026-07-12 ...' WHERE id=1

// 查询自动过滤已删除
db.Find(&users)
// SELECT * FROM users WHERE deleted_at IS NULL

// 查询包含已删除
db.Unscoped().Find(&users)
// SELECT * FROM users(无 deleted_at 条件)

// 永久删除
db.Unscoped().Delete(&user)
// DELETE FROM users WHERE id=1
✅ 软删除的便利

JPA 要实现软删除需 @SQLDelete + @Where 注解,配置繁琐;MyBatis 要在每个 SQL 里手写 WHERE deleted = 0。GORM 内置软删除,查询自动加过滤,无需关心。但要记得用 Unscoped() 才能查到已删除数据。

🔎09 · 条件查询

这是 GORM 的核心能力。MyBatis 用 <if> / <choose> 拼接动态 SQL;JPA 用 Specification / Criteria API;GORM 用链式调用构建条件。

基础条件

🐹 GORM · 条件查询全景
// 字符串条件(最灵活,类似 MyBatis)
db.Where("name = ?", "Alice").First(&user)
db.Where("name LIKE ?", "Al%").Find(&users)
db.Where("age BETWEEN ? AND ?", 18, 30).Find(&users)
db.Where("name IN ?", []string{"Alice", "Bob"}).Find(&users)

// 结构体条件(只认非零值,自动 AND)
db.Where(&User{Name: "Alice", Age: 18}).Find(&users)
// WHERE name = 'Alice' AND age = 18

// Map 条件(认零值)
db.Where(map[string]any{"age": 0}).Find(&users)
// WHERE age = 0

// Not 条件
db.Not("name = ?", "Alice").Find(&users)
// WHERE NOT name = 'Alice'

// Or 条件
db.Where("age = 18").Or("age = 20").Find(&users)
// WHERE age = 18 OR age = 20

内联条件 vs Where 链式

🐹 GORM
// Where 链式(推荐,可读性好)
db.Where("age > ?", 18).
   Where("name LIKE ?", "A%").
   Find(&users)
// WHERE age > 18 AND name LIKE 'A%'

// 内联条件(简洁,适合简单查询)
db.Find(&users, "age > ? AND name LIKE ?", 18, "A%")

// 多个 Where 是 AND 关系
// Or 需要用 .Or() 显式
db.Where("age > 18").
   Or(Where("name = 'Alice'").
            Where("age < 10")).
   Find(&users)
// WHERE age > 18 OR (name = 'Alice' AND age < 10)

动态条件 —— 替代 MyBatis <if>

这是从 MyBatis 过来的开发者最关心的问题:怎么根据参数动态拼接条件?

📄 MyBatis · <if> 动态 SQL
<select id="search"
        resultType="User">
  SELECT * FROM users
  <where>
    <if test="name != null">
      AND name = #{name}
    </if>
    <if test="minAge != null">
      AND age >= #{minAge}
    </if>
    <if test="maxAge != null">
      AND age <= #{maxAge}
    </if>
  </where>
</select>
🐹 GORM · 链式构建
func search(db *gorm.DB,
    name string, minAge, maxAge int,
) []User {
    q := db.Model(&User{})

    if name != "" {
        q = q.Where("name = ?", name)
    }
    if minAge > 0 {
        q = q.Where("age >= ?", minAge)
    }
    if maxAge > 0 {
        q = q.Where("age <= ?", maxAge)
    }

    var users []User
    q.Find(&users)
    return users
}
✅ GORM 动态条件更安全

MyBatis 的 <if> 拼接容易出 SQL 注入(用了 ${} 而非 #{});GORM 用 ? 占位符自动参数化,天然防注入。而且 Go 的 if 比 XML 的 <if test="..."> 更直观、可调试。

📊10 · 排序、分页、聚合

排序与分页

☕ JPA · Pageable
Pageable pageable = PageRequest.of(
    0, 10,               // page, size
    Sort.by("createdAt").descending()
);

Page<User> page =
    repo.findAll(pageable);

page.getContent();     // 数据
page.getTotalElements(); // 总数
🐹 GORM
page, size := 0, 10

var users []User
db.Order("created_at desc").
   Offset(page * size).
   Limit(size).
   Find(&users)

// 总数
var total int64
db.Model(&User{}).Count(&total)

// 或一步到位(查数据同时返回总数)
var total int64
result := db.Model(&User{}).
    Count(&total).
    Order("created_at desc").
    Offset(page * size).
    Limit(size).
    Find(&users)
⚠️ Count 和 Find 一起调用要小心

上面"一步到位"的写法在 GORM v2 中可用,但要注意:Count 会移除 LIMITOFFSET(因为要算总数),而 Find 保留它们。如果顺序写反或链式结构不当,可能得到错误的总数。稳妥做法:分两次查询。

聚合查询

☕ JPA · 自定义查询
@Query("SELECT AVG(age) FROM User")
double avgAge();

@Query("SELECT u.role, COUNT(u)" +
       " FROM User u GROUP BY u.role")
List<Object[]> countByRole();
🐹 GORM
type Result struct {
    Role string
    Count int64
    AvgAge float64
}

// 简单聚合
var avgAge float64
db.Model(&User{}).Select("AVG(age)").Scan(&avgAge)

var count int64
db.Model(&User{}).Count(&count)

// 分组聚合(Scan 到结构体)
var results []Result
db.Model(&User{}).
   Select("role, count(*) as count, avg(age) as avg_age").
   Group("role").
   Having("count > ?", 10).
   Scan(&results)
💡 Find vs Scan

Find 用于查询模型本身(映射到 struct tag 对应的表);Scan 用于查询任意结果(聚合、JOIN、原生 SQL 的结果映射到自定义 struct)。规则:查模型用 Find,查派生数据用 Scan。

Distinct / Select 字段

🐹 GORM
// 查询指定列
db.Select("name, age").Find(&users)
// SELECT name, age FROM users

// DISTINCT
db.Distinct("name").Find(&users)
db.Model(&User{}).Distinct("role").Pluck("role", &roles)

// Pluck:查单列到切片(比 Select 简洁)
var names []string
db.Model(&User{}).Pluck("name", &names)
// SELECT name FROM users

📝11 · 原生 SQL

当 GORM 的链式 API 不够用时,可以随时降级到原生 SQL。这是 GORM 相对 JPA 的优势——不绑架你写 SQL 的自由

📄 MyBatis · 复杂 SQL
<select id="stat"
        resultType="UserStat">
  SELECT role,
         COUNT(*) as cnt,
         AVG(age) as avg_age
  FROM users
  WHERE created_at > #{startDate}
  GROUP BY role
  HAVING cnt > #{minCount}
  ORDER BY cnt DESC
</select>
🐹 GORM · Raw SQL
type UserStat struct {
    Role   string
    Cnt    int64
    AvgAge float64
}

var stats []UserStat
db.Raw(`SELECT role,
              COUNT(*) as cnt,
              AVG(age) as avg_age
       FROM users
       WHERE created_at > ?
       GROUP BY role
       HAVING cnt > ?
       ORDER BY cnt DESC`,
    startDate, minCount).Scan(&stats)

命名参数

🐹 GORM · 命名参数(类似 MyBatis #{})
// 用 @name 占位
db.Raw("SELECT * FROM users WHERE name = @name AND age > @age",
    sql.Named("name", "Alice"),
    sql.Named("age", 18)).Scan(&users)

// 或用 map
db.Raw("SELECT * FROM users WHERE name = @name",
    map[string]any{"name": "Alice"}).Scan(&users)

Exec 执行非查询语句

🐹 GORM
// 执行 UPDATE/DELETE/DDL
db.Exec("UPDATE users SET age = age + 1 WHERE id = ?", 1)
db.Exec("DROP TABLE IF EXISTS old_logs")

// 获取影响的行数
result := db.Exec("UPDATE users SET active = 0 WHERE age > 100")
if result.Error == nil {
    fmt.Println(result.RowsAffected())
}
✅ GORM 的 Raw 是降级,不是抛弃

MyBatis 开发者会很喜欢 Raw:复杂 SQL 直接写,结果自动映射到 struct。但它不走 GORM 的软删除、钩子等机制——所以简单 CRUD 用链式 API 享受便利,复杂查询用 Raw 保留控制力。这正是 GORM "两者兼得"的设计。

🔗12 · 四种关联关系

JPA 用 @OneToMany / @ManyToOne / @OneToOne / @ManyToMany 注解;GORM 用结构体字段 + tag 声明,更直观。

Belongs To(多对一)—— 对应 JPA @ManyToOne

☕ JPA
@Entity
public class Article {
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}
🐹 GORM
type Article struct {
    ID     uint
    Title  string
    UserID uint          // 外键(自动推断)
    User   User           // Belongs To
}

// 等价写法:显式声明
type Article struct {
    ID    uint
    Title string
    User  User `gorm:foreignKey:UserID`
}

Has One(一对一)—— 对应 JPA @OneToOne

☕ JPA
@Entity
public class User {
    @Id
    private Long id;

    @OneToOne(cascade = ALL)
    @JoinColumn(name = "credit_card_id")
    private CreditCard card;
}
🐹 GORM
type User struct {
    gorm.Model
    Name string
    Card CreditCard // Has One
}

type CreditCard struct {
    gorm.Model
    Number string
    UserID uint      // 外键在 CreditCard 这边
}

Has Many(一对多)—— 对应 JPA @OneToMany

☕ JPA
@Entity
public class User {
    @OneToMany(mappedBy = "user")
    private List<Article> articles;
}
🐹 GORM
type User struct {
    gorm.Model
    Name     string
    Articles []Article // Has Many
}

type Article struct {
    gorm.Model
    Title  string
    UserID uint    // 外键
}

Many To Many(多对多)—— 对应 JPA @ManyToMany

☕ JPA
@Entity
public class User {
    @ManyToMany
    @JoinTable(
        name = "user_tags",
        joinColumns = @JoinColumn(name="user_id"),
        inverseJoinColumns = @JoinColumn(name="tag_id")
    )
    private Set<Tag> tags;
}
🐹 GORM
type User struct {
    gorm.Model
    Name string
    Tags []Tag `gorm:many2many:user_tags;`
    // GORM 自动创建中间表 user_tags
}

type Tag struct {
    gorm.Model
    Name string
}

四种关联速查表

GORMJPA外键位置典型场景
Belongs To@ManyToOne本模型文章→用户
Has One@OneToOne关联模型用户→信用卡
Has Many@OneToMany关联模型用户→多篇文章
Many To Many@ManyToMany中间表用户↔标签
💡 GORM 关联的自动推断

GORM 会根据字段名自动推断外键User User 字段 → 外键 UserIDArticles []Article → 外键 UserID。想自定义用 foreignKey tag。这比 JPA 的 mappedBy 更简洁。

📥13 · 预加载(Preload)

JPA 的 N+1 问题臭名昭著——默认懒加载,访问关联属性时触发额外 SQL。GORM 没有懒加载,关联数据必须显式 Preload,否则字段为零值。这看似麻烦,实则避免了 N+1 陷阱

N+1 问题对比

☕ JPA · N+1 经典陷阱
List<User> users = repo.findAll();
for (User u : users) {
    System.out.println(
        u.getArticles().size());
    // ❌ 每次循环都发一条 SQL!
}
// N+1 条 SQL:1 查 users + N 查 articles

// 修复:JOIN FETCH 或 @EntityGraph
@Query("SELECT u FROM User u " +
       "LEFT JOIN FETCH u.articles")
List<User> findAllWithArticles();
🐹 GORM · Preload 显式
var users []User

// ❌ 不 Preload:articles 为空
db.Find(&users)

// ✅ Preload:一次额外查询加载关联
db.Preload("Articles").Find(&users)
// 2 条 SQL:1 查 users + 1 查 articles WHERE user_id IN (...) 

// ✅ 嵌套 Preload
db.Preload("Articles.Comments").
   Find(&users)

// ✅ 条件 Preload
db.Preload("Articles", "title LIKE ?", "Go%").
   Find(&users)
加载策略JPAGORM
默认行为懒加载(首次访问触发)不加载(零值)
避免 N+1JOIN FETCH / @EntityGraphPreload("field")
加载方式JOIN 或子查询独立查询 WHERE id IN (...)
条件加载@WhereJoinTable 等复杂Preload("f", "cond", args) 简洁
N+1 风险(默认懒加载)(不 Preload 就没数据)
✅ GORM 的设计更安全

JPA 的懒加载让你不知不觉触发 N+1(访问 user.getArticles() 时才发 SQL);GORM 让你必须显式声明要加载什么——多用一个 Preload 换来的是无 N+1 惊喜。生产代码更安全、更可预测。

Joins 预加载(JOIN 方式)

Preload 是独立查询(适合一对多);Belongs To / Has One 可以用 Joins 做 JOIN 加载。

🐹 GORM · Joins Preload
type Article struct {
    ID     uint
    Title  string
    UserID uint
    User   User   // Belongs To
}

var articles []Article
db.Joins("User").Find(&articles)
// SELECT articles.*, User.* FROM articles
//   LEFT JOIN users User ON articles.user_id = User.id

// 带条件的 Joins
db.Joins("User", db.Where(&User{Name: "Alice"})).Find(&articles)

🔧14 · 关联的增删改

创建时带关联

☕ JPA · cascade
@OneToMany(cascade = CascadeType.ALL)
private List<Article> articles;

User u = new User();
u.setArticles(List.of(
    new Article("title1"),
    new Article("title2")
));
repo.save(u);
// 自动 INSERT user + 2 articles
🐹 GORM · Create 自动级联
user := User{
    Name: "Alice",
    Articles: []Article{
        {Title: "title1"},
        {Title: "title2"},
    },
}

db.Create(&user)
// INSERT INTO users (name) VALUES ('Alice')
// INSERT INTO articles (title, user_id) VALUES ('title1', 1)
// INSERT INTO articles (title, user_id) VALUES ('title2', 1)

// Omit 跳过关联
db.Omit("Articles").Create(&user)
// 只 INSERT user,不创建 articles

Association 模式操作关联

JPA 直接操作集合 user.getArticles().add(a);GORM 用 db.Model(&user).Association("field") 模式。

🐹 GORM · Association API
user := User{}
user.ID = 1

// 追加关联
db.Model(&user).Association("Articles").Append(&Article{Title: "new"})

// 替换关联(Many2Many)
db.Model(&user).Association("Tags").Replace(&Tag{Name: "go"}, &Tag{Name: "web"})

// 删除关联(只断开关系,不删记录)
db.Model(&user).Association("Tags").Delete(&tag)

// 清空关联
db.Model(&user).Association("Tags").Clear()

// 关联数量
count := db.Model(&user).Association("Articles").Count()

多对多中间表

🐹 GORM · 自定义中间表
// 自定义中间表(带额外字段)
type UserTag struct {
    UserID uint `gorm:primaryKey`
    TagID  uint `gorm:primaryKey`
    CreatedAt time.Time
    Note  string
}

type User struct {
    gorm.Model
    Tags []Tag `gorm:many2many:user_tags;`
}

type Tag struct {
    gorm.Model
    Name string
}

🔄15 · 事务管理

Spring 用 @Transactional 注解(AOP 代理);GORM 用函数式闭包,更直观,也更"显式"。

☕ Spring · @Transactional
@Service
public class TransferService {

    @Transactional
    public void transfer(Long from,
                            Long to,
                            BigDecimal amt) {
        accountRepo.debit(from, amt);
        accountRepo.credit(to, amt);
        logRepo.save(new Log(...));
        // 异常自动回滚
    }
}

// 注意:@Transactional 基于 AOP 代理,
// 同类内部调用不会生效!
🐹 GORM · db.Transaction
func Transfer(db *gorm.DB,
    from, to uint, amt int,
) error {
    return db.Transaction(func(tx *gorm.DB) error {
        if err := tx.Model(&Account{}).
            Where("id = ?", from).
            Update("balance", gorm.Expr("balance - ?", amt)).Error; err != nil {
            return err  // 返回 error 自动回滚
        }

        if err := tx.Model(&Account{}).
            Where("id = ?", to).
            Update("balance", gorm.Expr("balance + ?", amt)).Error; err != nil {
            return err
        }

        return tx.Create(&Log{...}).Error
    })
}

事务规则

⚠️ 用 tx 不要用 db

事务中最常见的错误:在闭包内用了外层的 db 而非 tx,导致该操作不在事务中。务必用闭包参数 tx 执行所有数据库操作。Spring 的 @Transactional 隐式替换了数据源,GORM 要求你显式使用 tx。

手动控制事务

🐹 GORM · 手动 begin/commit/rollback
tx := db.Begin()
defer func() {
    if r := recover(); r != nil {
        tx.Rollback()
    }
}()

if err := tx.Create(user).Error; err != nil {
    tx.Rollback()
    return err
}

if err := tx.Create(order).Error; err != nil {
    tx.Rollback()
    return err
}

return tx.Commit().Error
✅ 推荐用 db.Transaction 闭包

闭包形式自动处理 commit/rollback/panic,代码更简洁,不会忘记回滚。手动 begin/commit 仅在需要跨函数传递事务细粒度控制时使用。

🪝16 · 钩子(Hooks)/ 回调

JPA 用 @PrePersist / @PostPersist 等 Entity Listener 注解;GORM 用方法——在模型上定义特定签名的方法即可。

☕ JPA · Entity Listener
@Entity
@EntityListeners(AuditListener.class)
public class User {

    @PrePersist
    public void beforeInsert() {
        this.createdAt = LocalDateTime.now();
    }

    @PreUpdate
    public void beforeUpdate() {
        this.updatedAt = LocalDateTime.now();
    }
}
🐹 GORM · 方法钩子
func (u *User) BeforeCreate(tx *gorm.DB) error {
    if u.Name == "" {
        return errors.New("name required")
    }
    u.CreatedAt = time.Now()
    return nil
}

func (u *User) BeforeUpdate(tx *gorm.DB) error {
    if u.Age < 0 {
        return errors.New("invalid age")
    }
    return nil
}

钩子全览

GORM 钩子JPA 等价触发时机
BeforeCreate@PrePersistINSERT 前
AfterCreate@PostPersistINSERT 后
BeforeUpdate@PreUpdateUPDATE 前
AfterUpdate@PostUpdateUPDATE 后
BeforeDelete@PreRemoveDELETE 前
AfterDelete@PostRemoveDELETE 后
BeforeFindSELECT 前
AfterFind@PostLoadSELECT 后(可改字段值)
BeforeSaveCreate + Update 前(两者都触发)
AfterSaveCreate + Update 后
⚠️ 钩子返回 error 会中止操作

这是 JPA 没有的特性:钩子返回非 nil error 会中止 Create/Update/Delete,并返回该 error。所以钩子可以做业务校验(如"年龄必须大于 0"),不只是填充字段。但注意:原生 SQL(Raw / Exec不触发钩子。

全局回调(跨模型)

🐹 GORM · 注册全局钩子
// 所有模型的 INSERT 都会经过这个钩子
db.Callback().Create().Before("gorm:create").Register(
    "audit_log",
    func(tx *gorm.DB) {
        log.Println("creating:", tx.Statement.Table)
    })

// 类似 Spring AOP 的 @Around 通知

📐17 · Schema 迁移

Java 生态用 Flyway / Liquibase 管理 DDL 版本;GORM 内置 AutoMigrate,但定位不同——它是温和的结构同步,不是版本化迁移。

☕ Flyway · 版本化迁移
# src/main/resources/db/migration/
# V1__create_user.sql
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    age INT DEFAULT 18
);

# V2__add_email.sql
ALTER TABLE users ADD COLUMN email VARCHAR(100);

# 启动时自动执行未应用的迁移
spring.flyway.enabled=true
🐹 GORM · AutoMigrate
func main() {
    db := initDB()

    // 自动同步结构体到表
    db.AutoMigrate(
        &User{},
        &Article{},
        &Tag{},
    )
    // 不存在的表→创建
    // 缺字段→ALTER ADD
    // 类型变化→ALTER MODIFY(尽量)
    // ❌ 不会删字段、不会删表
}
维度Flyway/LiquibaseGORM AutoMigrate
定位版本化迁移(DB 作为代码)结构同步(struct 即真理)
新增表/字段✅ 需写 SQL✅ 自动
修改字段类型✅ 需写 SQL⚠️ 尽量(部分类型不转)
删除字段/表✅ 需写 SQL❌ 不会删(安全)
回滚✅ undo 脚本❌ 无
版本追踪✅ schema_history 表❌ 无
团队协作✅ 强(SQL 文件入 Git)⚠️ 弱(依赖代码)
⚠️ 生产环境慎用 AutoMigrate

AutoMigrate 适合开发期快速迭代初始化建表。生产环境推荐:开发用 AutoMigrate,上线用 Flyway/golang-migrate。原因:① AutoMigrate 不可回滚;② 无版本记录;③ 团队协作时 struct 改了谁也不知道 DB 变了什么。GORM 官方也建议生产用专业迁移工具。

其他 DDL 操作

🐹 GORM
// 手动建表
db.Migrator().CreateTable(&User{})

// 添加列
db.Migrator().AddColumn(&User{}, "Age")

// 修改列类型
db.Migrator().AlterColumn(&User{}, "Age")

// 添加索引
db.Migrator().CreateIndex(&User{}, "idx_name")

// 删除表
db.Migrator().DropTable("users")

// 检查表是否存在
db.Migrator().HasTable(&User{})

🔧18 · Scopes 复用查询条件

这是 GORM 替代 MyBatis <sql> 片段复用和 JPA Specification 的特性——把常用查询条件封装成函数。

☕ JPA · Specification
public class UserSpecs {

    public static Specification<User> isAdult() {
        return (root, q, cb) ->
            cb.greaterThan(
                root.get("age"), 18);
    }

    public static Specification<User> isActive() {
        return (root, q, cb) ->
            cb.equal(
                root.get("active"), true);
    }
}

repo.findAll(
    Specification.where(UserSpecs.isAdult())
        .and(UserSpecs.isActive())
);
🐹 GORM · Scopes
func Adult(db *gorm.DB) *gorm.DB {
    return db.Where("age > ?", 18)
}

func Active(db *gorm.DB) *gorm.DB {
    return db.Where("active = ?", true)
}

func Paginate(page, size int) func(*gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Offset((page - 1) * size).Limit(size)
    }
}

// 组合使用
db.Scopes(Adult, Active, Paginate(1, 10)).
   Find(&users)
// WHERE age > 18 AND active = true LIMIT 10 OFFSET 0
✅ Scopes 的价值

把分页、软删除过滤、权限过滤等通用逻辑封装成 Scope,任意组合复用。比 MyBatis 的 <sql> 片段更灵活(可编程),比 JPA Specification 更简洁(无需 Criteria API 的冗长写法)。

19 · 性能优化

1. 用 Preload 避免 N+1

🐹 GORM · N+1 检测
// ❌ N+1:循环里查关联
db.Find(&users)
for _, u := range users {
    db.Model(&u).Related(&u.Articles)  // N 次查询!
}

// ✅ Preload:1 次查询(WHERE user_id IN (...))
db.Preload("Articles").Find(&users)

2. 开启预编译缓存

🐹 GORM · PrepareStmt
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
    PrepareStmt: true,   // 缓存预编译语句
})
// 相同 SQL 第二次执行走预编译,减少解析开销
// 高并发场景提升 20%~30%

3. 只查需要的列

🐹 GORM · Select 字段
// ❌ SELECT * 查出大字段(如 TEXT/BLOB)
db.Find(&users)

// ✅ 只查需要的列
db.Select("id, name, age").Find(&users)

4. 批量操作

🐹 GORM · 批量插入/更新
// 批量插入,每批 1000 条
db.CreateInBatches(users, 1000)

// 批量更新用 CASE WHEN(GORM 自动优化)
db.Save(users)

// 批量 upsert(不存在则建,存在则更新)
db.Clauses(clause.OnConflict{
    Columns:   []clause.Column{{Name: "id"}},
    DoUpdates: clause.Assignments(map[string]any{
        "age": 20,
    }),
}).Create(&users)
// INSERT INTO users ... ON DUPLICATE KEY UPDATE age=20

5. 关闭日志提升性能

🐹 GORM · 日志级别
// 开发环境:打印 SQL
db.Debug().Find(&users)

// 生产环境:静默(或只记错误)
db, _ = gorm.Open(mysql.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Error),
})

6. DryRun 调试 SQL

🐹 GORM · 不执行只生成 SQL
// 生成 SQL 但不执行(调试用)
stmt := db.Session(&gorm.Session{DryRun: true}).
    Where("age > ?", 18).Find(&users)

stmt.Statement.SQL.String()
// 输出:SELECT * FROM users WHERE age > ?

性能优化清单

优化点JPA 对应说明
Preload 避免 N+1JOIN FETCH / EntityGraphGORM 无懒加载,更安全
PrepareStmt预编译缓存高并发场景必开
Only Select 必要列DTO 投影避免大字段传输
CreateInBatchesJDBC batch_size批量插入
OnConflict Upsert原生 upsert,无额外查询
连接池调优HikariCP必设 ConnMaxLifetime
关闭 Debug 日志show-sql=false生产环境

💣20 · 常见陷阱

1. First 报错 vs Find 不报错

查单条用 First,找不到返回 ErrRecordNotFound;查列表用 Find,找不到返回空切片不报错。不要用 Find 当 First 用

2. 结构体条件忽略零值

⚠️ GORM
// ❌ 想查 age=0 的用户,但 0 被忽略
db.Where(&User{Age: 0).Find(&users)
// 实际:SELECT * FROM users(无 age 条件)

// ✅ 用字符串条件或 map
db.Where("age = ?", 0).Find(&users)
db.Where(map[string]any{"age": 0}).Find(&users)

3. Updates 用 struct 忽略零值

⚠️ GORM
// ❌ 想把 active 设为 false,但 false 被忽略
db.Updates(User{Active: false})

// ✅ 用 map
db.Updates(map[string]any{"active": false})

// ✅ 或用 Select 强制指定
db.Select("active").Updates(User{Active: false})

4. 软删除忘记 Unscoped

⚠️ GORM
// 已软删除的数据查不到
db.Find(&users)  // WHERE deleted_at IS NULL

// ✅ 加 Unscoped 才能查到
db.Unscoped().Find(&users)

// ✅ 永久删除也要 Unscoped
db.Unscoped().Delete(&user)

5. 事务内用 db 而非 tx

⚠️ GORM
db.Transaction(func(tx *gorm.DB) error {
    // ❌ 用了外层 db,不在事务中!
    db.Create(user)

    // ✅ 必须用 tx
    return tx.Create(user).Error
})

6. 关联不 Preload 为空

从 JPA 过来会以为关联字段自动加载——GORM 不会。必须 Preload("Articles"),否则 Articles 为 nil。这避免了 N+1,但需要你显式声明。

7. GORM tag 分号 vs JSON tag 逗号

gorm:"a;b;c" 用分号,json:"a,b,c" 用逗号。混用是最常见低级错误。

8. AutoMigrate 不删字段

struct 删了某字段,DB 里该列不会被删除(AutoMigrate 只增不减)。需要手动 db.Migrator().DropColumn(&User{}, "old_field")

9. 字符串拼接 SQL 注入

⚠️ GORM
// ❌ 字符串拼接(SQL 注入风险)
db.Raw("SELECT * FROM users WHERE name = '" + name + "'")

// ✅ 参数化
db.Raw("SELECT * FROM users WHERE name = ?", name)

🎓21 · 迁移建议与速查

思维转变清单

  1. 放下懒加载:JPA 默认懒加载,GORM 无懒加载。关联数据要显式 Preload
  2. 放下 save 全自动:JPA save 智能判断新增/更新;GORM 区分 Create / Save / Update / Updates
  3. 放下 cascade 魔法:JPA 的级联很"智能"但也常出错;GORM 级联更可控,用 Association 显式操作。
  4. 放下二级缓存:JPA 有二级缓存,GORM 无。需要缓存用 Redis 或内存缓存。
  5. 拥抱显式事务:Spring @Transactional 是声明式(AOP 代理);GORM db.Transaction(func) 是函数式,更直观。
  6. 拥抱零值陷阱:Go 的零值机制导致 struct 条件/更新忽略零值——用 map 替代。
  7. 享受原生 SQL:复杂查询随时 Raw,不必死磕 ORM API。

概念映射速查表

MyBatis / JPAGORM
@Entity / <resultMap>struct + gorm tag
@Id @GeneratedValuegorm:"primarykey;autoIncrement"
@Column(name=)gorm:"column:name"
@TableLogic 软删除gorm.DeletedAt(内置)
save() / insert()Create() / Save()
findById() / selectById()First(&u, id)
findAll() / selectList()Find(&list)
updateById()Updates() / Save()
deleteById()Delete(&model, id)
@Transactionaldb.Transaction(func)
@OneToManyHas Many
@ManyToOneBelongs To
@OneToOneHas One
@ManyToManygorm:"many2many:table"
JOIN FETCH(避免 N+1)Preload("field")
SpecificationScopes
<if test=...> 动态 SQLGo if + 链式
<foreach> IN 查询Where("id IN ?", slice)
Pageable 分页Offset().Limit()
@PrePersist 钩子BeforeCreate(tx) error
Flyway 迁移AutoMigrate(开发)/ golang-migrate(生产)
HikariCP 连接池db.DB() + SetMax*
MyBatis-Plus LambdaQueryWrapper链式 .Where().Or().Order()

推荐技术栈

领域JavaGo
ORMJPA / MyBatis-PlusGORM / sqlx / ent / sqlc
数据库迁移Flyway / Liquibasegolang-migrate / goose / Atlas
连接池HikariCPdatabase/sql(内置)
缓存Spring Cache + Redisgo-redis + 自封装
事务Spring @TransactionalGORM db.Transaction
SQL 日志p6spy / datasource-proxyGORM Logger
测试@DataJpaTest + TestcontainersTestcontainers + docker
✅ 什么时候选 GORM,什么时候选别的
  • GORM:业务系统、CRUD 为主、需要关联查询、想快速开发
  • sqlx:性能极致、SQL 完全可控、简单查询为主
  • ent(Facebook 出品):图式查询、复杂关联、代码生成优先
  • sqlc:写 SQL,编译期生成类型安全的 Go 代码——MyBatis 开发者会喜欢
💡 给 MyBatis 开发者的特别建议

如果你最爱 MyBatis 的"SQL 我全写"控制感,可能 sqlc 比 GORM 更适合你——它让你写原生 SQL,然后编译期生成类型安全的 Go 函数。GORM 适合"想要 ORM 便利但需要时能降级到 SQL"的场景。两者可以共存:主体用 GORM,特殊查询用 Raw。

GORM 开发指南 · MyBatis/JPA 开发者专属

愿你的 Go 数据层既高效又可控 🐹