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 | DATETIME |
| PostgreSQL | timestamptz | timestamp |
名字陷阱就在这张表里:MySQL 的 TIMESTAMP 对应 PG 的 timestamptz,不是 PG 的 timestamp。跨库迁移或写 ORM 时极易搞反。后面所有内容都是这张表的展开。
二、MySQL:DATETIME 与 TIMESTAMP
2.1 两种类型
| DATETIME | TIMESTAMP | |
|---|---|---|
| 语义 | 墙钟时间,存什么读什么 | 绝对时刻 |
| 时区处理 | 不做任何转换 | 写入时按会话 time_zone 转成 UTC 存储,读取时再转回会话时区 |
| 范围 | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 | 1970-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 DATETIME | MySQL TIMESTAMP | PG timestamp | PG 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 原样存;PGtimestamptz收到微秒数直接存。
推论: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 ✓。但这只是"自洽地错着":
- 换客户端就穿帮:CLI(会话 +08)看到
20:00,以为业务发生在晚上 8 点;另一个客户端时区为 UTC 的服务读到09-04 12:00 UTC,都差 12 小时。DATETIME 列被 UTC 客户端读则差 4 小时(美东与 UTC 之差)。 - SQL 内部语义全错:存储值整体漂移 12 小时,
WHERE created_at > NOW()漏掉刚写入 12 小时内的行,按天分组、分区裁剪也跟着错。 - 读出值的标签是美东:Go 里
.Hour()返回 20,Format显示的是前一天晚上。 DEFAULT CURRENT_TIMESTAMP生成的行被反向污染:服务端生成的值没有写入漂移,是真实时刻;同配置读回时被洗一遍,多出 12 小时。同一张表里"应用写入的行"与"服务端生成的行"互相矛盾。- 偏差随美东 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(美东已进夏令时,伦敦没有) | -4 | 0 | +4h | -4h |
| 10/25 ~ 11/1(伦敦已回 GMT,美东没有) | -4 | 0 | +4h | -4h |
| 冬季(同在标准时) | -5 | 0 | +5h | -5h |
| 后果跟着升级: |
- 同配置、同写者,往返也不再保证无损:9 月写的行(漂 -5h)在 10/25~11/1 读回(此时 Δ=+4h)会少 1 小时;那几周写的行 11 月读回会多 1 小时。错误每年准时出现两次。
- 表内绝对值分裂:不同季节写入的行漂移不同,表里同时存在 -5h 和 -4h 两套数据,任何"统一加减 N 小时"的修数脚本都无法收敛。
DEFAULT CURRENT_TIMESTAMP的行随读取季节漂动:真实时刻被按当前 Δ 洗一遍,洗出 +5h 或 +4h。- 会话时区自身的 DST 时段:伦敦 10 月最后一个周日 01:0002:00 回拨,同一墙钟出现两次,写入解释有歧义;3 月最后一个周日 01:0002:00 跳变,墙钟不存在。落在其中的写入有 ±1h 的不确定性。
- 前提就未必成立:会话用具名时区要求服务器已加载时区表(见 2.2)。
结论:客户端时区不是"显示偏好",而是声明线上字段串的墙钟语义,必须与数据约定和会话 time_zone 配套。固定偏移的不一致产生恒定漂移;带 DST 的具名时区不一致产生随时间变化的漂移,后者更糟,连"错多少"都不是常数。最省事的解法就是第八章那条铁律:全链路 UTC。
六、Go 应用对接
以go-sql-driver/mysqlv1.10.1 与jackc/pgxv5.10.0 源码为准。
6.1 go-sql-driver/mysql:三个参数
DSN 上与时间相关的参数就三个,各管一层:
| 参数 | 默认值 | 作用层 | 说明 |
|---|---|---|---|
parseTime | false | 读 | false 时 DATE/DATETIME/TIMESTAMP 扫出来是 []byte,扫进 *time.Time 直接报 unsupported Scan 错 |
loc | UTC | 读+写 | 第五章说的"客户端时区",双向都生效 |
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 再取墙钟字段。 |
四条路径的完整链路:
两个推论:
- DATETIME 的"时刻"语义完全由 loc 定义。库里同一个
08:00,loc=UTC 的服务读成"UTC 8 点",loc=Local 的服务读成"本地 8 点"。列里没有任何时区信息,约定全在客户端。 - 同一条连接配置内自洽,跨客户端错位。这就是 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(无时区)列上:
timestamptz读出的time.Time默认挂time.Local标签(二进制解码是time.Unix(µs),天然 Local)。时刻本身正确(.Equal()恒真),但Format()、Hour()、JSON 序列化都按 Local 渲染。想固定 UTC 输出:出参时.UTC(),或给 codec 设ScanLocation(只改标签不改时刻):timestamp(无时区)列的两端标签都是假的:编码原样取 time.Time 的墙钟字段(discardTimeZone只换标签不换字段),解码把裸字段挂成 UTC。把08:00 +08的 time.Time 写进timestamp列,存的是北京墙钟08:00;读出来是"标签 UTC 的 08:00",哪段代码把它当真 UTC 换算,8 小时就没了。确需墙钟语义时更推荐:仍存timestamptz,查询里用AT TIME ZONE 'Asia/Shanghai'现场转。- 会话
TimeZone只影响文本层:文本渲染、不带参数的AT TIME ZONE、now()的显示。pgx 默认走二进制扩展协议,timestamptz 值本身不受会话时区影响。会话时区可在连接串里透传:postgres://user:pass@host/db?TimeZone=UTC(未识别的查询参数会作为运行时参数发给服务器)。 infinity扫不进time.Time:pgx 报cannot scan Infinity into *time.Time。用pgtype.Timestamptz(带InfinityModifier字段)接收,反而可以优雅处理"永不过期"这类开放区间。- lib/pq 已是维护模式:新项目直接用 pgx(
stdlib.OpenDB可无缝兼容database/sql)。
6.3 Go 症状速查表
| 症状 | 根因 | 修复 |
|---|---|---|
unsupported Scan ... []byte into *time.Time | 没开 parseTime | DSN 加 parseTime=true;别拿 []byte 自己 time.Parse 再写死 UTC,那是又埋一处不一致 |
| 读出的值差 8 小时,标签是 UTC | DATETIME 列存的是本地墙钟,loc 默认 UTC;JSON 序列化后带 +00:00,前端再转一次 | loc 对齐写入方,或全链路 UTC |
| CLI 里是 08:00,Go 读出 00:00 | TIMESTAMP 按会话 +08 渲染,Go loc=UTC | loc 与会话 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:00 | timestamptz 读出挂 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 timestamp | LocalDateTime | 墙钟,字面量进出 |
| TIMESTAMP / PG timestamptz | Instant / OffsetDateTime(或 Timestamp 兼容层) | 绝对时刻 |
| DATE | LocalDate | 日期 |
7.2 MySQL Connector/J:三个参数
| 属性 | 默认值 | 作用 |
|---|---|---|
connectionTimeZone | LOCAL | 第五章说的"客户端时区"。LOCAL=JVM 默认时区;SERVER=查询会话 time_zone;或显式指定。旧名 serverTimezone 是它的别名 |
preserveInstants | true | 是否按"时刻"语义做转换。false 时字面量原样进出,保显示不保时刻 |
forceConnectionTimeZoneToSession | false | true 时驱动自动执行 SET time_zone=connectionTimeZone(具名时区要求服务器已装时区表,偏移量直接可用) |
与 Go 参数的对应:connectionTimeZone ≈ loc;forceConnectionTimeZoneToSession ≈ DSN 里 time_zone 透传的自动化版;preserveInstants ≈ "做不做 6.1 那套 In(loc) 转换"的开关。 |
官方文档给出四种标准组合:
LOCAL+ force=false(默认):假定 JVM 时区 = 会话时区,不查不转。两边都在 +08 时没问题;容器 JVM=UTC + 服务器 +08,就是 5.3 场景一的 Java 版。SERVER+ preserveInstants=true:驱动查询会话time_zone,在 JVM 时区与会话时区之间双向转换。Go 需要手动配套的loc与time_zone,这里一个属性就自动对齐了。LOCAL/显式时区 + force=true:驱动把会话time_zone改成连接时区,之后零转换。注意两点:会改变NOW()/CURDATE()的返回;不同时区的客户端会各自改自己的会话。想全客户端显示一致,回到 DATETIME +LocalDateTime。- 显式
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:00 | pgjdbc 二进制解码硬编码 UTC | 正常行为,展示层转 |
八、落地清单
8.1 数据库建模
- 绝对时刻(下单时间、消息时间这类):PG 用
timestamptz(最省心,官方也推荐);MySQL 用DATETIME(6)+ 应用层统一写 UTC,或者TIMESTAMP(6)但要接受 2038 上限和会话时区转换的隐式行为。 - 墙钟时间(每天 9:00 的闹钟、营业时间、生日):PG 的
timestamp、MySQL 的DATETIME/TIME/DATE。这类语义跟着本地时区走,存 UTC 反而是错的。 - 精度显式声明:跨库系统统一微秒,MySQL 写
DATETIME(6)/TIMESTAMP(6),PG 默认已是微秒。GORM 记得改DefaultDatetimePrecision。 created_at/updated_at:MySQL 用DEFAULT CURRENT_TIMESTAMP+ON UPDATE CURRENT_TIMESTAMP;PG 用DEFAULT now(),updated_at在应用层或触发器里维护。- 可空时间用 NULL,不用零值哨兵。Go 侧
*time.Time,读侧IsZero()防御老数据。 - 实在受不了隐式转换:用
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 和会话时区转换。