ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GORM 实战避坑指南:从 ORM 原理到 CRUD、事务与缓存

GORM 实战避坑指南:从 ORM 原理到 CRUD、事务与缓存

在 Go 后端项目里摸爬滚打这几年,我敢说GORM是我用得最久、也最“又爱又恨”的一个 ORM 框架。要说清楚它到底解决了什么问题,最直接的一句话是:它让你在 Go 里操作数据库,从“手写一堆重复的 SQL 和结构体赋值”变成“直接把数据库表当成对象来读写”。如果你正准备用GORM搭项目,或者已经在用但时不时被它的行为坑一下,这篇东西应该能省下你不少爬坑时间。

我会从一个真实使用者的角度,围绕GORM的核心机制、CRUD 习惯用法、关联查询、事务和钩子,最后结合Gin和go-redis聊一个实际可落地的项目场景。不吹不黑,把好的坏的一起说清楚。

1. 先聊聊什么是 ORM,为什么 GORM 值得聊

1.1 从 database/sql 到 ORM 的演进

在 Go 诞生早期,大家写数据库操作基本靠标准库database/sql。这套东西能力很强,但同时也很“原始”:你要手动写 SQL 语句,手动Scan每一列到结构体字段,还得处理空值、类型转换这些琐碎问题。一次简单的查询,业务代码还没写,光数据搬运就占了一半篇幅。

比如一个最普通的用户表查询,用database/sql大概是这样的:

