去年一次值班,线上突然告警刷屏,一批订单全部显示"已过期"。排查了大半夜,最后定位到一行代码:前端把Date.now()的毫秒时间戳直接当成了秒发给后端,后端按秒存库,再一换算,所有订单的创建时间全变成了 1970 年附近的日期。这种低级又隐蔽的错误,在 Unix 时间戳的处理里其实特别常见,可以说是接口联调阶段最高发的"事故"类型之一。
Unix 时间戳本身不复杂:从 1970 年 1 月 1 日 00:00:00 UTC 开始,每过一秒加一,是一个纯粹的、与时区无关的时刻标记。正因为简单,它被几乎所有语言和数据库支持。但"支持"不等于"统一":有的接口回秒,有的回毫秒,有的甚至给微秒;有人习惯存 ISO 8601 字符串,有人坚持存整数。这篇指南把最核心的几个问题集中讲透,包括怎么判断一个时间戳是秒还是毫秒、各语言怎么转换、ISO 8601 的正确写法,以及 2038 年这类边界问题。无论你是后端、前端、数据工程师,还是刚入门的学生,都可以直接收藏参考。
1. 混乱源头:秒与毫秒之争,是谁搞出来的
1.1 各语言默认值的差异
时间戳单位混乱不是谁故意做错,而是每个技术栈的历史和习惯不一样。早期的 C 语言以秒为单位,直接决定了 Unix 生态的默认约定;后来 JavaScript 和 Java 为了拿到更细粒度的时间,选择了毫秒作为基础单位。一个项目里前端用 JS,后端用 Java 或 Python,网关再经过 Go 或 PHP,单位不统一几乎是必然的。
我整理了一张常用 API 的参考表,方便排查时快速定位。
| 技术栈 | 常用 API | 返回单位 | 参考值(2025年附近) |
|---|---|---|---|
| PHP | time() | 秒 | 1736948730 |
| PHP | microtime() | 秒+微秒 | 0.12345600 1736948730 |
| JavaScript | Date.now() | 毫秒 | 1736948730123 |
| Java | System.currentTimeMillis() | 毫秒 | 1736948730123 |
| Python | time.time() | 秒(浮点) | 1736948730.123456 |
| Go | time.Now().Unix() | 秒 | 1736948730 |
| Go | time.Now().UnixMilli() | 毫秒 | 1736948730123 |
| C | time(NULL) | 秒 | 1736948730 |
| MySQL | UNIX_TIMESTAMP() | 秒 | 1736948730 |
| Redis | TIME命令 | 秒+微秒 | 1736948730 123456 |
这张表不用死记,但最好留在手边。我每次排查时间戳异常,第一步就是查调用方用的是哪个 API、什么单位,往往几十秒就能定位问题。
1.2 为什么"毫秒当秒"的 bug 特别隐蔽
先看最常见的场景:请求体里有个timestamp字段,前端传的是1736948730123,后端按秒解析。这个值除以 86 400 再除以 365,大约是五万多年,理论上肉眼能看出来。但问题在于,很多系统会把这种异常值当成"合法的大数"处理,或者直接写入数据库后,再由其他服务去读,等用户侧发现问题时,根本不知道是哪个环节埋下的雷。
反过来也一样常见:某个接口按毫秒接收,调用方却传了秒数1736948730。后端把它当毫秒除以 1000 后转成时间,得到 1970 年 1 月 21 日附近的日期。这种错误日期不会夸张到报错,也不会触发字段长度溢出,数据会带着一个"看起来挺合理"的值继续流转。等排查时发现大量数据全部挤在 1970-01-21 附近,基本就能锁定是单位换算反了。
这种 bug 的隐蔽性来自两点:第一,程序不会抛异常;第二,错误日期恰好落在合法时间戳范围内。所以我一直强调,时间戳单位不是"文档里写清楚"就够,必须在命名、注释、校验三个层面同时体现。
1.3 字段命名留下的历史欠账
很多老项目的字段直接叫time、timestamp、create_time。当系统只有秒的时候没什么影响,一旦接入第三方库或跨端联调,歧义就来了。我之前见过一个内部 API,time字段在不同版本里分别是秒、毫秒、字符串年份,三套调用方各按各的理解,最终报表里出现了三种格式混乱的日期。
后来我们定了一条硬规矩:凡是时间戳整数,字段名必须带单位后缀,比如create_ts_s、expire_ts_ms;凡是字符串时间,统一命名occurred_at或created_at,并且必须带时区。命名虽然丑一点,但排查问题时基本不用靠猜。
2. 三招快速判断一个时间戳是秒还是毫秒
2.1 位数法:10 位和 13 位背后的数学
最直接的方法就是数位数。当前年代的秒级时间戳是 10 位,毫秒级是 13 位。为什么刚好是这个数量级?从 1970 年到 2025 年大约 55 年,按每年约 3155 万秒换算,秒数大约在 1.74×10^9,落在 10 位数字区间;毫秒再乘 1000,就是 1.74×10^12,落在 13 位区间。
这个规律在很长一段时间内都成立:秒级要到 2286 年 11 月才会突破 11 位,毫秒级也是到 2286 年 11 月左右突破 14 位,因为两者恰好是 1000 倍关系。所以日常开发和接口调试,碰到 10 位基本就是秒,13 位就是毫秒。如果你拿到 16 位,那是微秒;19 位,那是纳秒。这类情况在金融系统、硬件驱动和某些 SDK 里偶尔会遇到,判断方法完全一样:数位数。
2.2 区间法:从数字大小反推年份
位数法对常见场景够用,但遇到接口文档缺失、或者数据本身是极早年月份的情况,最好再用区间法验证。把时间戳当作秒数,除以 31 557 600(平年秒数),再加 1970,就能得到大致年份。
举个例子,1736948730除以 31 557 600 约等于 55.04,加上 1970 是 2025 年附近,和当前时间吻合,说明它大概率是秒。如果同一个值你怀疑是毫秒,先除以 1000,再按同样的公式算,年份会掉到 1970 年附近,显然不合理。
实际操作不用这么麻烦:在本地跑一句date -d @1736948730(macOS 用date -r)就能立刻看到对应时间。如果输出年份合理,说明单位没猜错;如果输出一个 1970 年附近的日期,或者一个远超 2100 年的日期,那大概率是单位搞反了。
2.3 上下文推断法:日志、字段名和调用链
位数只解决"大概不大概"的问题,真正要确认单位,还得结合上下文。先看字段名:xxx_ms、xxx_millis基本是毫秒;xxx_sec、xxx_unix基本是秒。再看日志格式:如果日志里同一个体系的字段值都在 10^9 量级,那 10^12 量级的字段可能不是同一单位。
还有一招比较实用:去调用链里找源头。时间戳如果是来自某个 SDK 或内部库的返回值,直接翻这个方法的文档和源码确认。比如 Flutter/Dart 的DateTime.now().millisecondsSinceEpoch是毫秒,Swift 的Date().timeIntervalSince1970却是秒,而且返回的是浮点。这种"语义相同、单位不同"的设计最容易让人踩坑,务必回源头确认。
2.4 写个零成本的自检脚本
如果你经常要判断时间戳,可以在本地工具目录放一个 30 行以内的小脚本。输入一个数字,分别按秒、按毫秒转换成日期,让人肉眼判断哪个合理。
# usage: tscheck 1736948730 ts=$1 echo "as seconds: $(date -d @$ts)" echo "as millis: $(date -d @$((ts / 1000)))"这个脚本虽然简陋,但我在排查联调问题时几乎天天用。它的价值不是省那几秒,而是逼自己把"单位"这个问题显式化,而不是靠感觉。
3. 多语言转换实操:从数字时间戳到 ISO 字符串
3.1 先记住一组最朴素的换算
秒和毫秒之间的换算不需要任何库:秒转毫秒就是乘 1000,毫秒转秒就是除 1000。唯一要注意的是整数除法。在 Java、C、Go 里,两个整数相除会直接截断,这符合大多数场景;在 Python 3 里/得到浮点数,想要整数部分必须用//。
浮点数在精度上容易引入隐藏误差,尤其是跨语言传输时,建议在边界处统一转成整数。下面的公式可以当速查表用:
| 转换目标 | 公式 | 示例 |
|---|---|---|
| 秒 → 毫秒 | ms = s * 1000 | 1736948730 → 1736948730000 |
| 毫秒 → 秒 | s = ms / 1000(整除) | 1736948730123 → 1736948730 |
| 微秒 → 秒 | s = us / 1_000_000 | 1736948730123456 → 1736948730 |
| 纳秒 → 秒 | s = ns / 1_000_000_000 | 1736948730123456789 → 1736948730 |
3.2 Python:time 与 datetime 的相互转换
Python 是处理时间戳最频繁的语言之一。核心要注意的是:time.time()返回秒级浮点数,datetime.fromtimestamp()接收的是秒级参数。
import time from datetime import datetime, timezone # 当前时间:秒、毫秒 now_s = int(time.time()) now_ms = int(time.time() * 1000) # 秒 → datetime 注意 fromtimestamp 默认用本地时区,做分析请用 UTC dt = datetime.fromtimestamp(now_s, tz=timezone.utc) # 毫秒 → datetime 先除以 1000 转成秒 dt_ms = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc) # datetime → 秒 / 毫秒 back_s = int(dt.timestamp()) back_ms = int(dt.timestamp() * 1000) # datetime → ISO 8601 字符串 iso_text = dt.isoformat() # 2025-07-15T13:45:30.123456+00:00在 Pandas 里处理批量数据时,pd.to_datetime的unit参数非常容易写错。unit='s'表示数据是秒,unit='ms'表示是毫秒。我见过一个分析脚本把毫秒列当秒处理,结果所有时间都偏移到 1970 年,程序不报错,直到画图时才发现。建议读取数据后先打印df['ts'].min()和df['ts'].max()检查数量级,再决定用哪个 unit。
3.3 JavaScript:毫秒才是主场
JavaScript 的Date系列 API 默认全部是毫秒:Date.now()、new Date().getTime()、Date.parse()返回的都是毫秒。新手最常见的错误是把秒数直接塞进new Date(),结果得到一个 1970 年附近的日期。
const ms = Date.now(); // 毫秒 const sec = Math.floor(Date.now() / 1000); // 秒 // 毫秒 → Date → ISO 字符串 const dateFromMs = new Date(ms); console.log(dateFromMs.toISOString()); // 2025-01-15T13:45:30.123Z // 秒 → Date 必须乘以 1000 const dateFromSec = new Date(sec * 1000); // ISO 字符串 → 毫秒 const parsedMs = Date.parse('2025-01-15T13:45:30.123Z');有一点要提醒:Date.parse()不是完全可靠的解析器。现代浏览器能正确解析 ISO 8601 带时区字符串,但如果你传入"2025-01-15 13:45:30"这种空格分隔、没有时区的写法,不同环境的处理结果可能不一样。所以跨端传递时间字符串,务必使用严格的 ISO 8601 写法。
3.4 Go 与 Java:标准库已经替你封装好了
Go 从 1.17 开始提供UnixMilli(),Java 从一开始就有System.currentTimeMillis(),两者都比较清晰。易混淆点在于 Go 的time.Unix(sec int64, nsec int64)需要接收秒和纳秒两个参数,你不能把毫秒直接塞进第一个参数。
now := time.Now() sec := now.Unix() ms := now.UnixMilli() // 毫秒 -> time.Time(Go 1.17+) t := time.UnixMilli(ms) // 输出 ISO 8601 fmt.Println(t.UTC().Format(time.RFC3339Nano)) // 2025-01-15T13:45:30.123Zlong ms = System.currentTimeMillis(); long sec = Instant.now().getEpochSecond(); Instant instant = Instant.ofEpochMilli(ms); String iso = instant.toString(); // 2025-01-15T13:45:30.123Z Instant fromSec = Instant.ofEpochSecond(sec);Go 的time.Time是携带位置信息的对象,格式化输出会受本地时区影响。如果你要的是绝对时刻,统一调用.UTC()再格式化成 RFC3339 更稳妥。Java 的Instant天然就是 UTC,几乎不会踩时区坑,只要别混用java.util.Date老 API 和SimpleDateFormat就行。
3.5 跨语言设计的三个约定
代码怎么写都行,但一旦要跨语言对接,我建议在接口层死守三个约定:
- 整数传输,避免浮点精度带来的误解。
- 字段名带单位,例如
created_ts_ms、expires_ts_s,或者额外加一个type枚举标明单位。 - 对外输出优先用 ISO 8601 字符串,尤其是日志和给前端渲染的场景,可读性远高于裸数字。
4. ISO 8601 全格式拆解与容易踩到的细节
4.1 一个标准写法长什么样
ISO 8601 最常见的组合是YYYY-MM-DDTHH:MM:SS.sssZ,比如2025-01-15T13:45:30.123Z。其中T是日期和时间的强制分隔符,Z表示 UTC。如果带时区偏移,写作2025-01-15T21:45:30+08:00,它和前面的 UTC 写法代表同一个时刻。
规范里其实允许很多变体:基础格式可以去掉短横线和冒号写成20250115T134530Z;日期可以单独表示2025-01-15;时间可以省略秒13:45;一天结束可以用24:00:00表示第二天的零点。这些写法虽然合法,但实际使用中尽量别碰,因为不同解析库的实现参差不齐。我的原则是:对外输出的 ISO 字符串,永远带着T、带时区、小数秒最多三位,这是所有主流语言都能稳定解析的写法。
4.2 各语言对 ISO 字符串的支持差异
| 场景 | Python | JavaScript | Go | Java |
|---|---|---|---|---|
| datetime → ISO | dt.isoformat() | d.toISOString() | t.Format(time.RFC3339) | Instant.toString() |
| ISO → datetime | datetime.fromisoformat(s) | Date.parse(s)或手写解析 | time.Parse(time.RFC3339, s) | Instant.parse(s) |
| 推荐方案 | 标准库 | 现代引擎直接解析 | 标准库 | 标准库 |
这里有个容易踩的坑:datetime.fromisoformat()在不同 Python 版本里的解析能力不一样。Python 3.11 之前,它不太认大写的Z,更习惯+00:00;3.11 之后才补齐了对Z的支持。如果你在 3.10 环境里跑,一定要处理这一点,或者统一用datetime.fromisoformat(s.replace('Z', '+00:00'))。这是我迁移服务时踩过的真实问题。
4.3 时区是字符串格式里隐藏最深的雷
ISO 8601 字符串如果不带时区信息,比如2025-01-15T13:45:30,它默认表示"本地时间"。问题是"本地"到底指哪个本地?发送方认为是自己的时区,接收方解析时可能当成接收方所在时区,两边一旦相差 8 个小时,日期就会错一天。这种情况在移动端、网页端与后端协作时特别常见。
我的对策很明确:任何跨服务、跨端的时间字符串,必须显式携带时区。要么用Z,要么用+08:00这样的偏移。如果你拿到一个不带时区的字符串,宁可先用正则判一下,也不要直接丢给解析库当本地时间。
4.4 小数秒位数的选择
ISO 8601 允许小数秒有任意位数,但实际传输中位数越多,兼容性越差。毫秒精度写 3 位足够,微秒写 6 位,纳秒写 9 位。很多日志系统会输出 9 位甚至更长的纳秒时间,传到别的系统如果目标语言不支持这么高的精度,就可能被截断或直接解析失败。接口层建议统一截断到 3 位毫秒,除非业务真的需要微秒级时间语义。
5. 边界情况:2038 年、负数时间戳和闰秒都不放过
5.1 2038 年问题的真面目
所有 32 位有符号整数能表示的最大秒数是 2 147 483 647,对应 2038 年 1 月 19 日 03:14:07 UTC。超过这一秒,老式的 32 位time_t就会回绕成负数。如果你的服务跑在 64 位系统上,这个隐患基本已经不存在,因为 64 位time_t能用到宇宙热寂。但嵌入式设备、旧系统、部分 SDK 和数据库老版本仍然可能保留 32 位time_t。
排查方法很简单,C/C++ 项目里加一行断言:
static_assert(sizeof(time_t) >= 8, "time_t must be 64-bit");数据库层面也有类似的坑。MySQL 的TIMESTAMP类型取值范围是 1970-01-01 00:00:01 UTC 到 2038-01-19 03:14:07 UTC。如果你为了省 4 个字节用了TIMESTAMP,到了 2038 年前后会直接出问题。新表建议直接用DATETIME,或者用INT UNSIGNED/BIGINT存毫秒。另一个细节是:TIMESTAMP存的是 UTC 时间,读出来时会按会话时区转换;DATETIME存的是字面值,没有时区概念。两种语义完全不同,我早年间就因为在旧库里混用这两类字段,导致报表时间整体平移了 8 个小时。
5.2 负数时间戳
Unix 时间戳可以表示 1970 年之前的时间,比如-86400对应 1969-12-31T00:00:00Z。负数本身没有问题,但会意外触发一些系统 Bug:比如某些接口校验逻辑只判断"是否大于 0",把 1970 年前的合法时间当成非法值;或者在强类型语言里,无符号类型接收负数会变成超大正数,直接产生离谱日期。如果你需要处理历史数据,务必确认存储字段是有符号的。
5.3 闰秒:时间戳不会等你
地球自转并不均匀,所以 UTC 偶尔会插入闰秒,最近一次是 2017 年 1 月 1 日的 23:59:60。POSIX 规定,Unix 时间戳的一天固定是 86 400 秒,不允许插秒。因此带闰秒的那一分钟,Unix 时间戳不会多出第 86 401 秒,而是直接忽略掉这一秒。
大部分业务场景对单秒误差没有感知,但在高精度交易或天文计算里,必须清楚你的系统到底使用哪种时间基准。Unix 时间是近似均匀的"秒计数",严格来说并不等于 UTC。网络时间同步时通常会有闰秒标记或 smearing 处理,如果你不是做底层基础设施,一般不用管,但别在文档里把"UTC"和"Unix 时间戳"画等号。
5.4 存储与展示的边界策略
时间戳的存储和展示是两个不同的问题。存储建议一律用 UTC 的整数时间戳或 ISO 字符串,绝不要存带着特定时区的"本地时间字符串"。展示时再转成用户所在时区,前端拿到 UTC 时间后用Intl.DateTimeFormat或toLocaleString()渲染。如果存储层就开始"人性化",一旦业务拓展到跨时区,所有数据都会变成一团浆糊。
6. 我把踩过的坑写成了一张事故档案和防御清单
6.1 事故档案一:Redis 过期时间差了一千倍
一次缓存策略调整,运营反馈 App 端数据总是刷新不出来。我拉日志发现 Redis 里的 key 在写入后很短时间就被删了。原因是上游用Date.now() + 3600000生成了一个毫秒单位的时间值,而 Redis 的EX参数期望的是秒。毫秒值被当成秒设置进去,实际过期时间被放大了 1000 倍,缓存相当于"永不过期";反过来,如果把秒当毫秒设置,缓存可能 1 秒之内就蒸发了。
教训是:凡是 TTL、过期时间这种对单位敏感的参数,必须用带单位命名的常量,比如TTL_ONE_HOUR_SECONDS = 3600,并在接口文档里写明单位。
6.2 事故档案二:日志里全是 1970-01-01
有一次分析用户访问分布,发现大批日志的request_time字段显示 1970 年 1 月 1 日。原因是收集端用 Python 的datetime.fromtimestamp(ms)直接把毫秒当秒传了进去。毫秒级数值除以 86 400 后对应 1970 年 1 月 21 日附近,和真实时间差了 55 年,视觉上非常刺眼。
排查时我用第 2.4 节里的tscheck脚本,很快定位到是收集端在序列化时漏了/1000。修复只有一行,但排查花了一个多小时。
6.3 事故档案三:不带时区的 ISO 字符串
某 App 上线后,用户反馈后台创建的定时任务总是提前或延后 8 小时。排查发现后端存的是2025-01-15T12:00:00,没有后缀时区;前端按 UTC 解析,后端按服务器东八区解析,两边差了 8 个小时。
修复方案是把所有时间字符串统一为带Z或+08:00的格式,并且让后端接口在入参校验时强制拒绝"无时区"的 ISO 字符串。这种校验看起来严苛,实际能挡掉大量上游脏数据。
6.4 防御习惯清单
如果让我给一个从零开始的团队立规矩,我会浓缩成五条:
- 命名带单位:接口字段名直接体现
_s、_ms,代码变量同理。 - 定义统一的时间工具类:
nowMs()、nowSec()、toIso()、parseIso()全封装在公共模块里,不允许业务代码到处直接调Date.now()或time()。 - 日志打印用 ISO 8601:给人看的、写日志的时间一律输出
2025-01-15T13:45:30.123Z,不要输出裸数字。 - 写边界测试:至少覆盖
0、-1、10^9、10^12、253402300799(9999 年)这些已知边界值,确保工具函数在极端时间点不会崩。 - 接口文档给死示例:在 Swagger/OpenAPI 里明确写
created_ts_ms: 1736948730123,让人一眼看到 13 位数字就知道单位。文档里的示例数值本身,就是最有效的防呆提示。
这些习惯不一定能第一天全部落地,但每做一条,就能少一次半夜告警。时间戳看着是基础知识点,可单位、时区、边界这三大类问题,几乎覆盖了我在生产环境里见过的全部时间相关事故来源。