一、背景
近期在开发过程中,发现一个奇怪现象:go程序使用gorm进行insert操作,日志却出现了一行insert以及一行select,一度让我怀疑是代码Bug。经过仔细排查,排除了程序主动select逻辑,确认只有insert的操作,因此展开对此现象的分析。
二、分析过程
1、现场还原
本次案例中,将日志现场经简化、脱敏如下:
scss
(C:/www/project-name/internal/dao/dao_impls/dao_test/test.go:23)
[2025-12-11 18:30:33] [5.54ms] INSERT INTO `test` (`gid`,`atime`) VALUES (1000001,1765449029)
[1 rows affected or returned ]
(C:/www/project-name/internal/dao/dao_impls/dao_test/test.go:23)
[2025-12-11 18:30:33] [13.33ms] SELECT `score` FROM `test` WHERE (id = 5)
[1 rows affected or returned ]
日志中第二行,SELECT score FROM test WHERE (id = 5),便是预期外的select。
将表结构的struct简化后如下:
sql
type Test struct {
ID int64 `gorm:"column:id;PRIMARY_KEY;AUTO_INCREMENT;TYPE:bigint(20);NOT NULL" json:"id"`,
Gid int `gorm:"column:gid;TYPE:int(11);NOT NULL" sql:"DEFAULT:0" json:"gid"`,
Score int64 `gorm:"column:score;TYPE:int(11);NOT NULL" sql:"DEFAULT:0" json:"score"`,
Atime int `gorm:"column:atime;TYPE:int(11);NOT NULL" sql:"DEFAULT:0" json:"atime"`
}
2、gorm源码定位
阅读gorm源码后定位到,此现象是gorm的回读操作(gorm版本是v1.9.14)。
一个create操作包含以下几个过程:
1) Create 方法入口(触发回调链)
go
// github.com/jinzhu/gorm/main.go (1.9.14)
func (s *DB) Create(value interface{}) *DB {
// 初始化 Scope(GORM v1 核心上下文,包含 SQL、模型、字段等信息)
scope := s.NewScope(value)
// 执行 Create 回调链(核心:before_create → create → after_create)
scope.CallCallbacks(s.parent.callbacks.creates)
// 处理错误、返回结果
if scope.HasError() {
s.db.AddError(scope.DB().Error)
}
return s.db
}
2)Create 回调注册
scss
// github.com/jinzhu/gorm/callback_create.go (1.9.14)
// RegisterCallbacks 注册 Create 相关回调(v1 初始化时执行)
func RegisterCallbacks(db *DB) {
// 注册「插入中」回调:执行 INSERT 语句
db.Callback().Create().Register("gorm:create", create)
// 注册「插入后」回调:触发字段回读(核心)
db.Callback().Create().Register("gorm:after_create", afterCreate)
}
// afterCreate 是 v1 插入后回读字段的核心回调
func afterCreate(scope *Scope) {
if scope.HasError() {
return
}
// 核心:触发「从数据库回读字段值」的逻辑
if err := scope.RetrieveDBValues(); err != nil {
scope.Err(err)
}
}
3)回读字段的核心实现(生成 SELECT 语句)
go
// github.com/jinzhu/gorm/scope.go (1.9.14)
func (scope *Scope) RetrieveDBValues() error {
// 1. 筛选需要回读的字段(自增ID、生成列、默认值字段等)
fields := scope.Fields()
var retrieveFields []*Field
for _, field := range fields {
// 判断字段是否需要回读(自增、有默认值、生成列等)
if field.NeedToRetrieveValue() {
retrieveFields = append(retrieveFields, field)
}
}
if len(retrieveFields) == 0 {
return nil // 无需要回读的字段,直接返回
}
// 2. 拼接需要回读的字段名
var fieldNames []string
for _, field := range retrieveFields {
fieldNames = append(fieldNames, scope.Quote(field.DBName))
}
// 3. 构建 WHERE 条件(基于主键,如 id = 5)
primaryField := scope.PrimaryField()
if primaryField == nil {
return errors.New("primary field not found")
}
// 4. 拼接 SELECT SQL(对应日志中的 SELECT 语句)
sql := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s = %v",
strings.Join(fieldNames, ", "), // 回读字段列表
scope.QuotedTableName(), // 表名
scope.Quote(primaryField.DBName), // 主键字段 id
scope.AddToVars(scope.PrimaryKeyValue()), // 主键值(如 5)
)
// 5. 执行 SELECT 查询(日志中看到的 SELECT 操作在此执行)
row := scope.SQLDB().QueryRow(sql, scope.SQLVars()...)
if row.Err() != nil {
return row.Err()
}
// 6. 将查询结果扫描回模型字段
dest := make([]interface{}, len(retrieveFields))
for i, field := range retrieveFields {
dest[i] = field.Field.Interface()
}
return row.Scan(dest...)
}
整个过程简化成一张流程图就是:

3、触发回读的条件
在本次案例中,回读产生的原因是字段有数据库默认值(sql:"default:'xxx'")且插入时未显式赋值。这也是最典型的原因,其实还有另外几种因素也能导致,个人觉得在开发中不太常见,就不枚举了。
4、关闭回读
如果不需要回读,可以通过以下方式关闭:
go
// 方式1:全局禁用 after_create 回调(影响所有 Create 操作)
db.Callback().Create().Remove("gorm:after_create")
// 方式2:单次 Create 跳过回调(仅影响当前操作)
scope := db.NewScope(&rec)
scope.SkipAfterCreate = true
db.Create(&rec)
// 方式3:移除模型中字段的 sql:"DEFAULT:0" 标签(数据库层面的默认值仍生效)
type Test struct {
Score int64 `gorm:"column:score;TYPE:int(11);NOT NULL" json:"score"`,
// 其他字段...
}
// 方式4:使用原生 SQL 插入(绕过 GORM 回调链)
db.Exec("INSERT INTO test (...) VALUES (...)", args...)
// 方式5:程序代码显式赋值
rec.Score=0
三、回读的影响
其实一个基于主键的select回读,在大多数时候影响并不大,但是对于高并发的业务场景,任何一个微小的逻辑都可能被放大!
1、最直接影响就是性能损耗,会占用更多数据库连接、消耗IO资源,多一次网络往返。
2、数据不一致。RetrieveDBValues() 方法中,将回读的 SELECT 结果覆盖到模型结构体的内存,多个事务并发场景下可能导致业务读取到脏数据。
3、回读会产生额外的select日志,增加日志体积,且可能干扰问题排查。
四、结语
对于go开发者而言,这个案例有几个启示:其一,orm框架的"黑盒操作"背后往往有清晰的设计逻辑,遇到疑难问题时,溯源源码是最直接有效的排查手段;其二,使用框架时不能仅关注上层API调用,还需了解其核心机制(如Gorm的回调链),才能精准规避风险;其三,在高并发等场景下,需格外关注框架的隐式操作,才能把性能压榨极致或者规避不必要的bug。