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

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

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

一、先分清两种语义

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

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

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

绝对时刻墙钟时间
MySQLTIMESTAMPDATETIME
PostgreSQLtimestamptztimestamp

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

二、MySQL:DATETIME 与 TIMESTAMP

2.1 两种类型

DATETIMETIMESTAMP
语义墙钟时间,存什么读什么绝对时刻
时区处理不做任何转换写入时按会话 time_zone 转成 UTC 存储,读取时再转回会话时区
范围1000-01-01 00:00:00 ~ 9999-12-31 23:59:591970-01-01 00:00:01 ~ 2038-01-19 03:14:07 UTC
存储5 字节(8.0),小数秒最多再加 3 字节4 字节(32 位秒数),小数秒最多再加 3 字节

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 两种类型

timestamp(without time zone)timestamptz(with time zone)
语义墙钟时间,存什么是什么绝对时刻
时区处理不转换写入:输入带偏移就按偏移转 UTC,不带就按会话 TimeZone 转 UTC;读取:按会话 TimeZone 显示
范围公元前 4713 年 ~ 公元 294276 年同左
存储8 字节,自 2000-01-01 起的微秒数8 字节,内部统一存 UTC 微秒
timestamptz 并不存储原始时区,它存的只是 UTC 时刻。"with time zone" 的意思是"按带时区的方式解释输入、按会话时区显示输出"。

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 扩展),更多做法是在应用层维护。

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

四、四种类型对照

MySQL DATETIMEMySQL TIMESTAMPPG timestampPG timestamptz
语义墙钟时间绝对时刻墙钟时间绝对时刻
存储5~8 字节4~7 字节8 字节8 字节
内部表示字面量打包32 位 UTC 秒微秒整数UTC 微秒整数
范围1000 ~ 9999 年1970 ~ 2038 年前 4713 ~ 后 294276 年同左
精度最高微秒,默认秒最高微秒,默认秒默认微秒默认微秒
时区转换不转换存取双向转换不转换存取双向转换
Y2038无有无无
特殊值 infinity无无有有
自动更新列支持支持需触发器需触发器

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

数据库侧的规则其实不复杂,绝大多数事故出在应用与数据库之间的那段链路上。本章内容与语言无关,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'(固定偏移,中国服务器默认)

漂移的来源就一句话:写入方语境是美东墙钟,服务器却按 +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)

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

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

时段美东伦敦Δ = 会话 − 客户端TIMESTAMP 写入漂移
3/29 ~ 10/25(同在夏令时)-4+1+5h-5h
3/8 ~ 3/29(美东已进夏令时,伦敦没有)-40+4h-4h
10/25 ~ 11/1(伦敦已回 GMT,美东没有)-40+4h-4h
冬季(同在标准时)-50+5h-5h
后果跟着升级:
  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 上与时间相关的参数就三个,各管一层:

参数默认值作用层说明
parseTimefalse读false 时 DATE/DATETIME/TIMESTAMP 扫出来是 []byte,扫进 *time.Time 直接报 unsupported Scan 错
locUTC读+写第五章说的"客户端时区",双向都生效
time_zone(系统变量透传)SYSTEM服务端等价于连接后执行 SET time_zone='...',控制 TIMESTAMP 的服务端转换
loc 在源码中的使用位置:读是 parseDateTime(buf, mc.cfg.Loc)(文本协议,packets.go)和 parseBinaryDateTime(..., cfg.Loc)(二进制协议);写是 appendDateTime(b, v.In(mc.cfg.Loc), ...),两种协议都先转 loc 再取墙钟字段。

四条路径的完整链路:

两个推论:

  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 方案,最稳):

读写全链路 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(只改标签不改时刻):
  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 症状速查表

