鸟窝

MySQL 和 PostgreSQL 中的时间戳:Go 和 Java 访问时的坑

同样叫 timestamp,MySQL 和 PostgreSQL 的含义完全不同:一个是绝对时刻,一个是墙钟时间,正好相反。踩坑重灾区,也是面试高频题。这篇文章把两者放在一起对齐比较,并且提供了 Go 语言和 Java 语言应用访问数据库时间类型字段的坑。

前四节讲数据库侧(类型语义与对照),后四节讲应用侧(时间从应用流到列的链路、Go 与 Java 驱动的行为、可直接抄的配置清单)。

一、先分清两种语义

一切混乱的根源是"时间"这个词同时指两样东西:

  • 绝对时刻(instant):世界上唯一的一个瞬间,比如 2026-09-05 00:00:00 UTC。下单、日志、消息发送用它。任何时区看到的都是同一个点,只是显示不同。
  • 墙钟时间(wall time):挂钟上的读数,比如"早上 9 点开会"。不带时区就没有唯一时刻,但业务要的就是这个字面值:每天 9:00 的闹钟、营业时间、生日、定时任务。

四种数据库类型各占一边:

名字陷阱就在这张表里:MySQL 的 TIMESTAMP 对应 PG 的 timestamptz,不是 PG 的 timestamp。跨库迁移或写 ORM 时极易搞反。后面所有内容都是这张表的展开。

二、MySQL:DATETIME 与 TIMESTAMP

2.1 两种类型

1
2
3
4
5
6
7
8
9
10
SET time_zone = '+08:00';

CREATE TABLE t (
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME(6) DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP(6)
);

INSERT INTO t (created_at) VALUES ('2026-09-05 08:00:00');
SET time_zone = '+00:00';
SELECT created_at FROM t;

2.2 要记住的细节

Y2038。TIMESTAMP 内部是 32 位秒数,上限 2038-01-19。会员到期 2040 年、身份证有效期这类未来日期,只能用 DATETIME 或 DATE。

小数秒精度。两者都支持 TIMESTAMP(6) / DATETIME(6),最大 6 位(微秒)。默认精度是 0(不带小数秒),和 PostgreSQL 的默认微秒不一致,跨库对齐时要显式声明。插入超出精度的部分会四舍五入;如果进位跨天(比如 23:59:59.999999 写进精度 0 的列),严格模式直接报错。

自动初始化/更新。两种类型都支持 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP,做 created_at / updated_at 很方便。注意这些值由服务端生成,不经过驱动的时区转换,后面第五章会用到这一点。

两种时区写法。后文会反复用到这组概念,先说清楚:

  • 固定偏移(fixed offset):'+08:00'、'-04:00',就是"比 UTC 快/慢几小时"这一个常数,任何时候都不变。
  • 具名时区(named time zone,即 IANA/Olson 时区名):Asia/Shanghai、Europe/London、America/New_York。它不是一个数字,而是一套规则:这个地区在哪些日期用哪个偏移、夏令时何时切换、历史上怎么改过。Europe/London 冬天是 +00:00,夏天是 +01:00,写成任何一个固定偏移都只对半年正确。

时区表。TIMESTAMP 的转换依赖会话变量 time_zone。用具名时区之前必须先加载时区表(mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql mysql),否则 SET time_zone='Asia/Shanghai' 报 Unknown or incorrect time zone。没条件装就用固定偏移 '+08:00'(中国没有夏令时,这样写没问题),但有夏令时的地区不能用固定偏移代替。会话时区带 DST 时,回拨时段同一墙钟出现两次,TIMESTAMP 的双向转换不可逆,范围查询可能漏行。

相关函数。NOW() / CURRENT_TIMESTAMP 受会话时区影响;UNIX_TIMESTAMP() / FROM_UNIXTIME() 是一对按会话时区互转的函数;UTC_TIMESTAMP() 永远返回 UTC。

