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
比如Where
、Limit
、Select
、Tables
、Join
、Clauses
等等,这些在执行SQL前调用、用于设置和修改语句内容的,都叫 Chain Method
Finisher Method
比如Create
、First
、Find
、Take
、Save
、Update
、Delete
、Scan
、Row
、Rows
等等,会设置和修改语句内容,并执行SQL的,都叫 Finisher Method。
New Session Method
只有Session
、WithContext
、Debug
这三个方法,他们会新建一个Session。WithContext
和Debug
都只是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{}.NewDB
为false
,则为返回的*gorm.DB
实例clone字段是2,如果为true
,则为1。
- 在调用
(*gorm.Gorm).Session()
时,如果Session{}.Initialized
为true
,则返回的*gorm.DB
实例clone字段是0。这条规则优先级高于Session.NewDB
。 - 在调用了任意Chain Method、Finisher Method之后,返回的
Gorm
对象clone字段是0。
这也就符合文档中的说法:
After aChain 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预想的习惯,则可能会出现并发安全。
参考资料
https://www.slideshare.net/JinzhuZhang2/gorm-gopher-china