JsonMind
编码与文本9 分钟阅读更新于 2026年11月9日

Unix 时间戳:秒和毫秒的区别与转换方法

讲清 Unix 时间戳的秒、毫秒、微秒单位,避免 1970 年和公元 57770 年错误,包含 JavaScript、SQL、JWT 和 cron 的转换示例。

JM作者:JsonMind Engineering最后复核: 2026年11月9日依据权威规范与引用来源复核。
快速答案

Unix 时间通常以秒为单位,但 JavaScript 的 Date.now() 返回毫秒。把秒传给 new Date() 之前要先乘以 1000;把毫秒发给 JWT exp 等按秒计算的系统之前要先除以 1000。字段名里带单位,可以从源头减少这类错误。

核心要点
  • Unix 时间戳通常指秒,JavaScript 使用毫秒。
  • 把秒直接传给 new Date() 会被当成毫秒,结果显示 1970 年。
  • 把毫秒发给按秒计算的接口,会得到几千年后的日期。
  • JWT 的 exp、iat、nbf 是秒,Date.now() 是毫秒。
  • 存储和传输时保持一种单位,并让字段名体现单位。

大多数时间戳 bug 都是单位 bug。数值本身合法,解释错了。秒被当成毫秒,日期落到 1970 年;毫秒被当成秒,日期跑到几千年后。

这篇文章给出转换规则、常见错误和一套能减少混淆的命名方式。

四种常见单位

单位示例典型来源
秒1761500000date +%s、JWT exp、很多接口
毫秒1761500000000JavaScript Date.now()、Java currentTimeMillis()
微秒1761500000000000部分数据库和链路追踪系统
纳秒1761500000000000000高精度时钟和部分日志

实际排查时可以先数位数:当前时间戳在秒级是 10 位,毫秒级 13 位,微秒级 16 位,纳秒级 19 位。

数位数只是经验,不是定律。位数取决于年代和数量级:

数值按什么单位解释对应时间
999999999(9 位)秒2001-09-09T01:46:39Z
1000000000(10 位)秒2001-09-09T01:46:40Z
1000000000000(13 位)毫秒2001-09-09T01:46:40Z
10000000000(11 位)秒2286-11-20T17:46:40Z
-1秒1969-12-31T23:59:59Z

一个 10 位数值,可能是 2025 年的秒,也可能是 1970 年的毫秒,光看位数判断不了。1970 年以前的时间戳可以是负数。纳秒级也不是永远 19 位:epoch 之后 2^53 纳秒是 1970-04-15T05:59:59Z,也就是说,现代纳秒时间戳根本没法用 JavaScript 的 number 精确表示。纳秒要用字符串或 BigInt 保存,真需要 Date 时再转换。

单位还有精度上限。JavaScript 的 number 是 IEEE 754 双精度,能精确表示的整数上限是 2^53 - 1(9007199254740991)。换算成毫秒大约是 28.5 万年,换算成微秒只到 2255 年左右,换算成纳秒只有约 104 天。链路追踪系统如果把 timeUnixNano 当 JSON number 发过来,你的代码还没看到,最后几位已经丢了。

JavaScript 转换

const nowMs = Date.now();                 // 毫秒
const nowS = Math.floor(nowMs / 1000);    // 秒

const fromSeconds = new Date(nowS * 1000);
const fromMilliseconds = new Date(nowMs);

console.log(fromSeconds.toISOString());
console.log(fromMilliseconds.toISOString());

如果直接把秒传给 new Date,构造函数会把它当毫秒:

new Date(1761500000).toISOString(); // 1970-01-21T09:18:20.000Z

这不是时区 bug,是单位 bug。

反方向同样容易错。Date.parse("2025-10-26T17:33:20Z") 返回 1761500000000,因为 Date.parse 也是毫秒;要秒就再 Math.floor(Date.parse(iso) / 1000)。字符串不带偏移时,引擎的规则会让很多人意外:只有日期的 "2025-10-26" 按 UTC 解析,带时间但不带偏移的 "2025-10-26T00:00:00" 按本地时间解析。同一个字面量,形状不同就得到两个不同时刻,差值正好是宿主机时区偏移。