版本与兼容。8.0 起 explicit_defaults_for_timestamp 默认开启,TIMESTAMP 不再有隐式 NOT NULL DEFAULT CURRENT_TIMESTAMP 的老魔法,行为和 DATETIME 一致;8.0.19+ 字面量可以带时区偏移,如 '2026-09-05 08:00:00+08:00';默认严格模式下不允许零值 '0000-00-00',老代码迁移时注意 NO_ZERO_DATE 等 sql_mode。

三、PostgreSQL:timestamp 与 timestamptz

PostgreSQL 提供 date、time、timestamp、timestamptz 四种时间类型,外加表示时长的 interval。

3.1 两种类型

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE t (
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);

INSERT INTO t (created_at) VALUES ('2026-09-05 08:00:00+08');

SET TimeZone = 'UTC';
SELECT created_at FROM t;


SELECT created_at AT TIME ZONE 'Asia/Shanghai' FROM t;

3.2 要记住的细节

没有 Y2038 问题。64 位微秒,未来日期随便存。

精度默认就是微秒。声明 timestamp(0) 可以截到秒。

特殊值。支持 infinity / -infinity,做"永不过期""开始不限"这类开放区间很干净(MySQL 只能用 NULL 或哨兵值)。

取当前时间的函数家族(容易混):

  • now() / current_timestamp / transaction_timestamp():事务开始时间,同一事务内多次调用结果相同
  • statement_timestamp():当前语句开始时间
  • clock_timestamp():真实的墙钟时间,每次调用都变
  • localtimestamp:不带时区的本地墙钟时间(一般不建议用)

updated_at 自动更新。没有 MySQL 的 ON UPDATE 语法,需要触发器(或 moddatetime 扩展),更多做法是在应用层维护。

闰秒。不存储闰秒,用平滑方式处理,一般无需关心。

四、四种类型对照

五、应用层为什么会错:一条时间流经三层

数据库侧的规则其实不复杂,绝大多数事故出在应用与数据库之间的那段链路上。本章内容与语言无关,Go 和 Java 都适用。

5.1 三层模型

  • 应用层:Go 的 time.Time 是"时刻 + Location 标签";Java 的 Instant 是纯时刻、LocalDateTime 是纯墙钟。时刻是唯一的,标签只影响"墙钟怎么显示"。
  • 线路层:MySQL 无论 DATETIME 还是 TIMESTAMP,线上传的都是不带时区的字段串;驱动按自己配置的"客户端时区"(Go 的 loc、Java 的 connectionTimeZone)把时间对象转成这个字符串。PG 二进制协议下 timestamptz 传的是自 2000-01-01 起的微秒数,本身就是绝对时刻。
  • 服务端:MySQL TIMESTAMP 按会话 time_zone 把字段串解释成 UTC 存起来,DATETIME 原样存;PG timestamptz 收到微秒数直接存。

推论:MySQL 侧的"绝对时刻"要靠客户端时区与服务端会话 time_zone 两端配合才能还原,任何一端配置不对就错位;PG 的 timestamptz 天然无损。这就是 PG 侧的坑比 MySQL 少得多的根本原因。

5.2 通用公式

MySQL TIMESTAMP 列的漂移量可以直接算出来:

存储的 UTC = 真实 UTC −(会话时区 − 客户端时区)

两个时区都取写入时刻各自的 UTC 偏移(东为正、西为负)。两端相等,漂移为零;两端都是固定偏移但不相等,漂移是常数;任何一端带 DST,漂移就成了时间的函数。同一条连接用同一套配置读回时,写入和读出的误差正好抵消,所以自己看不出问题。坑全部转嫁给第二个读者(CLI、另一个服务、BI 工具)和 SQL 层的时间比较。

一个验证口诀:东边的时钟走得更快。北京(+08)比 UTC 早 8 小时,北京显示 20:00 时 UTC 是当天中午 12:00,不是次日凌晨 4 点。

