引言
时间bug是软件里最「阴」的一类:本地测试全对,上线就乱;跨时区用户各报各的;夏令时让日志神秘地「倒退一小时」。根因永远是同一个:没分清「时刻」与「墙上时间」。本文把时间处理拆成三个层次——时刻(物理时间)、表示(ISO 8601)、日历(时区/夏令时),每层给正确做法与经典陷阱。
前置:基本数据库与分布式概念。数据库时间存储见 [[database]],跨时区调度见 /text-processing-toolkit/(
date命令)。
目录
- 1. 时刻 vs 本地时间:时间处理的地基
- 2. UTC 与 Unix 时间戳
- 3. ISO 8601 表示格式
- 4. IANA 时区与时区规则
- 5. 夏令时(DST)陷阱
- 6. 日历计算:闰年、月份与星期
- 7. 主流语言时间 API 对照
- 8. 数据库时间存储
- 9. 分布式系统时钟:NTP 与逻辑时钟
- 10. 速查表
- 延伸阅读
1. 时刻 vs 本地时间:时间处理的地基
两个不可混为一谈的概念:
| 概念 | 定义 | 特点 | 示例 |
|---|---|---|---|
| 时刻(Instant) | 物理时间点 | 全球唯一、可比较 | 2026-09-28T02:00:00Z |
| 墙上时间(LocalDateTime) | 某时区日历显示 | 无绝对语义 | 北京 2026-09-28 10:00 |
经典 bug 模式:
存 "2026-09-28 10:00"(本地时间字符串)
→ 另一台机器(不同时区)读出来当成自己本地时间
→ 用户看到的时间错了
正确姿势:存储/传输用时刻(带时区/UTC),只有展示给用户时才转成其本地墙上时间。
记忆:世界只有一个时刻,墙上时间因时区而异——存储时刻,展示本地。
2. UTC 与 Unix 时间戳
UTC(协调世界时):全球时区基准,+00:00。
Unix 时间戳:从 1970-01-01T00:00:00Z 以来的秒数(或毫秒):
import time, datetime
ts = time.time() # 秒(float)
ms = int(time.time() * 1000) # 毫秒
print(datetime.datetime.fromtimestamp(ts)) # → 本地墙上时间
print(datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)) # → UTC
Unix 时间戳的优势:
| 优势 | 说明 |
|---|---|
| 无时区歧义 | 数字本身就是时刻 |
| 可比较 | 数值大小即先后 |
| 紧凑 | 单个数存储/传输 |
陷阱:秒 vs 毫秒 vs 微秒(date 用秒,JS Date.now() 用毫秒——互转忘乘除是最常见的差 1000 倍 bug)。
3. ISO 8601 表示格式
国际标准的时刻文本格式——所有 API 与存储的统一语言:
2026-09-28T10:00:00+08:00 # 带偏移(北京)
2026-09-28T02:00:00Z # UTC(Z = 零偏移)
2026-09-28 # 仅日期
10:00:00 # 仅时间
为什么必须带偏移:2026-09-28T10:00:00 没有时区 = 歧义时间。
最佳实践:
时刻统一存 ISO 8601 + Z(UTC):
2026-09-28T02:00:00Z
展示时转本地:
2026-09-28 10:00 (Asia/Shanghai)
规则:内部与传输一律 UTC(
Z),展示才带时区;别存「无时区的本地时间字符串」。
4. IANA 时区与时区规则
IANA 时区数据库(tzdata)是全球时区事实标准,按「城市/区域」命名:
Asia/Shanghai # 中国标准时(UTC+8,无夏令时)
Asia/Tokyo # 日本
America/New_York # 美东(有夏令时)
Europe/Berlin # 中欧(有夏令时)
UTC
为什么不用 UTC+8 这种偏移:
| 方式 | 问题 |
|---|---|
固定偏移 +08:00 | 不含夏令时规则、不含历史变更 |
IANA Asia/Shanghai | 自动跟随政策变更 |
国家/地区变更:时区规则会变(如俄罗斯取消夏令时、埃及 2023 恢复夏令时)——别硬编码偏移,永远查 tzdata。
常见语言取 IANA 时区:
import zoneinfo
tz = zoneinfo.ZoneInfo("Asia/Shanghai")
now = datetime.datetime.now(tz)
5. 夏令时(DST)陷阱
DST:春夏拨快一小时(如美东 UTC-5 → UTC-4),秋冬拨回——产生两个经典坑:
坑 1:小时不存在(spring forward)
2026-03-08 02:00 → 03:00(美东):02:00-02:59 不存在
「02:30」的本地时间在该天无意义
坑 2:小时重复(fall back)
2026-11-01 01:00 → 01:00(美东):01:00-01:59 出现两次
「01:30」到底是第一个还是第二个?模糊!
处理策略:
| 策略 | 做法 |
|---|---|
| 全程用时刻 | 内部永远 Instant/UTC,不碰墙上时间运算 |
| 运算用 UTC | 加天数/小时在 UTC 上算,再转显示 |
| 排期类需求 | 用「本地时间 + 规则」库(如 dateutil 的 fold 属性) |
| 记录日志 | 日志一律 UTC 时间戳(或带偏移) |
铁律:绝对不要在「本地墙上时间」上做算术——加 24 小时不等于加一天(夏令时那天 24h ≠ 24h)。
6. 日历计算:闰年、月份与星期
闰年规则(不能被直觉覆盖):
能被 4 整除但不是百年 → 闰
是百年则必须能被 400 整除 → 闰
例:2000 闰,1900 不闰,2024 闰
月份天数:30/31 月分布、二月 28/29——永远用标准库,不手写。
星期计算(如某天是星期几)——Zeller 公式或直接库:
from datetime import date
d = date(2026, 9, 28)
print(d.weekday()) # 0=周一
print(d.isoformat()) # 2026-09-28
print(d.isoweekday()) # 1=周一..7=周日
日历陷阱:
- 「下个月」不等于「加 30 天」(跨月会漂)
- 「一年后」跨闰年天数不同
- 周起始(周一 vs 周日)在各文化不同
- 数据库
DATE运算有边界(溢出/非法日期)
7. 主流语言时间 API 对照
| 语言 | 时刻类型 | 本地类型 | 时区 | 说明 |
|---|---|---|---|---|
| Java | Instant | LocalDateTime | ZoneId/ZonedDateTime | 推荐 Instant 存储 |
| Python | datetime(timezone-aware) | datetime naive | zoneinfo.ZoneInfo | naive 对象极易踩坑 |
| JavaScript | Date(内部毫秒) | Date 本地方法 | Intl.DateTimeFormat | 无独立 Instant 类型 |
| Go | time.Time(内部带区) | 同上 | time.LoadLocation | 自带区,较稳 |
| Rust | time::OffsetDateTime | time::LocalDateTime | chrono::FixedOffset | 类型分化清晰 |
Java 推荐(JSR 310):
Instant now = Instant.now(); // 时刻
OffsetDateTime utc = now.atOffset(ZoneOffset.UTC); // 表示
ZonedDateTime bj = now.atZone(ZoneId.of("Asia/Shanghai")); // 本地展示
Python 反模式:datetime.now()(naive,依赖系统时区)——用 datetime.now(timezone.utc) 起步。
规则:语言内置的 naive 本地时间 = 定时炸弹;Java 用 Instant、Python 用 aware datetime、Go 默认就是带区时间。
8. 数据库时间存储
数据库时间的两种哲学:
| 类型 | 语义 | 推荐 |
|---|---|---|
TIMESTAMP WITH TIME ZONE | 存时刻(内部 UTC,展示转本地) | ✅ 绝大多数场景 |
TIMESTAMP / DATETIME(无区) | 存墙上时间(需自行约定时区) | ❌ 除非明确业务是本地日历 |
DATE | 纯日期(生日/账单日) | 合适场景用 |
PostgreSQL 实践:
-- 推荐:存时刻
created_at timestamptz NOT NULL DEFAULT now()
-- 查询按用户时区展示
SELECT name, created_at AT TIME ZONE 'Asia/Shanghai' AS local_time
FROM users;
MySQL 提醒:TIMESTAMP 有时区换算(存 UTC 转本地),DATETIME 是原样字符串——DATETIME + 应用层约定 UTC 是常见稳妥方案。
存储建议汇总:
- 一律
timestamptz(PG)/DATETIME+ 约定 UTC(MySQL) - 索引时间字段用 UTC 值(排序稳定)
- 备份/迁移注意时区配置
9. 分布式系统时钟:NTP 与逻辑时钟
物理时钟漂移:服务器晶振有偏差,NTP 定期校准,但多机之间仍非完全一致——分布式系统不能依赖墙钟判断先后。
解决方案:
| 时钟类型 | 用途 | 说明 |
|---|---|---|
| NTP | 对齐物理时钟 | 校准漂移,仍有窗口 |
| 逻辑时钟(Lamport) | 事件先后 | 用计数而非时间排序 |
| 向量时钟 | 因果一致 | 分布式版本控制/合并 |
| 混合逻辑时钟(HLC) | 兼顾 | 现代分布式数据库用 |
Lamport 时钟规则:每个事件 C++;发送方附 C,接收方 C = max(C, 收到的 C) + 1——保证因果关系可排序。
实战注意:
- 日志排序按「接收方记录时间」,多机可能乱序
- 分布式 ID(雪花算法)用「机器号 + 毫秒 + 序列」避开时钟依赖
- 判断两个事件谁先发生——时钟漂移大时用逻辑时钟,别比墙钟
记忆:墙钟管「显示」,逻辑时钟管「因果」;跨机判先后,宁可信序号不可信时间。
10. 速查表
| 需求 | 做法 |
|---|---|
| 存储/传输时刻 | ISO 8601 + Z / Unix 时间戳 |
| 展示本地时间 | 时刻 → 转 IANA 时区 |
| 时间运算 | 全部在 UTC/Instant 上做 |
| 比较/排序 | 统一时刻(转 UTC) |
| 排期(本地规则) | 用「本地时间 + tzdata 规则」库 |
| 日志时间 | 一律 UTC |
| 数据库 | timestamptz / 约定 UTC |
| 跨机先后 | 逻辑时钟 / 混合时钟 |
| 时区解析 | IANA tzdata,别硬编码偏移 |
一句话记忆:存储用时刻,展示转本地;运算在 UTC,排期问 tzdata;墙钟骗人,逻辑时钟管因果。
延伸阅读
- /text-processing-toolkit/ —
date/tz命令与时间戳转换 - [[database]] — 数据库时间类型与索引
- [[distributed-systems]] — 分布式时钟与一致性理论
- [[observability]] — 日志/追踪的时间戳规范
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。