计算机里的时间 —— 延迟、时钟、表示、感知
计算机时间不是一个东西。一个工程师每天都在跟 4 种不同的「时间」 打交道:
- 延迟(Latency) —— 一个操作等多久。
- 时钟(Clock) —— 现在几点 / 已经过了多久。
- 表示(Representation) —— 怎么存、怎么在网络上传、怎么跨时区表达。
- 感知(Perception) —— 用户感觉到这个东西有多慢。
把它们混在一起谈是大部分时间相关 bug 的根因 —— 用 wall clock 测耗时、用字符串排序时间戳、目标 latency 设成”越快越好”而不是”用户感觉不到”。本文一篇文章把这 4 层串起来,从纳秒到秒、从 Jeff Dean 延迟数字 到 leap second、从 wall clock 到 Lamport 时间戳。
1. 延迟尺度:从 1ns 到 1s,跨 9 个数量级¶
第一件事是建立尺度感。下面是 2025 年前后典型现代硬件的数字(数量级估算,不同硬件 2~5 倍浮动正常):
| 操作 | 延迟 | 备注 |
|---|---|---|
| L1 cache 访问 | 1 ns | ~3 个 CPU cycle |
| L2 cache 访问 | 4 ns | |
| L3 cache 访问 | 12 ns | 跨核共享 |
| Main memory (DRAM) 随机读 | 100 ns | 一次 cache miss 的代价 |
| 同核 mutex lock + unlock(无争用) | 20 ns | |
| 一次 syscall(用户态↔内核态) | ~100 ns | spectre/meltdown 后涨到 ~500 ns |
| Context switch | 1~3 μs | 进程间切换 |
| NVMe SSD 4KB 随机读 | 10 μs | |
| 同机房 RTT(10Gb 网络) | 100~250 μs | |
| HDD 随机寻道 | 5~10 ms | 机械臂物理移动 |
| 跨大陆 RTT(US ↔ EU) | 80~100 ms | 光速 + 路由 |
| 跨地球 RTT(US ↔ 亚洲) | 150~200 ms | 海底电缆 |
| 用户感知到”明显卡” | ~1000 ms | 见 §5 |
横跨 9 个数量级。这些数字之间的相对关系比绝对值更重要 —— 一次 DRAM 访问比 L1 慢 100 倍、一次跨洲 RTT 比 DRAM 访问慢 100 万倍。
把 ns 放大成 s,直觉立刻不一样¶
最有用的视觉化是 Colin Scott 那个交互式页面 用的方法 —— 把 1 ns 放大成 1 s(× 10⁹),整个谱重新呈现:
| 真实操作 | 放大后(1ns = 1s) |
|---|---|
| L1 cache | 1 秒 |
| Main memory | 1 分 40 秒 |
| NVMe SSD 读 | 2.8 小时 |
| 同机房 RTT | 3 天 |
| HDD 寻道 | 4 个月 |
| 跨洲 RTT | 3.2 年 |
| 1 秒 | 31.7 年 |
这个表的意义:一次跨洲 HTTP 请求等于”取一份 L1 cache 数据 3.2 年”。这不是文学比喻,是真实的 CPU 浪费 —— 同步阻塞那 100ms 内本来可以做 1 亿次 cache 访问。理解这一点之后,“为什么异步 IO 重要”、“为什么 CDN 重要”、“为什么 cache 是优化的核心”全部变成废话级别的常识。
2. 三种”时钟”,别混用¶
很多人没意识到操作系统暴露了至少 3 种不同的时钟,各自适合不同场景。
Wall clock(墙钟,CLOCK_REALTIME)¶
跟着 UTC 走,跟着 NTP 跳。
- 含义:“现在是公历几年几月几日几点几分几秒”
- 行为:可前可后 —— NTP 同步可能把它向前跳几秒、向后退几秒,DST 切换会人为加减 1 小时
- 适合:给用户显示当前时间、写入日志的时间戳、跨机器 / 跨进程比较”什么时候发生的”
- 不适合:测函数耗时、做 timeout
- API:
System.currentTimeMillis()/Date.now()/time.time()/clock_gettime(CLOCK_REALTIME)
Monotonic clock(单调钟,CLOCK_MONOTONIC)¶
自系统启动以来单调递增。
- 含义:没有”几点”的语义,只有”经过了多少”
- 行为:永远不回退,不被 NTP 影响、不被 DST 影响
- 适合:测耗时、超时控制、计算 elapsed time、性能 benchmark
- 不适合:跨机器比较(每台机器从自己的 boot 开始算)、显示给用户
- API:
System.nanoTime()/time.monotonic()/process.hrtime.bigint()/clock_gettime(CLOCK_MONOTONIC)
Logical clock(逻辑钟,Lamport / 向量时钟)¶
完全不试图描述真实时间,只描述事件之间的因果关系。
- 含义:A 事件的逻辑时间 < B 事件的逻辑时间 ⟹ A 可能影响 B;反之不一定
- 适合:分布式系统的事件排序、CRDT 冲突解决、Raft / Paxos 内部
- 不适合:任何”真实经过多少时间”的问题
- 它治的是”分布式系统中,物理时钟根本不能可靠地告诉你 A 和 B 谁先发生”这个问题,是分布式时间的基础
经典错用:用 wall clock 做 timeout¶
// 错:用 Date.now() 做 timeout
const start = Date.now();
while (Date.now() - start < 5000) { doWork(); }
// 如果中途 NTP 把系统时间向后调 10 秒,循环会立刻退出 (差值变成负的或巨大值)。
// 反过来如果向前调 1 小时,循环立刻"超时"也退出。
// 对:用 monotonic
const start = performance.now(); // 浏览器
while (performance.now() - start < 5000) { doWork(); }
简单记忆:测耗时用 monotonic,显示用 wall。两个不能换。
3. 闰秒:小数字,大灾难¶
地球自转不是匀速的(潮汐摩擦让自转变慢),所以 UTC 偶尔要加一个 leap second(闰秒)—— 在 6 月 30 日或 12 月 31 日的 23:59
后塞进一个 23:59。听起来无害的一秒,制造了 IT 史上几次知名 outage:
- 2012-06-30 —— Linux kernel 在加闰秒后触发 hrtimer 死循环,Reddit / LinkedIn / Mozilla 大面积宕机;Java 应用 CPU 100% 卡死
- 2017-01-01 —— Cloudflare 因为 Go runtime 在闰秒时返回了负的 elapsed time,触发 DNS 解析路径的 panic,1% 的请求在 30 分钟内失败
应对方案分裂成两派:
| 方案 | 实现 | 用户 |
|---|---|---|
| 直接塞 | 系统真的播放 23:59,应用要能处理”重复的一秒”或”60 秒分钟” | Linux 默认(实际把 23:59 重复一次) |
| Smear(平摊) | 把这 1 秒平摊到 20 小时,每秒慢一点点 | Google / AWS / Facebook |
| Step(跳过) | 闰秒发生时直接跳,应用看到时间”少了一秒” | 部分系统 |
2022 年 Meta / Google 等联名呼吁停止 leap second,IERS 在 2022 通过决议 2035 年前后停止 leap second。在那之前,做基础设施的工程师要假定 leap second 仍然存在,并且默认 NTP 行为会让你的系统在某一天 23:59
那刻突然崩溃。4. 时间表示的陷阱¶
Unix epoch & 2038 问题¶
Unix 时间从 1970-01-01 00:00
UTC 起算秒数。time_t 在历史上是 32-bit signed int,能表示的最大值是 2147483647 —— 对应 2038-01-19 03:14 UTC。再加 1 秒会溢出成负数,回到 1901 年。
2038 还有 12 年。绝大多数 64-bit 系统已经把 time_t 升到 64-bit,但 32-bit 嵌入式设备 / 旧 32-bit Linux 系统 / 某些数据库列 / 一些金融老系统 还在用 32-bit。NTP 协议本身也有 2036 问题(NTP timestamp 是从 1900 起的 32-bit)。Y2K 在 2000 年没炸是因为有 5 年准备时间,Y2K38 倒数中。
Unix epoch 也不是唯一的:
| 系统 | epoch |
|---|---|
| Unix / POSIX | 1970-01-01 |
| NSDate (Apple) | 2001-01-01 |
| Windows FILETIME | 1601-01-01 |
| OLE Automation Date | 1899-12-30 |
| Excel | 1900-01-01(但 错把 1900 当闰年) |
跨系统传时间戳要小心。
Timezone 不只是 UTC offset¶
很多工程师以为 "timezone = UTC offset",比如”上海 = UTC+8”。这是错的:
- DST 切换:同一个时区,夏天和冬天的 offset 不一样。
America/New_York冬天 UTC-5、夏天 UTC-4 - 历史变化:时区规则随政府决定改。2007 年美国把 DST 从 4 月第一周改成 3 月第二周;俄罗斯 2011 年取消 DST、2014 年又改了;中国 1949~1991 之间有过 DST
- 政治变化:朝鲜 2015 年从 UTC+9 改到 UTC+8、2018 年改回 UTC+9
- 半小时 / 45 分钟 offset:印度 UTC+5、尼泊尔 UTC+5
正确的 timezone 是一个 IANA 名字(Asia/Shanghai、America/New_York),不是一个数字 offset。运行时根据日期查 tzdb 计算 offset。永远存 IANA 名 + UTC timestamp,不要存”东八区时间”或”local time string”。
ISO 8601 vs RFC 3339¶
写时间字符串只需要知道一件事:用 RFC 3339,带 timezone offset。
2026-06-09T14:30:00+08:00 ✓ RFC 3339, 带偏移
2026-06-09T06:30:00Z ✓ RFC 3339, UTC ('Z' = +00:00)
2026-06-09T14:30:00 ✗ 没 offset, 不知道哪个时区
20260609T143000 ✗ ISO 8601 允许但 RFC 3339 不允许, 各种 parser 不一致
2026/06/09 14:30:00 ✗ 自定义格式, 跨语言解析地狱
ISO 8601 是个 metaspec、允许大量变体;RFC 3339 是它的严格子集、网络传输标准。写代码就用 RFC 3339。
各语言 time API 的常见坑¶
| 语言 / 库 | 坑 |
|---|---|
JavaScript Date | new Date('2026-01-01') 解析成 UTC,new Date('2026/01/01') 解析成本地时间。getMonth() 0-indexed(11 = 12 月)。Date 本身没有 timezone 信息,只是 epoch ms |
Python datetime | naive datetime(无 timezone)vs aware datetime。datetime.now() 返回 naive 是常见坑 —— 跟其他 aware 的算术会抛 TypeError。永远用 datetime.now(tz=timezone.utc) |
Go time.Time | 内部同时有 wall + monotonic 两部分。.Round() / .Truncate() 和某些序列化会剥离 monotonic,之后做 t2.Sub(t1) 又退化成 wall 差值 |
Java Date / Calendar | 已经废弃,新代码用 java.time.Instant / ZonedDateTime。Calendar.MONTH 也是 0-indexed |
| PostgreSQL | timestamp 不带时区(=naive,不要用),timestamp with time zone(timestamptz,实际存 UTC、显示按 session timezone 转)才是默认 |
| MySQL | DATETIME 不带时区(naive),TIMESTAMP 带(但只能到 2038-01-19,因为内部是 32-bit) |
5. 人类感知阈值¶
来自 Jakob Nielsen 1993 《Response Times: The 3 Important Limits》 的经典三档:
| 阈值 | 用户感受 |
|---|---|
| < 100 ms | 感觉是”立即响应”,操作和结果在脑子里是同一个动作 |
| 100 ms ~ 1 s | 感觉到延迟,但思维不被打断,仍在”操作流”里 |
| 1 s ~ 10 s | 注意力开始转移,需要 loading 提示 |
| > 10 s | 用户会切到别的事情,需要进度条 + 可取消 |
再叠加屏幕刷新:
| 帧率 | 帧预算 | 场景 |
|---|---|---|
| 60 fps | 16.67 ms | 主流屏幕;浏览器 JS 主线程要在这之内完成一帧的工作 |
| 120 fps | 8.33 ms | 高刷屏 / Apple ProMotion |
| 144 fps | 6.94 ms | 电竞屏 |
反推 latency 预算:
- HTTP API:目标 200ms 内(用户感觉流畅,包含网络 + 解析 + 渲染)
- DB 查询:目标 50ms 内(给 API 处理留 150ms)
- 单个 syscall:目标 10μs 内(一次 HTTP 路径有几百到几千个 syscall)
- 一帧 JS 工作:目标 < 10ms(给浏览器 layout/paint 留 6ms)
这些数字不是”越快越好”,是”用户感知不到的临界点”。HTTP API 优化到 200ms 和 50ms 对用户没有可感差异,把同样的工程时间花到”让 1500ms 的接口降到 500ms”上 ROI 高得多。
6. 测量时间的正确姿势¶
要么用对工具,要么数据都是噪音。
测函数耗时¶
永远用 monotonic clock。Date.now() / System.currentTimeMillis() / time.time() 全部不行 —— 它们可能在 NTP 调时刻把你的”已用 50ms”变成”已用 -3 秒”或者”已用 1 小时”。
| 语言 | 推荐 API |
|---|---|
| C/C++ | clock_gettime(CLOCK_MONOTONIC, &ts) |
| Java | System.nanoTime() |
| Python | time.monotonic() 或 time.perf_counter()(高精度) |
| Go | time.Since(start)(内部用 monotonic) |
| Node.js | process.hrtime.bigint() |
| 浏览器 JS | performance.now() |
微基准(< 1μs 级别)¶
在 x86 上可以用 RDTSC 周期计数器直接读 CPU cycle counter。但要注意:
- 跨核可能不同步(现代 invariant TSC 大多 OK)
- 频率随 P-state 变(要么固定频率,要么用
rdtscp算实际频率) - 多次测量取最小值,不要取平均(GC / 中断会把单次拉高)
更稳的做法:用专门的 benchmark 框架(Go testing.B / Rust criterion / JMH for Java),自动处理 warmup、统计、稳态判定。
应用 profiling¶
不要靠 printf 加时间戳推断瓶颈。用专门工具:
- Linux:
perf record+perf report/ FlameGraph - macOS: Instruments /
samply - Java: async-profiler / JFR
- Node.js:
--prof+ 0x flamegraph / clinic.js - Python:
py-spy/austin - Go:
pprof(内置)
浏览器特殊:故意降低精度¶
performance.now() 在浏览器里默认 100μs ~ 1ms 颗粒度(不是 ns)—— Spectre 时间侧信道防护。想要更高精度要 crossOriginIsolated 上下文(COOP + COEP header),开启后大约 5μs。
7. Anti-pattern 清单¶
按工程师日常踩到的顺序排:
- 用 wall clock 测耗时 / 做 timeout —— NTP 调时一秒,你的 5 秒 timeout 立刻触发。改 monotonic
- 字符串排序时间戳 ——
"9:00" > "12:00"因为'9' > '1'。要么 zero-pad("09:00"),要么解析成数值再比 - 假设网络 RTT 稳定 —— p50 30ms 不代表 p99 不会到 500ms。timeout 永远参考 p99,不是平均
- 跨时区存 local time —— 数据库永远存 UTC,显示时按 IANA timezone 转
- 假设 1 秒永远 = 1 秒 —— leap second / NTP smear / VM clock drift 都会让”1 秒”实际是 0.99~1.5 秒
- 假设 timezone = UTC offset —— 不是,DST + 历史 + 政治变化让 offset 随日期变
- 假设所有日期都是公历 —— 还有阴历、日本年号、民国纪年、波斯历、希伯来历,国际化产品会撞上
- leap second 假装不存在 —— 默认 NTP 处理可能让你的系统在 23:59 那秒崩。要么 smear(推荐),要么明确测试过 step 行为
- 假设 epoch = 1970-01-01 —— 跨系统传时间戳前确认 epoch(macOS / Windows / Excel 全是不同的)
- 测试里写死”现在” ——
assert today == "2026-06-09"这种测试在 23 跑会失败、跨 DST 跑会失败、跨时区跑会失败。Inject clock,不要硬编码 Date.now()当随机数种子 —— 同一毫秒内两个进程会拿到同样的种子,导致碰撞32-bit time_t还活在你的数据库里 —— 检查所有INT UNSIGNED存 epoch 的列,2038 之前要升 64-bit
收尾:3 条时间直觉¶
文章很长,记得这 3 条就够:
- 尺度感 —— 看一个 op 立刻知道它在 ns / μs / ms / s 哪个量级。这决定哪里需要优化、哪里不必。一次跨洲 RTT 等于 1 亿次 cache 访问 —— 同步等它就是浪费
- 时钟分清 —— 测耗时用 monotonic,显示给用户用 wall,分布式排序用 logical。三条规则,不能换
- 人类感知锚点 ——
200ms(API 流畅)、1s(注意力临界)、16.67ms(一帧)刻在心里。这些反向告诉你哪里值得花工程时间
进一步阅读¶
- Latency Numbers Every Programmer Should Know — Colin Scott 交互版
- Jeff Dean 的 Numbers Everyone Should Know(原版)
- Martin Kleppmann 《Designing Data-Intensive Applications》ch.8 The Trouble with Distributed Systems —— 时钟、网络、故障三个不确定性的合集
- Falsehoods Programmers Believe About Time & more falsehoods
- Response Times: The 3 Important Limits — Jakob Nielsen
- Leap Smear — Google’s implementation