症状根因修复
unsupported Scan ... []byte into *time.Time没开 parseTimeDSN 加 parseTime=true;别拿 []byte 自己 time.Parse 再写死 UTC,那是又埋一处不一致
读出的值差 8 小时,标签是 UTCDATETIME 列存的是本地墙钟,loc 默认 UTC;JSON 序列化后带 +00:00,前端再转一次loc 对齐写入方,或全链路 UTC
CLI 里是 08:00,Go 读出 00:00TIMESTAMP 按会话 +08 渲染,Go loc=UTCloc 与会话 time_zone 配套(6.1 推论 2)
库里有值但 Go 读出零值时间行里是 '0000-00-00',驱动静默转成 time.Time{}(parseDateTime 有特判)IsZero() 防御 + 清洗数据
写入报 Incorrect datetime value: '0000-00-00'零值 time.Time{} 被发成字面量 '0000-00-00'可空时间列用 *time.Time
小数秒丢失,或 xx.7 秒 进位到下一秒Go 纳秒 → 驱动只发微秒 → 列精度以下四舍五入;GORM 默认建 datetime(3)(DefaultDatetimePrecision=3),NowFunc 在应用侧就截到毫秒列声明 (6);GORM 把 DefaultDatetimePrecision 设为 6,或字段 tag type:datetime(6)
参数化时间和 SQL 字面量比较结果不一致SQL 里的字符串字面量、NOW()、UNIX_TIMESTAMP() 走服务端时区语义,与 loc 无关同一条 SQL 不混用两种时间来源,最难查的一类错位
Unknown or incorrect time zone时区表未导入mysql_tzinfo_to_sql,或用偏移量(见 2.2)
夏令时切换日范围查询漏行会话时区带 DST,回拨时段转换不可逆会话时区用 UTC 或固定偏移;国内业务无感,服务海外时要警惕
PG:JSON 输出带 +08:00timestamptz 读出挂 Local 标签时刻没错,.UTC() 或设 ScanLocation
PG:cannot scan Infinity into *time.Time列含 infinity用 pgtype.Timestamptz
PG:timestamp 列读出来差 8 小时无时区列两端标签都是假的(6.2 第 2 条)改存 timestamptz,查询用 AT TIME ZONE

七、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,和国内业务一组合就错位),再加驱动的参数。

列类型推荐 Java 类型语义
DATETIME / PG timestampLocalDateTime墙钟,字面量进出
TIMESTAMP / PG timestamptzInstant / OffsetDateTime(或 Timestamp 兼容层)绝对时刻
DATELocalDate日期

7.2 MySQL Connector/J:三个参数

属性默认值作用
connectionTimeZoneLOCAL第五章说的"客户端时区"。LOCAL=JVM 默认时区;SERVER=查询会话 time_zone;或显式指定。旧名 serverTimezone 是它的别名
preserveInstantstrue是否按"时刻"语义做转换。false 时字面量原样进出,保显示不保时刻
forceConnectionTimeZoneToSessionfalsetrue 时驱动自动执行 SET time_zone=connectionTimeZone(具名时区要求服务器已装时区表,偏移量直接可用)
与 Go 参数的对应:connectionTimeZone ≈ loc;forceConnectionTimeZoneToSession ≈ DSN 里 time_zone 透传的自动化版;preserveInstants ≈ "做不做 6.1 那套 In(loc) 转换"的开关。

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

  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 症状速查表

症状根因修复
The server time zone value 'CST' is unrecognized...服务器时区表未装,time_zone 返回缩写,而 CST 有歧义(中国标准时间 / 美国中部标准时间),驱动拒绝猜测。8.0.23 起默认(LOCAL)不再检查,只在 connectionTimeZone=SERVER 且首次使用时间功能时抛出服务端装时区表 + 具名时区,或 URL 显式指定 connectionTimeZone
容器里所有时间差 8 小时JVM 默认时区是 UTC,LOCAL 模式下它就是"loc"-Duser.timezone 显式固定,或全链路 UTC
配了 connectionTimeZone 没任何效果forceConnectionTimeZoneToSession=false 且 preserveInstants=false 时它完全不参与,既不改会话也不做转换按 7.2 四种组合之一配齐;排查时最容易漏
日志里时间对,库里不对Timestamp.toString() 按 JVM 默认时区渲染,看着对不代表时刻对对比 epoch 毫秒或 Instant
DATETIME 列读出来被"转了时区"用 Timestamp 接墙钟列,驱动按连接时区解释成时刻墙钟列用 LocalDateTime 接
升级驱动后时间突变8.0.22 及以前 serverTimezone 是"覆盖会话时区",8.0.23+ 是"连接时区"语义,同一个 URL 两代行为不同滚动升级期显式配齐三个参数
PG:OffsetDateTime 偏移永远是 +00:00pgjdbc 二进制解码硬编码 UTC正常行为,展示层转

八、落地清单

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 复现不出来)。四套可直接抄的配置:

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 和会话时区转换。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论