目录

Go 源码之 gorm 并发安全机制 clone

一、简介

写在前面:先写个结论,让大家对该结构的源码有个大概了解,之后再一步一步解析源码:

gorm 框架已经成为国内 go 开发者操作 mysql 数据库的必备 orm 框架,那么在使用过程中,你有没有好奇过,gorm 是如何处理并发安全的呢?

全局 gorm db 只初始化一次,全局并发使用,为什么不会出现并发安全呢?

在处理并发安全问题上,一般来说就两种处理机制:

  • 1.使用锁机制

  • 2.深拷贝,解决数据竞态问题(没有竞争不就没有并发安全喽….)

gorm 框架在处理并发安全问题上采用的就是方法 2,通过深拷贝,解决并发安全问题

二、并发机制

并发机制:Clone 字段的设计。

gorm DB 结构中有一个 clone 字段,该字段的设置就是用来控制并发安全的

(一) clone = 1

表示一个全新配置、全新 statement( sql 条件)的 db

func main(){
  // 创建一个db句柄,此时db.clone=1
  db, err := gorm.Open(mysql.New(mysql.Config{
            }), &gorm.Config{})

}
func Open(dialector Dialector, opts ...Option) (db *DB, err error) {
    config := &Config{}
    .............

    db = &DB{Config: config, clone: 1}

    .............

    return
}

(二)clone = 0

表示 db 的 statement 将会持续复用

func main(){
  db,_:=gorm.Open(...) // db.clone = 1

  db.Where(...)  // 这里db的statement条件时不会生效的,db.clone=1 

  db=db.Where(...) // db.clone = 0, 内部调用了db.getInstance(),可以理解为返回了db.statement的副本,并发安全

}

调用db.Table,Where,Or,Not等链式操作方法,都会调用此方法

// 传入的db的clone:
//  1.clone为0:
//        则返回原先的db,后续的db操作会影响原先的db,clone为原先的clone的值为0
//    2.clone为1:
//        返回一个带有db.Config的新db,且Statement为全新创建的,且clone值为0,后续的操作不会影响原先的db
//    3.clone为2:
//        返回一个带有db.Config的新db,且Statement为原先db的Statement,且clone值为0,后续的操作不会影响原先的db

func (db *DB) getInstance() *DB {
    if db.clone > 0 { // clone=1 or clone=2,修改db的一些参数,然后返回副本
        tx := &DB{Config: db.Config, Error: db.Error} // 复用db的配置创建新的db,tx.clone=0(int零值)

        if db.clone == 1 {
            // clone with new statement
            tx.Statement = &Statement{
                DB:        tx,
                ConnPool:  db.Statement.ConnPool,
                Context:   db.Statement.Context,
                Clauses:   map[string]clause.Clause{},
                Vars:      make([]interface{}, 0, 8),
                SkipHooks: db.Statement.SkipHooks,
            }
            if db.Config.PropagateUnscoped {
                tx.Statement.Unscoped = db.Statement.Unscoped
            }
        } else {
            // db.clone=2,则复制db的statement
            // with clone statement
            tx.Statement = db.Statement.clone()
            tx.Statement.DB = tx
        }
        return tx
    }
    // db.clone = 0,则直接返回当前db
    return db
}
// 代码简化
func (db *DB) getInstance() *DB {
    switch db.clone:
    case 0:
        return db
    case 1:
        return newStatement() // 一个全新的,空白的Statement
    case 2:
        return db.cloneStatement() // 将之前的Statement复制一份
}

(三)clone = 2

