gorm 时间字段时区问题关键在于统一锚点为 utc:dsn 必须显式设 loc=utc,写入前调用 .utc(),读取后验证 t.string() 与 t.utc().string() 一致,避免各环节时区错位导致物理时刻错误。

GORM 时间字段的时区问题,本质不是「怎么转」,而是「别让它错」——只要 DSN 漏写 loc=UTC 或误用 loc=Local,同一段代码在本地、CI、K8s 里查出的 CreatedAt 就是不同物理时刻。
DSN 必须显式指定 loc=UTC,不能依赖环境或默认值
MySQL 驱动在 parseTime=true 开启后,若 DSN 中没带 loc 参数,会 fallback 到 time.Local。而容器里 time.Local 往往是 UTC,开发机却是 Asia/Shanghai,结果查出来的 time.Time 值直接套错时区标签(比如显示 +0800 CST,实际应为 +0000 UTC)。
-
dsn := "user:pass@tcp(127.0.0.1:3306)/db?parseTime=true&loc=UTC"—— 正确,强制解析为 UTC - 别写
&loc=Local:它不等于系统时区,而是 Go 运行时当前time.Local值,不可控 - 别拼
&loc=Asia%2FShanghai除非你真要读成上海时间(但写入仍按 UTC,语义割裂) - 用
time.Now().Location().String()打印确认当前time.Local是什么,不是空字符串也不是Local
写入前必须调用 .UTC(),不能靠 DSN 补救
loc=UTC 只影响「读取解析」,对「写入」完全无效。如果代码里传的是 time.Now()(本地时区),GORM 会原样塞进 MySQL 的 DATETIME 字段——数据库存的其实是带偏移的时间值,只是 DATETIME 类型不存时区,导致物理时刻已错。
- 所有写入前:
u.CreatedAt = u.CreatedAt.UTC() - 避免在模型里用
NowFunc返回未归一的时间,它不自动转 UTC - 游标分页依赖时间单调性,一旦写入时区混乱,
WHERE created_at > ?就会漏数据或重复 - 如果必须显示本地时间,也在读取后用
t.In(loc)转,而不是写入时就塞本地时间
查询后立刻验证 t.String() 和 t.UTC().String() 是否一致
这是最简单的线上自检手段。如果两者输出不同,说明驱动没按预期解析,或者写入时已错。
- 加一行日志:
log.Printf("raw: %s, utc: %s", t.String(), t.UTC().String()) - 若不一致,优先检查 DSN 是否真含
&loc=UTC(注意 URL 编码,/要写成%2F) - 避免用
db.Raw().Scan()直接扫裸time.Time,改用sql.NullTime或自定义Scan()方法 - 对外输出(JSON、日志)一律走
t.UTC().Format("2006-01-02T15:04:05Z"),不依赖结构体字段默认行为
真正容易被忽略的是:时区问题从来不是某一行代码错了,而是多个环节(运行时、驱动、数据库配置、写入逻辑)的时区锚点没对齐。一旦某个环节偷偷用了 time.Local,后面所有基于该时间的排序、分页、比较都会无声失效。











