DevKitLab Logo DevKitLab
时间戳 / ISO 8601 / 数据库 / 日期与时间

时间该存时间戳还是 ISO 字符串?

Code review 上为同一列吵起来:一个人存 Unix 时间戳整数,另一个存 ISO 字符串,第三方 API 又甩回一串带 Z 的文本。到底哪个「对」?争论几乎总卡在整数还是字符串上——可这恰恰是最不重要、也最该最后问的那个问题。真正决定成败的,是它之前的那几问。

一次 code review 上,两个人为同一列争了起来。一个把 created_at 存成 BIGINT——一个 Unix 时间戳整数;另一个坚持用 PostgreSQL 的 timestamptz,驱动把它序列化成一串 ISO 字符串。与此同时,你对接的那个第三方 API 甩回 "2026-07-15T22:00:00Z",而你自己签发的 JWT 里,exp 又是一个十位整数。到底哪个才「对」?这场争论几乎总是卡在同一个地方——整数还是字符串——然后就再也走不动了。

而这里要说的是:整数还是字符串,是这一连串问题里最不重要、也最该最后问的那一个。在它之前有一个问题,一旦答错,你选整数还是字符串就根本无所谓了——两条路都会翻车。那个问题是:这到底是个什么值?是一个瞬间,一个日历日期,一个未来的本地约定,还是一段时长?先把这个答清楚,再问怎么落库。因为一旦确定它是一个瞬间,一个整数时间戳和一个带偏移的 ISO 字符串就是同一份信息的两种记法——谁也不比谁更「对」,你是在工程权衡上做取舍,而不是在对错之间站队。真正制造 bug 的,是没人有意去选、却总能溜进来的第三个答案:一个没有时区的本地时间

这是「日期与时间」这一簇的第四篇,也是收尾。前三篇分别讲了那个数字(Unix 时间戳为什么不对)、围着它的一堆名字(UTC、GMT、ISO 8601 有什么区别)、以及日历日期这道边界(日期为什么会差一天)。这一篇把它们收在一个非常实际的问题上:当你要把一个时间真的写进数据库、或塞进一个 API 响应,你到底该写下什么。

先问「这是什么」,再问「存成什么形态」

整数还是字符串是形态问题;它之前那个更要紧的,是种类问题。绝大多数存储层的时间 bug,都是在这一步就选错了类型,之后无论用整数还是字符串都补不回来。像日期的值一共四种,各自该存成不同的东西:

  • 瞬间——created_at、日志时间、一笔支付成功的时刻。这是时间线上一个绝对的点,对所有人都一样。存成一个带明确锚点的瞬间:一个 Unix 时间戳,或一个带偏移的 ISO 字符串。只有对这一类,那两者才是同一份信息的两种记法。
  • 日历日期——生日、发票日、截止日、一个「全天」事件。它没有时刻、也没有时区(1990 年 5 月 1 日在东京和芝加哥是同一个 5 月 1 日)。存进一个 DATE 类型,或干脆就是字符串 "1990-05-01"——永远别让它经过一个瞬间类型,否则你会亲手把它挪早一天。
  • 未来的本地约定——「明年 3 号柏林的早上 9 点」、每周例会。此刻真正生效的偏移,会随那个时区的夏令时规则变动而改变,所以要存本地时间加上 IANA 时区名Europe/Berlin),而不是一个冻结的偏移。如果它会周期重复,还要把重复规则(每周、每月,或 RFC 5545 的 RRULE)连同本地时间和时区一起存。
  • 时长/间隔——缓存 TTL、超时时间、「30 天后过期」里的那个「30 天」。这是一个数量,不是一个时刻——存一个整数(秒或毫秒,单位写进列名或文档),或者,如果它其实是一个到期时刻,那就把那个到期瞬间存下来。这里还要分清一点:固定时长(30 × 24 小时)是一个秒数,而日历周期(「一个月」「30 个日历日」)带着日历语义,不能压平成固定的秒数——它可能跨过一个夏令时边界,或落在一个较短的月份里。

换句话说,「同一份信息、两种记法」这种干净的等价,对瞬间成立。另外三类也各有自己的表示形式——DATE 还是字符串、整数还是像 P30D 这样的 ISO 8601 时长、周期事件用一个结构化对象——但它们并不像瞬间的时间戳和 ISO 字符串那样,是同一个值可以互换的两种拼写。选错种类,比在瞬间里选错记法,危害要大得多,也隐蔽得多。

对一个瞬间,两个答案都对

现在把讨论限定在「瞬间」上。这里有一件事能立刻化解掉一半的争论:一个整数 Unix 时间戳和一个带偏移的 ISO 8601 字符串,是同一个瞬间的两种记法,都锚定在 UTC 上。它们不是两个互相竞争的「时间」,而是同一份信息的两种拼写——一种写成数字,一种写成文本(这正是四层里的「记法」那一层):

