如果你和我一样,是从上一篇文章一路实践过来的,会发现 GORM 2.0 的入门路线里,最容易卡住的不是环境配置,而是“模型到底该怎么定义”。我见过太多人把表结构直接丢给结构体,结果字段类型对不上、查询条件被静默忽略、软删数据莫名查不到。这篇是 GORM 系列的第二篇,主题很明确:模型定义与基础查询。作为 Go 生态里使用频率最高的 ORM 之一,GORM 把建表、读写、关联都收进了一层结构体里,但恰恰是这层“包装”,让不少新手走了弯路。如果你能把这篇文章里的模型定义吃透,基础查询后面再复杂的操作都会顺畅很多。
1. 模型定义:从结构体到数据表的映射逻辑
1.1 GORM 默认映射规则
GORM 之所以能让你少写 SQL,核心在于一套默认的映射约定。你定义一个结构体,它会按照约定翻译成建表语句和查询语句。
以 MySQL 为例,默认规则是这样的:
- 结构体名
User对应表名users,即蛇形复数。 - 字段名
CreatedAt对应列名created_at。 ID字段默认作为主键,类型为uint。- 字段
gorm.DeletedAt会被识别为软删除列,名称是deleted_at。 time.Time字段默认映射为datetime类型,且支持自动填充时间。
很多人觉得这套规则可有可无,非要手动指定表名和字段名。但我的建议是:能按约定来就按约定来。因为当你后面写关联查询、Preload、迁移时,GORM 会频繁依赖这套默认规则,一旦你打破约定,每次自定义都要付出额外的维护成本。只有在确实无法匹配的情况下,才去手动覆盖。
1.2 一个最基本的模型长什么样
假设我们要建一张用户表,通常我会这样定义:
type User struct { ID uint `gorm:"primaryKey"` Name string `gorm:"size:64;not null;comment:用户名"` Email string `gorm:"size:128;uniqueIndex;comment:邮箱"` Age int `gorm:"default:0;comment:年龄"` Birthday *time.Time `gorm:"comment:生日"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` }这个结构体里需要注意几个点:
ID uint是 GORM 默认识别的主键,不用额外写标签也能生效。但我习惯显式标出primaryKey,因为代码的可读性会更好,团队成员一眼能看出主键字段。Birthday *time.Time我用了指针类型。这是为了避免零值问题,后面会专门讲。在模型定义阶段用指针,查询时就能明确区分“字段为空”和“字段值为零值”。DeletedAt gorm.DeletedAt是软删除的标配。只要加了它,GORM 会在你执行Delete时改成UPDATE设置deleted_at,查询时自动加上WHERE deleted_at IS NULL。
定义完之后,开发环境里直接用AutoMigrate建表:
db.AutoMigrate(&User{})这条语句会把结构体翻译成CREATE TABLE执行,开发阶段非常方便。但注意,生产环境的表结构变更不建议依赖 AutoMigrate,这点我在第六部分会展开说。
1.3 什么时候必须自定义表名
默认规则虽然好,但总有一些场景绕不过去。最常见的是多租户分表,比如订单表按月份拆成orders_202501、orders_202502,或者你接入了一套历史遗留数据库,表名根本不是users这种规范写法。
此时只需要给模型实现一个TableName方法:
func (Order) TableName() string { return "orders_202501" }这个方法会让 GORM 在后续所有操作中使用指定的表名。需要注意,如果你动态拼接月份,不要在TableName里写死逻辑,而是通过结构体字段或上下文方式传入,否则模型复用会出问题。
另外还有一个临时指定表的链式方法:
db.Table("orders_202501").Where("status = ?", 1).Find(&orders)TableName是模型级别的,Table是单次查询级别的。两者使用场景不同,我的经验是:如果只是某次查询需要换表,优先用Table;如果整个模型都绑定一张固定表,才用TableName。
2. 字段标签:类型、主键、索引与约束
2.1 Go 类型与数据库类型的映射
看过很多人的模型定义,最常犯的错就是把所有字符串都写成string,也不指定长度。GORM 默认对string字段生成的类型是longtext或varchar(255),这取决于数据库方言,但不管哪种,直接裸写都容易出问题。
我整理了一份常用对应关系,以 MySQL 为例:
| Go 类型 | 默认数据库类型 | 建议标签写法 |
|---|---|---|
string | longtext或varchar(255) | type:varchar(64)或size:64 |
int | bigint | type:int或type:bigint |
uint | bigint unsigned | type:int unsigned |
float64 | double | type:decimal(10,2) |
bool | boolean | 无需特殊标签 |
time.Time | datetime | 可选type:date只存日期 |
[]byte | blob | type:blob |
json.RawMessage | 不默认支持 | type:json |
如果你没有显式声明类型,就可能出现:数据库里是varchar(10),但 GORM 迁移时给生成成了longtext,或者是反过来的情况。虽然大多数查询不受影响,但一旦涉及索引长度限制(比如 MySQL 5.7 的索引字节限制),就会报错。
我的习惯是:长度有限的字符串必须写size,金额用decimal,时间用datetime,布尔用bool。查问题的时候,数据库的表结构和模型结构保持一致,能省掉太多不必要的排查时间。
2.2 主键、普通索引和复合索引
模型定义时把索引写清楚,比建表后手动加索引要规范得多。比如唯一索引:
type User struct { ID uint `gorm:"primaryKey"` Email string `gorm:"uniqueIndex"` }如果需要多字段的复合唯一索引,可以这样:
type User struct { ID uint `gorm:"primaryKey"` AppID uint `gorm:"uniqueIndex:idx_app_id_account"` Account string `gorm:"uniqueIndex:idx_app_id_account"` }这里idx_app_id_account是索引名,两个字段共用同一个索引名,GORM 就会把它们组合成一个复合唯一索引。查询时如果条件里同时出现app_id和account,就能直接用上这个索引。
普通查询索引同理:
type Order struct { ID uint `gorm:"primaryKey"` UserID uint `gorm:"index"` OrderNo string `gorm:"uniqueIndex"` Status int `gorm:"index"` }注意,索引不是越多越好。模型定义阶段最容易犯的错是只要是查询条件就加索引,结果写入频繁的表的性能反而下降。我一般只在两类字段上建索引:一是外键关联字段,二是高频查询的筛选字段。
2.3 字段的读写控制
GORM 模型里可以控制某个字段是否参与写入、更新或迁移。这个能力很多人没用过,但实际业务中很有用。
忽略字段,用gorm:"-":
type User struct { ID uint Name string Password string `gorm:"<-:create"` // 只允许创建时写入,更新时忽略 LoginCount int `gorm:"->"` // 只读,永远不会写入 }<-:create表示该字段只在创建记录时写入,更新时忽略;->表示只读。比如密码、PV 字段、统计类字段,就有很强的应用场景。
还有一个非常常见的标签是default:
Status int `gorm:"default:1"`如果不写这个标签,当你的 Go 结构体里Status为零值或没赋值时,GORM 可能不会把它包含在 INSERT 语句里,数据库就会用表定义里的默认值;但如果你模型写明了default:1,GORM 就会明确告诉数据库“这里的默认值是 1”,两者效果在大多数情况下一致,但迁移建表时,后者才会在表结构里生成DEFAULT 1。所以要保证表结构正确,标签必须写清楚。
3. 基础查询:条件组合、排序与分页
3.1 First、Take、Last、Find 这一族方法
GORM 2.x 的基础查询方法有四个容易混淆的,我直接说它们的区别:
First:按主键升序取第一条记录。Last:按主键降序取第一条记录。Take:不排序,直接取一条。Find:返回满足条件的所有记录。
其中First和Last有一个非常隐蔽的坑:如果你的表结构没有主键,或者使用了复合主键,它们的行为可能不符合预期。另外,First在查不到数据时会返回gorm.ErrRecordNotFound,而Find查不到数据时返回nil。判断逻辑别搞混了。
var user User err := db.First(&user, 10).Error if errors.Is(err, gorm.ErrRecordNotFound) { // 处理查不到的情况 } var users []User db.Find(&users) // 全部记录First的第二个参数可以直接传主键值,等价于WHERE id = 10。这是最常用的简写方式。注意,这里传的是主键值,不是任意字段,如果你传了字符串,GORM 会尝试按主键类型解析。
3.2 Where 的三种写法
Where有三种写法,写不对就很容易出 bug。
第一种是 map:
db.Where(map[string]interface{}{ "name": "张三", "age": 18, }).Find(&users)map 的优点是条件里的零值也会被正常带上。比如age: 0这类条件不会被忽略,适合动态拼接筛选条件。
第二种是 struct:
db.Where(&User{Name: "张三", Age: 18}).Find(&users)struct 写法最简单,但有一个致命问题:零值字段不会被作为条件。如果你要查Status = 0的记录,这种写法会直接忽略这个条件,查出来的结果就是错的。所以业务中如果存在“零值本身是有效条件”的场景,千万别用 struct 写法。
第三种是字符串:
db.Where("name = ? AND age >= ?", "张三", 18).Find(&users)字符串写法最灵活,只要注意用?占位符,就基本不会有 SQL 注入风险。我的建议是:复杂的动态条件用 map 或者字符串拼接片段;简单且不会有零值歧义的等值查询用 struct;查询里一旦出现!=、LIKE、IN这类操作符,直接上字符串写法最省心。
3.3 常用条件操作符
实际项目中,基础查询的条件远不止等值判断。我挑几个高频操作符列一下:
// IN 查询 db.Where("age IN ?", []int{18, 20, 22}).Find(&users) // LIKE 模糊查询 db.Where("name LIKE ?", "%张%").Find(&users) // BETWEEN 范围查询 db.Where("age BETWEEN ? AND ?", 18, 30).Find(&users) // NOT 条件 db.Not("name", "张三").Find(&users) db.Where("name NOT LIKE ?", "%李%").Find(&users) // NULL 判断 db.Where("email IS NULL").Find(&users) // OR 条件 db.Where("age > ?", 25).Or("status = ?", 1).Find(&users)有几个细节需要提醒:
LIKE的参数要自己把%写进去,GORM 不会帮你自动补充。刚开始用的时候我经常漏掉%,导致查出来的是精确匹配,还以为是数据问题。
Or和Where混用时,优先级很容易被忽略。比如Where("a = ?", 1).Or("b = ?", 2).Where("c = ?", 3)生成的 SQL 是WHERE a = 1 OR (b = 2 AND c = 3)还是WHERE (a = 1 OR b = 2) AND c = 3,取决于 GORM 内部的组合顺序。如果要保证优先级,建议把Or包在一个子查询里,或者拆成两次查询再合并结果。
3.4 排序、分页与计数
分页是每个接口都躲不开的操作,GORM 的链式写法很直接:
var users []User var total int64 db.Model(&User{}).Count(&total) db.Order("created_at DESC"). Limit(10). Offset(20). Find(&users)这里有个容易错的地方:Count和Find要分别执行。很多人会写成这样:
db.Order("created_at DESC"). Limit(10). Offset(20). Count(&total). // 这是错的 Find(&users)因为Limit和Offset会影响Count的结果,Count 加上了分页条件之后,统计出来的总数就是错的。正确做法是先单独执行Count,再执行带分页的Find,两边的条件要分开写。
排序方面,多字段排序用逗号分隔:
db.Order("status ASC, created_at DESC").Find(&users)注意Order("status", "ASC")这种写法在 GORM 2.x 里并不按你想象的方式工作,Order接受的是一个字符串表达式,不是两个参数。我看到过不少同事在这里踩坑。
去重也很常用:
db.Distinct("name").Find(&users)需要特别小心:Distinct只去重查询结果的指定字段,如果同时用Find(&users)把整条记录填进去,去重效果可能不符合预期。更安全的方式是只查需要的字段:
db.Model(&User{}).Distinct("name").Pluck("name", &names)4. 模型关联与基础联表查询
4.1 关联关系在模型里就得定义好
很多人觉得关联查询是很后面的事,基础查询阶段用不上。但模型定义是一体的,你不在结构体里写关联关系,后面的Preload、Joins全都没法用。
举个最简单的例子,用户和订单:
type User struct { ID uint Name string Orders []Order // 一对多 } type Order struct { ID uint UserID uint Amount float64 User User // 反向关联 }GORM 会默认把结构体名加ID组合作为外键,也就是UserID对应users.id。如果你想自定义外键,可以写标签:
type Order struct { ID uint OwnerID uint Owner User `gorm:"foreignKey:OwnerID"` }多对多的场景也常见,比如用户和角色:
type User struct { ID uint Roles []Role `gorm:"many2many:user_roles;"` } type Role struct { ID uint Name string }这个user_roles就是中间表,GORM 会自动帮你维护。模型关联写在结构体里,建表时就会自动创建外键和中间表,查询时也能直接预加载。
4.2 用 Preload 解决查询 N+1
最典型的查询场景是这样的:查出所有用户,然后还需要每个用户的订单数据。如果循环查订单,就是 N+1 次查询,性能会很差。
用Preload一行解决:
var users []User db.Preload("Orders").Find(&users)这样生成的是两次查询:一次查用户,一次查订单,然后 GORM 在内存里完成关联组装。注意,Preload只对关联字段生效,并且必须提前在模型里定义好关联关系。
如果只需要部分订单,可以给预加载加条件:
db.Preload("Orders", "amount > ?", 100).Find(&users)如果是嵌套关联,比如用户 -> 订单 -> 商品,可以写成:
db.Preload("Orders.Items").Find(&users)实际项目中我建议:列表接口尽量用Preload而不是Joins,因为Preload会生成更清晰的 SQL,结果映射也更安全。Joins更适合需要基于关联字段做筛选的场景。
4.3 用 Joins 做联表筛选
当你需要根据关联表的字段来过滤主表数据时,Preload就不太好用了,因为Preload不会影响主查询的WHERE。比如“查所有下过单的用户”,就需要Joins:
var users []User db.Distinct("users.*"). Joins("JOIN orders ON orders.user_id = users.id"). Where("orders.amount > ?", 10). Find(&users)GORM 2.x 还支持一种更语义化的写法:
db.Joins("Company").Find(&users)前提是模型里已经定义了Company关联。这种写法会自动构建INNER JOIN,非常方便。但注意如果你的表名称和字段名跟默认规则不一致,就要多检查一下Joins生成的 SQL。
5. 基础查询常见的坑与排查思路
5.1 零值条件被静默忽略
这是 GORM 新手最容易踩的坑,也是我前面反复强调的。用 struct 传条件时,所有零值字段都不会被拼进 SQL。比如:
db.Where(&User{Status: 0}).Find(&users)你以为会查status = 0,实际上 GORM 生成的 SQL 里根本没有status条件。解决办法有两个:
一是改用 map:
db.Where(map[string]interface{}{"status": 0}).Find(&users)二是用字符串条件:
db.Where("status = ?", 0).Find(&users)这个坑在写筛选接口时极其常见,因为前端的“全部/未处理/已处理”选项里,零值往往是有效的业务含义。排查的时候,如果发现查出来的数据明显偏多,先检查是不是条件被忽略了。
5.2 软删除字段带来的隐性 WHERE
只要模型里加了gorm.DeletedAt,GORM 默认的Find、First、Count都会自动追加AND deleted_at IS NULL。这在绝大多数场景下是好事,但会带来两个隐藏问题。
第一个问题:你想查包含已删除记录的数据时必须显式用Unscoped:
db.Unscoped().Find(&users)第二个问题:如果表结构里没有deleted_at列,但模型定义了,任何查询都会报错;反过来,表里有这个列但模型没定义,删除就是物理删除,恢复不了。所以创建表结构后,最好先跑一条First验证一下。
排查时通常的做法是打开 SQL 日志:
db = db.Debug()这时候 GORM 会在终端打印实际执行的 SQL,你一眼就能看到自动追加的deleted_at条件。
5.3 复用 DB 实例导致条件残留
GORM 的链式调用并不总是无状态的。如果你把同一个*gorm.DB实例反复使用,并且用变量接收链式方法的结果,可能会出现条件残留的问题。
比如:
tx := db.Where("age > ?", 18) tx.Find(&users1) tx.Find(&users2) // users2 也带着 age > 18 的条件因为tx已经包含了之前的条件状态。正确做法是每次链式查询都用原始db,或者用Session创建干净的会话:
tx := db.Session(&gorm.Session{})如果你只是简单地把db当作一个模板来复用,并且每次都从db开始调用方法,一般不会遇到这个问题。但要注意db.Model(&User{})这种写法本身不会改变db,可一旦你保存链式返回值的中间变量,就要警惕条件残留。
这个坑的排查思路很简单:打的 SQL 条件莫名其妙就多了一条,检查是否声明了可复用的中间变量。
6. 模型设计的一些工程经验
6.1 模型层和接口 DTO 分离
基础查询写久了,你会发现一个很现实的问题:如果把 GORM 模型直接暴露给接口层,前端传参和返回字段会被模型绑死。模型里可能有密码散列、内部状态码等字段,直接暴露出去既不安全也难维护。
我的做法是:GORM 模型负责数据库访问,接口 DTO 负责对外交互。查询时先把模型查出来,再转成 DTO 返回。这个转换过程虽然多写了点代码,但后续改动接口字段时,不会牵连数据库模型。
模型层命名尽量直接用业务名词,比如User、Order,不要用TUserEntity这类没有信息量的名字。字段名也要能直接表达含义,Status比Flag好,CreatedAt比Ctime好。
6.2 分页查询封装
如果你的项目里列表接口很多,我建议把分页逻辑抽成一个小工具。这里给一个基础版本:
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { offset := (page - 1) * pageSize return db.Offset(offset).Limit(pageSize) } }使用的时候:
var users []User db.Scopes(Paginate(1, 10)).Find(&users)Scopes是 GORM 提供的方法复用机制,可以把排序、分页、租户隔离这类通用逻辑封装进去。总数统计还是那句话,要单独查一次Count。
封装分页时,我会顺手处理几个边界:page和pageSize必须不小于 1;pageSize上限要限住,防止有人传 9999 把全表拉出来;排序字段要白名单校验,不能直接拼用户输入,否则会有注入风险。
6.3 迁移策略:开发和生产的区别
开发环境用AutoMigrate确实方便,改完模型重启服务表结构就变了。但生产环境如果也这么干,风险很大。数据库类型变更、索引重建、大表加列都可能在迁移过程中锁表,直接影响线上服务。
我见过的比较稳妥的做法是:开发环境随便AutoMigrate,生产环境走专门的迁移流程。先把模型变更生成 SQL,交给 DBA 审核,再在低峰期执行。GORM 官方提供了Migrator,你可以用它查询表结构信息,但不要把整个AutoMigrate直接往生产环境扔。
另外,如果你在模型里把字段类型从string改成int,在某些数据库上AutoMigrate可能会尝试改列类型,而这个操作可能失败或耗时很长。所以我在团队里定的规矩是:生产环境的表结构变更必须脚本化,模型只作为开发环境同步的依据。
最后再聊一点个人体会。模型定义看起来是“写结构体”的小事,实际上它决定了后面能不能写出干净的查询。我刚接手一个老项目时,表名、字段名全用自定义,模型里塞满了与业务无关的字段,每个查询都带着十几个Where条件,每次改需求都想骂人。后来把模型收敛、命名归一、标签规范,查询代码的可读性立刻上了一个档次。如果你正在用 GORM 2.x,我的建议是:先把模型定义这块的功夫下足,再谈查询技巧。这是投入产出比最高的一步。