gorm是如何保证协程安全的

Gorm 官方文档提供了如何正确使用链式调用的例子以及会引起协程不安全的反例,知道了如何正确使用,也要知道原理才能用的更安心。下面以文档示例和源码切入,浅析 Gorm 在链式调用时时如何保证协程安全的?

源码分析

下面是一段 gorm 常见的使用代码,先初始化连接,然后根据链式调用进行增删改。

db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
    panic("failed to connect database")
}

tx := db.Where("age = 22").Where("name = '小明'").Find(&user)

通过 gorm.Open() 初始化连接,将拿到一个 *gorm.DB 结构体指针 db。结构体的 clone 属性为 1

type DB struct {
    *Config
    Error        error
    RowsAffected int64
    Statement    *Statement
    clone        int
}

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

通过 db.Where() 进行链式查询,Where() 方法也将返回一个*gorm.DB结构体指针 tx。

// Where add conditions
func (db *DB) Where(query interface{}, args ...interface{}) (tx *DB) {
    tx = db.getInstance()
    if conds := tx.Statement.BuildCondition(query, args...); len(conds) > 0 {
        tx.Statement.AddClause(clause.Where{Exprs: conds})
    }
    return
}

查看源码可以发现,像Where()、Select()、Limit() 等每个链式方法中,都要先去获取 *gorm.DB结构体指针 tx,也就是 tx = db.getInstance()

func (db *DB) getInstance() *DB {
    if db.clone > 0 {
        tx := &DB{Config: db.Config, Error: db.Error}

        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),
            }
        } else {
            // with clone statement
            tx.Statement = db.Statement.clone()
            tx.Statement.DB = tx
        }

        return tx
    }

    return db
}

当传入的 *gorm.DB指针指向的结构体属性 clone 为 1时,将会克隆一份 DB 结构体,并返回一个新的指针指向这个 DB 结构体,也即是创建一个新的会话。clone 为 0 时,返回原来的指针指向地址。

db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
    panic("failed to connect database")
}

db.Where().Where()

// 等同于
tx1 = db.Where()
tx2 = tx.Where()

基于上面的分析,在此例代码中,由于 db 指针指向 gorm.DB 结构体的 clone 属性为1。tx1 将指向一个复制的新的 gorm.DB 结构体,它的 clone 属性为 0。所以,tx.Where() 时将不会复制一个新的结构体(不会创建新会话),即 tx2 与 tx1 都指向同一个 gorm.DB 结构体,与 db 指向不同。

例子

经过上面的剖析,再看官方文档的例子,就能理解怎样使用是协程安全的链式调用。

例子1

db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

// 安全的使用新初始化的 *gorm.DB
for i := 0; i < 100; i++ {
  go db.Where(...).First(&user)
}

在 100 个协程中,db.Where() 会分别复制 gorm.DB 结构体,返回其指向指针,即创建了新会话,然后继续进行链式调用。所以,在 100 个协程中,链式调用拼接的 sql 查询是不会相互干扰的。

例子2

tx := db.Where("name = ?", "jinzhu")
// 不安全的复用 Statement
for i := 0; i < 100; i++ {
  go tx.Where(...).First(&user)
}

本例中,tx 指向了一个新的 gorm.DB 结构体,且 clone 为 0,所以 tx. Where() 将不会产生新的结构体,即不会创建新会话。那么,在100个协程中,共用 tx 指向的 gorm.BD,这样就会产生协程间相互干扰的问题。

例子3

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

通过 Session() 方法,创建新会话,将 tx 指向的新结构体 的 clone 属性置为 1。“tx 便有了例子一中 db 的效果”。

例子4

ctx, _ := context.WithTimeout(context.Background(), time.Second)
ctxDB := db.WithContext(ctx)
// 在 `新建会话方法` 之后是安全的
for i := 0; i < 100; i++ {
  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'` 会应用到查询中
}

这两种方式是复用了 Session 方法。

finish!

参考
链式方法 - GORM