GORM 开发指南
写给 MyBatis / JPA 开发者的完整 GORM 实战手册 —— 从模型定义到关联查询,从事务到性能
🎯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 @Transactional | Spring @Transactional | db.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 条。
<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);
// 方式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(...);
var users []User
db.Where("age > ?", 18).
Order("created_at desc").
Limit(10).
Find(&users)
可以看到:MyBatis 要写 XML + 接口两个文件;JPA 方法名长到离谱或 Specification 啰嗦;GORM 一行链式调用清晰直观。这就是 GORM 的魅力。
⚙️02 · 安装与连接
安装依赖
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 显式建立连接。
# 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;
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 配置。
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)
这是 MyBatis/JPA 开发者最容易忽略的点。Spring Boot 的 HikariCP 默认 30 分钟回收连接;GORM 默认不设,长连接会被 MySQL 的 wait_timeout(默认 8 小时)断开,导致 "connection reset" 错误。生产环境务必设置 SetConnMaxLifetime 小于 DB 的 wait_timeout。
全局配置项
| GORM 配置 | JPA 对应 | 说明 |
|---|---|---|
Logger.LogMode | show-sql / logging.level | 日志级别:Silent/Error/Warn/Info |
NamingStrategy | — | 表名/列名命名策略 |
FullSaveAssociations | cascade | 保存时是否完整保存关联 |
DryRun | — | 生成 SQL 但不执行(调试用) |
PrepareStmt | 预编译缓存 | 缓存预编译语句,提升性能 |
DisableForeignKeyConstraintWhenMigrating | — | 迁移时不创建外键 |
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。
// 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>
@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;
}
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 抽象基类实现同样的事。
@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.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
...
}
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 | 内嵌字段前缀 |
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`
复合索引
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 · 插入数据
<insert id="insert"
parameterType="User"
useGeneratedKeys="true"
keyProperty="id">
INSERT INTO users
(name, age)
VALUES
(#{name}, #{age})
</insert>
userMapper.insert(user);
// user.id 自动回填
// save 方法,自动判断新增/更新
userRepository.save(user);
// 批量
userRepository.saveAll(users);
// 或 EntityManager.persist
em.persist(user);
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,
})
指定字段插入
// 只插入指定字段
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 条
默认情况下,db.Create(&user) 会把结构体的所有字段(含零值)都写入。如果你只想插入非零字段,用 db.Select("Name").Create(&user)。这与 JPA 的 save() 行为一致,但与 MyBatis 的 <insert>(只插入 SQL 里写的字段)不同。
🔍06 · 查询数据
主键查询
<select id="selectById"
resultType="User">
SELECT * FROM users
WHERE id = #{id}
</select>
User u = mapper.selectById(1L);
Optional<User> opt =
repo.findById(1L);
if (opt.isPresent()) {
User u = opt.get();
}
// 或 MyBatis-Plus
User u = mapper.selectById(1);
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 | 找不到时 |
|---|---|---|
First | ORDER BY id LIMIT 1 | 返回 ErrRecordNotFound |
Take | LIMIT 1(无 ORDER BY) | 返回 ErrRecordNotFound |
Last | ORDER BY id DESC LIMIT 1 | 返回 ErrRecordNotFound |
Find | 查多条 | 不报错,返回空切片 |
这是 JPA/MyBatis 开发者最容易困惑的点:First / Take / Last 找不到记录时会返回 ErrRecordNotFound(类似 JPA 的 EmptyResultDataAccessException);但 Find 查多条时找不到不报错,返回空切片。所以:查单条用 First 要判 err;查列表用 Find 不用判 NotFound。
查询多条
// 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);
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
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 字段
// 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 |
Updates(User{Age: 0, Active: false}) 不会更新 age 和 active(零值被忽略)。改用 Updates(map[string]any{"age": 0, "active": false}) 就能更新。这是与 JPA/MyBatis 最大的行为差异。
批量更新
// 条件批量更新
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 · 删除数据
<delete id="deleteById">
DELETE FROM users
WHERE id = #{id}
</delete>
mapper.deleteById(1L);
// 批量
mapper.deleteBatchIds(
Arrays.asList(1L, 2L));
// 先查再删
User u = repo.findById(1L).get();
repo.delete(u);
repo.deleteById(1L);
repo.deleteAllById(
List.of(1L, 2L));
// 按主键删(需有主键值)
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 默认硬删除的重要差异。
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 用链式调用构建条件。
基础条件
// 字符串条件(最灵活,类似 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 链式
// 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 过来的开发者最关心的问题:怎么根据参数动态拼接条件?
<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>
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
}
MyBatis 的 <if> 拼接容易出 SQL 注入(用了 ${} 而非 #{});GORM 用 ? 占位符自动参数化,天然防注入。而且 Go 的 if 比 XML 的 <if test="..."> 更直观、可调试。
📊10 · 排序、分页、聚合
排序与分页
Pageable pageable = PageRequest.of(
0, 10, // page, size
Sort.by("createdAt").descending()
);
Page<User> page =
repo.findAll(pageable);
page.getContent(); // 数据
page.getTotalElements(); // 总数
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)
上面"一步到位"的写法在 GORM v2 中可用,但要注意:Count 会移除 LIMIT 和 OFFSET(因为要算总数),而 Find 保留它们。如果顺序写反或链式结构不当,可能得到错误的总数。稳妥做法:分两次查询。
聚合查询
@Query("SELECT AVG(age) FROM User")
double avgAge();
@Query("SELECT u.role, COUNT(u)" +
" FROM User u GROUP BY u.role")
List<Object[]> countByRole();
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 用于查询模型本身(映射到 struct tag 对应的表);Scan 用于查询任意结果(聚合、JOIN、原生 SQL 的结果映射到自定义 struct)。规则:查模型用 Find,查派生数据用 Scan。
Distinct / Select 字段
// 查询指定列
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 的自由。
<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>
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)
命名参数
// 用 @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 执行非查询语句
// 执行 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())
}
MyBatis 开发者会很喜欢 Raw:复杂 SQL 直接写,结果自动映射到 struct。但它不走 GORM 的软删除、钩子等机制——所以简单 CRUD 用链式 API 享受便利,复杂查询用 Raw 保留控制力。这正是 GORM "两者兼得"的设计。
🔗12 · 四种关联关系
JPA 用 @OneToMany / @ManyToOne / @OneToOne / @ManyToMany 注解;GORM 用结构体字段 + tag 声明,更直观。
Belongs To(多对一)—— 对应 JPA @ManyToOne
@Entity
public class Article {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
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
@Entity
public class User {
@Id
private Long id;
@OneToOne(cascade = ALL)
@JoinColumn(name = "credit_card_id")
private CreditCard card;
}
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
@Entity
public class User {
@OneToMany(mappedBy = "user")
private List<Article> articles;
}
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
@Entity
public class User {
@ManyToMany
@JoinTable(
name = "user_tags",
joinColumns = @JoinColumn(name="user_id"),
inverseJoinColumns = @JoinColumn(name="tag_id")
)
private Set<Tag> tags;
}
type User struct {
gorm.Model
Name string
Tags []Tag `gorm:many2many:user_tags;`
// GORM 自动创建中间表 user_tags
}
type Tag struct {
gorm.Model
Name string
}
四种关联速查表
| GORM | JPA | 外键位置 | 典型场景 |
|---|---|---|---|
| Belongs To | @ManyToOne | 本模型 | 文章→用户 |
| Has One | @OneToOne | 关联模型 | 用户→信用卡 |
| Has Many | @OneToMany | 关联模型 | 用户→多篇文章 |
| Many To Many | @ManyToMany | 中间表 | 用户↔标签 |
GORM 会根据字段名自动推断外键:User User 字段 → 外键 UserID;Articles []Article → 外键 UserID。想自定义用 foreignKey tag。这比 JPA 的 mappedBy 更简洁。
📥13 · 预加载(Preload)
JPA 的 N+1 问题臭名昭著——默认懒加载,访问关联属性时触发额外 SQL。GORM 没有懒加载,关联数据必须显式 Preload,否则字段为零值。这看似麻烦,实则避免了 N+1 陷阱。
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();
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)
| 加载策略 | JPA | GORM |
|---|---|---|
| 默认行为 | 懒加载(首次访问触发) | 不加载(零值) |
| 避免 N+1 | JOIN FETCH / @EntityGraph | Preload("field") |
| 加载方式 | JOIN 或子查询 | 独立查询 WHERE id IN (...) |
| 条件加载 | @WhereJoinTable 等复杂 | Preload("f", "cond", args) 简洁 |
| N+1 风险 | 高(默认懒加载) | 无(不 Preload 就没数据) |
JPA 的懒加载让你不知不觉触发 N+1(访问 user.getArticles() 时才发 SQL);GORM 让你必须显式声明要加载什么——多用一个 Preload 换来的是无 N+1 惊喜。生产代码更安全、更可预测。
Joins 预加载(JOIN 方式)
Preload 是独立查询(适合一对多);Belongs To / Has One 可以用 Joins 做 JOIN 加载。
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 · 关联的增删改
创建时带关联
@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
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") 模式。
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()
多对多中间表
// 自定义中间表(带额外字段)
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 用函数式闭包,更直观,也更"显式"。
@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 代理,
// 同类内部调用不会生效!
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
})
}
事务规则
- 返回 nil 自动提交,返回 error 自动回滚
- 函数内 panic 也会自动回滚(GORM v2 已处理)
- 事务内的操作用传入的
tx(而非原db),否则不在事务中 - 嵌套
db.Transaction自动复用外层事务(传播行为类似 REQUIRED)
事务中最常见的错误:在闭包内用了外层的 db 而非 tx,导致该操作不在事务中。务必用闭包参数 tx 执行所有数据库操作。Spring 的 @Transactional 隐式替换了数据源,GORM 要求你显式使用 tx。
手动控制事务
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
闭包形式自动处理 commit/rollback/panic,代码更简洁,不会忘记回滚。手动 begin/commit 仅在需要跨函数传递事务或细粒度控制时使用。
🪝16 · 钩子(Hooks)/ 回调
JPA 用 @PrePersist / @PostPersist 等 Entity Listener 注解;GORM 用方法——在模型上定义特定签名的方法即可。
@Entity
@EntityListeners(AuditListener.class)
public class User {
@PrePersist
public void beforeInsert() {
this.createdAt = LocalDateTime.now();
}
@PreUpdate
public void beforeUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
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 | @PrePersist | INSERT 前 |
AfterCreate | @PostPersist | INSERT 后 |
BeforeUpdate | @PreUpdate | UPDATE 前 |
AfterUpdate | @PostUpdate | UPDATE 后 |
BeforeDelete | @PreRemove | DELETE 前 |
AfterDelete | @PostRemove | DELETE 后 |
BeforeFind | — | SELECT 前 |
AfterFind | @PostLoad | SELECT 后(可改字段值) |
BeforeSave | — | Create + Update 前(两者都触发) |
AfterSave | — | Create + Update 后 |
这是 JPA 没有的特性:钩子返回非 nil error 会中止 Create/Update/Delete,并返回该 error。所以钩子可以做业务校验(如"年龄必须大于 0"),不只是填充字段。但注意:原生 SQL(Raw / Exec)不触发钩子。
全局回调(跨模型)
// 所有模型的 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,但定位不同——它是温和的结构同步,不是版本化迁移。
# 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
func main() {
db := initDB()
// 自动同步结构体到表
db.AutoMigrate(
&User{},
&Article{},
&Tag{},
)
// 不存在的表→创建
// 缺字段→ALTER ADD
// 类型变化→ALTER MODIFY(尽量)
// ❌ 不会删字段、不会删表
}
| 维度 | Flyway/Liquibase | GORM AutoMigrate |
|---|---|---|
| 定位 | 版本化迁移(DB 作为代码) | 结构同步(struct 即真理) |
| 新增表/字段 | ✅ 需写 SQL | ✅ 自动 |
| 修改字段类型 | ✅ 需写 SQL | ⚠️ 尽量(部分类型不转) |
| 删除字段/表 | ✅ 需写 SQL | ❌ 不会删(安全) |
| 回滚 | ✅ undo 脚本 | ❌ 无 |
| 版本追踪 | ✅ schema_history 表 | ❌ 无 |
| 团队协作 | ✅ 强(SQL 文件入 Git) | ⚠️ 弱(依赖代码) |
AutoMigrate 适合开发期快速迭代和初始化建表。生产环境推荐:开发用 AutoMigrate,上线用 Flyway/golang-migrate。原因:① AutoMigrate 不可回滚;② 无版本记录;③ 团队协作时 struct 改了谁也不知道 DB 变了什么。GORM 官方也建议生产用专业迁移工具。
其他 DDL 操作
// 手动建表
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 的特性——把常用查询条件封装成函数。
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())
);
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
把分页、软删除过滤、权限过滤等通用逻辑封装成 Scope,任意组合复用。比 MyBatis 的 <sql> 片段更灵活(可编程),比 JPA Specification 更简洁(无需 Criteria API 的冗长写法)。
⚡19 · 性能优化
1. 用 Preload 避免 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. 开启预编译缓存
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
PrepareStmt: true, // 缓存预编译语句
})
// 相同 SQL 第二次执行走预编译,减少解析开销
// 高并发场景提升 20%~30%
3. 只查需要的列
// ❌ SELECT * 查出大字段(如 TEXT/BLOB)
db.Find(&users)
// ✅ 只查需要的列
db.Select("id, name, age").Find(&users)
4. 批量操作
// 批量插入,每批 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. 关闭日志提升性能
// 开发环境:打印 SQL
db.Debug().Find(&users)
// 生产环境:静默(或只记错误)
db, _ = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Error),
})
6. DryRun 调试 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+1 | JOIN FETCH / EntityGraph | GORM 无懒加载,更安全 |
| PrepareStmt | 预编译缓存 | 高并发场景必开 |
| Only Select 必要列 | DTO 投影 | 避免大字段传输 |
| CreateInBatches | JDBC batch_size | 批量插入 |
| OnConflict Upsert | — | 原生 upsert,无额外查询 |
| 连接池调优 | HikariCP | 必设 ConnMaxLifetime |
| 关闭 Debug 日志 | show-sql=false | 生产环境 |
💣20 · 常见陷阱
1. First 报错 vs Find 不报错
查单条用 First,找不到返回 ErrRecordNotFound;查列表用 Find,找不到返回空切片不报错。不要用 Find 当 First 用。
2. 结构体条件忽略零值
// ❌ 想查 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 忽略零值
// ❌ 想把 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
// 已软删除的数据查不到
db.Find(&users) // WHERE deleted_at IS NULL
// ✅ 加 Unscoped 才能查到
db.Unscoped().Find(&users)
// ✅ 永久删除也要 Unscoped
db.Unscoped().Delete(&user)
5. 事务内用 db 而非 tx
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 注入
// ❌ 字符串拼接(SQL 注入风险)
db.Raw("SELECT * FROM users WHERE name = '" + name + "'")
// ✅ 参数化
db.Raw("SELECT * FROM users WHERE name = ?", name)
🎓21 · 迁移建议与速查
思维转变清单
- 放下懒加载:JPA 默认懒加载,GORM 无懒加载。关联数据要显式 Preload。
- 放下 save 全自动:JPA
save智能判断新增/更新;GORM 区分Create/Save/Update/Updates。 - 放下 cascade 魔法:JPA 的级联很"智能"但也常出错;GORM 级联更可控,用
Association显式操作。 - 放下二级缓存:JPA 有二级缓存,GORM 无。需要缓存用 Redis 或内存缓存。
- 拥抱显式事务:Spring
@Transactional是声明式(AOP 代理);GORMdb.Transaction(func)是函数式,更直观。 - 拥抱零值陷阱:Go 的零值机制导致 struct 条件/更新忽略零值——用 map 替代。
- 享受原生 SQL:复杂查询随时
Raw,不必死磕 ORM API。
概念映射速查表
| MyBatis / JPA | GORM |
|---|---|
@Entity / <resultMap> | struct + gorm tag |
@Id @GeneratedValue | gorm:"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) |
@Transactional | db.Transaction(func) |
@OneToMany | Has Many |
@ManyToOne | Belongs To |
@OneToOne | Has One |
@ManyToMany | gorm:"many2many:table" |
| JOIN FETCH(避免 N+1) | Preload("field") |
| Specification | Scopes |
<if test=...> 动态 SQL | Go 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() |
推荐技术栈
| 领域 | Java | Go |
|---|---|---|
| ORM | JPA / MyBatis-Plus | GORM / sqlx / ent / sqlc |
| 数据库迁移 | Flyway / Liquibase | golang-migrate / goose / Atlas |
| 连接池 | HikariCP | database/sql(内置) |
| 缓存 | Spring Cache + Redis | go-redis + 自封装 |
| 事务 | Spring @Transactional | GORM db.Transaction |
| SQL 日志 | p6spy / datasource-proxy | GORM Logger |
| 测试 | @DataJpaTest + Testcontainers | Testcontainers + docker |
- GORM:业务系统、CRUD 为主、需要关联查询、想快速开发
- sqlx:性能极致、SQL 完全可控、简单查询为主
- ent(Facebook 出品):图式查询、复杂关联、代码生成优先
- sqlc:写 SQL,编译期生成类型安全的 Go 代码——MyBatis 开发者会喜欢
如果你最爱 MyBatis 的"SQL 我全写"控制感,可能 sqlc 比 GORM 更适合你——它让你写原生 SQL,然后编译期生成类型安全的 Go 函数。GORM 适合"想要 ORM 便利但需要时能降级到 SQL"的场景。两者可以共存:主体用 GORM,特殊查询用 Raw。
GORM 开发指南 · MyBatis/JPA 开发者专属
愿你的 Go 数据层既高效又可控 🐹