无效日期不会在构造时失败。new Date("not-a-date") 会返回一个 getTime() 为 NaN 的 Date,只有格式化时才抛错:new Date("not-a-date").toISOString() 抛出 RangeError: Invalid time value。存库或发送之前,用 Number.isNaN(date.getTime()) 检查。同理,new Date(8.64e15).toISOString() 是 +275760-09-13T00:00:00.000Z,再加一毫秒就无效,所以一个被写坏的时间戳做几次算术后可能悄悄变成 NaN。

项目能用 Temporal 时,优先用 Temporal.Instant.fromEpochMilliseconds(ms) 和 Temporal.Instant.fromEpochSeconds(s):单位写在方法名里,类型系统能区分两者,转成带时区的日期也必须显式操作。没有 Temporal 的旧代码,也应该照这个纪律写。

JWT 使用秒

JWT 的 exp、iat、nbf 是秒级 NumericDate。如果 exp 写成了毫秒,Token 会有效几万年,这是安全问题,不只是显示问题。

const payload = {
  sub: "user_1042",
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 60 * 15
};

排查 Token 时,用 JWT 解析工具 解码,再用 Unix 时间戳转换工具 对比当前时间。10 位是秒,13 位是毫秒。

报错形态很有辨识度。Token 过期时 jwt.verify 抛 TokenExpiredError: jwt expired;nbf 还没到就抛 NotBeforeError: jwt not active。但 exp 写成毫秒时,大多数库根本不会报错:它被当成一个巨大的数字,Token 会一直有效到公元 57770 年左右,所以这个 bug 能轻松通过代码评审。有的库会拒绝字符串形式的 exp,报 invalid exp value,有的则默默转成数字,不能指望校验兜底。clockTolerance 的单位是秒,不是毫秒:clockTolerance: 5 允许 5 秒时钟偏差,clockTolerance: 5000 则悄悄允许了一个多小时。

SQL 和数据库类型

不同数据库的约定不同:

  • PostgreSQL 的 timestamp with time zone 存储绝对时间点。
  • MySQL TIMESTAMP 存秒;DATETIME 不带时区。
  • SQLite 根据 Schema 把日期存成文本、实数或整数。
  • 部分分析系统使用 BIGINT 存毫秒。

范围差异足以造成线上事故。MySQL TIMESTAMP 的范围是 1970-01-01 00:00:01 UTC 到 2038-01-19 03:14:07 UTC,和 Unix 的 32 位上限一样;DATETIME 能存 1000-01-01 到 9999-12-31,但不带时区。更要命的是,MySQL 会在写入时把 TIMESTAMP 从会话时区转成 UTC,读取时再转回来。改一下服务器 time_zone,已有数据看起来就“变”了:字节没变,解释变了。PostgreSQL 的 timestamptz 用 8 字节存绝对时刻,精度到微秒,EXTRACT(EPOCH FROM now()) 返回的是带小数的秒,不是毫秒。SQLite 干脆没有日期类型:strftime('%s','now') 返回文本形式的秒,同一列里完全可能一部分行是秒、另一部分是毫秒,取决于写入代码是否一致。

最安全的做法是用原生日期时间类型存储时间点,只在集成边界使用 epoch 整数。如果必须存整数,把单位写进字段名:

created_at_ms BIGINT NOT NULL

一个叫 created_at 但没有单位的字段,迟早会产生歧义。

接口契约

很多接口混用单位:

  • Stripe 的部分字段用秒,部分用毫秒。
  • OAuth Token 过期时间通常用秒。
  • 浏览器性能时间戳用毫秒。
  • 部分日志系统用微秒。

几个真实例子能把这种混用讲清楚:

来源字段单位和形式
Stripe APIcreated整数秒
GitHub REST APIcreated_atISO 8601 字符串
OpenTelemetry JSONtimeUnixNanouint64 纳秒,编码成字符串
浏览器 Performance APIperformance.now()毫秒,带小数,相对页面开始
OAuth 2.0expires_in秒数时长,不是绝对时间戳

读文档和示例值。10 位示例大概率是秒,13 位大概率是毫秒。更好的做法是字段名直接带单位,例如 expires_at_ms;expires_in 通常是秒数表示的时长,而不是绝对时间戳。

时长和时间戳

时间戳是绝对时间点,时长是一段时间。混用会出 bug:

{
  "expires_in": 3600,
  "expires_at": 1761503600
}

expires_in 是相对值,时钟变化不影响;expires_at 是绝对值,依赖时钟同步。选一种并写清楚。不要把时长和时间戳相减而不检查单位。

