gorm不并发安全

gorm不并发安全

gorm是并发安全的吗?

我们先来看一些例子,这是真实可以跑的代码:

版本:

module mygo

go 1.16

require (
    gorm.io/driver/mysql v1.3.6
    gorm.io/gorm v1.23.8
)

基础代码:

package main

import (
    "log"
    "os"
    "strconv"
    "sync"

    "golang.org/x/sync/errgroup"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "gorm.io/gorm/clause"
    "gorm.io/gorm/logger"
)

func main() {
    conn, _ := gorm.Open(mysql.Open("root:@tcp(127.0.0.1:3306)/test?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{
        Logger: logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{
            LogLevel: logger.Info,
        }),
    })

    Case1(conn)
    //Case2(conn)
    //Case3(conn)
    //Case4(conn)
}

查询条件污染

// Case1 : 查询条件污染
func Case1(conn *gorm.DB) {
    query := conn.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)
    }
}

输出:

Error 1064: 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 'SELECT * FROM `students` students` WHERE id = id = SELECT * FROM `students` WHER' at line 1
[2.944ms] [rows:0] SELECT * FROM SELECT * FROM `students` students` WHERE id = id = SELECT * FROM `students` WHERE id = 1 AND id = 1 AND id = 2 AND id = 01 AND id = 2 AND id = 0

查询条件丢失

// Case2 : 查询条件丢失
func Case2(conn *gorm.DB) {
    query := conn.Table("students")
    wg := sync.WaitGroup{}
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) { // 0-1000都有数据
            defer wg.Done()
            query.Or("id = ?", id)
        }(i)
    }
    wg.Wait()

    var c int64
    if err := query.Count(&c).Error; err != nil {
        panic(err)
    }
    print(c)
}

输出:

[2.017ms] [rows:1] SELECT count(*) FROM `students` WHERE id = 9 OR id = 3 OR id = 8 OR id = 0 OR id = 2 OR id = 5 OR id = 6
6

导致panic1

// Case3 : 并发设置查询条件,导致panic
func Case3(conn *gorm.DB) {
    query := conn.Where("id = ?", 1)
    wg := sync.WaitGroup{}
    for i := 0; i < 64; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            query.Where("id = ?", 1).Where("id = ?", 1).Where("id = ?", 1)
        }()
    }
    wg.Wait()
}

输出:

fatal error: concurrent map read and map write

导致panic2

// Case4 : 新建session的同时设置查询条件,导致panic
func Case4(conn *gorm.DB) {
    query := conn.Select("abc").Table("abc").Where("id = ?", 1).
        Joins("cba").Offset(1).Limit(1).Group("111").Order("bbb").
        Clauses(clause.OnConflict{}).Clauses(clause.Locking{})
    wg := sync.WaitGroup{}
    for i := 0; i < 1024; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            query.Session(&gorm.Session{Context: context.Background()})
        }()
        query.Where("id = ?", i) // 没有并发设置查询条件。设置查询条件是串行的
    }
    wg.Wait()
}

输出:

fatal error: concurrent map read and map write

前置知识

Chain Method

比如WhereLimitSelectTablesJoinClauses等等,这些在执行SQL前调用、用于设置和修改语句内容的,都叫 Chain Method

Finisher Method

比如CreateFirstFindTakeSaveUpdateDeleteScanRowRows等等,会设置和修改语句内容,并执行SQL的,都叫 Finisher Method。

New Session Method

只有SessionWithContextDebug 这三个方法,他们会新建一个Session。WithContextDebug 都只是Session方法特定调用的简写,底层都是调用的Session方法。

Statement

每个*gorm.DB 实例都会有一个Statement的字段,Statement就是我们真正要执行的语句,我们的 Chain Method 和 Finisher Method,事实上都是在修改Statement这个结构体。最后这个结构体会被渲染为SQL语句。

gorm的并发模型

首先,我们需要先去理解几乎每个方法中都会调用的函数: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
}

将上述改写并简化一下,大概是这么个逻辑:

func (db *DB) getInstance() *DB {
    switch db.clone:
    case 0:
        return db
    case 1:
        return newStatement() // 一个全新的,空白的Statement
    case 2:
        return db.cloneStatement() // 将之前的Statement复制一份
}

当clone=1时,这个*gorm.DB 实例总是并发安全的,因为它总是会返回一个全新的*gorm.DB 实例,不会对老*gorm.DB 实例有什么读写。

当clone=2时,这个*gorm.DB 实例也总是并发安全的,因为任何的 Chain Method 和 Finisher Method 都只会去读和复制当前*gorm.DB 实例的值,而不会修改,因此只会对这个*gorm.DB 实例并发读,那么当然是并发安全的。

当clone=0时,这个*gorm.DB 实例就不并发安全

那clone字段分别会在什么情况下等于0、1、2呢?

  • 在使用gorm.Open()之后,新建出来的*gorm.DB 实例clone字段总是1。
  • 在调用(*gorm.Gorm).Session()时,如果Session{}.NewDBfalse,则为返回的*gorm.DB 实例clone字段是2,如果为true,则为1。
  • 在调用(*gorm.Gorm).Session()时,如果Session{}.Initializedtrue,则返回的*gorm.DB 实例clone字段是0。这条规则优先级高于Session.NewDB
  • 在调用了任意Chain Method、Finisher Method之后,返回的Gorm对象clone字段是0。

这也就符合文档中的说法:

After a Chain method, Finisher Method, GORM returns an initialized *gorm.DB instance, which is NOT safe to reuse anymore.
在调用过了 Chain Method 和 Finisher Method 后,GORM返回一个初始化好的 *gorm.DB 实例,这个实例不再能被安全重用。

更近一步,在抛开(*gorm.Gorm).Session()那些复杂的配置项后,我们可以得出这些非常实用的结论:

  • 使用gorm.Open()创建出来的对象,完全无法被修改。因为对他调用任何方法,最后都只会创建出新的*gorm.DB 实例。所以不妨称为connection,简称为conn。
  • 使用conn.Session()新建一个*gorm.DB 实例来查询,和直接使用conn来查询,效果是一样的。因为conn.Session()也只会clone一个空的Statement。
  • 如果想让之后的查询都带上特定的条件,那么需要先设定好初始的条件,再使用 New Session Method 来创建新的*gorm.DB 实例,并且之后的查询都使用这个*gorm.DB 实例。比如db.Unscoped().Session(&gorm.Session{})
  • 无论如何不能并发调用 Chain Method 和 Finisher Method,要么会查询条件污染,要么查询条件丢失,那么还可能会panic。

gorm的并发哲学

gorm没说自己是并发安全的,从主页上看不到任何对并发的描述。网上有一些说法,说gorm是并发安全的,但是从一开始的例子大家也就知道,gorm不是并发安全的。

gorm使用复制来新建对象,避免了锁的开销,一定程度上保证了并发安全。我个人感觉思路上有些像多版本控制(不同的*gorm.DB实例就是不同的版本),但是学疏才浅,想不到特别准确的描述。

因此,gorm并没有承诺并发安全,gorm只是提供了一套符合使用习惯的并发范式,来兼顾性能和并发安全。如果你的使用习惯不符合gorm预想的习惯,则可能会出现并发安全。

参考资料

slideshare.net/JinzhuZh

jishuin.proginn.com/p/7

gorm.io/zh_CN/docs/inde

编辑于 2023-01-13 13:59・IP 属地河北