1700000000                    Unix 时间戳(秒)
2023-11-14T22:13:20Z          同一个瞬间,写成 ISO 8601

这两行描述的是同一个时刻。所以「该存整数还是字符串」不是一个正确性问题——两者都能无歧义地钉住这个瞬间。它是一个工程权衡:读起来方不方便、占多少空间、比较和排序快不快、跨语言好不好传。下面就来拆这份权衡。但先要把那个「当你需要一个瞬间时就会出错」的选项挡在门外,因为它才是 bug 的源头。

当你需要一个瞬间时,最危险的第三种答案:没有时区的本地时间

一个裸的本地挂钟时间,本身并不一定错——「每天早上 9 点开门」本来就是一个无时区的本地时间,一个 LocalDateTime 也可能是你有意保留的墙上时间。它变成 bug,是在你把它当成一个需要唯一确定某个瞬间的值的那一刻。这就是那个几乎没人有意选中、却总能从默认设置里钻进来的第三个选项:一个裸的本地挂钟时间,不带任何偏移

2026-07-15 17:00:00        ← 哪个时区的 17:00?没人知道。这些信息不足以确定一个瞬间。

这个字符串看着人畜无害,可它丢掉了判断自己是哪个瞬间所需的信息。同一串文本,在纽约、在上海、在 UTC 各指一个不同的时刻。一旦它落进库、再被读出来,就得靠某个默认时区去补上缺的那一半——而那个默认,往往是运行环境自己的时区(这正是「差一天」和「偏几小时」这类 bug 的温床)。

这个陷阱在数据库类型里尤其容易踩:

  • MySQLDATETIME 不带任何时区语义——你存什么它就还什么,一个裸挂钟值;而 TIMESTAMP 会按会话时区在 UTC 之间做转换(并非无条件正确),且取值范围有上限,到 2038-01-19 03:14:07 UTC 为止
  • PostgreSQLtimestamp without time zone 存的也是一个裸挂钟值,timestamptz 才把它当成一个真正的瞬间(而且即便如此,它也会归一化成 UTC——并不保留输入里的偏移或时区名)。名字里那个 without time zone 常被当成「默认就是 UTC」,其实它是「没有时区、语义待定」。
  • 应用层——Python 的「naive」datetime、不带偏移的 Java LocalDateTime——同样是这个坑的另一副面孔。

规则很简单,也贯穿这整簇文章:存一个瞬间,就永远带上它的锚——要么是记法里显式的 Z 或偏移,要么是一个语义上真正「带时区」的列类型。一个没有锚的挂钟时间不是一个瞬间,而是一道待解的谜题。

工程权衡:整数 vs 带偏移的 ISO 字符串

确认了是瞬间、也带上了锚,剩下的才是那个真正可以各有偏好的选择。两种记法各有取舍:

整数 Unix 时间戳带偏移的 ISO 8601 字符串
体积小(BIGINT 通常 8 字节)大(约 20–30 字节文本)
比较 / 范围查询 / 索引天生就是整数运算,快需要先归一化才可靠
人是否可读否——得先转换才知道是何时是——瞄一眼就懂
是否自描述否——秒还是毫秒全靠约定是——格式自带偏移与精度
排序按数值,天然有序文本顺序=时间顺序,仅当先归一化到 UTC 的 Z、且格式与精度一致
精度受单位所限(秒截到秒)任意——小数秒可随需增减
跨语言 / JSON需约定单位JSON 无日期类型,RFC 3339 字符串是事实标准

两句话概括这张表。整数紧凑、可比、快,代价是人读不懂、且单位得靠纪律来维持——把秒喂给一个想要毫秒的东西,就会把日期甩到 1970 年。带偏移的 ISO 字符串自描述、跨语言、一眼能读,代价是更占地方、比较前要归一化,而且更容易被人手滑写成不带偏移的那种裸挂钟——也就是上一节那个第三答案。

最省心的第三条路:让列类型替你记住「这是个瞬间」

