速查:同一个瞬间的六种表示

下面这些值指向的是同一个瞬间:

格式 示例 这个值本身告诉你什么
Unix 秒 1700000000 单位得从约定里得知
Unix 毫秒 1700000000000 单位得从约定里得知
Unix 微秒 1700000000000000 单位得从约定里得知
Unix 纳秒 1700000000000000000 单位和整数宽度都要留意
RFC 3339 / ISO 风格字符串 2023-11-14T22:13:20Z 日期、时间和 UTC 偏移都看得见
HTTP 日期 Tue, 14 Nov 2023 22:13:20 GMT HTTP 头里固定的那种表示

几种数值形式长得像,是因为它们共用 Unix 纪元,差别只在量级上。字符串携带的语法信息更多,但即便是带偏移的字符串,也未必能确定 America/New_York 这样的 IANA 时区。

“timestamp”是个宽泛的词

开发者聊天时,Unix 时间、纪元时间、POSIX 时间、Unix 时间戳经常混着用,一般都指从 1970-01-01 00:00:00 UTC 起算的那个数值计数。

timestamp 这个词要宽泛得多。视系统而定,它可能指:

  • Unix 秒或毫秒
  • 数据库里的 timestamp
  • 一个 RFC 3339 字符串
  • HTTP 的 Date
  • Windows FILETIME 或 .NET ticks
  • Apple、NTP、GPS、Excel 或 WebKit 那些起算纪元各不相同的计数器

做数据迁移时这个区别尤其要命。一个叫 timestamp 的列,几乎不告诉你任何关于单位、纪元、时区处理和精度的信息。而一个叫 created_at_ms 的字段,是真的给了你一份约定。

位数是线索,不是证据

对于当下这个年代的正时间戳,数位数用来做初步分诊很好使:

位数 大概率的 Unix 单位 除以多少得到秒
10 1
13 毫秒 1,000
16 微秒 1,000,000
19 纳秒 1,000,000,000

但这条规则有边界:

  • 2001 年 9 月之前的合法 Unix 秒值只有九位。
  • 到 2286 年 11 月,Unix 秒会变成十一位。
  • 负值得先去掉符号,数位数才有意义。
  • 01 这种小的夹具值,没有单位就完全无从判断。
  • 一个 16 位的值,可能是从 1601 年起算的 WebKit 微秒,而不是 Unix 微秒。
  • 一个整数可能是从 1900 年、2001 年或别的平台纪元算起的。

碰到陌生输入时,按这个顺序来:

  1. 去读 API、表结构或字段文档。
  2. 确定起算纪元。
  3. 确定单位和整数类型。
  4. 按 UTC 解码这个值。
  5. 检查结果对这份数据集来说是否合理。

本站的转换器会按项目设定的“秒 vs 毫秒”阈值,自动判断输入是 Unix 秒还是毫秒。微秒和纳秒的值请先自行折算成秒或毫秒再输入——工具不会去推断这两种更高精度的单位。

靠故障的样子来判断是秒还是毫秒

单位搞错有两种一眼能认出的症状。一个明明是近期的时间戳却渲染成 1970 年 1 月,多半是把秒喂给了按毫秒收的 API。而一个算出遥远未来年份、或者直接抛范围错误的值,多半是把毫秒喂给了按秒收的 API。

new Date(1700000000).toISOString();
// "1970-01-20T16:13:20.000Z" —— 秒被当成了毫秒

new Date(1700000000 * 1000).toISOString();
// "2023-11-14T22:13:20.000Z"
from datetime import datetime, timezone

datetime.fromtimestamp(1700000000000 / 1000, tz=timezone.utc)
# 2023-11-14 22:13:20+00:00

JWT 里的 expiatnbf 是另一个常见边界:它们用的是 NumericDate 秒,而 JavaScript 的 Date.now() 返回毫秒。请在边界上一次性转换好,然后让每个子系统内部只用一种单位。

const expiresAtSeconds = Math.floor(Date.now() / 1000) + 60 * 60;

数据库和接口字段起名时,created_at_secondscreated_at_msevent_time_ns 都比 timestamptime 安全得多。如果用 TypeScript,带品牌标记的单位类型能在编译期加一道阻力,但外部传进来的值仍然需要运行时校验。

Unix 秒:经典的 POSIX 形态

Unix 秒是自纪元以来的整秒数。命令行工具、操作系统 API 和服务端语言里最常见的就是它。

用本文贯穿全篇的那个例子:

1700000000 = 2023-11-14T22:13:20Z

典型的产出方包括:

平台 取当前 Unix 秒
Python int(time.time())
PHP time()
Go time.Now().Unix()
Ruby Time.now.to_i
C time(NULL)
PostgreSQL FLOOR(EXTRACT(EPOCH FROM clock_timestamp()))::bigint

存储宽度和单位是两回事。32 位有符号的秒字段上限是 2147483647,也就是 2038-01-19T03:14:07Z。换成 64 位有符号整数就没有这个存储瓶颈了,不过日期库、数据库或序列化格式仍可能施加一个窄得多的可用范围。