北京业务,驱动客户端时区却配成了 America/New_York(照抄海外教程时常发生)。同一个错误配置,会话时区分别看固定偏移 +08:00 与具名时区 Europe/London 两种情况(两种写法的区别见 2.2)。业务时间是北京 2026-09-05 08:00。每个场景拆成三个时刻:① 应用层对象;② 驱动传输的线上内容;③ 数据库服务器的解释。

场景一:会话 time_zone='+08:00'(固定偏移,中国服务器默认)

1
2
3
4
5
6
7
8
9
10
11
① 应用层
2026-09-05 08:00:00 +08:00(Asia/Shanghai)
时刻(UTC)= 2026-09-05 00:00:00

② 传输时(客户端时区 = America/New_York,9 月 = EDT −04:00)
转成美东墙钟 → 2026-09-04 20:00:00
线上内容 = "2026-09-04 20:00:00"(裸字符串,无时区信息)

③ 服务器解释(会话 time_zone = '+08:00')
TIMESTAMP:把裸串当 +08:00 墙钟 → 存 UTC 2026-09-04 12:00:00(漂 −12h)
DATETIME :不解释,原样存 "2026-09-04 20:00:00"

漂移的来源就一句话:写入方语境是美东墙钟,服务器却按 +08 解释同一份线上内容。同一套配置读回时误差正好抵消,都回到 2026-09-05 00:00 UTC ✓。但这只是"自洽地错着":

  1. 换客户端就穿帮:CLI(会话 +08)看到 20:00,以为业务发生在晚上 8 点;另一个客户端时区为 UTC 的服务读到 09-04 12:00 UTC,都差 12 小时。DATETIME 列被 UTC 客户端读则差 4 小时(美东与 UTC 之差)。
  2. SQL 内部语义全错:存储值整体漂移 12 小时,WHERE created_at > NOW() 漏掉刚写入 12 小时内的行,按天分组、分区裁剪也跟着错。
  3. 读出值的标签是美东:Go 里 .Hour() 返回 20,Format 显示的是前一天晚上。
  4. DEFAULT CURRENT_TIMESTAMP 生成的行被反向污染:服务端生成的值没有写入漂移,是真实时刻;同配置读回时被洗一遍,多出 12 小时。同一张表里"应用写入的行"与"服务端生成的行"互相矛盾。
  5. 偏差随美东 DST 变化:11 月初美东切回 EST(-5),漂移从 12 小时变成 13 小时。

场景一里会话是固定偏移,漂移恒定(−12h),错误稳定,事后统一修数还有救。

场景二:会话 time_zone='Europe/London'(具名时区,带 DST)

1
2
3
4
5
6
7
8
9
① 应用层
2026-09-05 08:00:00 +08:00 —— 与场景一同一个值

② 传输时(客户端时区 = America/New_York,EDT −04:00)
线上内容 = "2026-09-04 20:00:00" —— 与场景一的字符串一字不差

③ 服务器解释(会话 time_zone = 'Europe/London',9 月 = BST +01:00)
TIMESTAMP:把裸串当伦敦墙钟 → 存 UTC 2026-09-04 19:00:00(此刻漂 −5h)
DATETIME :不解释,原样存 "2026-09-04 20:00:00"

注意 ②:同一个时间对象、同一个客户端时区,线上字节与场景一一模一样,变的只是服务器的解释时区,存储结果就从漂 −12h 变成漂 −5h。而会话时区是任何连接都能改的服务端状态,写入方根本管不住。

此刻漂移 −5 小时,同季读回 ✓ 无损。但美东和伦敦的夏令时切换日期不同(美国:3 月第二个周日 ~ 11 月第一个周日;欧盟:3 月最后一个周日 ~ 10 月最后一个周日),一年里有几周两边处于不同 regime(以 2026 年为例):

  1. 同配置、同写者,往返也不再保证无损:9 月写的行(漂 -5h)在 10/25~11/1 读回(此时 Δ=+4h)会少 1 小时;那几周写的行 11 月读回会多 1 小时。错误每年准时出现两次。
  2. 表内绝对值分裂:不同季节写入的行漂移不同,表里同时存在 -5h 和 -4h 两套数据,任何"统一加减 N 小时"的修数脚本都无法收敛。
  3. DEFAULT CURRENT_TIMESTAMP 的行随读取季节漂动:真实时刻被按当前 Δ 洗一遍,洗出 +5h 或 +4h。
  4. 会话时区自身的 DST 时段:伦敦 10 月最后一个周日 01:0002:00 回拨,同一墙钟出现两次,写入解释有歧义;3 月最后一个周日 01:0002:00 跳变,墙钟不存在。落在其中的写入有 ±1h 的不确定性。
  5. 前提就未必成立:会话用具名时区要求服务器已加载时区表(见 2.2)。