单位错了,症状很具体。Date.now() + 3600 加的是 3600 毫秒而不是一小时,本该一小时有效的 Token 3.6 秒后就过期,用户刚登录就被踢出。Math.floor(Date.now() / 1000) + 3_600_000 加的是 360 万秒,约 42 天,而不是一小时。这两个值在日志里都不刺眼,都是看起来合理的整数。把单位写进变量名(ttlSeconds、expiresAtMs),并在类型系统里把“时长”和“时刻”分开,否则下一个接口还会犯同样的错。

时区

Unix 时间戳与时区无关,它从 1970-01-01T00:00:00Z 开始计数。用本地时区显示时间戳是格式化问题,不是存储问题。

const instant = new Date(1761500000 * 1000);
instant.toISOString();            // UTC
instant.toLocaleString("zh-CN");  // 本地时区

不要在存储值上加时区偏移。存时间点,在展示层格式化。

时区 bug 通常出现在格式化这一步。toLocaleString("zh-CN") 用的是宿主机时区:服务端渲染时是服务器时区,浏览器里是用户时区,同一个时间戳可能显示成两个不同的日期。应该给 Intl.DateTimeFormat 传明确的 timeZone,并把 IANA 时区标识存在用户设置里,而不是塞进时间戳。夏令时会让算术更麻烦:加 86,400,000 毫秒并不等于“明天”,因为夏令时切换当天的本地一天是 23 或 25 小时。2026-03-08 在 America/New_York,本地 02:30 根本不存在;2026-11-01 的 01:30 则出现两次。只存时刻和固定偏移的调度器,切换后会按错误的墙上时间触发。需求是“下周一本地 09:00”时,要存 IANA 时区(如 America/New_York),并用日历感知的算术(Temporal.ZonedDateTime 或 date-fns-tz 之类的库)。

时区规则本身也会变。政府会调整偏移和夏令时日期,IANA tzdata 会持续更新;一年前构建的容器可能按旧规则格式化。运行镜像里的 tzdata 要保持更新,应用代码里不要硬编码偏移。

2038 问题

使用有符号 32 位整数存 Unix 秒的系统会在 2038-01-19T03:14:07Z 溢出。维护旧系统时要检查存储类型。新系统应该使用 64 位整数或原生日期类型。

JavaScript number 是 IEEE 754 双精度浮点数,当前毫秒时间戳可以精确表示,但超过 2^53 的整数会丢精度。这影响的是超大 ID,不是当前日期。

几个实际上限值得记下来。有符号 32 位 time_t 在 2038-01-19T03:14:07Z 溢出,对应 2147483647 秒。JavaScript Date 能表示 ±8.64e15 毫秒,约 ±1 亿天,所以 new Date(8.64e15).toISOString() 是 +275760-09-13T00:00:00.000Z,再加 1 毫秒就抛 RangeError: Invalid time value。精确整数上限出现得更早,而且取决于单位:2^53 毫秒远超 Date 范围,2^53 微秒落在 2255 年,2^53 纳秒落在 1970-04-15。如果系统把纳秒存在 number 字段里,1970 年 4 月之后的任何时间都已经丢精度了。

存储方式上限失败表现
有符号 32 位秒2038-01-19T03:14:07Z回绕成负数
JavaScript Date±8.64e15 毫秒超范围抛 RangeError
IEEE 754 整数精度2^53 - 1静默四舍五入
number 存纳秒1970-04-15从第一天起就静默丢精度

新系统应该用 64 位整数存秒或毫秒,或者直接用原生时间戳类型。纳秒要用带明确 epoch 的 64 位整数,或者在 JSON 里用字符串,绝不要用 IEEE 754 双精度。

Cron 和调度

Cron 表达式使用的是字段化的本地时间,不是 Unix 时间戳。0 9 * * 1-5 表示服务器时区的 09:00,可能和用户时区不同。

在时间戳和 cron 之间转换时:

  1. 确定调度使用的时区。
  2. 把时间戳转换到该时区。
  3. 匹配星期和时间字段。
  4. 显式处理夏令时切换。

Cron 表达式的语法并不统一。经典五字段 cron(分 时 日 月 周)没有秒字段;Quartz 用六到七个字段,秒在最前,还支持 ? 和 L;有的实现接受 CRON_TZ=America/New_York 前缀,有的则从独立配置项读取时区。0 9 * * 1-5 在不同系统之间通常能直接搬,0 0 9 ? * MON-FRI 就不一定。春季跳时的 02:30 任务可能被整个跳过,秋季回拨时则可能跑两次。如果任务必须每个本地日只跑一次,应该让它幂等并记录最后完成的本地日期,而不是依赖调度器的夏令时行为。

