gorm回读机制溯源

一、背景

近期在开发过程中,发现一个奇怪现象: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。

相关推荐
古城小栈2 小时前
Rust 的 validator 库
开发语言·后端·rust
C雨后彩虹2 小时前
竖直四子棋
java·数据结构·算法·华为·面试
上进小菜猪3 小时前
基于 YOLOv8 的昆虫智能识别工程实践 [目标检测完整源码]
后端
superman超哥3 小时前
Rust 异步递归的解决方案
开发语言·后端·rust·编程语言·rust异步递归
CC码码3 小时前
不修改DOM的高亮黑科技,你可能还不知道
前端·javascript·面试
散峰而望3 小时前
【算法竞赛】栈和 stack
开发语言·数据结构·c++·算法·leetcode·github·推荐算法
猫头虎4 小时前
2026最新|GitHub 启用双因素身份验证 2FA 教程:TOTP.app 一键生成动态验证码(新手小白图文实操)
git·开源·gitlab·github·开源软件·开源协议·gitcode
开心就好20254 小时前
iOS Crash日志全面解析:结构、类型与分析方法
后端
毕设源码-钟学长4 小时前
【开题答辩全过程】以 基于Spring Boot的社区养老服务管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端