结论:客户端时区不是"显示偏好",而是声明线上字段串的墙钟语义,必须与数据约定和会话 time_zone 配套。固定偏移的不一致产生恒定漂移;带 DST 的具名时区不一致产生随时间变化的漂移,后者更糟,连"错多少"都不是常数。最省事的解法就是第八章那条铁律:全链路 UTC。

六、Go 应用对接

以 go-sql-driver/mysql v1.10.1 与 jackc/pgx v5.10.0 源码为准。

6.1 go-sql-driver/mysql:三个参数

DSN 上与时间相关的参数就三个,各管一层:

四条路径的完整链路:

1
2
3
4
写 DATETIME : time.Time ──In(loc)──► 墙钟字段串 ──► 原样存储
读 DATETIME : 字段串 ──按 loc 构造──► time.Time
写 TIMESTAMP: time.Time ──In(loc)──► 字段串 ──服务端按会话 time_zone──► 转 UTC 存储
读 TIMESTAMP: UTC ──服务端按会话 time_zone 渲染──► 字段串 ──按 loc 构造──► time.Time

两个推论:

  1. DATETIME 的"时刻"语义完全由 loc 定义。库里同一个 08:00,loc=UTC 的服务读成"UTC 8 点",loc=Local 的服务读成"本地 8 点"。列里没有任何时区信息,约定全在客户端。
  2. 同一条连接配置内自洽,跨客户端错位。这就是 5.2 公式在 Go 里的样子:只要所有客户端的 loc 与会话 time_zone 配套一致,TIMESTAMP 往返无损;任何一环默认值不同(JDBC、Python、mysql CLI、另一个 Go 服务的 DSN),读出来就差出一个时区差。"MySQL 差 8 小时"几乎从来不是驱动算错,而是两端配置不一致。

推荐 DSN(UTC 方案,最稳):

1
2
3
4
dsn := "user:pass@tcp(host:3306)/db?charset=utf8mb4" +
"&parseTime=true" +
"&loc=UTC" +
"&time_zone=%27%2B00%3A00%27"

读写全链路 UTC:业务传 time.Now() 也没关系(驱动会转到 loc 再发),展示时再 .In(shanghai)。

6.2 pgx:语义干净,但有两个"标签"细节

先说结论:PG + pgx 下只要列用 timestamptz,写入永远正确。编码直接算 Unix 微秒(绝对时刻),传任何 Location 的 time.Time 都不会写错。坑集中在"读出来的标签"和 timestamp(无时区)列上:

  1. timestamptz 读出的 time.Time 默认挂 time.Local 标签(二进制解码是 time.Unix(µs),天然 Local)。时刻本身正确(.Equal() 恒真),但 Format()、Hour()、JSON 序列化都按 Local 渲染。想固定 UTC 输出:出参时 .UTC(),或给 codec 设 ScanLocation(只改标签不改时刻):

    1
    2
    3
    4
    5
    6
    7
    cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
    conn.TypeMap().RegisterType(&pgtype.Type{
    Name: "timestamptz", OID: pgtype.TimestamptzOID,
    Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
    })
    return nil
    }
  2. timestamp(无时区)列的两端标签都是假的:编码原样取 time.Time 的墙钟字段(discardTimeZone 只换标签不换字段),解码把裸字段挂成 UTC。把 08:00 +08 的 time.Time 写进 timestamp 列,存的是北京墙钟 08:00;读出来是"标签 UTC 的 08:00",哪段代码把它当真 UTC 换算,8 小时就没了。确需墙钟语义时更推荐:仍存 timestamptz,查询里用 AT TIME ZONE 'Asia/Shanghai' 现场转。

  3. 会话 TimeZone 只影响文本层:文本渲染、不带参数的 AT TIME ZONE、now() 的显示。pgx 默认走二进制扩展协议,timestamptz 值本身不受会话时区影响。会话时区可在连接串里透传:postgres://user:pass@host/db?TimeZone=UTC(未识别的查询参数会作为运行时参数发给服务器)。

  4. infinity 扫不进 time.Time:pgx 报 cannot scan Infinity into *time.Time。用 pgtype.Timestamptz(带 InfinityModifier 字段)接收,反而可以优雅处理"永不过期"这类开放区间。

  5. lib/pq 已是维护模式:新项目直接用 pgx(stdlib.OpenDB 可无缝兼容 database/sql)。

