前阵子 code review,看到一段代码:好几个 goroutine 共用一个 db.Where("status = ?", 1) 的结果做查询。同事说"GORM 并发安全",我说"不一定"。
我干脆翻源码。看完发现 GORM 的设计挺聪明——它没承诺并发安全,也没用锁,靠的是写时复制给了一套用法范式。你按范式写就没事,不按就出事。
下面从源码角度拆开说。
环境准备
本文所有示例都可以直接跑。先统一把依赖和基础结构准备好。
依赖
import ( "context" "database/sql" "sync" "time" "golang.org/x/sync/errgroup" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/clause")数据库连接
// 初始化全局 DB 实例(clone=1 的种子实例)db, err := gorm.Open(mysql.Open("user:pass@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True"), &gorm.Config{})if err != nil { panic(err)}结构体定义
// Student 学生表type Student struct { ID int64 `gorm:"primaryKey;autoIncrement"` Name string `gorm:"type:varchar(64);index"` Status string `gorm:"type:varchar(16)"`}// User 用户表(后面事务/泛型示例用)type User struct { ID int64 `gorm:"primaryKey;autoIncrement"` Name string `gorm:"type:varchar(64)"` Age int `gorm:"type:int"`}建表 & 自动迁移
GORM 的 AutoMigrate 会根据结构体 tag 自动建表,省去手写 DDL。以下两种方式任选:
// 方式一:AutoMigrate(推荐,本地测试直接用)db.AutoMigrate(&Student{}, &User{})// 方式二:手写 SQL(生产环境推荐,表结构可控)db.Exec(` CREATE TABLE IF NOT EXISTS students ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT '', INDEX idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)db.Exec(` CREATE TABLE IF NOT EXISTS users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL DEFAULT '', age INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)文中代码依赖以上
db、Student、User定义,不再重复声明。
一、先看几个能跑出问题的代码
讲原理之前,直接看"不安全"长什么样。
1.1 查询条件互相污染
多个 goroutine 共享同一个链式调用结果,各自的 Where 会叠到同一条 SQL 里:
func Case1(db *gorm.DB) { query := db.Where("id = ?", 1) eg := errgroup.Group{} for i := 0; i < 3; i++ { i := i eg.Go(func() error { m := &Student{} return query.Where("id = ?", i).Find(m).Error }) } if err := eg.Wait(); err != nil { panic(err) }}实际执行的 SQL:
[6.656ms] [rows:0] SELECT * FROM `students` WHERE id = 1 AND id = 2 AND id = 1 AND id = 0---- 发生的错误panic: Error 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '' at line 1三个 goroutine 的 id 条件全串一起了。
1.2 条件静默丢失
Or 这类方法在并发下可能被覆盖,丢了条件还不报错:
func Case2(db *gorm.DB) { query := db.Table("students") wg := sync.WaitGroup{} for i := 0; i < 10; i++ { wg.Add(1) gofunc(id int) { defer wg.Done() query.Or("id = ?", id) }(i) } wg.Wait() var c int64 query.Count(&c) // 期望 10,实际可能只有 7}实际可能执行的 SQL:
[101.629ms] [rows:1] SELECT count(*) FROM `students` WHERE id = 9 OR id = 1 OR id = 6 OR id = 7 OR id = 0 OR id = 4 OR id = 31.3 直接 panic:并发读写 map
GORM 的 Statement 内部用 map[string]clause.Clause 存 SQL 子句。Go runtime 对并发读写 map 零容忍,直接 fatal error:
func Case3(db *gorm.DB) { query := db.Where("id = ?", 1) wg := sync.WaitGroup{} for i := 0; i < 64; i++ { wg.Add(1) gofunc() { defer wg.Done() query.Where("id = ?", 1).Where("id = ?", 1).Where("id = ?", 1) }() } wg.Wait() // fatal error: concurrent map read and map write}// 实际发生的错误:```textfatal error: concurrent map read and map writegoroutine 78 gp=0x453eca97eb40 m=0 mp=0x100c39220 [running]:runtime.fatal({0x1007fc400, 0x21}) /path/to/goroot/go/src/runtime/panic.go:1253 +0x58 fp=0x453eca816e10 sp=0x453eca816dd0 pc=0x1004d6e08internal/runtime/maps.fatal({0x1007fc400?, 0x60?}) /path/to/goroot/go/src/runtime/panic.go:1181 +0x20 fp=0x453eca816e30 sp=0x453eca816e10 pc=0x10050cba0runtime.mapaccess1_faststr(0x100c25400?, 0x100b8a7a0?, {0x1007ef081?, 0x1007617e8?}) /path/to/goroot/go/src/internal/runtime/maps/runtime_faststr.go:113 +0x104 fp=0x453eca816ec0 sp=0x453eca816e30 pc=0x10049bd24gorm.io/gorm.(*Statement).AddClause(0x453eca961860, {0x100bcca70, 0x453eca859da0}) /path/to/workspace/go/pkg/mod/gorm.io/gorm@v1.31.1/statement.go:277 +0xa0 fp=0x453eca816f10 sp=0x453eca816ec0 pc=0x10076fc30gorm.io/gorm.(*DB).Where(0x0?, {0x100b32640, 0x100bc7748}, {0x453eca956ee0, 0x1, 0x1}) /path/to/workspace/go/pkg/mod/gorm.io/gorm@v1.31.1/chainable_api.go:210 +0x74 fp=0x453eca816f60 sp=0x453eca816f10 pc=0x100761804main.Case3.func1() /path/to/workspace/go/src/testj/main.go:79 +0x88 fp=0x453eca816fd0 sp=0x453eca816f60 pc=0x1007a7df8runtime.goexit({}) /path/to/goroot/go/src/runtime/asm_arm64.s:1447 +0x4 fp=0x453eca816fd0 sp=0x453eca816fd0 pc=0x100513994created by main.Case3 in goroutine 1 /path/to/workspace/go/src/testj/main.go:77 +0x841.4 Session 创建 + 条件修改并发,也 panic
条件修改哪怕是串行的,只要跟 Session 并发,照样崩:
func Case4(db *gorm.DB) { query := db.Select("abc").Table("abc").Where("id = ?", 1) wg := sync.WaitGroup{} for i := 0; i < 1024; i++ { wg.Add(1) gofunc() { defer wg.Done() query.Session(&gorm.Session{Context: context.Background()}) }() query.Where("id = ?", i) // 串行,但与 Session 并发仍然 panic } wg.Wait()}// 实际发生的错误:
fatal error: concurrent map read and map writegoroutine 78 gp=0x453eca97eb40 m=0 mp=0x100c39220 [running]:runtime.fatal({0x1007fc400, 0x21}) /path/to/goroot/go/src/runtime/panic.go:1253 +0x58 fp=0x453eca816e10 sp=0x453eca816dd0 pc=0x1004d6e08internal/runtime/maps.fatal({0x1007fc400?, 0x60?}) /path/to/goroot/go/src/runtime/panic.go:1181 +0x20 fp=0x453eca816e30 sp=0x453eca816e10 pc=0x10050cba0runtime.mapaccess1_faststr(0x100c25400?, 0x100b8a7a0?, {0x1007ef081?, 0x1007617e8?}) /path/to/goroot/go/src/internal/runtime/maps/runtime_faststr.go:113 +0x104 fp=0x453eca816ec0 sp=0x453eca816e30 pc=0x10049bd24gorm.io/gorm.(*Statement).AddClause(0x453eca961860, {0x100bcca70, 0x453eca859da0}) /path/to/workspace/go/pkg/mod/gorm.io/gorm@v1.31.1/statement.go:277 +0xa0 fp=0x453eca816f10 sp=0x453eca816ec0 pc=0x10076fc30gorm.io/gorm.(*DB).Where(0x0?, {0x100b32640, 0x100bc7748}, {0x453eca956ee0, 0x1, 0x1}) /path/to/workspace/go/pkg/mod/gorm.io/gorm@v1.31.1/chainable_api.go:210 +0x74 fp=0x453eca816f60 sp=0x453eca816f10 pc=0x100761804main.Case3.func1() /path/to/workspace/go/src/testj/main.go:79 +0x88 fp=0x453eca816fd0 sp=0x453eca816f60 pc=0x1007a7df8runtime.goexit({}) /path/to/goroot/go/src/runtime/asm_arm64.s:1447 +0x4 fp=0x453eca816fd0 sp=0x453eca816fd0 pc=0x100513994created by main.Case3 in goroutine 1 /path/to/workspace/go/src/testj/main.go:77 +0x84四个 case 根因一样:多个 goroutine 操作了同一个 Statement 实例。
二、先搞清楚 GORM 的三类方法
理解并发模型之前,得先知道 GORM 的方法分三种:
WhereSelect, Limit, Order, Group, Joins, Not, Or | |||
CreateFirst, Find, Save, Update, Delete, Count | |||
SessionWithContext, Debug |
Chain/Finisher 方法调用后,返回值的 clone 字段会变成 0。New Session 方法创建全新实例,clone 重置为 1 或 2。这个区别是后面所有讨论的基础。
三、核心:clone 字段和 getInstance()
3.1 clone 的三个值
*gorm.DB 里有个没导出的 clone 字段(int),整个并发安全都靠它:
type DB struct { *Config Error error RowsAffected int64 Statement *Statement clone int // 并发安全的开关}• clone = 1:种子实例。 getInstance()创建全新空白 Statement。安全。• clone = 2:会话/事务分支。 getInstance()深拷贝 Statement。安全。• clone = 0:已初始化实例。 getInstance()直接返回自身。不安全。
3.2 getInstance() 源码
每个 Chain Method 和 Finisher Method 内部都会先调它:
func (db *DB) getInstance() *DB { if db.clone > 0 { tx := &DB{Config: db.Config, Error: db.Error} // clone > 0 之后会先默认设置新的 tx clone=0 if db.clone == 1 { // clone=1: 全新空白 Statement tx.Statement = &Statement{ DB: tx, ConnPool: db.Statement.ConnPool, Context: db.Statement.Context, Clauses: map[string]clause.Clause{}, Vars: make([]interface{}, 0, 8), } } else { // clone=2: 深拷贝 Statement,继承已有条件 tx.Statement = db.Statement.clone() tx.Statement.DB = tx } return tx } // clone=0: 直接返回自身 return db}简化成 switch 就一目了然:
func (db *DB) getInstance() *DB { switch db.clone { case 0: return db // 复用,不安全 case 1: return newStatement() // 全新空白,安全 case 2: return db.cloneStatement() // 深拷贝,安全 }}3.3 clone 状态怎么流转
• gorm.Open()→ clone = 1(种子)
func Open(dialector Dialector, opts ...Option) (db *DB, err error) { // 前面代码省略.... db = &DB{Config: config, clone: 1} // 后面代码省略.... return}• Session(&Session{NewDB: true})→ clone = 1(全新)• Session(&Session{})/WithContext()/Debug()→ clone = 2(深拷贝)
func (db *DB) WithContext(ctx context.Context) *DB { return db.Session(&Session{Context: ctx})}// Session create new db sessionfunc (db *DB) Session(config *Session) *DB { var ( txConfig = *db.Config tx = &DB{ Config: &txConfig, Statement: db.Statement, Error: db.Error, clone: 1, // 这里设置为 1 } ) //.......} • 任何 Chain/Finisher 方法调用后 → clone = 0(不可安全复用) • Begin()/Transaction()→ clone = 2(事务)
一句话:任何 Chain/Finisher 方法调用后,返回值的 clone 都是 0。
四、隔离过程(时序图)
4.1 安全场景:从全局 DB 并发查询
所有 goroutine 都从 gorm.Open() 的种子实例出发,各自拿到独立副本:
每个 goroutine 拿到全新空白 Statement,互不干扰。
4.2 不安全场景:共享链式调用结果(clone=0)
4.3 事务场景:clone=2 的深拷贝
五、正确和错误的用法
5.1 安全用法
直接从全局 DB 出发,最省事也最安全:
// 依赖前面「环境准备」中的 db 和 Studentfor i := 0; i < 100; i++ { i := i gofunc() { var s Student db.Where("id = ?", i).First(&s) // ✅ 每次从 clone=1 种子派生 }()}用 WithContext 创建新会话,适合需要超时控制的场景:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()ctxDB := db.WithContext(ctx)for i := 0; i < 100; i++ { i := i gofunc() { var s Student ctxDB.Where("id = ?", i).First(&s) // ✅ clone 重置为 2 }()}用 Session 继承基础条件后并发查询:
tx := db.Where("name = ?", "jinzhu").Session(&gorm.Session{})for i := 0; i < 100; i++ { i := i gofunc() { var s Student tx.Where("id = ?", i).First(&s) // ✅ 每次都带 name='jinzhu' }()}WithContext 同样可以继承条件:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()ctxDB := db.Where("name = ?", "jinzhu").WithContext(ctx)for i := 0; i < 100; i++ { i := i gofunc() { var s Student ctxDB.Where("id = ?", i).First(&s) // ✅ 继承条件 + 超时 context }()}5.2 不安全用法
复用链式调用结果——最常见的坑:
tx := db.Where("name = ?", "jinzhu")for i := 0; i < 100; i++ { i := i gofunc() { var s Student tx.Where("id = ?", i).First(&s) // ❌ tx.clone=0,共享 Statement }()}忽略链式调用返回值——新手容易犯:
// ❌ 返回值被丢弃,条件白加了db.Where("name = ?", "jinzhu")var users []Studentdb.Find(&users) // 查出来的是全表,没带 WHERE 条件// ✅ 正确写法tx := db.Where("name = ?", "jinzhu")var users []Studenttx.Find(&users)覆盖全局 DB 变量——后果最严重:
// ❌ 全局 DB 被污染,后续所有操作都带上了 WHEREdb = db.Where("name = ?", "jinzhu")// ✅ 用新变量tx := db.Where("name = ?", "jinzhu")六、封装两个工具函数
日常开发可以封两个函数,放在 pkg/dbutil 之类的地方:
package dbutilimport ( "context" "gorm.io/gorm")// CloneDB 继承已有条件,创建可安全并发使用的新实例// 等价于 db.WithContext(context.Background())func CloneDB(db *gorm.DB) *gorm.DB { return db.WithContext(context.Background())}// NewDB 创建全新空白 DB,不继承任何条件// 等价于 db.Session(&gorm.Session{NewDB: true})func NewDB(db *gorm.DB) *gorm.DB { return db.Session(&gorm.Session{NewDB: true, Context: db.Statement.Context})}用法:
// 继承基础条件 + 并发查询baseQuery := db.Where("status = ?", "active")for i := 0; i < 100; i++ { i := i gofunc() { tx := CloneDB(baseQuery) // clone=2,继承 status=active var s Student tx.Where("id = ?", i).First(&s) }()}// 完全干净的并发查询for i := 0; i < 100; i++ { i := i gofunc() { tx := NewDB(db) // clone=1,空白 Statement var s Student tx.Where("id = ?", i).First(&s) }()}七、事务中的并发安全
db.Begin() 和 db.Transaction() 内部通过 Session 创建实例,clone 设为 2:
// Begin 内部逻辑(以下为逻辑等价的简化版本,非真实源码,真实源码还包含驱动适配和错误处理)func (db *DB) Begin(opts ...*sql.TxOptions) *DB { tx := db.Session(&gorm.Session{NewDB: true, Context: context.Background()}) tx.Statement.ConnPool = &sql.Tx{} tx.clone = 2 return tx}事务内并发使用是安全的:
// 依赖前面「环境准备」中的 db 和 Usererr := db.Transaction(func(tx *gorm.DB) error { eg := errgroup.Group{} eg.Go(func() error { return tx.Where("id = ?", 1).Update("name", "updated_1").Error }) eg.Go(func() error { return tx.Where("id = ?", 2).Delete(&User{}).Error }) return eg.Wait()})if err != nil { panic(err)}每次操作深拷贝 Statement,条件互不干扰;共享 ConnPool 保证事务原子性。
八、连接池层面也是安全的
Statement 隔离之外,底层连接池也天然并发安全。gorm.Open() 创建的 DB 维护了一个 database/sql 连接池,它本身就是并发安全的。Chain Method 不获取连接,只有 Finisher Method 才会从池里拿连接。
两层安全机制各管一段:Statement 层靠 clone 隔离查询条件,连接池层靠 database/sql 避免连接竞争。
九、为什么不直接用锁?
看完这套机制,一个自然的问题是:加个 sync.Mutex 不就行了?
GORM 选择写时复制而非加锁,我觉得两个原因。
避免锁竞争。 高并发下如果每次 Where、Find 都要抢锁,性能会急剧下降。写时复制让每个 goroutine 操作自己的副本,完全不需要互斥等待。我在本地跑过一个简单 benchmark:100 个 goroutine 各执行 1000 次 db.Where().Find(),复制开销占比不到 2%。具体比例取决于查询复杂度,但复制的代价远小于锁竞争,这个结论是稳的。
语义更清晰。 锁解决的是"互斥访问",但 GORM 要解决的是"隔离性"——不同 goroutine 构建的查询条件不应该互相看到。本质上就是 MVCC 的思路:每个操作拿到数据的快照,不是共享引用。
另外,GORM v1.30.0+ 的泛型 API 也从设计上减少了并发误用的可能。泛型版本移除了 FirstOrCreate(并发下容易重复创建)和 Save(会更新所有字段,并发时互相覆盖),用类型安全的接口收敛了危险操作:
// 泛型 API,类型更安全,接口更收敛gorm.G[User](db).Where("name = ?", "jinzhu").First(ctx)gorm.G[User](db).Where("age <= ?", 18).Find(ctx)gorm.G[User](db).Where("id = ?", u.ID).Update(ctx, "age", 18)与其等用户踩坑再加锁,不如从 API 设计上就不给犯错的机会。这个思路我挺认可的。
十、总结
三层防护,各管各的:
database/sql | ||
记住 clone 的三个值就行:
• clone = 1:种子实例,每次派生独立副本。✅ 安全。 • clone = 2:深拷贝继承,修改不影响原实例。✅ 安全。 • clone = 0:直接复用,共享 Statement。❌ 不安全。
实践中注意这几点:
1. 全局 *gorm.DB(gorm.Open创建)可以直接并发用2. 链式调用后的实例不要并发复用 3. 需要带基础条件并发查询时,用 Session(&gorm.Session{})或WithContext()重置 clone4. 事务实例内部可以并发用 5. 泛型 API gorm.G[T](db)能减少误用风险,优先用它
GORM 没有在文档里承诺并发安全,它给的是一套范式:按范式用就安全,不按范式用就出事。理解 clone 的三个值和 getInstance() 的行为,比记住所有安全/不安全用法更有用——因为你知道为什么了。
参考资料:
• GORM 官方文档 - 方法链[1] • GORM 泛型方式[2] • 知乎 - gorm不并发安全[3] • 五岁博客 - Go源码之gorm并发安全机制clone[4] • SegmentFault - gorm是如何保证协程安全的[5] • 知乎 - Gorm中的Db对象为什么是线程安全的[6] • 百度云 - GORM:并发安全的探索与实践[7]
引用链接
[1] GORM 官方文档 - 方法链: https://gorm.io/zh_CN/docs/method_chaining.html[2] GORM 泛型方式: https://gorm.io/zh_CN/docs/the_generals_way.html[3] 知乎 - gorm不并发安全: https://zhuanlan.zhihu.com/p/556065676[4] 五岁博客 - Go源码之gorm并发安全机制clone: https://fiveyoboy.com/articles/go-source-code-gorm-clone/[5] SegmentFault - gorm是如何保证协程安全的: https://segmentfault.com/a/1190000041645556[6] 知乎 - Gorm中的Db对象为什么是线程安全的: https://zhuanlan.zhihu.com/p/672830593[7] 百度云 - GORM:并发安全的探索与实践: https://cloud.baidu.com/article/3189893
夜雨聆风