Unix 毫秒:JavaScript 的数值日期单位

JavaScript 的 Date API 用的是自 Unix 纪元以来的毫秒数。MDN 对 Date.now() 的定义就是:返回自 1970-01-01 UTC 起经过的毫秒数。

const unixSeconds = 1700000000;
const unixMilliseconds = 1700000000000;

new Date(unixSeconds * 1000).toISOString();
// "2023-11-14T22:13:20.000Z"

new Date(unixMilliseconds).toISOString();
// "2023-11-14T22:13:20.000Z"

单位对不上就会产生那个眼熟的 1970 bug:

new Date(1700000000).toISOString();
// "1970-01-20T16:13:20.000Z"

Java 和 .NET 也提供了显式的毫秒 API,比如 System.currentTimeMillis()Instant.ofEpochMilli()DateTimeOffset.FromUnixTimeMilliseconds()

MDN:Date.now() · JavaScript Date 与 Unix 时间戳

微秒和纳秒:精度需要整数上的纪律

事件管道、数据库、链路追踪和系统代码,往往会用到微秒和纳秒时间戳。多出来的这几位数带来两个彼此独立的问题:API 得知道自己收的是什么单位,整数类型得能精确装下这个值。

Go 提供了单位明确的构造函数和访问器:

package main

import "time"

func main() {
    microseconds := int64(1700000000000000)
    nanoseconds := int64(1700000000000000000)

    time.UnixMicro(microseconds).UTC()
    time.Unix(0, nanoseconds).UTC()
}

Rust 先拿到一个相对纪元的 Duration,再去取整微秒或整纳秒:

use std::time::{SystemTime, UNIX_EPOCH};

fn main() {
    let elapsed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock is before the Unix epoch");

    let unix_micros = elapsed.as_micros();
    let unix_nanos = elapsed.as_nanos();

    println!("{unix_micros} {unix_nanos}");
}

PostgreSQL 给出的纪元秒是带小数部分的。如果下游要的是整数微秒约定,就显式转过去:

