速查:同一个瞬间的六种表示
下面这些值指向的是同一个瞬间:
| 格式 | 示例 | 这个值本身告诉你什么 |
|---|---|---|
| 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 秒会变成十一位。
- 负值得先去掉符号,数位数才有意义。
0、1这种小的夹具值,没有单位就完全无从判断。- 一个 16 位的值,可能是从 1601 年起算的 WebKit 微秒,而不是 Unix 微秒。
- 一个整数可能是从 1900 年、2001 年或别的平台纪元算起的。
碰到陌生输入时,按这个顺序来:
- 去读 API、表结构或字段文档。
- 确定起算纪元。
- 确定单位和整数类型。
- 按 UTC 解码这个值。
- 检查结果对这份数据集来说是否合理。
本站的转换器会按项目设定的“秒 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 里的 exp、iat、nbf 是另一个常见边界:它们用的是 NumericDate 秒,而 JavaScript 的 Date.now() 返回毫秒。请在边界上一次性转换好,然后让每个子系统内部只用一种单位。
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 60 * 60;
数据库和接口字段起名时,created_at_seconds、created_at_ms、event_time_ns 都比 timestamp 或 time 安全得多。如果用 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;
那个余数让精度损失变得看得见。先转成毫秒再转回纳秒,是找不回被丢掉的那部分的。
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.RFC3339 或 time.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 纪元时,用这些工具:
官方参考资料
- The Open Group:Seconds Since the Epoch
- MDN:
Date.now() - PostgreSQL 日期时间函数
- Go 的
time包 - Rust 的
Duration - RFC 3339:互联网上的日期与时间
- RFC 5322:日期时间规范
- RFC 9110:HTTP 日期时间格式
- RFC 5905:NTPv4 规范
相关文章
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 解码一遍,确认得到的日期是否合理。