先分诊单位,再看时区

线上冒出时间戳 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()

官方参考资料

相关时间戳指南

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 的相等比较。逐个检查每处匹配的单位、时区和区间语义。