6.3 Go 症状速查表

七、Java 应用对接(JDBC)

以 MySQL Connector/J 8.0.23+(8.x/9.x 通用)与 PostgreSQL JDBC 42.x 为准,语义核对自官方文档与驱动源码。

7.1 类型模型:与 Go 的差异

Go 的 time.Time 是"时刻 + Location 标签";java.util.Date / java.sql.Timestamp 是纯时刻(内部就是 epoch 毫秒),连标签都没有,格式化输出一律用 JVM 默认时区。JSR-310(Java 8+)才把两者分开:Instant(时刻)、LocalDateTime(墙钟)、OffsetDateTime / ZonedDateTime(时刻 + 偏移)。

所以坑的位置不同:Go 的坑在"标签被谁解释",Java 的坑在"JVM 默认时区"这个全局隐变量(容器里默认 UTC,和国内业务一组合就错位),再加驱动的参数。

7.2 MySQL Connector/J:三个参数

官方文档给出四种标准组合:

  1. LOCAL + force=false(默认):假定 JVM 时区 = 会话时区,不查不转。两边都在 +08 时没问题;容器 JVM=UTC + 服务器 +08,就是 5.3 场景一的 Java 版。
  2. SERVER + preserveInstants=true:驱动查询会话 time_zone,在 JVM 时区与会话时区之间双向转换。Go 需要手动配套的 loc 与 time_zone,这里一个属性就自动对齐了。
  3. LOCAL/显式时区 + force=true:驱动把会话 time_zone 改成连接时区,之后零转换。注意两点:会改变 NOW() / CURDATE() 的返回;不同时区的客户端会各自改自己的会话。想全客户端显示一致,回到 DATETIME + LocalDateTime。
  4. 显式 connectionTimeZone + preserveInstants=true:JVM 时区 ↔ 指定时区转换。典型用途:服务器时区值驱动不认识时兜底(官方示例就是 CST)。

转换只发生在"时刻型"目标上:存入仅当目标列是 TIMESTAMP;读出仅当列是 TIMESTAMP/DATETIME(或字符类型)且目标类是 Timestamp / OffsetDateTime 这类时刻类。LocalDateTime 任何情况下都不转换,拿它接 TIMESTAMP 列,等于 Go 里"字符串参数绕过一切"(按会话时区解释)。

7.3 PostgreSQL JDBC(pgjdbc)

pgjdbc 没有时区相关的连接参数,行为由 JVM 默认时区 + 服务端会话时区直接决定:

  • timestamptz 读取:二进制解码硬编码 UTC(源码注释原话 "Postgres is always UTC"),getObject(OffsetDateTime.class) 返回 UTC 偏移的结果。注意 pgx 默认挂 time.Local,pgjdbc 挂 UTC,两个生态不一样。
  • timestamp(无时区):与 pgx 一样的假标签问题,字面墙钟配一个随意的时区解释,别当真。
  • infinity:pgjdbc 映射为 OffsetDateTime.MAX / OffsetDateTime.MIN,比 Go 顺畅,pgtype.Timestamptz 的等价物直接内置在驱动里。
  • 绑定参数:setTimestamp 不传 Calendar 时按 JVM 默认时区提取字段;要独立于部署环境的时刻语义,用 Instant / OffsetDateTime,或显式传 Calendar。
  • 会话时区固定:JDBC URL 加 options=-c%20TimeZone=UTC。