则创建新的 db,并且复用之前旧 db 的 配置和 Statement(深拷贝 statement

func main(){
  db,_:=gorm.Open(...) // db.clone = 1

  db.Transaction(func(tx *gorm.DB) error {} // 非嵌套事务时内部调用 db.Begin(),Begin 内部通过 Session 返回 clone=2 的 tx
  // 嵌套事务时内部调用 fc(db.Session(&Session{NewDB: db.clone == 1}))

  db.Session(&gorm.Session{NewDB: true, Context: context.Background()}) // 带NewDB: true,则返回的db为全新的db,全新的statement,clone=1
}

开启事务会设置 clone = 2,从而也可以通过 db.clone=2 来判断当前是否处于事务中

通过 clone 字段的值不同,来决定是是否深拷贝 db 句柄,所以但是你使用 db:=GlobalDB.where(….)之后, 实际上这里的 db 是一个全新的、独立的 db,自然不会有并发安全问题

三、状态流转拆解

上述代码中,三种状态的流转形成了"隔离链",确保并发操作不冲突,我们分步骤拆解:

  1. 初始状态:clone=1。通过 gorm.Open 生成的全局 db 实例 clone=1,这个实例是"种子实例",仅用于生成克隆实例,不直接执行操作——若直接修改其 statement,会引发并发安全问题。

  2. 协程 1/2:clone=1 → clone=0。当协程 1 调用 db.Where 时,内部调用 getInstance() 方法:因原实例 clone=1,创建一个全新的 Statement 并生成新的 db 实例(新实例的 clone=0),在新实例中添加 age > 18 条件。协程 2 同理,从 clone=1 种子实例克隆出独立的 clone=0 实例,添加 status = 1 条件——两个协程的 statement 完全独立,不会互相污染。而原始全局 db 始终保持 clone=1 不变,可以持续安全地派生新实例。

  3. 协程 3:事务场景 clone=2 → clone=0。调用 db.Begin()db.Transaction() 时,内部通过 Session(&Session{}) 生成 clone=2 的事务实例,复用事务专用的连接池,同时深拷贝 statement

    事务内调用 WhereUpdate 等方法时,getInstance() 发现 clone=2>0,会再次深拷贝 statement 生成 clone=0 的新实例,确保事务内的每个操作与其他协程完全隔离,同时继承事务连接——保证了事务原子性,也避免了并发冲突。

四、封装工具

以下工具可以直接复用,适用开发中不同的场景:

// CloneDB 只会复制 db.Statement
func CloneDB(db *gorm.DB) *gorm.DB {
    // return db.WithContext(db.Statement.Context)
    return db.WithContext(context.Background())
}

// NewDB 完全新创建一个 DB
func NewDB(db *gorm.DB) *gorm.DB {
    // return db.Session(&gorm.Session{NewDB: true, Context: context.Background()})
    return db.Session(&gorm.Session{NewDB: true, Context: db.Statement.Context}) // 如果不想继承 context,则用 context.Background()
}

// CleanDB 使用原有 context 创建一个全新的 DB(不继承之前的 Where 等条件)
func CleanDB(db *gorm.DB) *gorm.DB {
    return db.Session(&gorm.Session{NewDB: true, Context: db.Statement.Context})  // 如果不想继承 context,则用 context.Background()
}

常见问题

Q1. gorm 创建的全局句柄在使用过程中为什么不会出现 statement(where等)混乱?

正如本文所讲,gorm 内部有个 clone 字段,实现对 db 上下的克隆,实现了并发安全。

// 创建全局DB句柄,此时的clone=1
var db=dao.NewDevInventoryDBHelper().DB
func TestGorm(t *testing.T)  {
    db.Where("name=?","******") //clone=1会返回的新的db,如果不用新的变量接受,该where不做落在原的db上,
    db=db.Where("name=?","******") // clone=1,会返回一个新的db,但是会覆盖原先全局的db,导致后续的db操作都会带上此where的条件
    db2:=db.Where("name=?","******") // 该操作合理,返回新的db的clone=0,后续的操作可以不需要用变量再接收,也不会影响全局的db
    db2.Where("name=?","******")
}

官方所说的协程安全?https://gorm.io/zh_CN/docs/method_chaining.html

验证代码如下:

// Where 内部会调用db.getInstance()
// 此时的db的clone为 1
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

// 安全的使用新初始化的 *gorm.DB
for i := 0; i < 100; i++ {
  // 每次返回的db都是clone为0的新db,所以是安全的
  go db.Where(...).First(&user)
}
// 此tx的clone=0
tx := db.Where("name = ?", "jinzhu")
// 不安全的复用 Statement
for i := 0; i < 100; i++ {
  // 此处的tx的clone为0,调用where之后会复用tx,导致所有的where都坐落在同一个tx上
  go tx.Where(...).First(&user)
}

ctx, _ := context.WithTimeout(context.Background(), time.Second)
// db.Session(&Session{Context: ctx}) 将clone重置为2
ctxDB := db.WithContext(ctx)
// 在 `新建会话方法` 之后是安全的
for i := 0; i < 100; i++ {
  // 每次返回的db都是clone为0的新db,所以是安全的
  go ctxDB.Where(...).First(&user)
}

ctx, _ := context.WithTimeout(context.Background(), time.Second)
ctxDB := db.Where("name = ?", "jinzhu").WithContext(ctx)
// 在 `新建会话方法` 之后是安全的
for i := 0; i < 100; i++ {
  go ctxDB.Where(...).First(&user) // `name = 'jinzhu'` 会应用到查询中
}

tx := db.Where("name = ?", "jinzhu").Session(&gorm.Session{})
// 在 `新建会话方法` 之后是安全的
for i := 0; i < 100; i++ {
  go tx.Where(...).First(&user) // `name = 'jinzhu'` 会应用到查询中
}

Q2. clone=1 和 clone=2 都为克隆状态,两者有什么区别?

clone=1 会创建一个全新的空白 Statement,不继承任何 Where、Order 等条件,适用于 gorm.Open 初始化和 Session(&Session{NewDB: true}) 场景。

clone=2 会深拷贝原有的 Statement,继承之前的 Where 等条件,适用于 Session()WithContext()Transaction() 等场景。这样做的目的是:

一是与原实例隔离(修改新实例的 statement 不会影响原实例);

二是继承已有条件(如事务场景中需要保持事务连接、WithContext 需要保留已有查询条件)。

事务内可能有多个链式调用(如 Where→Update→Where→Delete),每次调用 getInstance() 都会再次深拷贝,确保每个步骤的 statement 独立,避免事务内的条件污染。

Q3. 如何手动查看当前实例的 clone 状态?

GORM 未直接提供状态查询方法,但可通过反射获取未导出的 clone 字段判断:

import "reflect"

func GetCloneStatus(db *gorm.DB) int {
    val := reflect.ValueOf(db).Elem()
    cloneField := val.FieldByName("clone")
    return int(cloneField.Int())
}

// 使用
status := GetCloneStatus(db)
fmt.Println("当前 clone 状态:", status) // 0, 1, 或 2

Q4. 高并发场景下,clone=1 复用 statement 会有性能瓶颈吗?

不会。

因为 clone=1 时 getInstance() 创建的是全新的空白 Statement,属于独立实例的私有资源,不存在并发竞争。而 clone=2 虽然深拷贝了原有 Statement,拷贝后也是各自独立的副本,同样不会有竞争。

整个克隆过程仅涉及结构体的浅/深拷贝和 map/slice 的创建,开销极小,实际测试中百万级链式调用的克隆开销仅占总耗时的 2% 以下。

Q5. 为什么有些版本的 GORM 源码中没有显式的 clone=2 状态?

因为 clone=2 是"逻辑状态",部分早期版本通过 tx 相关字段(如 connPool 是否为事务连接)隐含实现,而非显式定义。

核心逻辑一致:事务场景下需要更严格的状态隔离,因此会强制深拷贝 statement,本质就是 clone=2 状态的体现。

总结

GORM 的 clone 字段设计之所以能成为并发安全的核心,关键在于通过 clone=0、1、2 三种状态实现了"按需隔离":

  • clone=1 作为初始种子(gorm.Open 创建),保证克隆的"源头纯净";

  • clone=0 作为链式调用后的实例,直接复用 statement,实现"高效操作";

  • clone=2 作为会话/事务分支,通过深拷贝 statement 实现"严格隔离"。

整理如下表:

clone 描述 设置
1 创建 DB 的默认值,表示 全新 DB,但是复用 db.Session如果NewDb=true时,则clone=1
2 则创建新的 db,并且复用之前旧 db 的 配置和Statement db.Session如果NewDb=false时,则clone=2
0 则直接复用原 db db.Where 等链式调用

clone 的设计目的:并发安全,我们都知道解决并发安全方法有:加锁解决,避免数据冲突。

gorm 通过设计 clone 的值来避免并发操作 db,如在构建 where 条件时,内部通过调用 db.getInstance() 返回 db 的副本,从而避免并发操作。

如果大家关于 go gorm 框架的并发安全涉及的解读还有哪些不清楚的地方,欢迎大家在评论区交流~~~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/go-source-code-gorm-clone/

备用原文链接: https://blog.fiveyoboy.com/articles/go-source-code-gorm-clone/