Cron 表达式解释工具 会显示下一次运行时间,可以让时区错误暴露出来。

一个单位安全的辅助写法

type EpochSeconds = number & { readonly __unit: "seconds" };
type EpochMillis = number & { readonly __unit: "milliseconds" };

function toSeconds(value: EpochMillis): EpochSeconds {
  return Math.floor(value / 1000) as EpochSeconds;
}

function toMillis(value: EpochSeconds): EpochMillis {
  return value * 1000 as EpochMillis;
}

品牌类型只是编译期辅助,不是运行时保证,但它能让单位转换在边界上显式化。在动态语言里,命名规范和校验是实际等价物。

限制在数据跨边界时立刻出现。JSON、数据库驱动、第三方 SDK 返回的都是普通 number,brand 会被擦掉,没有任何东西阻止它被传给错误单位的函数。类型要和运行时守卫配对:Number.isSafeInteger(value) 拒绝小数和不精确值,范围检查拒绝明显的单位错误(2025 年的秒级时间戳大约 17 亿,不是 1.7 万亿),文档化的最大值拒绝损坏输入。公开 API 里不要把品牌类型当成唯一防线;它是给自己代码看的文档,不是校验层。

排查清单

  1. 数位数:10 位是秒,13 位是毫秒。
  2. 检查字段名和文档。
  3. 和当前时间比较;落在很久以前或很久以后的值都可疑。
  4. 确认消费方期望的单位。
  5. 在边界转换一次,不要反复转换。
  6. 存储和传输保持一种规范单位。
  7. 用已知值测试,例如 1761500000 对应 2025-10-26T17:33:20Z。

几个可以交叉验证的值:

数值期望时间
01970-01-01T00:00:00Z
-11969-12-31T23:59:59Z
17615000002025-10-26T17:33:20Z
21474836472038-01-19T03:14:07Z
2534023007999999-12-31T23:59:59Z

命令行上,date +%s 打印秒;macOS/BSD 用 date -r 1761500000、GNU 用 date -d @1761500000 转回去;node -e "console.log(new Date(1761500000 * 1000).toISOString())" 检查 JavaScript 路径;Python 的 datetime.datetime.fromtimestamp(1761500000, datetime.timezone.utc) 给出独立答案。两个工具结果不一致时,一定有一个按不同的单位或时区解释了这个数,那个分歧就是 bug。

“Unix 时间是秒”这条规则有一个刻意的例外:在单个 JavaScript 进程内部,Date.now() 的毫秒就是原生单位,把所有值都换成秒只会增加噪音。契约边界用秒,JavaScript 内部继续用毫秒。库有自己的原生表示时,例如 Java 的 Instant(秒 + 纳秒)或 OpenTelemetry 的 timeUnixNano,就跟随库的类型,只在边界转换一次,而不是把所有值都压成一个裸 number。

如果只记一条规则,就记这条:Unix 时间是秒,JavaScript 是毫秒。所有转换都从这里推导。

常见问题

Unix 时间戳是秒还是毫秒?

标准 Unix 时间戳是秒。JavaScript 的 Date.now() 返回毫秒,很多接口使用秒。转换前必须查看文档或字段名,不能靠猜。

为什么 new Date(1761500000) 显示 1970 年?

Date 构造函数把单个数字参数当作毫秒。1761500000 如果按毫秒解释,只比 epoch 晚约 20 天。应该先乘以 1000。

JavaScript 怎么获取当前 Unix 秒?

用 Math.floor(Date.now() / 1000) 获取秒,用 Date.now() 获取毫秒。使用 floor 而不是 round,避免当前时间提前跳进下一秒。

2038 问题是什么?

使用有符号 32 位整数存储 Unix 秒的系统会在 2038-01-19 溢出。应该使用 64 位整数或范围更大的日期类型。JavaScript number 对当前时间戳是安全的,但超大整数 ID 会丢精度。

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

整数紧凑、便于比较;ISO 8601 字符串可读且带时区信息。根据存储和接口契约选择,但每个边界只保留一种规范表示。

参考资料

把指南用起来

浏览全部工具

打开浏览器工具。输入内容保留在浏览器中,不会上传到业务服务器。

相关指南

全部指南