7.4 Java 症状速查表

八、落地清单

8.1 数据库建模

  1. 绝对时刻(下单时间、消息时间这类):PG 用 timestamptz(最省心,官方也推荐);MySQL 用 DATETIME(6) + 应用层统一写 UTC,或者 TIMESTAMP(6) 但要接受 2038 上限和会话时区转换的隐式行为。
  2. 墙钟时间(每天 9:00 的闹钟、营业时间、生日):PG 的 timestamp、MySQL 的 DATETIME / TIME / DATE。这类语义跟着本地时区走,存 UTC 反而是错的。
  3. 精度显式声明:跨库系统统一微秒,MySQL 写 DATETIME(6) / TIMESTAMP(6),PG 默认已是微秒。GORM 记得改 DefaultDatetimePrecision。
  4. created_at / updated_at:MySQL 用 DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP;PG 用 DEFAULT now(),updated_at 在应用层或触发器里维护。
  5. 可空时间用 NULL,不用零值哨兵。Go 侧 *time.Time,读侧 IsZero() 防御老数据。
  6. 实在受不了隐式转换:用 BIGINT 存 Unix 毫秒,彻底绕开时区和 2038 问题;代价是失去日期函数与可读性,量力而行。

8.2 连接配置:全链路 UTC

一条铁律:库里与连接全程 UTC,展示层再转本地时区。写进团队规范和 DSN 生成代码,不要依赖各机器的 TZ 环境变量(loc=Local 或 JVM 默认时区都会让测试容器与生产镜像行为不同,时区 bug 复现不出来)。四套可直接抄的配置:

1
2
3
4
5

"user:pass@tcp(host:3306)/db?charset=utf8mb4&parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27"


"postgres://user:pass@host/db?TimeZone=UTC"
1
2
3
4
5
# Java + MySQL(服务器没装时区表时把 UTC 换成 %2B00%3A00)
jdbc:mysql://host/db?connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true

# Java + PostgreSQL
jdbc:postgresql://host/db?options=-c%20TimeZone=UTC

Java 侧同时加 -Duser.timezone=UTC,让 LocalDateTime 列和日志的语义也统一;用 HikariCP 的话 connectionInitSql=SET time_zone='+00:00' 也能固定会话时区,效果等同 forceConnectionTimeZoneToSession=true 但不依赖驱动属性。

MySQL 的客户端时区与会话 time_zone 必须成对出现,并保证所有访问方(脚本、JDBC 老服务、BI 工具)一致;跨语言混访前,先对一遍各语言驱动的默认值。

8.3 类型规矩

  • Go:可空列 *time.Time;PG 的 timestamptz 出参 .UTC() 或设 ScanLocation;含 infinity 的列用 pgtype.Timestamptz。
  • Java:DATETIME / PG timestamp 列 ↔ LocalDateTime;TIMESTAMP / timestamptz 列 ↔ Instant / OffsetDateTime。不要用 LocalDateTime 接时刻列,也不要用 Timestamp 接墙钟列,一旦混用,"墙钟"与"时刻"的转换就散落在驱动配置里,谁也说不清。
  • ORM:Hibernate 用 hibernate.jdbc.time_zone=UTC 统一绑定行为(5.2.3+);MyBatis 无额外时区逻辑,行为等于驱动默认,更需要类型纪律。

一句话总结:时刻用 UTC + 绝对时间类型(PG timestamptz / MySQL DATETIME(6) 或 BIGINT 毫秒),墙钟时间才用无时区类型;MySQL 的 TIMESTAMP 记住两件事:Y2038 和会话时区转换。