大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
有一次跨区域项目对账,差了一天。金额都对,笔数也对,就是几笔的日期对不上。我先怀疑数据错了,把那一整天的流水翻了个遍。交易记录都在,时间戳也在,没找出毛病。最后才发现,同一笔交易,主库这边看是昨天,报表那边看是今天。
问题不在数据,在时间字段存的是什么。那套系统里,有的表用 TIMESTAMP,有的用 DATETIME。两个类型看着都是时间,底下的存法完全不同。一跨时区,它们就分了叉。我这才明白,时间字段最难的地方不是精度,是先把语义定下来。
差在存储,不在显示
先看它们各自存了什么。TIMESTAMP 存的是一个 UTC 时间戳,4 字节,本质是个整数。DATETIME 存的是打包好的日期时间字面量,5 字节起。这两个是不是一回事,看字节就知道。
sql
SET time_zone = '+08:00';
SELECT ts_col, HEX(ts_col) AS ts_hex, dt_col, HEX(dt_col) AS dt_hex
FROM t_order WHERE id = 1;
-- ts_col: 2026-10-01 08:00:00 ts_hex: 6ABDA280 ← 4 字节
-- dt_col: 2026-10-01 08:00:00 dt_hex: 99BB028000 ← 5 字节
SET time_zone = '+00:00';
SELECT ts_col, HEX(ts_col) AS ts_hex, dt_col, HEX(dt_col) AS dt_hex
FROM t_order WHERE id = 1;
-- ts_col: 2026-10-01 00:00:00 ts_hex: 6ABDA280 ← 显示变了,字节没变
-- dt_col: 2026-10-01 08:00:00 dt_hex: 99BB028000 ← 显示和字节都没变
同一行数据,换个时区再看。TIMESTAMP 的显示变了,可它底下的字节没动。6ABDA280 就是那一刻的 UTC 秒数,0x6ABDA280 = 1790812800。换个会话时区,这 4 个字节还是老样子,变的只是读出来时那道换算。DATETIME 的显示和字节都没动,因为 99BB028000 里压着的,就是你写进去的字面值本身。谁对?看你这个字段想表达什么。记"这一刻发生的事",TIMESTAMP 更贴。记"单据上写的日期",DATETIME 更稳。我当时的问题,是两套语义混着用,还指望它们对得上。
(数值按 MySQL 8.0 的时间类型内部格式,换版本以实测为准。)
一个存时刻,一个存日期
除了存法,范围和换算规则也不同。这张表我常放身边,选型时对着看。
| TIMESTAMP | DATETIME | |
|---|---|---|
| 存什么 | UTC 时间戳(整数) | 日期时间字面量 |
| 占用 | 4 字节 | 5 字节起 |
| 范围 | 1970 起,2038 止 | 1000 到 9999 |
| 会话时区 | 读和写都受它影响 | 完全不受 |
| 适合记 | 事件发生的绝对时刻 | 单据上的业务日期 |
最要命的是范围。TIMESTAMP 到 2038 年就到头,对长期数据是硬伤。它对时区的敏感,是优点也是坑。记"哪一刻",它更准。记"哪一天",它会把你的日子搅乱。
还有个坑不常被提起。老默认下,表里第一个 TIMESTAMP 列,会被悄悄加上 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP。你以为建的是普通时间列,它却在每次更新行时自己改写。MySQL 5.6 起有 explicit_defaults_for_timestamp,打开它这个隐式行为才关掉。建表前先确认这个开关,比事后奇怪"谁改了我的时间"省事。
时区不止一层
时区不是只有一个设置。会话层一个 time_zone,全局层一个,连接层还可能从驱动或参数里再带一个。谁盖住谁,得理清楚。你在客户端改了 time_zone,结果却不合预期,多半是上面一层把它盖了。
sql
SELECT @@global.time_zone, @@session.time_zone, @@system_time_zone;
这里有个我踩过的坑。我想用 'Asia/Shanghai' 这种名字,觉得比 +08:00 明白。结果报错,说时区不认识。后来才知道,名字型时区得先把时区表灌进去,不然只能写偏移量。
sql
-- 时区表没灌时,名字型时区会报错
SET time_zone = 'Asia/Shanghai'; -- Unknown or incorrect time zone
-- 退回偏移量能生效,但它丢掉了夏令时规则
SET time_zone = '+08:00';
偏移量能生效,可它只是一个固定差值,碰上夏令时不会替你换算。换个有夏令时的地区,还把 +08:00 写死,切换的那一个小时,TIMESTAMP 存进去的 UTC 时刻就会偏一小时。对账时又对不上。同一笔单子,两边差出一小时,报表按小时聚合时直接错位。
查询和索引,也在这里翻车
时区还藏在查询里。一个看着没问题的按天过滤,结果会漂。
sql
-- 写法一:函数包住字段,索引用不上,还按会话时区取日期
WHERE DATE(ts_col) = '2026-10-01'
-- 写法二:范围条件,索引能用,时区也说得清
WHERE ts_col >= '2026-10-01 00:00:00'
AND ts_col < '2026-10-02 00:00:00'
第一种写法叠了两个问题。DATE() 包在字段上,索引直接失效。而且它按当前会话时区取日期,会话时区一变,同一条 SQL 归出来的天就跟着变。对账那天,我以为查询写错了,其实是这两层叠在一起。
差 8 小时,常常是两头都在换算
更常见的来源,在应用和数据库之间。数据库这头按会话时区换算,驱动那头也可能自己再算一遍。两个都算,时间就被平移了两次。我那次就是这样,应用连上来带了一个时区,客户端里设的又是另一个,一叠加,8 小时就出来了。排查时别只盯数据库,先看连接串里带的时区参数。Java 这边常见 serverTimezone、connectionTimeZone。开了 forceConnectionTimeZoneToSession,驱动还会去改写会话时区。对完参数,再看驱动版本怎么处理时间类型。Connector/J 8.0.23 之后,serverTimezone 是 connectionTimeZone 的别名。8.0.22 之前,它覆盖的是服务器时区,不是会话时区。版本不同,同一个参数名的行为可能不同。两头对齐,问题往往就没了。
夏令时那一个小时
时区是暗坑,夏令时是坑里还有坑。有夏令时的地区,切换那天会有一个小时重复,或凭空少掉一个小时。落在这个小时里的本地时间是有歧义的,同一个字符串能对应两个绝对时刻。UTC 存得没问题,可按本地时间归到哪天,规则不写清楚,两个系统就能算出两个日期。要对账,就统一换成 UTC 再比。
迁移和主从,会被它放大
时区的问题,在换库和主从时会放大。存 UTC 的字段,节点挪到别的时区,读出来还是那一刻,对得上。存本地时间字面量的,一旦两端时区设置不一致,同一条记录在两边就是两个值。所以迁移前,我习惯先把时间字段的口径盘一遍,比迁移后救火省事得多。
复制链路上还有一个易漏点。主从用 ROW 格式的 binlog,传的是那个 UTC 秒数,从库按自己的时区换算,只要指向同一时刻就对得上。可要是 STATEMENT 格式,从库重放的是原语句,NOW()、CURRENT_TIMESTAMP 这类会各算各的时区,一路复制下来就能差出小时数。跨时区的主从,binlog 格式和两端 time_zone 最好一起看。
时间字段到底怎么选
踩过这些之后,我的判断是这样。记绝对时刻的,事件、日志、创建时间这类,统一存 UTC,用 TIMESTAMP 或者干脆存时间戳数字。记业务日期的,单据日期、会计日这类,用 DATETIME 存字面值,别让它跟着时区漂。最怕两种混着用,一张表里一个字段想记时刻,一个字段想记日期,边界又不写清楚。对账那天,坑我的就是这种表。
还有两个细节。写当前时间用 UTC_TIMESTAMP(),别用 NOW(),省得依赖会话时区。跨时区对账,先把两边都换成 UTC,再比。
避坑清单
全库时间统一口径。要么都存 UTC,要么都存本地,别混,混了迟早出对账问题。
TIMESTAMP 别当万能。它有 2038 的范围线,还会随会话时区换算。存业务日期就用 DATETIME,存时刻才用它。
还有一条是我自己踩的。我一开始只用偏移量 '+08:00',觉得够用。后来碰上夏令时项目才发现,偏移量不会替我做夏令时换算,得用名字型时区,而名字型时区要先灌时区表。现在接手一套系统,我排查时区先看三件事:连接串带了什么、驱动什么版本、时区表灌没灌。
写在最后
时间字段平时不显眼,出问题就是报表、对账、跨区域协作一起翻车。它难的地方从来不是精度,是语义。你得先说清楚这个字段记的是"哪一刻"还是"哪一天",再谈怎么存、怎么查。这两个都定了,时区、夏令时、跨区对账才有得聊。
你们系统的时间字段存 UTC 还是本地时间?跨时区对账又是怎么处理的?评论区聊聊。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