上面那份取舍,很大程度上可以交给数据库自己去处理。许多关系型数据库都提供原生的「带时区的瞬间」类型——用它,别自己拿裸整数或裸字符串去发明一套。不过各家语义并不一样——有的归一化成 UTC,有的保留偏移,还有的数据库压根没有带时区的原生类型——所以具体要查你所用数据库的文档:

  • PostgreSQL 的 timestamptz 把瞬间按 UTC 存下来——它并不保留输入里的偏移或时区名。你写入一个带偏移的值,它归一化成那个瞬间;你读出时,它按会话的 TimeZone 渲染成对应的挂钟时间。你得到的是整数那样的正确瞬间,加上一个可读的接口。
  • MySQL 上,要么用 DATETIME 并在应用层统一约定「所有值都是 UTC」(避开 2038,但纪律得靠自己守),要么用 TIMESTAMP 并清楚它按会话时区做转换、也清楚那道 2038 的上限。
  • API / JSON 那一层,就用 RFC 3339——ISO 8601 那个更严格的互联网子集——因为 JSON 压根没有日期类型,一个 RFC 3339 字符串是把偏移随值一起携带、最直接也最便于跨语言互操作的方式。

这里要认清一件常被当成矛盾、其实并不矛盾的事:数据库列里存的是「瞬间」,序列化进 JSON 时用的是 ISO 字符串——这不是自相打架,而是「存成 UTC」和「给我一个 ISO 字符串」本就处在不同的层。存储层关心的是把瞬间锚定到明确的时间基准,传输层关心的是拿一种双方都能解析的文本。同一个瞬间,不同的层,各用各的记法。

几个别忘了的细节

大方向定了,还有几处容易在细节上翻车:

  • 精度会咬人。一个以为单位的整数存不下毫秒或微秒——想要亚秒精度,要么把单位升上去(毫秒、微秒),要么用一种能表示小数秒的字符串或列类型。别默默把精度截掉又指望它还在。
  • 排序那个「超能力」是有条件的。「ISO 字符串能按文本排序」只有在它们先被归一化之后才成立——同一种格式、同样的精度,而且最好都转成 UTC 的 Z偏移一混用,文本顺序就不再等于时间顺序
  • 到期时间存「时刻」,不存「时长」。想表达「创建后 30 天到期」,去存那个到期的瞬间,而不是「30」这个数字加一句注释——这样比较和查询都直截了当。至于那个到期日到底落在哪一天,别用「加 30 × 86400 秒」去手算(会在夏令时和月份长短上出错)——把两个日期之间隔几天、往后数 30 天是哪天这类活儿,交给日期计算器去数。
  • 未来的本地事件不存偏移,存时区名。这条前面提过,但值得再钉一遍:+02:00规则在某个瞬间算出的结果,会变Europe/Berlin 是带着规则的时区本身,才是未来约定该存的东西。

调试时,如果你手上是一个裸整数、拿不准它是秒还是毫秒、落在哪一天,把它丢进 Unix 时间戳转换器按两种单位各读一遍;如果你想看一个存好的瞬间在用户各自的时区里分别是几点几号,时区转换器会把它们并排列出来。

一张决策表

把上面收成一张「存什么」的表,覆盖了几乎所有情况:

你要存的东西就存这个
一个已发生的时刻(created_at、日志、审计)一个瞬间——原生的带时区列类型,或一个整数时间戳/带 Z 的 ISO 字符串
一个日历日期(生日、发票日、截止日)DATE 类型或字符串 "1990-05-01"——绝不碰瞬间类型
一个未来的本地约定(「明年柏林 9 点」)本地时间 加 IANA 时区名Europe/Berlin)——不用光秃秃的偏移
一段时长 / 间隔(TTL、超时)固定时长:一个整数(写清单位)。日历周期:保留日历语义(P30D 或结构化的周期)。到期:存那个到期瞬间
一个要发给别的系统的时间一个 RFC 3339 ISO 字符串(JSON 没有日期类型)

收尾清单

下次又为「时间戳还是 ISO 字符串」争起来时,别从整数还是字符串开始吵。按顺序问这几句:

  1. 这是什么值?瞬间、日历日期、未来本地约定,还是时长?「同一份信息、两种记法」只对瞬间成立;另外三类,选错种类比选错记法糟得多。
  2. 带锚了吗?瞬间必须带上 Z/偏移,或存进一个语义上真正带时区的列类型。一个没有偏移的裸挂钟值不是瞬间,是个待解的谜。
  3. 整数还是字符串——按工程权衡选。要紧凑、可比、快,选整数(但把单位钉死);要自描述、跨语言、可读,选带偏移的 ISO(但比较前先归一化)。能用原生的带时区列类型,就用它,两头的好处都能占上。
  4. 细节对齐了吗?精度够不够(亚秒别被截掉)、排序前有没有归一化、到期存的是时刻不是时长、未来事件存的是时区名不是偏移。

这四步底下,还是那条贯穿全簇的线:先分清这是一个瞬间还是别的什么,给瞬间带上它的锚,剩下的才是记法之争。把顺序理对了,「时间戳还是 ISO 字符串」就从一场没有答案的口水战,变成一个有明确取舍、你随时能解释清楚的工程决定。