GORM 再次 First 后,已变 NULL 的时间字段还留着旧值
GORM 1.31.2 复用结构体读取 NULL 时,时间指针可能不被清零。用新目标承接完整重载,查询成功后再替换,保留缺失与部分查询边界。
同一条记录第一次查询时,*time.Time 字段有值。数据库随后把这一列更新为 NULL,再次 First(&record, id) 没报错,Go 对象里却仍是旧时间;换一个新结构体查询,字段才变成 nil。
这条表现有 GORM 1.31.1、1.31.2 配 PostgreSQL 驱动 1.6.0 的明确触发条件:复用已经填过值的目标结构体,列由非空变成 NULL,没有自定义扫描类型或 hook。它不要求查询缓存,也不能只凭这一现象认定数据库更新失败。
对照同一事务里的数据库值和新目标
先确认第二次 SELECT 确实包含该时间列,而不是使用 Select、Omit 或其他投影把它排除了。未被选中的字段保留原值,是另一条需要按查询契约判断的路径。
然后在同一连接或事务视图里核对列值、查询错误和目标主键。若数据库值已为 NULL,新目标读到 nil,只有复用目标还留着旧指针,问题才落到这次扫描赋值。
GORM 1.31.2 的时间指针字段 setter 接收扫描得到的 **time.Time。内层指针为空时,这个分支直接返回,没有将已经存在的目标指针清零。因此“SQL 已返回 NULL”和“对象字段已被置 nil”在这里不是同一件事。
RowsAffected 非零只能说明找到了记录,不能说明旧目标的每个字段都被覆盖。
用新目标承接完整重载,成功后再替换
如果业务需要重新取得整条记录,可以给这次完整查询一个零值目标,再在查询成功后替换原对象。下面的示意模型只含主键和时间字段;实际项目应使用自己的完整模型、查询作用域和事务对象:
import (
"time"
"gorm.io/gorm"
)
type Record struct {
ID uint
DeletedAt *time.Time
}
func reloadRecord(db *gorm.DB, current *Record) error {
var fresh Record
if err := db.First(&fresh, current.ID).Error; err != nil {
return err
}
*current = fresh
return nil
}
这里要求 current 非 nil,且本次查询选取的是需要重载的完整模型。示例的时间字段只是普通可空时间;模型若使用 gorm.DeletedAt 实现软删除,应保留自己的软删除类型和作用域,不按这个示意改变删除行为。
先查询、后赋值,避免查询失败时就把原对象清空。ErrRecordNotFound 也应按原来的缺失处理返回,不能把未找到记录的零值对象当作新的有效记录。
如果查询故意只取部分字段,直接用 fresh 覆盖整个旧对象会丢掉未查询的数据。这时应明确更新哪些字段及 NULL 的含义,不能照搬完整重载方法。多个 goroutine 共用这个对象时,还要按应用原来的并发规则保护替换,新的查询目标不会自动消除数据竞争。
别把 nil 一律变成零时间
nil 可以表达“没有时间”,&time.Time{} 则是“有一个零时间值”。接口序列化、业务筛选和后续保存可能区分它们。不要为了看起来更新了字段,将所有 NULL 改成零时间或把数据库列设为不允许空值。
验收覆盖非空到 NULL、NULL 到非空和非空到另一个时间,比较同一事务内的新查询结果与最终对象。再检查查询失败、记录不存在及部分字段查询,确保修正没有把旧对象过早清除,也没有把未加载字段写回数据库。


