当然可以!以下是重新排版后的代码和解释,适合粘贴在 CSDN 上:
GORM 查询污染问题及解决方案:如何防止查询条件污染
问题背景
在使用 GORM 进行数据库操作时,链式调用可能会导致查询条件污染。例如,当你在查询时添加了分页条件(LIMIT
和 OFFSET
),这些分页条件可能会影响到统计查询(COUNT
)。下面是一个示例,展示了这个问题。
示例代码:
type Account struct {
UserId string
Username string
Email string
Phone string
}
func main() {
// getDB 实现省略
db, err := getDB("root", "secret", "localhost", 15432, "testdb")
if err != nil {
log.Println(err)
return
}
var cnt int64
var items []Account
// 假设此处只做了一个简单的 where 条件
db = db.Table("account").Where("username='admin'")
// 希望在这里保留上面 db 的 where 条件,用于统计总数
// 但又不想让后续的操作影响到这里,所以创建了 cntDb
cntDb := db
// 下面这行代码对 db 做了分页限制
// 期望 cntDb 不被影响,然而执行后会发现 cntDb 被污染了
db = db.Limit(1).Offset(2)
// 执行 .Count 时却带上了分页限制
if err := cntDb.Debug().Count(&cnt).Error; err != nil {
log.Println(err)
return
}
// 查询实际数据
if err := db.Debug().Find(&items).Error; err != nil {
log.Println(err)
return
}
log.Printf("items: %#v, cnt: %d", items, cnt)
time.Sleep(3 * time.Second)
}
输出的 SQL 日志:
[2.208ms] [rows:0] SELECT count(*) FROM "account" WHERE username='admin' LIMIT 1 OFFSET 2
[0.979ms] [rows:0] SELECT * FROM "account" WHERE username='admin' LIMIT 1 OFFSET 2
items: []main.Account{}, cnt: 0
可以看到,原本只想给查询数据(Find
)加上 LIMIT 1 OFFSET 2
的语句,却意外影响到了统计总数(Count
)的 SQL。显然,cntDb
并没有独立于 db
,导致最终统计结果也带上了分页限制。
初步尝试:使用 Session 函数
为了解决这个问题,我尝试使用 Session
函数来创建一个新的数据库会话:
cntDb := db.Session(&gorm.Session{})
根据 GORM 的注释,Session
应该创建一个新的数据库会话。可惜实际测试中,cntDb
依然会被后续 db.Limit(...)
的操作所影响。
再试:将 NewDB
设为 true
GORM 的 Session
配置结构体中有一个 NewDB
选项。如果将其设置为 true
,会在新的 Session 中将 clone
值置为 1
,这应该会导致创建一个全新的 Statement
。然而,这样虽然可以得到一个干净的 db
,但原本希望保留的 Where
条件也被清空了,仍旧无法满足需求。
深入研究:为什么会“污染”?
要理解为什么会出现“污染”问题,我们需要了解 GORM 内部如何管理查询条件的。GORM 中的大部分链式调用都会将查询条件保存在一个名为 Statement
的结构体里。每当我们调用 Where()
、Limit()
、Offset()
等查询方法时,都会修改同一个 Statement
的内容。
在 gorm.Session
内部,有一个与 clone
相关的逻辑,核心代码大致如下:
func (db *DB) getInstance() *DB {
if db.clone > 0 {
tx := &DB{Config: db.Config, Error: db.Error}
if db.clone == 1 {
// 当 clone == 1 时,创建一个全新的 Statement
tx.Statement = &Statement{...} // 干净的 Statement
} else {
// 当 clone == 2 时,进行深拷贝
tx.Statement = db.Statement.clone()
tx.Statement.DB = tx
}
return tx
}
return db
}
- 当
clone == 1
时,Statement
会被全新创建,原先的查询条件不再保留。 - 当
clone == 2
时,Statement
会深拷贝到新的tx
,继承已设置的查询条件,但与原对象分离,不再互相影响。
解决方案:在 Session 时触发 Statement 克隆
为了保留原先的查询条件,并确保后续查询不受影响,关键是让 GORM 在调用 Session
时,立即进行 Statement
的克隆。
我们可以通过为 Session
的配置赋值(例如 Context
),来触发 Statement
的克隆。这样可以避免后续操作影响到 cntDb
。
cntDb := db.Session(&gorm.Session{Context: context.Background()})
这段代码通过为 Session
提供一个 Context
,触发了 Statement
的克隆,使得 cntDb
与 db
完全分离,保留原有的查询条件,又不会受到后续分页条件的影响。
总结
- 问题本质:GORM 通过共享一个
Statement
对象来构建查询条件,多个链式调用会修改同一个Statement
,导致查询条件相互污染。 - Session 关键点:仅调用
Session(&gorm.Session{})
不足以触发深拷贝;需要给Context
或其他配置项赋值,才能立刻克隆Statement
。 - 正确用法:如果你希望保留当前查询条件,并避免后续修改影响到原查询,可以使用以下方式:
cntDb := db.Session(&gorm.Session{Context: context.Background()})
或者:
cntDb := db.Session(&gorm.Session{PrepareStmt: true})
或者:
cntDb := db.Session(&gorm.Session{SkipHooks: true})
只要能够触发 Statement
克隆的操作,cntDb
就会和 db
完全分离,避免相互影响。