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

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

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

## 核心要点

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

---

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

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

## 四种常见单位

| 单位 | 示例 | 典型来源 |
| --- | --- | --- |
| 秒 | `1761500000` | `date +%s`、JWT `exp`、很多接口 |
| 毫秒 | `1761500000000` | JavaScript `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 转换

```js
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`，构造函数会把它当毫秒：

```js
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 会有效几万年，这是安全问题，不只是显示问题。

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

排查 Token 时，用 [JWT 解析工具](/tools/jwt-decoder) 解码，再用 [Unix 时间戳转换工具](/tools/unix-timestamp) 对比当前时间。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 整数。如果必须存整数，把单位写进字段名：

```sql
created_at_ms BIGINT NOT NULL
```

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

## 接口契约

很多接口混用单位：

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

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

| 来源 | 字段 | 单位和形式 |
| --- | --- | --- |
| Stripe API | `created` | 整数秒 |
| GitHub REST API | `created_at` | ISO 8601 字符串 |
| OpenTelemetry JSON | `timeUnixNano` | uint64 纳秒，编码成字符串 |
| 浏览器 Performance API | `performance.now()` | 毫秒，带小数，相对页面开始 |
| OAuth 2.0 | `expires_in` | 秒数时长，不是绝对时间戳 |

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

## 时长和时间戳

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

```json
{
  "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 开始计数。用本地时区显示时间戳是格式化问题，不是存储问题。

```js
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 表达式解释工具](/tools/cron) 会显示下一次运行时间，可以让时区错误暴露出来。

## 一个单位安全的辅助写法

```ts
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。

几个可以交叉验证的值：

| 数值 | 期望时间 |
| --- | --- |
| `0` | 1970-01-01T00:00:00Z |
| `-1` | 1969-12-31T23:59:59Z |
| `1761500000` | 2025-10-26T17:33:20Z |
| `2147483647` | 2038-01-19T03:14:07Z |
| `253402300799` | 9999-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 字符串可读且带时区信息。根据存储和接口契约选择，但每个边界只保留一种规范表示。

## 参考资料

- [MDN - Date.now()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Date/now)
- [RFC 3339 - 互联网日期和时间戳](https://www.rfc-editor.org/rfc/rfc3339)
- [Unix time - Wikipedia](https://en.wikipedia.org/wiki/Unix_time)
- [RFC 7519 - JWT NumericDate](https://www.rfc-editor.org/rfc/rfc7519#section-2)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/unix-timestamp-seconds-vs-milliseconds
- Locale: zh-CN
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: unix-timestamp, cron, jwt-decoder, json-formatter