凌晨一点二十,群消息把我叫醒:运营贴了张后台截图,三月那批订单的"创建时间"整列变成了 1970-01-21 00:13:20。值班的前端爬起来看代码,五分钟就定位到了——接口返回的 create_time 是 10 位秒数,而他写的 new Date(create_time) 把这个数字当成毫秒。同一个 1700000000,按秒读落在 2023 年 11 月,按毫秒读就掉回 1970 年 1 月。那次之后,我们组把时间戳相关的约定写成一张卡片钉在 wiki 首页,新人接手排障基本不用再问人。
一、位数就是精度:先学会一眼判断单位
时间戳本身只是一个整数,含义完全取决于它被按什么单位生产出来。我的第一判断依据永远是数位数,而不是猜后端用了哪门语言:
| 位数 | 单位 | 典型来源 | 与秒级的关系 |
|---|---|---|---|
| 10 位 | 秒 | PHP time()、Go Unix()、Linux date +%s | 基准 |
| 13 位 | 毫秒 | JavaScript Date.now()、Java currentTimeMillis() | 秒 × 1000 |
| 16 位 | 微秒 | Go UnixMicro()、Python time_ns()//1000 | 秒 × 1000000 |
| 19 位 | 纳秒 | Python time_ns()、Go UnixNano() | 秒 × 10⁹ |
再补一条值域检查:2020-01-01 到 2030-01-01 之间,秒级落在 1577836800 到 1893456000,毫秒级是 1577836800000 到 1893456000000。把 13 位当 10 位解析,日期会飞到公元五万年附近;反过来把 10 位当 13 位,就掉回 1970 年。我遇到的"时间显示异常"基本都落在这两种。
二、差的 8 小时不在时间戳里,在读它的那一层
时间戳是从 1970-01-01 00:00:00 UTC 起算的绝对量,不带任何时区信息。同一个数字在伦敦还是上海都是同一个值,分歧只发生在"把整数翻译成人能读的日期"这一步。以 1700000000 为例,它的 UTC 时刻是 2023-11-14 22:13:20,换成北京时间是 2023-11-15 06:13:20,正好差 8 小时。
实际项目里造成 8 小时错觉的常见四个位置,值得挨个排除:
- 容器默认时区是 UTC、宿主机是 Asia/Shanghai,日志墙钟时间和业务看到的差 8 小时。
TZ=Asia/Shanghai date -d @1700000000和不带TZ各跑一次,就能确认错在哪一层。 - MySQL 的
FROM_UNIXTIME()按会话时区输出。先SELECT @@session.time_zone,再和UNIX_TIMESTAMP()双向核对,字段本身往往没错。 - PHP 没配
date_default_timezone时按 UTC 走,date()输出整体平移。 - 前端
new Date(ts).toISOString()永远返回 UTC 且末尾带 Z,toLocaleString()按系统时区渲染。同一份数据在两个函数里"不一致",是调试里最常见的自我怀疑。
三、我现在的排查顺序,以及在哪一步用上工具
- 先数位数定单位,写下"这批数字按秒还是按毫秒"的结论,避免后面两个人各按一种理解算。
- 再定基准:我们最后把规矩压成一句——库里只存 10 位 UTC 秒,接口字段名后缀写清单位(
create_time_ms),展示层负责转本地时区。 - 双向验一次:拿一个已知时刻(比如"昨晚 23:07")转成时间戳,再转回来,看是否落回同一个值。这一步能同时暴露单位和时区两处错。
- 批量核对时,我把可疑数字贴进时间戳转换,它会自己分辨秒级还是毫秒,同时给出北京时间、UTC、ISO 8601 和"几天前"这类相对描述,省掉我心算时区偏移;日期反推时间戳也在同一页做,页面上还备了几种语言取时间戳的写法,换语言时直接抄那一段。
- 要算两个时间点的间隔,或推"往后 90 天是哪天",用时间加减计算,结果连星期几和时间戳一起给出,比手动除以 86400 可靠。
四、容易忽略的边界情况
这几条踩过之后才会主动检查,写在这里当清单用:
| 边界 | 具体表现 | 处理方式 |
|---|---|---|
| 32 位溢出 | 有符号 32 位存秒级,到 2038-01-19 03:14:07 UTC 翻负;MySQL TIMESTAMP 字段上限也是这个时刻 | 字段改 BIGINT,迁移前先用 SELECT MAX() 看余量 |
| 负数时间戳 | 1970 年之前是负值,部分数据库函数和图表库显示成空或直接报错 | 历史档案用日期字符串存,时间戳只留给近几十年业务 |
| 闰秒 | 时间戳不表达闰秒,对齐 UTC 秒的调度器在闰秒当天可能看到重复的一秒 | 关键计时走单调时钟(CLOCK_MONOTONIC),别拿墙上时间做倒计时 |
| 夏令时 | 美国 2024-03-10 02:00 直接跳到 03:00,这一小时的本地时间不存在;11-03 回拨又重复一小时 | 存 IANA 时区名(如 America/New_York),别只存 UTC+4 这种固定偏移 |
| 同秒并发 | 拿时间戳当文件名或缓存 Key,同一秒内的请求会互相覆盖 | 毫秒级再拼随机后缀,或用 UUID |
跨国排会议是另一类需求:真正要的往往不是时间戳,而是"对方现在算不算工作时间"。这种我会先看世界时间里两城的当前时刻和时差,定下来再把时刻转成时间戳写进日程接口。
五、常见疑问
Q:转换出来差 8 小时,是时间戳错了吗?
几乎不是。先确认解析用的时区,再确认位数单位;时间戳数值本身没变,变的只是渲染它的这一层。
Q:JavaScript 里到底该乘 1000 还是除 1000?
看输入位数。JS 的 Date 只认毫秒,拿到 10 位要乘 1000;反过来把 Date.now() 发给一个按秒解析的接口,就要除 1000 并取整。最稳的办法是在字段名里写清单位。
Q:怎么快速判断一批混乱的日志时间是秒还是毫秒?
取最大值数位数,再用值域对照:2020 到 2030 年之间,10 位是秒、13 位是毫秒。同一份日志里两种混用并不罕见,先分桶再统一转换。
Q:2038 问题离我们远吗?
只要还有 32 位整型字段在跑老系统就不算远。成本主要在改字段类型和重建索引,越晚动越麻烦,最好在下一轮表结构调整里顺手做掉。
回头看那次故障,真正的修复不是那一行 new Date(),而是"谁生产数字谁标注单位、谁展示时间谁负责时区"这条约定终于落进了接口文档。以后再看到 1970 或者公元五万年,先数位数,再看时区,两件事按顺序查完,绝大多数时间错乱都到不了第二步。