必须统一锚点为utc,否则时间字段在开发机、docker、ci、生产库间必然错乱;dsn需显式写loc=utc,写入前强制转utc,分页排序字段必须是utc时间戳,对外输出一律用t.utc().format("2006-01-02t15:04:05z")。

必须统一锚点为 UTC,否则时间字段在开发机、Docker、CI、生产库之间必然错乱——这不是配置遗漏,而是三个环节(Go 运行时、MySQL 服务、数据库驱动)默认行为天然不一致。
DSN 中必须显式写 loc=UTC,不是 loc=Local 也不是漏掉
MySQL 驱动在 parseTime=true 开启后,若 DSN 没带 loc 参数,会 fallback 到 time.Local。而容器里 time.Local 常是 UTC,本地开发机却是 Asia/Shanghai,同一段代码查出的 CreatedAt 在不同环境显示为不同物理时刻。
- 正确写法:
user:pass@tcp(127.0.0.1:3306)/db?parseTime=true&loc=UTC - 错误写法:
&loc=Local(Local 是变量名,不是字符串值)、&loc=CST(time.LoadLocation("CST")返回nil)、&loc=China/Beijing(IANA 不认) - 验证方式:打印
time.Now().Location().String(),应为UTC;再用db.Raw("SELECT NOW()").Scan(&t)看t.String()和t.UTC().String()是否一致
time.Time 字段写入前必须强制转 UTC,别信 GORM 默认填充
GORM v2 默认用 time.Now() 填充 CreatedAt,但它不自动转时区。宿主机是 CST 就写 CST,是 UTC 就写 UTC——而 MySQL 的 DATETIME 字段无时区语义,存进去就是“所见即所得”。一旦写错,后续怎么读都救不回来。
- 最稳做法:注册回调,强制用
time.Now().UTC()db.Callback().Create().Replace("gorm:create_time_stamp", func(tx *gorm.DB) { tx.Statement.SetColumn("CreatedAt", time.Now().UTC()) }) - 避免依赖
time.Local = time.UTC全局设置——它影响整个进程,且必须在main()最开头、任何 goroutine 启动前执行,微服务中极易出问题 - 如果业务强依赖本地时区(如仅服务单一时区用户),则所有写入也必须统一走
time.Now().In(shanghai),且数据库和 DSN 全部配成Asia/Shanghai
分页排序字段必须是 UTC 时间戳,否则 Offset 分页直接失效
Offset 分页(Order("created_at DESC").Offset(20).Limit(10))只管跳过多少行,不管字段值代表哪个物理时刻。如果 created_at 因时区混乱被解析成不同时间点,排序结果就不可靠——第一页末尾和第二页开头可能物理时间重叠或倒序,导致数据重复或丢失。
- 游标分页更安全:
WHERE created_at ,但前提是该字段在数据库里存的就是 UTC 值,且查询时没被驱动误解析 - 验证方法:用 MySQL CLI 执行
SELECT @@global.time_zone, @@session.time_zone;,确认 MySQL 本身没设成SYSTEM(它会随系统时区漂移) - 对外输出(JSON/API)一律用
t.UTC().Format("2006-01-02T15:04:05Z"),禁止用t.String()或默认 JSON marshal
真正容易被忽略的是:时区问题从来不是某一行代码错了,而是多个环节(运行时、ORM、数据库、部署环境)各自按自己的默认规则行事,最终在时间字段上叠加出不可预测的偏差。你改了 DSN,但没改写入逻辑;你设了 time.Local = time.UTC,但第三方库已提前调用了 time.Now();你用 loc=Asia%2FShanghai 解析读取,但数据库存的其实是 UTC——这些组合才是线上时间错乱的真实原因。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











