同样叫 Timestamp,MySQL 和 PostgreSQL 的语义并不相同,也是跨库迁移和应用开发中最容易踩坑的地方。
一、先区分“时刻”和“墙钟时间”
时间通常有两种语义:
- 绝对时刻(Instant):世界上唯一的瞬间,例如订单创建时间、日志时间、消息发送时间。
- 墙钟时间(Wall Time):单纯的日期和时间读数,例如生日、营业时间、本地预约时间。
四种类型的对应关系如下:
| 数据库 | 墙钟时间 | 绝对时刻 |
|---|---|---|
| MySQL | DATETIME | TIMESTAMP |
| PostgreSQL | timestamp | timestamptz |
注意,MySQL 的 DATETIME 本身不带 UTC 语义。如果团队约定 DATETIME 永远保存 UTC,它可以用于存储绝对时刻,但这是应用约定,不是数据库类型本身的能力。
二、MySQL 最大的坑是 TIMESTAMP 的时区转换
MySQL TIMESTAMP 写入时会按当前连接的 time_zone 解释,内部转换后存储;读取时再按会话时区转换回来。
而 Go 的 MySQL 驱动还会根据 DSN 中的 loc,先把 time.Time 转成对应的墙钟时间再发送。
因此一条时间会经过:time.Time → 驱动 loc → 字符串 → MySQL session time_zone → TIMESTAMP
如果驱动使用 UTC,而 MySQL 会话使用 +08:00,或者不同服务使用不同配置,就可能出现“差 8 小时”“差 12 小时”等问题。
推荐绝对时刻统一使用 UTC:
Go + MySQL:parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27
不要依赖 loc=Local,否则 Docker、测试环境和生产环境的系统时区不同,问题很难复现。
另外,MySQL TIMESTAMP 仍有 2038 年范围限制,不适合会员到期、长期合同等未来日期。此类字段使用 DATETIME 或 DATE 更合适。
三、PostgreSQL 的 timestamptz 更适合存绝对时刻
PostgreSQL 的 timestamptz 不保存原始时区名称,保存的是绝对时刻。输入时按时区解释,输出时按当前 TimeZone 显示。
因此:
- 订单创建时间:timestamptz
- 日志时间:timestamptz
- 消息发送时间:timestamptz
- 本地预约时间:timestamp
- 生日:date
- 营业时间:time
需要注意,“每天纽约时间 9 点执行”不能只保存 09:00,还需要保存 America/New_York。墙钟时间如果依赖地区,还必须保存 IANA 时区。
四、Go 和 Java 的类型要与数据库语义匹配
Go 中:
time.Time表示一个时刻,但带有 Location 标签。- MySQL DATETIME 的解释依赖驱动
loc。 - PostgreSQL timestamptz 表示绝对时刻,接口输出建议统一
.UTC()。
Java 中建议直接使用 JSR-310 类型:
Instant:绝对时刻OffsetDateTime:时刻 + 偏移ZonedDateTime:时刻 + 时区规则LocalDateTime:墙钟时间
因此:
MySQL DATETIME / PG timestamp ↔ LocalDateTime
MySQL TIMESTAMP / PG timestamptz ↔ Instant 或 OffsetDateTime
不要用 LocalDateTime 表示绝对时刻,也不要用 Timestamp 承担墙钟时间,否则时区转换会散落在驱动和 JVM 默认配置中。
五、最终建议
绝对时刻数据统一采用 UTC:PostgreSQL 优先使用 timestamptz;MySQL 可使用 DATETIME(6) 并明确约定保存 UTC,也可以使用 TIMESTAMP,但必须统一驱动 loc 和 MySQL 会话 time_zone。
墙钟时间不要强行转换 UTC,保持原始业务语义。涉及“某个地区几点”的业务,还应保存 IANA 时区。
一句话总结:先区分“时刻”和“墙钟”,再选择数据库类型;绝对时刻统一 UTC,墙钟时间保持原样。MySQL 最容易踩的是 TIMESTAMP 的会话时区转换,PostgreSQL 最容易混淆的是 timestamp 与 timestamptz。