时间戳在线转换看着简单,但第一步就别猜时区:日志里的 created_at: 1700000000 就是 2023-11-14 22:13:20 UTC,全球同一时刻。
真正让人踩坑的是另外三件事:10 位和 13 位混着用、把「显示成北京时间」误当成「时间戳变过」、以及用 86400 秒做日期加减。这篇一次讲清,并给一张各语言取当前时间戳的速查表。
一、先纠正一个流传很广的错误认知
Unix 时间戳是「从 1970-01-01 00:00:00 UTC 起经过的秒数」,它本身不带时区属性。
同一时刻,北京(UTC+8)和纽约(UTC-5)墙上的时钟不同,但 Date.now()、time.time()、time.Now().Unix() 拿到的数是同一个。时区只在你把时间戳格式化成字符串的那一刻才参与进来。
所以「时间戳要不要转时区」是个伪命题:要转的是显示,不是值。
👉 时间戳在线转换(免登录,秒 / 毫秒一键切换) —— 同时给出本地时间、UTC 时间和 ISO 8601,不用自己心算时差
二、时间戳在线转换前,先分清 10 位和 13 位
| 位数 | 单位 | 示例值 | 对应 UTC 时间 | 常见来源 |
|---|---|---|---|---|
| 10 位 | 秒 | 1700000000 | 2023-11-14 22:13:20 | 后端接口、MySQL UNIX_TIMESTAMP()、Go time.Now().Unix() |
| 13 位 | 毫秒 | 1700000000000 | 2023-11-14 22:13:20 | JS Date.now()、Java System.currentTimeMillis()、前端埋点 |
判断不要用数位数(秒级会在 2286 年变成 11 位),用数量级判断:
function toMs(ts) {
const n = Number(ts)
// 1e11 毫秒 ≈ 1973 年,1e11 秒 ≈ 5138 年
return n < 1e11 ? n * 1000 : n
}
业务里最容易出事的边界是 JWT:exp / iat / nbf 按 RFC 7519 规定是秒级(NumericDate),而前端手里的是 Date.now() 毫秒。判断 token 是否过期必须写成 payload.exp * 1000 < Date.now();直接 payload.exp < Date.now() 的结果是「永远不过期」。同理,Redis 的 EXPIRE / EXPIREAT、Nginx 缓存时间、Cache-Control: max-age 全是秒,前端算出来的毫秒记得除回去。
顺带说一个常被误传的点:Number.MAX_SAFE_INTEGER 是 9007199254740991(约 9e15),毫秒级时间戳现在约 1.7e12,离它还差一千倍,时间戳本身没有 JS 整数精度问题。真正会出事的是秒 / 毫秒精度混用:后端给秒,前端直接 new Date(ts),时间回到 1970 年;反过来把毫秒写进「秒级」字段,时间会变成公元 5 万年,还不一定报错。
三、new Date(ts * 1000) 看着「差 8 小时」,怪谁
const ts = 1700000000
new Date(ts * 1000).toString()
// 'Wed Nov 15 2023 06:13:20 GMT+0800 (中国标准时间)'
new Date(ts * 1000).toISOString()
// '2023-11-14T22:13:20.000Z' ← 末尾的 Z 表示 UTC
两个字符串差 8 小时,但不是算错了:toString() 用运行环境的本地时区(中国是 UTC+8),toISOString() 固定输出 UTC。判断标准只有一个——看有没有 Z 后缀。
这个坑在 SSR 场景会被放大:Node 容器的 TZ 通常是 UTC,浏览器是 UTC+8,同一份代码两边渲染结果差 8 小时。解决办法是传输层统一用时间戳或带 Z 的 ISO 字符串,只在最外层展示时才格式化。
MySQL 是同样的道理,而且更隐蔽:
SELECT @@session.time_zone; -- 先看这个
SELECT FROM_UNIXTIME(1700000000); -- 按会话时区返回
SET time_zone = '+08:00';
SELECT FROM_UNIXTIME(1700000000); -- 2023-11-15 06:13:20
SELECT UNIX_TIMESTAMP('2023-11-15 06:13:20'); -- 1700000000,按 +08:00 解释入参
SELECT FROM_UNIXTIME(1700000000000); -- NULL,超出 TIMESTAMP 范围
FROM_UNIXTIME() 按会话时区返回,UNIX_TIMESTAMP(字符串) 按会话时区解释入参。所以「库里查出来的和接口返回的不一样」,先查会话时区,别急着改代码。
还有一个高频坑是时间字符串的解析:
new Date('2023-11-15 06:13:20') // Safari / 老浏览器:Invalid Date
new Date('2023-11-15T06:13:20') // 不带时区 → 按本地时区解释
new Date('2023-11-15T06:13:20Z') // 带 Z → 按 UTC 解释
new Date('2023-11-15') // 纯日期 → 按 UTC 解释(不是本地!)
ES 规范的规定是:带时间的字符串不带时区时按本地时区解释,纯日期字符串按 UTC 解释。中间是空格而不是 T 的写法不属于 ISO 8601,各浏览器行为不一致,Safari 上尤其容易直接 Invalid Date。接口返回时间字符串时,一律要求带 T 和 Z,或者干脆返回时间戳。
四、各语言取当前时间戳速查
| 语言 / 环境 | 当前(秒,10 位) | 当前(毫秒,13 位) | 时间戳 → 时间对象 |
|---|---|---|---|
| JavaScript | Math.floor(Date.now() / 1000) |
Date.now() |
new Date(ms),入参必须是毫秒 |
| Python | int(time.time()) |
int(time.time() * 1000) |
datetime.fromtimestamp(ts, tz=timezone.utc) |
| Go | time.Now().Unix() |
time.Now().UnixMilli() |
time.Unix(sec, 0)(第一参数是秒) |
| Java | Instant.now().getEpochSecond() |
System.currentTimeMillis() |
Instant.ofEpochSecond(sec) |
| PHP | time() |
round(microtime(true) * 1000) |
(new DateTime())->setTimestamp(sec) |
| MySQL | UNIX_TIMESTAMP() |
UNIX_TIMESTAMP() * 1000 |
FROM_UNIXTIME(sec) |
| Shell | date +%s |
date +%s%3N(GNU date) |
date -d @1700000000 |
Python 和 Go 这两个细节最容易写错:
import time
from datetime import datetime, timezone
int(time.time()) # 秒,10 位
int(time.time() * 1000) # 毫秒,13 位
# 坑:naive datetime(不带 tzinfo)的 timestamp() 会按本地时区解释
datetime(2023, 11, 15, 6, 13, 20).timestamp()
# 想要确定的结果,必须显式带时区
datetime(2023, 11, 15, 6, 13, 20, tzinfo=timezone.utc).timestamp()
now := time.Now()
now.Unix() // 秒,int64
now.UnixMilli() // 毫秒,Go 1.17+;低版本用 now.UnixNano() / 1e6
time.Unix(1700000000, 0) // 正确
time.Unix(1700000000000, 0) // 错:把毫秒当秒传,得到公元 5 万年
五、2038 年问题:还早,但已经该处理了
32 位有符号整数上限是 2147483647,对应 2038-01-19 03:14:07 UTC,再加一秒溢出成负数,时间跳回 1901 年。
会中招的地方:
- MySQL 的
TIMESTAMP类型范围就是 1970–2038,超范围会存成0000-00-00或直接报错;新表用DATETIME(1000–9999)或BIGINT - 数据库字段用
INT存时间戳——INT上限恰好是 2147483647 - C/C++ 老系统、嵌入式设备、部分老旧 ORM 里 32 位的
time_t - 用
9999999999(2286 年)当「永不过期」哨兵值,写进TIMESTAMP字段直接溢出
对策很简单:全链路用 64 位(BIGINT / int64 / bigint)。Java、Go、Python 3 的默认实现已经没问题,主要查数据库字段类型和老服务。
六、日期加减别手写 86400
// 错:有时区存在夏令时的地区,某一天只有 23 小时或 25 小时
const tomorrow = new Date(Date.now() + 86400 * 1000)
// 对:交给 Date 的日期运算,让运行时按本地日历处理
const d = new Date()
d.setDate(d.getDate() + 1)
关于闰秒要澄清一句:Unix 时间戳不计算闰秒,每天恒定 86400 秒,那一秒是被「抹平」的,所以 +86400 不会因为闰秒出错。真正会错的是夏令时——本地时间的某天是 23 或 25 小时,加 86400 秒会落在同一天的 23:00 或次日 01:00。跨月跨年同理,用日期 API 或 dayjs / date-fns 的 add(1, 'month'),别自己算天数。
算两个日期相隔多久也一样:别拿毫秒差除以 86400000,跨夏令时会得到 30.96 天这种小数。用 日期计算器 直接出天数、周数和工作日数,还能按天 / 周 / 月 / 年 / 工作日往前推,比手算可靠。
七、上线前跑一遍这份清单
- 接口文档写死单位,字段名带后缀:
created_at_s/created_at_ms,别让调用方猜 - 全链路统一用秒或统一用毫秒,跨语言边界(Go 秒 / JS 毫秒)处显式转换
- 前端
new Date()入参永远是毫秒,拿到秒记得 ×1000 - 数据库和日志存 UTC 时间戳或带
Z的 ISO 字符串,别存本地时间字符串 - 校验外部传进来的时间戳:先判数量级,不信调用方
- 空值和「永不过期」用 NULL,不用 0 或 9999999999
- MySQL 新表不用
TIMESTAMP+INT,改DATETIME或BIGINT - 日期加减用日期 API,不手写 86400
- 排查「差 8 小时」先打一行
toISOString()对比,确认是不是显示层时区问题 - 容器镜像里显式设
TZ,别让 Node 服务和浏览器各按各的时区渲染 - 涉及 JWT / Redis / HTTP 缓存时间时,先确认单位是秒,比较前 ×1000
全部纯前端实现,输入的内容不上传服务器。