🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
✨那些你一个人走过的夜路,终将化作照亮未 来的光



适合对象:会一点 Go 和 SQL,刚开始用 GORM 的同学
结合项目:影院票务(Gin + GORM + MySQL)
读完目标:能看懂
dao/里大部分代码,并能自己写出简单增删改查
写在前面
很多人第一次碰 GORM,会有两个错觉:
- 「这不就是把 SQL 换成英文链式调用吗?」------对,入门可以这么理解
- 「会了 Create/Find 就算会 GORM 了」------不够,课设/工作里还常碰到:条件拼接、
Omit、联表、Scan、事务、FOR UPDATE
本文不追求官方文档全文搬运,而是按票务项目真实写法,把最常用的操作讲透
一、GORM 是什么?在项目里处于哪一层?
GORM = Go 的 ORM 框架:用结构体和链式 API 操作数据库,少手写拼接 SQL
在本项目中的位置:
Handler(handlers/)
↓ 调用
DAO(dao/) ←── 几乎所有 GORM 写在这里
↓ 使用
db.DB(*gorm.DB)
↓
MySQL
记住一条规矩:
尽量不要在 Handler 里直接写
db.DB.xxx,GORM 操作收敛到 dao
二、连接数据库:项目怎么初始化 GORM
对应文件:db/db.go
var DB *gorm.DB
func Init(dsn string) error {
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return err
}
sqlDB, _ := DB.DB()
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
return sqlDB.Ping()
}
要点:
| 点 | 说明 |
|---|---|
gorm.Open |
打开连接,得到全局 *gorm.DB |
mysql.Open(dsn) |
用 MySQL 驱动;dsn 一般来自配置 |
DB.DB() |
拿到底层 *sql.DB,用来配连接池 |
全局变量 db.DB |
dao 里到处 db.DB.Create(...) |
新手注意:本项目表结构主要靠 sql/schema.sql 建好,不是 一上来就全靠 AutoMigrate 生成(课设这样更可控)
三、模型(Model):结构和表怎么对应
GORM 靠 struct + tag 知道字段怎么映射
示例(简化自 models.User):
type User struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"size:50;uniqueIndex;not null" json:"username"`
Password string `gorm:"size:32;not null" json:"-"`
Nickname string `gorm:"size:50;not null;default:''" json:"nickname"`
Role int `gorm:"not null;default:0" json:"role"`
CreatedAt time.Time `json:"created_at"`
}
func (User) TableName() string { return "users" }
3.1 常见 gorm tag
| tag | 含义 | 例子 |
|---|---|---|
primaryKey |
主键 | id |
autoIncrement |
自增 | |
size:50 |
字符串长度 | varchar(50) 语义 |
not null |
非空 | |
uniqueIndex |
唯一索引 | 用户名 |
index |
普通索引 | |
column:rows_num |
字段名和列名不同 | 影厅行数 |
type:text |
指定列类型 | 简介 |
-> |
只读:查出来可以填,写入时忽略 | GroupName、MovieTitle |
json:"-" |
JSON 不输出(不是 gorm 专属) | 密码 |
3.2 TableName() 干嘛?
告诉 GORM:这个结构体对应哪张表
不写的话,GORM 常会按复数规则猜表名;课设里显式写更稳
3.3 为什么有「假字段」?
比如 Movie.GroupName、Order.MovieTitle:
- 表里不一定有这一列
- 联表查询时
SELECT ... AS group_name扫进结构体 - 用
gorm:"->"+Omit("GroupName")避免Create时乱写不存在的列
这是本项目非常实用的技巧。
四、CRUD 总览(先建立地图)
| 操作 | SQL 直觉 | GORM 常见写法 |
|---|---|---|
| 增 Create | INSERT |
db.Create(&obj) |
| 查 Read | SELECT |
First / Find / Scan |
| 改 Update | UPDATE |
Update / Updates |
| 删 Delete | DELETE |
Delete |
下面按「增 → 查 → 改 → 删 → 条件 → 联表 → 事务」展开。
五、增(Create):插入一条 / 批量插入
5.1 插入一条
// dao/user.go
func CreateUser(u *models.User) error {
return db.DB.Create(u).Error
}
等价思路:
INSERT INTO users (...) VALUES (...);
成功后,若主键自增,u.ID 通常会被 GORM 回填。
5.2 为什么经常看到 Omit?
// 创建电影时,忽略联表读出来的 GroupName
return db.DB.Omit("GroupName").Create(m).Error
// 创建订单时,忽略展示用字段
return tx.Omit("MovieTitle", "HallName", "StartTime").Create(order).Error
Omit = 插入/更新时跳过这些字段。
联表展示字段、统计字段、不想被客户端篡改的字段,都适合 Omit。
5.3 批量插入
生成场次座位时:
return db.DB.CreateInBatches(seats, 100).Error
意思:切片里很多 Seat,每 100 条一批插入,减轻单次 SQL 过大。
六、查(Read):First / Find / 主键查询 / 找不到怎么办
6.1 按主键查一条:First(&obj, id)
err := db.DB.First(&h, id).Error
大致等价:
SELECT * FROM halls WHERE id = ? LIMIT 1;
6.2 带条件查一条:Where(...).First
err := db.DB.Where("username = ?", username).First(&u).Error
永远用 ? 占位符传参数,不要自己拼字符串,防 SQL 注入。
6.3 查列表:Find
var list []models.MovieGroup
err := db.DB.Order("id").Find(&list).Error
SELECT * FROM movie_groups ORDER BY id;
6.4 记录不存在怎么判断?
err := db.DB.Where("username = ?", username).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // 本项目约定:没找到返回 (nil, nil)
}
新手坑:
err != nil不一定是「系统挂了」,也可能是「没这条数据」- 业务上要分清:
ErrRecordNotFoundvs 真正的数据库错误
6.5 First vs Find vs Scan(很重要)
| API | 典型用途 | 说明 |
|---|---|---|
First |
取一条,按模型表查 | 找不到 → ErrRecordNotFound |
Find |
取多条,按模型表查 | 找不到常常是空切片,不一定当错误 |
Scan |
把查询结果扫进结构体 | 联表自定义 Select 时最常用 |
本项目电影列表是联表,所以多用 Scan:
err := q.Order("m.id DESC").Scan(&list).Error
七、改(Update):改一个字段 / 改多个字段
7.1 改单个字段:Update
// 取消场次:只改 status
return db.DB.Model(&models.Schedule{}).
Where("id = ?", id).
Update("status", models.ScheduleCancelled).Error
7.2 改多个字段:Updates
return db.DB.Model(g).Updates(map[string]interface{}{
"name": g.Name,
"description": g.Description,
}).Error
锁座时也是 Updates:
tx.Model(&models.Seat{}).Where("id = ?", sid).Updates(map[string]interface{}{
"status": models.SeatLocked,
"lock_user_id": userID,
"lock_until": lockUntil,
"order_id": order.ID,
})
7.3 Update / Updates 使用建议
| 写法 | 适合 |
|---|---|
Update("col", val) |
只改一列 |
Updates(map[string]interface{}{...}) |
改多列;零值也能更新(如把某字段更新成 0) |
Updates(结构体) |
也行,但注意:结构体零值字段可能被跳过 |
课设里多字段更新,用 map 往往更不容易踩零值坑
7.4 一定要有 Model + Where 吗?
更新时强烈建议:
db.Model(&models.Seat{}).Where("id = ?", sid).Updates(...)
避免误更新范围过大。没有条件的更新非常危险
八、删(Delete)
8.1 按主键删
return db.DB.Delete(&models.Movie{}, id).Error
大致:
DELETE FROM movies WHERE id = ?;
8.2 按条件删
res := db.DB.Where("end_time < ?", before).Delete(&models.Schedule{})
return res.RowsAffected, res.Error
RowsAffected = 影响了多少行,cleaner 打日志会用到。
8.3 软删除?
GORM 支持 DeletedAt 软删除。本票务项目座位/订单等主要是改状态 ,不是软删;别和 Delete 物理删除混为一谈。
九、条件构造:Where / Order / Limit / Count
9.1 动态拼接条件(列表筛选神器)
电影列表:
q := movieQuery()
if onlyOn {
q = q.Where("m.status = ?", models.MovieOn)
}
if keyword != "" {
kw := "%" + keyword + "%"
q = q.Where("(m.title LIKE ? OR m.director LIKE ? OR m.actors LIKE ?)", kw, kw, kw)
}
if groupID > 0 {
q = q.Where("m.group_id = ?", groupID)
}
err := q.Order("m.id DESC").Scan(&list).Error
这就是「有参数才加条件」------对应首页 ?q=、?group=1。
9.2 Order / Limit
q.Order("m.rating_avg DESC, m.rating_count DESC").Limit(limit).Scan(&list)
推荐列表:按评分排序,只取前 N 条。
9.3 Count
var n int64
err := db.DB.Model(&models.Seat{}).
Where("schedule_id = ?", scheduleID).
Count(&n).Error
用来统计某场次座位数量等。
9.4 Pluck:只取一列
超时订单清理:
var ids []int64
tx.Model(&models.Order{}).
Where("status = ? AND pay_deadline < NOW()", models.OrderPending).
Pluck("id", &ids)
只抽出 id 列表,再逐个处理。
十、联表查询:Table + Select + Joins + Scan
简单 Find 只适合「单表且字段都在模型里」。
订单要带电影名、影厅名,就要联表。
本项目典型写法:
func orderQuery() *gorm.DB {
return db.DB.Table("orders AS o").
Select(`o.id,o.order_no,o.user_id,o.schedule_id,
IFNULL(m.title,'') AS movie_title,
IFNULL(h.name,'') AS hall_name,
IFNULL(s.start_time,NOW()) AS start_time,
o.seat_info,o.amount,o.status,o.pay_deadline,o.created_at,o.paid_at`).
Joins("LEFT JOIN schedules s ON o.schedule_id=s.id").
Joins("LEFT JOIN movies m ON s.movie_id=m.id").
Joins("LEFT JOIN halls h ON s.hall_id=h.id")
}
func GetOrder(id int64) (*models.Order, error) {
var o models.Order
err := orderQuery().Where("o.id = ?", id).Scan(&o).Error
// ...
}
怎么读:
Table("orders AS o"):主表起别名Select(...):要哪些列,联表字段用AS movie_title对上结构体Joins(...):LEFT JOIN/JOINScan(&o):扫进结构体
电影联分组同理:movies LEFT JOIN movie_groups。
这比硬背 Association / Preload 更贴近很多课设的 SQL 思维,也好排查。
十一、链式调用怎么理解?
GORM 很多方法返回的还是 *gorm.DB,所以可以一路点下去:
db.DB.
Where("schedule_id = ?", scheduleID).
Order("row_no, col_no").
Find(&list)
可以想成在拼 SQL ,最后 Find/First/Scan/Create... 才真正执行。
注意:
q := db.DB.Where("status = ?", 1)
q2 := q.Where("id = ?", 10) // 一般是在原条件下继续加
列表筛选时,把 q 重新赋回去(q = q.Where(...))是本项目的常见写法。
十二、事务 Transaction:多步要么全成要么全败
下单要「建订单 + 锁多个座位」,不能订单建了座位没锁成功还留下半成品。
err := db.DB.Transaction(func(tx *gorm.DB) error {
// 注意:事务里用 tx,不要用 db.DB
// 1) 锁行并检查座位
// 2) Create order
// 3) Updates seats
return nil // 返回 nil → 提交;返回 error → 回滚
})
规则:
| 规则 | 说明 |
|---|---|
回调返回 nil |
提交事务 |
回调返回 error |
回滚 |
里面用 tx |
所有操作挂在同一事务上 |
支付、取消订单、释放超时订单,本项目同样包在 Transaction 里
十三、行锁 FOR UPDATE:防超卖的关键写法
import "gorm.io/gorm/clause"
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND schedule_id = ?", sid, scheduleID).
First(&seat).Error
生成的 SQL 直觉:
SELECT * FROM seats WHERE id=? AND schedule_id=? FOR UPDATE;
含义:在事务中锁定这一行,别人想改同一行会等待,避免两人同时读到「空闲」再一起下单。
配合业务字段(status=锁定、lock_user_id、lock_until)才完整:
- 行锁:管并发瞬间
- 业务锁字段:管支付窗口期内占座
十四、Upsert / 冲突更新(评分场景)
同一用户对同一电影重复评分,希望「有则更新,无则插入」:
tx.Clauses(clause.OnConflict{
// 按唯一键冲突时更新某些列
}).Create(&r)
(具体 Columns 以你们 dao/comment.go 为准。)
这比先查再决定 insert/update,在并发下更干净。
十五、错误处理固定套路(建议背下来)
err := db.DB.Where("username = ?", name).First(&u).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 业务:用户不存在
return nil, nil
}
// 系统:数据库真出错
return nil, err
}
return &u, nil
以及执行类:
if err := db.DB.Create(u).Error; err != nil {
return err
}
几乎所有 dao 函数都是:(结果, error) 或 error
十六、一张「看见代码就知道在干嘛」速查表
| 你想做的事 | 优先写法 |
|---|---|
| 插入 | Create / CreateInBatches |
| 主键取一条 | First(&m, id) |
| 条件取一条 | Where.First |
| 条件取多条 | Where.Find |
| 联表自定义列 | Table+Select+Joins+Scan |
| 改一列 | Model.Where.Update |
| 改多列 | Model.Where.Updates(map) |
| 删除 | Delete / Where.Delete |
| 统计 | Count |
| 抽一列 | Pluck |
| 跳过字段 | Omit |
| 多步骤原子 | Transaction |
| 行锁 | Clauses(Locking{UPDATE}) |
| 排序/条数 | Order / Limit |