rows, err := db.Query("SELECT id, name, age FROM users WHERE id = ?", id) if err != nil { // ... } defer rows.Close() var u User for rows.Next() { err := rows.Scan(&u.ID, &u.Name, &u.Age) if err != nil { // ... } }

这段代码本身没毛病,但问题是:如果User有 20 个字段,Scan那边就要写 20 个参数;如果多了一张表,这套模板代码又要再复制一遍。这种重复感会一直积累,直到你开始思考“能不能让程序根据结构体定义自动搞定这些”。

ORM 的思路正好就是补上这一环。它把数据表和 Go 结构体做了一层映射,用方法调用代替手写 SQL,让开发者把注意力放回业务逻辑上。在 Go 的 ORM 选型里,GORM是目前社区活跃度最高、资料最全、功能最丰富的一个,这也是我为什么把它作为首选项来聊。

1.2 GORM 的定位与优势

GORM 不是那种“最小可用”的轻量库,它是一个功能相当齐全的全功能 ORM。我的理解是,它的核心优势集中在三块:

第一是模型驱动。你只要写好结构体,GORM 会帮你完成建表、迁移、读写映射这一系列动作。表名、字段名、主键、索引、外键关系,全部可以通过结构体标签和约定来声明。

第二是链式 API。查询条件可以一直往后拼,代码读起来像在描述自然语言:

db.Where("status = ?", 1). Order("created_at DESC"). Limit(10). Find(&users)

这也让动态拼接查询条件变得非常自然,后面我会专门说。

第三是约定优于配置。GORM 对表名、列名、主键、时间戳字段有自己的默认约定,除非你特殊指定,否则它就用一套统一的规则来映射。这样做的好处是团队协作时,不同人写的模型风格统一,不容易“各自为政”。

当然,它也有自己的问题,最典型的就是性能损耗和复杂的 N+1 查询。但这些不是无解的,后面我会用实际经验说明怎么规避。

2. 快速上手:从安装到第一个模型

2.1 安装与数据库连接

GORM 的安装走的是标准 Go modules 流程。目前主流版本是gorm.io/gorm,数据库驱动需要单独装。以 MySQL 为例:

go get -u gorm.io/gorm go get -u gorm.io/driver/mysql

连接方式比database/sql稍微多一层封装,但逻辑很直接:

import ( "gorm.io/driver/mysql" "gorm.io/gorm" ) dsn := "root:password@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local" db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

这里有几个点值得注意。

charset=utf8mb4是必须的,除非你确定库里永远不会存 emoji 和生僻字,否则utf8会在插入特殊字符时翻车。parseTime=True也很关键,它让 MySQL 的DATETIME类型能正确解析到 Go 的time.Time,少了它你会遇到一堆怪异的字符串时间错误。loc=Local则解决时区问题,否则默认 UTC 时间会让业务侧的“本地时间”全部偏差 8 小时。

GORM 还支持连接池配置,通过底层的sql.DB来设置:

sqlDB, _ := db.DB() sqlDB.SetMaxIdleConns(10) sqlDB.SetMaxOpenConns(100) sqlDB.SetConnMaxLifetime(time.Hour)

这段建议在项目初始化时统一写好。线上环境的数据库连接是稀缺资源,不设上限的话,高并发下很容易把数据库连接数打满。

2.2 模型定义与表结构映射

GORM 的模型定义核心是“结构体 + 标签”。一个典型用户模型长这样:

type User struct { ID uint `gorm:"primaryKey"` Name string `gorm:"size:64;not null"` Email string `gorm:"size:128;uniqueIndex"` Age int `gorm:"default:0"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` }

几个关键约定我要重点说一下:

表名。GORM 默认会把结构体名转成下划线风格并加上复数。User会映射到users,OrderItem映射到order_items。如果你不喜欢这个规则,可以手动实现TableName()方法:

func (User) TableName() string { return "t_user" }

主键。字段名是ID时默认就是主键,也可以用primaryKey标签显式指定。在数据库里它对应自增主键,插入时不需要赋值。

时间戳。GORM 约定CreatedAt、UpdatedAt由框架自动维护,不需要你手动设置。这算是一个“甜蜜约定”,省掉了大量在业务代码里time.Now()的重复操作。

软删除。gorm.DeletedAt字段是 GORM 的一个特色。它在Delete时不真正删除记录,而是写入当前时间,查询时自动带上deleted_at IS NULL条件。这对数据审计和误删恢复非常有价值,但也容易让新手困惑,后面我会单独讲。

2.3 自动迁移的坑与建议

AutoMigrate是 GORM 提供的一个便捷功能:

db.AutoMigrate(&User{}, &Order{})

它会对比结构体定义和现有表结构,自动创建缺的表、加缺的列。看起来省心,但我强烈建议只在开发环境用。原因有两个:

第一,它不会删列。你从结构体里移除一个字段,数据库里的旧列还在,时间长了会出现一堆废弃字段。

第二,它不容易改类型。某些列类型变更、索引重构这种操作,自动迁移做不彻底,甚至可能因为不兼容而失败。

我的经验是:开发阶段可以用AutoMigrate快速迭代,线上环境的表结构变更必须走正式的 migration 流程,比如编写 SQL 脚本或者使用 golang-migrate 这类工具。否则你会在某次发布后突然发现生产环境的表结构莫名其妙少了索引。

3. CRUD 操作:常用 API 与背后逻辑

3.1 创建与批量插入

GORM 的创建操作很直观:

user := User{Name: "张三", Email: "zhangsan@example.com", Age: 25} result := db.Create(&user)

创建后user.ID会被自动填充,这是能立刻感知到的便利。

批量插入时要注意性能。网上常见说法是“循环单条插入”,在数据量小的时候没感觉,但几百条以上就很痛苦了——每一条都是一次完整的事务往返,再加上网络开销,慢是必然的。GORM 支持一次性传一个切片:

users := []User{ {Name: "张三", Email: "a@example.com"}, {Name: "李四", Email: "b@example.com"}, {Name: "王五", Email: "c@example.com"}, } db.Create(&users)

真正大批量导入时,可以禁用事务来提升速度。GORM 在批量创建时会自动开启事务以保证数据一致性,每批次默认 100 条。如果数据量极大且可以接受中途失败,可以关闭事务跑:

db.CreateInBatches(&users, 200)

CreateInBatches会分批插入,内存占用也更平稳。

3.2 查询:Retrieving 与链式条件

查询是 GORM 里用得最密集、也最容易出问题的一块。

基础查询三件套是:

// 取第一条记录,按主键升序 db.First(&user) // 取一条记录,不排序 db.Take(&user) // 取所有记录 db.Find(&users)

First有个隐藏行为要提醒:查不到数据时返回ErrRecordNotFound,而Find不会。很多刚用 GORM 的人在这上面栽过跟头——明明一样查不到数据,写的错误处理对不上,导致 panic 或者静默失败。

条件查询的核心是Where:

db.Where("age > ?", 18).Find(&adults) db.Where("name IN ?", []string{"张三", "李四"}).Find(&users) db.Where("created_at BETWEEN ? AND ?", start, end).Find(&users)

链式调用最爽的地方就是条件可以动态拼:

query := db.Model(&User{}) if req.Name != "" { query = query.Where("name LIKE ?", "%"+req.Name+"%") } if req.Age > 0 { query = query.Where("age >= ?", req.Age) } query.Order("created_at DESC").Find(&users)

这在写接口时几乎是刚需。你要知道,链式调用中很多方法会复用同一个db实例,如果不小心把带查询条件的db继续用于其他地方,可能会串条件。建议每个请求都从db的干净实例开始,或者用Session(&gorm.Session{NewDB: true})创建独立会话。

字段选择用Select:

db.Select("id", "name").Find(&users)

计数用Count:

var total int64 db.Model(&User{}).Where("age > ?", 18).Count(&total)

注意Model和Table的区别:Model(&User{})后面的Where会把users作为默认表名,而Table("users")则直接指定。如果你用了自定表名,这两个混用可能会产生让你困惑的 SQL。

3.3 更新与删除的常见陷阱

更新操作首先要区分两个概念:更新单个字段和更新整个模型。

单字段更新用Update,多字段用Updates。这里最大的坑是Updates配合结构体时,零值字段不会更新:

db.Model(&user).Updates(User{Name: "赵六", Age: 0})

上面这段代码,Age变成 0 的更新会被静默忽略。GORM 的做法是:结构体更新只更新非零字段。想更新零值,必须用 map:

db.Model(&user).Updates(map[string]interface{}{"name": "赵六", "age": 0})

或者用Select显式指定:

db.Model(&user).Select("age").Updates(User{Age: 0})

这是 GORM 用户反馈最多的问题之一,我在公司刚推广 GORM 时就发现组里新人反复踩这个。

删除操作同样有讲究。普通删除是软删除(只要有DeletedAt字段),不带DeletedAt时才是物理删除:

db.Delete(&user) // 有 DeletedAt → 软删除,其他情况 → 物理删除 db.Unscoped().Delete(&user) // 即使有 DeletedAt 也强制物理删除

另外Delete需要主键。如果你传入的是一个空结构体,GORM 会拒绝执行并报错,避免误删全表。这是个非常好的安全设计,但不少人一开始会困惑“为什么我删不掉”,其实是你没带主键。

4. 关联关系:一对一、一对多、多对多

4.1 关联标签与预加载

业务系统里几乎不可能只有一张孤立表。用户有订单,订单里有商品,商品又属于某个分类——这些关系在 GORM 里通过结构体嵌套字段表达。

一对一示例:

type User struct { ID uint Name string Profile Profile } type Profile struct { ID uint UserID uint Bio string }

一对多示例:

type User struct { ID uint Name string Orders []Order } type Order struct { ID uint UserID uint Amount float64 }

多对多示例:

type User struct { ID uint Name string Tags []Tag `gorm:"many2many:user_tags;"` } type Tag struct { ID uint Name string }

GORM 通过外键字段UserID自动识别归属关系。多对多需要指定连接表,名字由你自己定,这里是user_tags。

4.2 预加载与 N+1 问题

关联查询最经典的坑就是N+1 问题。不做预加载时,查询 10 个用户,再查每个用户的订单,总共会发 1 + 10 条 SQL。用户数越多,SQL 数量越爆炸,性能自然上不去。

GORM 的解决方式是Preload:

db.Preload("Orders").Find(&users)

这条语句会先查用户,再用WHERE user_id IN (…)一次性查出所有相关订单,总共 2 条 SQL。这比手动循环查询快了不止一个量级。

嵌套预加载也能做:

db.Preload("Orders.Items").Preload("Profile").Find(&users)

还有一个好用的功能叫Joins,它可以用 SQL 的 JOIN 直接查出来:

db.Preload("Orders", "status = ?", 1).Find(&users)

这段是只预加载状态为 1 的订单,适合按业务条件筛选关联数据。

这里我想强调一个实战原则:预加载字段不能无脑全开。有些关联数据(比如用户的所有流水明细)可能根本用不到,查询时按接口需要选择性地Preload,否则 2 条 SQL 会变成 3 条、4 条,仍然慢。

4.3 关联操作实战

关联记录的创建和更新也有自己的写法。给用户增加订单:

db.Create(&order) // 先创建订单 db.Model(&user).Association("Orders").Append(&order)

或者一次性创建带关联的模型:

user := User{ Name: "张三", Orders: []Order{{Amount: 99.9}}, } db.Create(&user)

GORM 会自动把Order的UserID填上。这种做法很省事,但前提是你对模型之间的关系定义要准确,否则会出现外键错位这种隐蔽问题。

关联记录的删除也要注意。默认情况下,Delete用户只会删用户记录,不会自动删关联订单。想级联删除,要在标签里声明约束:

type Order struct { ID uint UserID uint User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` }

我建议尽量不要依赖数据库的级联删除,业务层面先查出关联数据再手动处理,可控性更强。级联删除一时爽,误删的时候想哭都来不及。

5. 事务、钩子与进阶玩法

5.1 事务的两种写法

分布式、跨表写操作只要涉及多个更新,就该用事务。GORM 提供两种方式。

第一种是事务函数:

err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&order).Error; err != nil { return err // 回滚 } if err := tx.Create(&payment).Error; err != nil { return err // 回滚 } return nil // 提交 })

只要回调函数返回error,事务就会回滚。这个写法最推荐,因为它自动处理了提交和回滚的逻辑,代码里不会漏。

第二种是手动事务:

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

手动事务灵活,但你得记得所有操作都要用tx而不是全局的db,否则会出现“事务里执行了,但没生效”的诡异现象。

事务里还有个细节:事务要用的连接和在db上调用的连接可能不是同一个。所以回调函数里必须用传入的tx,不要图省事在闭包里捕获外面的db。

5.2 Hook 钩子的执行时机

GORM 的钩子机制能让你在生命周期事件里插入自定义逻辑。常用钩子有:

  • BeforeCreate/AfterCreate
  • BeforeUpdate/AfterUpdate
  • BeforeDelete/AfterDelete
  • AfterFind

典型场景是创建前自动填充一些字段:

func (u *User) BeforeCreate(tx *gorm.DB) (err error) { u.CreatedIP = ip return nil }

另一个实用场景是写操作前自动记录日志。我在一个对账系统里用AfterFind记录每次查询的耗时和来源,排查慢 SQL 时特别有用。

不过钩子要克制着用。钩子多了,数据流的可读性会下降——你根本不知道一次Create背后到底自动执行了多少东西。建议只放那些“无论如何都必须执行的字段填充”和“审计逻辑”。

5.3 软删除与防误删设计

前面提到gorm.DeletedAt,这里展开讲。软删除的核心价值是让删除变得可逆。

被软删除后,你可以用Unscoped()找回数据:

db.Unscoped().Where("id = ?", userID).Take(&user)

还可以物理清理永久不需要的数据:

db.Unscoped().Where("id = ?", userID).Delete(&User{})

要留意的是,软删除字段会影响唯一索引。比如email加了唯一索引,用户删除后想用同一个邮箱重新注册,直接插入会报Duplicate entry——因为旧记录只是被软删除了,索引依然存在。

解决思路有两个:一是唯一索引改成复合索引,把deleted_at也带进去;二是业务上重新注册时先物理清理旧记录。这个坑在真实项目里非常容易出现,我们线上就出过“用户注销后无法再注册”的工单。

6. 实战联动:Gin + GORM + go-redis 的典型场景

6.1 分层设计与 Model 层落地

GORM 最舒服的归宿,是在一个清晰的分层架构里当数据访问层。我常用的组合是Gin(Web 框架)+ GORM(ORM)+ go-redis(缓存)。

项目目录大致是这样:

├── main.go ├── config │ └── config.go ├── models │ ├── user.go │ └── order.go ├── dao │ ├── user_dao.go │ └── order_dao.go ├── services │ └── user_service.go └── handlers └── user_handler.go

models层只定义结构体和钩子,dao层封装所有 GORM 查询,services层做业务判断,handlers层处理 HTTP 请求。这样 GORM 的调用被隔离在dao层,换库换实现的影响范围能控制到最小。

一个典型的用户查询接口链路是:handler 收到请求 → service 判断参数 → dao 查数据库 → 数据返回 handler。如果 dao 返回的数据还要转成 DTO,那就再加一层转换逻辑,不要让 GORM 模型直接暴露给前端。

6.2 缓存穿透、击穿、雪崩场景下的 Redis 协作

go-redis 和 GORM 最常见的配合,就是给热点查询加缓存。

一个典型的“先查缓存,再查数据库”流程:

func GetUserByID(id uint) (*User, error) { var user User // 先查 Redis key := fmt.Sprintf("user:%d", id) val, err := redisClient.Get(ctx, key).Bytes() if err == nil { json.Unmarshal(val, &user) return &user, nil } // 缓存未命中,查数据库 err = db.First(&user, id).Error if err != nil { return nil, err } // 写回缓存,设置过期时间 data, _ := json.Marshal(&user) redisClient.Set(ctx, key, data, 10*time.Minute) return &user, nil }

这里有几个实际会遇上的问题。

缓存穿透:查一个不存在的 ID,缓存和数据库都没有。每次请求都会打到数据库,即使缓存层还在,数据库压力也不小。解决方式是把null也缓存起来,过期时间设短一点,比如 30 秒。

if errors.Is(err, gorm.ErrRecordNotFound) { redisClient.Set(ctx, key, "null", 30*time.Second) return nil, err }

缓存击穿:某个热点 key 过期的一瞬间,大量请求全部打向数据库。可以在服务端用单飞(singleflight)控制同一时间只有一个请求去数据库拉数据。GORM 查询本身没有这个机制,但你可以用golang.org/x/sync/singleflight包在 service 层做合并。

缓存雪崩:大量 key 同时过期,导致数据库被一波并发打垮。解决方式很简单,过期时间加一个随机扰动,避免整点过期。比如:

ttl := 10*time.Minute + time.Duration(rand.Intn(300))*time.Second

这三个场景是缓存系统绕不开的,Redis 本身不帮你解决,得在业务代码里自己判断。

6.3 性能调优与会话管理

GORM 实战中的性能问题,大多是查询习惯造成的。最常见的几个优化方向:

第一,只取需要的字段。有些接口只需要id和name,结果Find(&users)把全部字段都拖出来,浪费数据库和网络 IO。

db.Select("id", "name").Find(&users)

第二,善用分页。列表接口都要分页。GORM 里我习惯封装一个公共分页函数:

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) } } db.Scopes(Paginate(1, 20)).Find(&users)

Scopes是 GORM 很优雅的一个机制,它把分页、状态过滤这种公用逻辑做成了可组合的片段,比每个方法里都复制粘贴要好维护得多。

第三,慢 SQL 日志。GORM 内置 logger 支持慢查询输出:

db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Warn), })

我习惯把阈值设置在 200ms,一旦高于这个值,日志里会直接看到具体 SQL 和耗时,排查性能问题会直观很多。

第四,会话复用。GORM 的Session方法我经常用。当你在链式调用里拼了查询条件后,这个db的状态可能已经不是你期望的干净状态。用db.Session(&gorm.Session{NewDB: true})能拿到一个新的、干净会话,避免条件污染。

7. 避坑指南与问题排查

7.1 常见错误与排查速查表

我梳理了几个高频问题,方便你对照排查:

现象常见原因解决办法
更新字段不生效Updates传结构体,零值被忽略改用map或Select指定字段
删除后数据仍在模型带DeletedAt,默认软删除用Unscoped()强制物理删除
查不到数据提示ErrRecordNotFoundFirst查无记录时返回该错误判断errors.Is(err, gorm.ErrRecordNotFound)
批量插入非常慢默认开启事务且单条循环用CreateInBatches批量插入
关联字段为空未调用Preload按需加Preload
时间字段少 8 小时DSN 未设loc=Local或数据库时区不对DSN 统一添加loc=Local
唯一索引插入报冲突软删除记录占着索引索引加上deleted_at复合列
大量递归关联查询超时N+1 问题使用Preload或Joins避免循环查询

7.2 我踩过的坑

这里说几个我自己在真实项目中踩过的坑,每个都对应一次线上事故或者加班排查的夜晚。

第一个坑是Updates零值不更新的问题。当时做一个后台管理系统,运营人员想把某个商品的库存改成 0,前端提交过来,后端用Updates(&Product{Stock: 0})执行,结果库存纹丝不动。排查了半天,最后意识到 GORM 的零值省略规则。从那以后,凡是涉及字段可能要清零的操作,我全部改成map方式更新,并在代码注释里留了提醒。

第二个坑是软删除和唯一索引冲突。我们用户表有phone唯一索引,用户注销时走软删除,结果注销后的手机号无法再注册。那次问题排查了很久,因为测试环境数据量小,不容易暴露;线上数据一多就立刻报错。最后通过把唯一索引改成(phone, deleted_at)复合索引解决,同时业务层加了一个“注销后 7 天内不能重新注册”的提示。

第三个坑是事务里误用全局 db。我写过一段代码,在事务函数里用了外面声明的db更新数据,看起来事务没报错,但数据也没更新。后来才明白,事务用的是独立的连接,你用db操作的事务根本不属于那个tx。这种 bug 非常隐蔽,不会崩也不会告警,只有数据对不上时才暴露出来。

第四个坑是预加载太多导致慢查询。一开始做列表页,图省事把所有外键关系全部Preload一遍,结果每个列表请求都要执行五六个 SQL,接口耗时从 80ms 涨到 400ms。后来优化成一个通用列表只预加载核心关联,详情页再加载深度数据,耗时降到 100ms 以内。预加载不是越多越好,按需加载才有意义。

7.3 给新手的实用建议

如果你刚接触 GORM,我给几个非常具体的建议。

先把官方文档的 CRUD 部分完整读一遍。别只看博客和片段,官方文档里的约定和注意事项是博客作者最容易跳过的地方。特别是“约定”这一章,花 30 分钟看完,能少踩一半的坑。

开着 SQL 日志做开发。把 GORM 的 Logger 模式调成Info,直接在控制台看每次操作生成的 SQL。你会慢慢形成直觉:哪个操作会生成多条 SQL、哪个操作有隐含条件、哪个 select 在拖全表。这个习惯对理解 ORM 的本质帮助极大。

写好单元测试。GORM 支持sqlmock这样的测试库,但更推荐直接在本地跑一个 Docker 里的 MySQL,用真实数据库做测试。因为 GORM 的很多行为依赖数据库的约束和真实返回,mock 出来的结果容易失真。

不要抗拒手写 SQL。GORM 很强大,但不代表所有 SQL 都应该用链式 API 拼出来。复杂报表、多表 join、窗口函数这些场景,老老实实用Raw方法写 SQL,可读性和执行效率都比硬用 ORM 好。

db.Raw("SELECT DATE(created_at) as day, COUNT(*) FROM orders GROUP BY DATE(created_at)").Scan(&stats)

ORM 是提高效率的工具,不是束缚。该手写时就手写,不必有负罪感。

8. 写在最后的个人体会

聊了这么多,如果只让我留下一句话,那就是:ORM 是工具,不是你逃避 SQL 的借口。用了 GORM 三年多,我最深的体会是,那些 GORM 用得好的人,往往 SQL 本身写得也很好。框架帮你省去的是机械重复的映射代码,而不是你对数据关系、索引设计、查询性能这些基本原理的理解。

我在实际项目里见过两种人:一种把 GORM 当成万能药,什么查询都用链式调用拼,最后 SQL 冗长、性能一言难尽;另一种人遇到复杂查询立刻放弃 GORM,所有数据访问全部重写Raw,结果重复代码满天飞。这两种都走极端了。正确的姿势是:简单 CRUD 全身心交给 GORM,复杂的统计报表再用Raw回归 SQL,两条腿走路,既不浪费框架的效率,也不被框架局限。

最后分享一个小技巧。GORM 官方文档里有一个不起眼的章节,讲的是Scopes和NamedArg。如果你打算长期用 GORM,花时间研究这两个特性非常值得。它们能让你的查询代码组合性更强,同时减少字符串拼接带来的 SQL 注入风险。我刚用 Go 写项目时忽略了这些高级特性,后来在代码 Review 时被同事提了一嘴,才重新捡起来,用了之后就再也离不开了。希望我的这些经验能帮你少走弯路,把 GORM 真正变成顺手的好工具。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进