SELECT ROUND(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint;

JavaScript 的 Number 精确表示不了当下这个年代的 Unix 纳秒整数。请把纳秒保留为 BigInt 或十进制字符串:

const epochNanoseconds = 1700000000000000000n;
const epochMilliseconds = epochNanoseconds / 1_000_000n;
const discardedNanoseconds = epochNanoseconds % 1_000_000n;

那个余数让精度损失变得看得见。先转成毫秒再转回纳秒,是找不回被丢掉的那部分的。

Go 的 time · Rust 的 Duration

ISO 8601 与 RFC 3339:带偏移的可读字符串

ISO 8601 是一个宽泛的日期时间标准,RFC 3339 定义的则是开发者从接口和日志里最常见到的那个窄子集。

2023-11-14T22:13:20Z
2023-11-14T17:13:20-05:00
2023-11-14T22:13:20.123456Z

这些字符串比一个光秃秃的整数携带更多信息:

  • 日历日期和钟面时间都直接可见
  • T 把日期和时间分开
  • Z 表示相对 UTC 偏移为零
  • -05:00 记录了一个数值偏移
  • 小数部分携带亚秒精度

但偏移量不是一套时区规则。-05:00 不告诉你目标时区究竟是纽约、多伦多、利马,还是某个固定偏移的系统。如果将来的夏令时或日历计算依赖用户所在时区,请把 IANA 时区另外存一份。

JavaScript 的 toISOString() 总是返回带 Z 的 UTC 字符串。Python 的 datetime.isoformat() 会保留该 datetime 自身的偏移,Go 则常用 time.RFC3339time.RFC3339Nano

邮件日期和 HTTP 日期:有渊源,但不等同

邮件用的是 RFC 5322 定义的 date-time 语法。典型的 UTC 邮件头带一个数值偏移:

Date: Tue, 14 Nov 2023 22:13:20 +0000

HTTP 用的是 HTTP-date。RFC 9110 要求发送方生成首选的 IMF-fixdate 形式:

Tue, 14 Nov 2023 22:13:20 GMT

HTTP 这种固定表示,是从互联网消息格式派生出来的单时区子集。把两种字符串统统称作“RFC 2822”,会掩盖掉那些对解析器很要紧的约束——尤其是 HTTP 强制要求的 GMT 拼写和固定布局。

JavaScript 的 toUTCString() 产出的就是 HTTP 风格的形式:

new Date(1700000000 * 1000).toUTCString();
// "Tue, 14 Nov 2023 22:13:20 GMT"

分辨率、精度、准确度是三件事

一个纳秒字段具备纳秒分辨率:它能装下相差一纳秒的两个值。但这并不能证明源时钟准到 1 纳秒。一个系统完全可以用粗糙得多的时钟去测量时间,却照样把六位或九位小数填满。

表示 名义分辨率 通常要留意的点
Unix 秒 1 秒 不足以给同一秒内的事件排序
Unix 毫秒 1 毫秒 常见的 API 和 JavaScript 边界
Unix 微秒 1 微秒 可能超出某些库的精度
Unix 纳秒 1 纳秒 超出 JavaScript 安全整数范围
FILETIME / .NET ticks 100 纳秒 用的是非 Unix 纪元
NTP 时间戳小数部分 2^-32 秒 线上表示不是十进制纳秒

选那个刚好够用的、最粗的单位。多出来的位数要付出存储和互操作的代价,而且并不会让一块不准的钟变准。

换算单位时,别把精度损失藏起来

比例因子很简单:

运算
毫秒 1,000
毫秒 1,000
微秒 毫秒 1,000
纳秒 毫秒 1,000,000
纳秒 1,000,000,000

复杂的是取舍策略。一旦除法有余数,你就得决定应用是截断、向下取整、四舍五入,还是干脆拒绝这次转换。这一点对纪元之前的负值尤其关键,因为各语言的整数除法规则并不一致。

在系统边界上,先把原始整数和单位原样保留着,直到校验通过为止。审计一次迁移时,如果能拿解析出的瞬间和收到的确切值做对照,事情会好办得多。

有些大数字用的压根是另一个纪元

一个长整数不会自动就是高精度的 Unix 时间戳。下面这些常见格式,改的要么是纪元,要么是单位,要么两者都改:

格式 单位与纪元
Windows FILETIME 1601-01-01 UTC 起的 100 纳秒间隔
.NET DateTime.Ticks 0001-01-01 起的 100 纳秒间隔
WebKit / Chrome 时间戳 1601-01-01 UTC 起的微秒
Mac 绝对时间 / Core Data 2001-01-01 UTC 起的秒
Excel 序列日期 / OADate 常用换算模型下,自 1899-12-30 起的天数
NTP 时间戳 自 1900 年 NTP 纪元起的秒数加一个二进制小数
GPS 时间 1980-01-06 起的秒数,闰秒处理方式与 UTC 不同
儒略日 自一个从正午开始的历史天文纪元起的天数

光靠数位数,是没法安全区分这些格式的。要看来源系统、字段名和文档里写明的纪元。

选一种能让约定一目了然的格式

没有哪一种格式在所有边界上都最合适:

  • 公开 API,用 RFC 3339 字符串,或者一个名字起清楚的整数字段,比如 createdAtMs
  • 日志,UTC 的 RFC 3339 字符串既好读,字段规范化之后排序也天然正确。
  • 事件流,把整数单位和宽度写进 schema。
  • 数据库,除非确实需要裸整数的互操作性,否则优先用原生的、能感知瞬间的时间戳类型。
  • JavaScript 里处理纳秒或非 Unix 计数器,用 BigInt 或字符串,别用 Number
  • 用户日程,除了解析出的瞬间,还要把 IANA 时区一并保留。

最好的时间戳格式,就是下一个系统不用猜就能解码的那种。

时间戳格式转换器

源数据不是 Unix 纪元时,用这些工具:

官方参考资料

相关文章

Frequent questions:

Q: timestamp 和 Unix 时间是一回事吗?
A: 不一定。timestamp 是个宽泛的说法,泛指任何能标识日期、时间或瞬间的值。而 Unix 时间戳特指从 1970-01-01 00:00:00 UTC 起算的计数,通常以秒或毫秒为单位。
Q: 什么是 POSIX 时间戳?
A: POSIX 时间戳表示的是 POSIX 定义下「自纪元以来的秒数」。在日常应用代码里,它和大家平时说的 Unix 秒是同一个数值形式。
Q: Unix 时间戳有几位数?
A: 在当下这个年代,Unix 秒通常 10 位,毫秒 13 位,微秒 16 位,纳秒 19 位。但这只是个经验规则:太早的日期、太远的未来、负值以及非 Unix 纪元,都会打破这个规律。
Q: JavaScript 用的是哪种时间戳格式?
A: JavaScript 的 Date 用的是自 Unix 纪元以来的毫秒数。Date.now() 返回毫秒,new Date(value) 也把数值参数按毫秒解释。所以把 Unix 秒传给 Date 之前,记得先乘以 1000。
Q: Unix 时间和 ISO 8601 有什么区别?
A: Unix 时间是从 Unix 纪元起算的数值计数,单位得靠文档另行说明。ISO 8601 则是一族日期时间字符串格式。最常用的互联网子集 RFC 3339 会同时带上日期、时间和 UTC 偏移,比如 2023-11-14T22:13:20Z。
Q: 邮件和 HTTP 用的是什么日期格式?
A: 邮件日期用 RFC 5322 的 date-time 语法,通常带一个数值 UTC 偏移。HTTP 用的是 IMF-fixdate,一种固定的 UTC 形式,比如 Tue, 14 Nov 2023 22:13:20 GMT。两者有渊源,但不是可以互换的同一个标签。
Q: 怎么判断一个时间戳是秒还是毫秒?
A: 先去读字段说明或 API 约定。对于当下的值,10 位通常是秒,13 位通常是毫秒。然后按 UTC 解码一遍,确认得到的日期是否合理。