先分诊单位,再看时区
线上冒出时间戳 bug 时,别急着去改解析代码。先用三个问题判断故障类型:
| 问题 | 快速检查 | 常见症状 |
|---|---|---|
| 单位是什么? | 10 位秒、13 位毫秒、16 位微秒、19 位纳秒 | 日期落在 1970 年,或者 55000 年 |
| 显示用的是哪个时区? | UTC、用户的 IANA 时区,还是机器默认值? | 笔记本和服务器结果对不上 |
| 这是一个瞬间,还是一条本地日历规则? | 日志事件 vs “每周一上午 9 点” | 夏令时和周期性排期会漂 |
调试时手边留一个已知的时间戳:
1700000000 秒 = 2023-11-14T22:13:20Z
1700000000000 毫秒 = 2023-11-14T22:13:20Z
1700000000000000 微秒 = 2023-11-14T22:13:20Z
如果你的结果不在 2023 年 11 月附近,那你先有的是单位 bug,还轮不到时区。
Bug 1:把 Unix 秒传给了 JavaScript 的 Date
症状: 一个近期的时间戳显示成 1970 年 1 月。
new Date(1700000000).toISOString();
// "1970-01-20T16:13:20.000Z"
根因: JavaScript 的 Date 收的是自 Unix 纪元以来的毫秒数。而大量接口、JWT claims、shell 工具、数据库和 webhook 负载用的是 Unix 秒。
修法:
const createdAtSeconds = 1700000000;
new Date(createdAtSeconds * 1000).toISOString();
// "2023-11-14T22:13:20.000Z"
更稳妥的边界函数:
function unixSecondsToDate(seconds) {
if (!Number.isFinite(seconds)) {
throw new TypeError("timestamp must be a number");
}
return new Date(seconds * 1000);
}
别用泛泛的 timestamp 做字段名,优先这样:
created_at_seconds
createdAtMs
expires_at_seconds
event_time_us
代码检索:
rg 'new Date\(\s*\d{10}\b'
rg 'new Date\([^)]*(created_at|createdAt|timestamp|expires)'
回归测试:
console.assert(
new Date(1700000000 * 1000).toISOString() === "2023-11-14T22:13:20.000Z",
);
Bug 2:秒、毫秒、微秒、纳秒混着用
症状: 一个服务写下了一个看着没问题的数字,另一个服务读出来却是几千年后的日期。
new Date(1700000000000 * 1000).getUTCFullYear();
// 55840
这是因为纪元毫秒被当成秒又乘了一次。
根因: 接口约定只写了“timestamp”,没写单位。
常见单位:
| 单位 | 示例 | 通常来自 |
|---|---|---|
| 秒 | 1700000000 |
Unix 工具、JWT 的 NumericDate、大量接口 |
| 毫秒 | 1700000000000 |
JavaScript Date、MongoDB Date、浏览器事件 |
| 微秒 | 1700000000000000 |
数据库、日志、链路追踪系统 |
| 纳秒 | 1700000000000000000 |
Go、OpenTelemetry 风格的管道、高精度遥测 |
修法: 在边界上转换一次,应用内部一律用带单位的名字。
type ApiUser = {
created_at_seconds: number;
};
type ViewUser = {
createdAtMs: number;
};
function mapUser(user: ApiUser): ViewUser {
return {
createdAtMs: user.created_at_seconds * 1000,
};
}
代码检索:
rg '\btimestamp\b|\bcreatedAt\b|\bupdatedAt\b|\bexpiresAt\b'
rg '\* 1000|/ 1000|1000000|1000000000'
约定测试:
function assertModernEpochMs(value) {
if (value < 946684800000 || value > 4102444800000) {
throw new RangeError("expected epoch milliseconds between 2000 and 2100");
}
}
RFC 7519 为 JWT 的时间类 claims 定义了 NumericDate,它是自 1970-01-01T00:00:00Z 起的秒数,不是毫秒。
Bug 3:让服务器本地时区渗进了输出
症状: 同一个时间戳在开发机、CI 和生产上显示得不一样。
new Date("2023-11-14T22:13:20Z").toLocaleString();
// 取决于运行时的默认时区
根因: 除非你显式传 timeZone 选项,否则 toLocaleString() 用的是运行时的本地时区。
服务端输出的修法:
new Intl.DateTimeFormat("zh-CN", {
timeZone: "UTC",
dateStyle: "medium",
timeStyle: "long",
}).format(new Date("2023-11-14T22:13:20Z"));
面向用户输出的修法:
new Intl.DateTimeFormat("zh-CN", {
timeZone: "America/New_York",
dateStyle: "medium",
timeStyle: "short",
}).format(new Date("2023-11-14T22:13:20Z"));
// "2023年11月14日 17:13"
为了运维上的省心,服务器可以设成 UTC,但代码里仍然要显式传 timeZone。
代码检索:
rg 'toLocale(String|DateString|TimeString)\('
rg 'Date\(\)\.toString|new Date\(\)\.toString'
逐个检查每处匹配有没有显式的 timeZone。
Bug 4:把时间戳存成自由格式的字符串
症状: 排序和过滤看起来毫无规律。
-- 糟糕的事实来源列
created_at = 'Jun 20, 2026 9:25am'
这种值在数据库里没法可靠地当作时间来比较——它比的是文本。除非你去解析它,而解析结果又取决于 locale、格式和数据库设置。
修法: 用原生时间戳类型,或者一个单位写明白的纪元整数。
好的 SQL 写法:
-- PostgreSQL
created_at timestamptz NOT NULL DEFAULT now()
-- MySQL,应用负责写入 UTC
created_at_utc DATETIME(3) NOT NULL
-- 数值型事件管道
created_at_ms BIGINT NOT NULL
严格的 ISO 8601 字符串,在日志和某些文档型存储里是可以接受的:
2026-06-20T14:30:00Z
但绝不要拿给人看的格式化字符串当事实来源。
代码/数据检索:
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name LIKE '%_at'
AND data_type IN ('character varying', 'varchar', 'text');
Bug 5:解析有歧义的日期字符串
症状: 01/02/2026 在一台机器上是 1 月 2 日,在另一台上却成了 2 月 1 日。
Date.parse("01/02/2026");
// 非标准格式,行为由实现自行定义
根因: JavaScript 只保证特定日期时间字符串的行为。MDN 指出,标准不变量之外的格式由各实现自行定义。
有两条微妙的 ISO 规则要记住:
new Date("2019-01-01").toISOString();
// "2019-01-01T00:00:00.000Z" 只有日期时按 UTC 处理
new Date("2019-01-01T00:00:00");
// 按本地时间处理,因为没有时区偏移
修法: 系统边界上只接受带 Z 或明确偏移的字符串。
new Date("2026-01-02T00:00:00Z");
new Date("2026-01-02T00:00:00-05:00");
如果用户填的是本地日期,就用明确的格式和时区把它当本地日期来解析。别丢给 Date.parse() 然后祈祷。
代码检索:
rg 'Date\.parse\('
rg "new Date\\([\\\"'].*[/-].*[\\\"']\\)"
Bug 6:用“加 24 小时”来表示明天
症状: 在夏令时切换前后,“明天上午 9 点”变成了 10 点或 8 点。
const tomorrow = new Date(today.getTime() + 86_400_000);
这是流逝时间的算术,不是本地日历的算术。
根因: UTC 的一天被建模为 86,400,000 毫秒,但夏令时切换时,本地日历上的一天可能是 23 或 25 小时。
用固定毫秒没问题的场景:
- 缓存 TTL
- 流逝时间窗口
- 纯 UTC 的日志窗口
- 作为时长的“24 小时后重试”
用固定毫秒有风险的场景:
- 用户时区里的“明天”
- 日历提醒
- 工作日截止时间
- 周期性会议
- 基于本地日期的计费周期
修法: 在目标时区里用日历运算。如果你用 Temporal 或某个感知时区的库,请给带时区的日期时间加一个日历日,而不是给瞬间加 86,400,000 毫秒。
代码检索:
rg '86400000|86_400_000|24\s*\*\s*60\s*\*\s*60\s*\*\s*1000'
凡是涉及本地时间的匹配,都要逐个复查。
Bug 7:日终用闭区间过滤
症状: 报表漏掉了当天最后一秒、最后一毫秒或最后一微秒的事件。
WHERE created_at BETWEEN '2026-06-20 00:00:00'
AND '2026-06-20 23:59:59'
它会漏掉:
2026-06-20 23:59:59.001
2026-06-20 23:59:59.999999
修法: 用左闭右开区间。
WHERE created_at >= '2026-06-20 00:00:00'
AND created_at < '2026-06-21 00:00:00'
Unix 秒的版本:
WHERE created_at_seconds >= 1781913600
AND created_at_seconds < 1782000000
代码检索:
rg 'BETWEEN.*23:59:59|23:59:59.*BETWEEN'
rg '<= .*endOfDay|end_of_day'
回归测试: 插入一条 23:59:59.999 的记录,确认日报能把它统计进去。
Bug 8:以为 cron 处理夏令时的方式和你的应用一样
症状: 夏令时切换期间,某个每日任务被跳过、被延后,或者被特殊处理了。
cron 的行为因实现和发行版而异。有些实现会在春季前拨之后立刻补跑错过的定时任务,并且在回拨之后不重复执行;另一些则不然。所以安全的结论既不是“cron 总会跳过”,也不是“cron 总会跑两次”,而是:按本地钟面定义的排期,必然存在夏令时边界情况。
有风险的排期:
30 2 * * * /app/bill-customers
在 America/New_York,春季前拨那天本地时间 02:30 根本不存在。
更稳妥的选项:
CRON_TZ=UTC
30 7 * * * /app/bill-customers
或者,如果业务确实要求本地时间,就把执行时刻排在本地切换窗口之外。
代码检索:
rg 'CRON_TZ|TZ=' /etc/crontab /etc/cron* 2>/dev/null
rg '^[0-9*,/-]+[[:space:]]+[123][0-9,*/-]*[[:space:]]+\*' /etc/crontab 2>/dev/null
运维检查: 在每一个跑本地 cron 的时区里,都在夏令时那个周末之前确认一下下次执行时间。
Bug 9:按对象身份去比较 Date
症状: 两个看起来一模一样的日期居然不相等。
new Date(0) === new Date(0);
// false
根因: === 比的是对象身份,而这是两个不同的对象。
修法: 比较纪元毫秒值。
const a = new Date(0);
const b = new Date("1970-01-01T00:00:00Z");
a.getTime() === b.getTime();
// true
也可以用一元加号做转换:
+a === +b;
// true
Set/Map 的修法: 用数值时间戳做键。
const seen = new Set();
seen.add(date.getTime());
代码检索:
rg 'Date.*===|===.*Date|!==.*Date|Date.*!=='
Bug 10:用 Date.now 测量流逝时间
症状: 基准测试或超时时长抖动、跳变,有时甚至变成负数。
const started = Date.now();
doWork();
const elapsed = Date.now() - started;
根因: Date.now() 读的是墙上时钟。而墙上时钟会被 NTP、虚拟化、人工修改和操作系统的时间校正所调整。它回答的是“现在几点”,不是“这件事花了多久”。
浏览器里的修法:
const started = performance.now();
doWork();
const elapsedMs = performance.now() - started;
Node.js 里的修法:
const { performance } = require("node:perf_hooks");
const started = performance.now();
doWork();
const elapsedMs = performance.now() - started;
用法分工:
- 事件时间戳和墙上时钟记录,用
Date.now() - 时长、基准测试、超时和动画计时,用
performance.now()
代码检索:
rg 'Date\.now\(\)' --glob '*{bench,perf,timing,test}*'
rg 'Date\.now\(\).*-'
墙上时钟同样不适合用来做分布式排序。时钟会漂移,NTP 会先校正一台主机再校正另一台,虚拟机也可能被挂起——于是两台服务器就对不上了。请让各主机保持同步并跟踪它们的偏差;当应用确实需要跨主机的严格顺序时,请用序列号、数据库里的顺序,或者一套分布式协调机制。另外要注意:容器读的是宿主机的时钟,它并不会另外获得一个完美同步的时钟。
运维层面的检查:Chrony 主机上用 chronyc tracking,许多 Linux 系统上用 timedatectl timesync-status,Windows 上用 w32tm /query /status。而单进程内部测流逝时间,老老实实用单调时钟。
代码库排查清单
发版之前,或者出过时间戳事故之后,把这些检索跑一遍:
| bug 类别 | 检索命令 |
|---|---|
| 秒被传给了 JS Date | rg 'new Date\\(\\s*\\d{10}\\b' |
| 含义不明的时间戳字段 | `rg '\btimestamp\b |
| 格式化时漏了时区 | `rg 'toLocale(String |
| 时间戳存成了字符串 | 检查 *_at 列里类型为 text/varchar 的 |
| 有歧义的解析 | `rg "Date\.parse\( |
| 本地时间上的 24 小时算术 | `rg '86400000 |
| SQL 里的日终闭区间 | `rg 'BETWEEN.*23:59:59 |
| cron 的夏令时风险 | 检查本地时间落在 01:00 到 03:30 之间的任务 |
| Date 对象相等判断 | `rg 'Date.*=== |
| 用墙上时钟做基准测试 | rg 'Date\\.now\\(\\)' --glob '*{bench,perf,timing}*' |
别对每一处匹配都无脑自动修。先弄清每个值到底是什么意思:显示、存储、排期和流逝时间测量需要的是不同的操作——哪怕每个变量都叫 timestamp。
生产预防清单
新代码请按这些默认值来:
- 确切事件存成 UTC 瞬间。
- 本地排期存成“本地日期 + 本地时间 + IANA 时区”。
- 数值字段名里带上单位:
_seconds、_ms、_us、_ns。 - 用原生的 datetime/timestamp 列或单位写明的纪元整数,不要用自由格式字符串。
- 需要可读性的 API 边界上,用严格的 ISO 8601 / RFC 3339 字符串。
- 服务端格式化时显式传
timeZone。 - 日期区间用左闭右开:
start <= value < end。 - 本地日期用日历运算。
- 关键 cron 任务跑在 UTC 上,或者明确针对夏令时切换日做测试。
- 比较 JavaScript 日期用
.getTime()。 - 测流逝时间用
performance.now()。
官方参考资料
- MDN Date
- MDN Date 构造函数
- MDN Date.parse
- MDN Date.getTime
- MDN toLocaleString
- MDN Intl.DateTimeFormat
- MDN performance.now
- MDN 高精度计时
- RFC 7519 NumericDate
- IANA 时区数据库
- Red Hat 关于 cron 夏令时行为的说明
相关时间戳指南
Frequent questions:
- Q: 最常见的 Unix 时间戳 bug 是什么?
- A: 最常见的是把 Unix 秒传给了一个按毫秒收的 API,尤其是 JavaScript 的 Date。new Date(1700000000) 会落在 1970 年 1 月,因为 JavaScript 把这个值当成了毫秒。请写成 new Date(1700000000 * 1000),或者把字段命名为 created_at_seconds。
- Q: 怎么从根上防住秒和毫秒混淆?
- A: 把单位写进每一个数值字段的名字里,比如 created_at_ms、expires_at_seconds、event_time_us、logged_at_ns。在系统边界上一次性转换,并为 10 位秒、13 位毫秒和 16 位微秒各补一条测试。
- Q: 为什么我的时间戳显示成 1970 年?
- A: 多半是一个当代的 Unix 秒值被当成了毫秒。1700000000 秒是 2023-11-14T22:13:20Z,而 1700000000 毫秒却是 1970-01-20T16:13:20Z。
- Q: 为什么 Date.parse 给出的结果不一致?
- A: JavaScript 只保证一小部分日期字符串格式的行为。只有日期的 ISO 字符串按 UTC 处理,不带偏移的日期时间字符串按本地时间处理,而很多非 ISO 格式由各实现自行定义。请只接受严格的 ISO 8601,或者用明确的格式去解析。
- Q: 为什么加 86400000 毫秒来表示“明天”会出问题?
- A: 在 JavaScript 的时间模型里,UTC 的一天永远是 86400000 毫秒;但当夏令时切换时,本地日历上的一天可能是 23 或 25 小时。本地日期请在目标 IANA 时区里用日历运算。
- Q: 为什么日期区间查询会漏掉当天末尾的记录?
- A: 像 BETWEEN '2026-06-20 00:00:00' AND '2026-06-20 23:59:59' 这种闭区间的日终过滤,会漏掉带小数秒的行。请用左闭右开区间:created_at >= start AND created_at < next_day_start。
- Q: 为什么 cron 任务在夏令时期间表现怪异?
- A: cron 的行为因实现而异,但按本地钟面定义的排期,在时钟跳变时可能被跳过、被延后,或者被特殊处理。关键的每日任务,要么让 cron 跑在 UTC 上,要么把时间排在本地夏令时切换窗口之外。
- Q: 怎么比较两个 JavaScript Date 对象?
- A: 比较它们的数值,而不是对象本身:a.getTime() === b.getTime() 或者 +a === +b。new Date(0) === new Date(0) 是 false,因为它们是两个不同的对象。
- Q: 做基准测试该用 Date.now() 吗?
- A: 不该。Date.now() 读的是墙上时钟。测流逝时间,浏览器里用 performance.now(),Node.js 里用 node:perf_hooks——Performance API 是单调递增的,本来就是为高精度计时设计的。
- Q: 怎么在遗留代码库里找出时间戳 bug?
- A: 搜这些:带 10 位数字的 new Date(、Date.parse、不带 timeZone 的 toLocaleString、86400000、含 23:59:59 的 BETWEEN、基准测试文件里的 Date.now,以及 Date 的相等比较。逐个检查每处匹配的单位、时区和区间语义。