# 日期与时间

Source: https://codewiki.com/zh/foundations/dates-and-time/

> - **what**: 时间点说明事情何时发生；民用时间字段、日历和时区说明人们如何在当地称呼这个时间点。
> - **trap**: UTC 偏移不等于时区，一个日历日不一定经过 24 小时，有些本地时间还会出现两次或根本不存在。
> - **fix**: 保留每种值的含义，只在明确边界上转换；测量时长和截止时间时使用单调时钟。

## 是什么，为什么存在

日期与时间数据回答的是几种不同问题。时间点（instant）标识时间线上的一个位置。民用时间（civil time）提供 `2026-11-01 01:30` 这样的日历字段，时区（time zone）则提供把这些字段映射到时间线的规则。

时长回答经过了多久。日历运算回答的是另一个问题，例如「当地明天的同一时刻」或「下个月最后一天」。这些含义在普通日期恰好一致，所以错误设计可能一直显得正确，直到月末或时钟切换进入生产环境。

Unix 纪元（Unix epoch）为时间戳系统提供共同的数值起点。只要单位和时间尺度明确，从该起点开始的计数就能标识时间点。但这个数字不会记住来源时区、用户输入的日历说法，也不会说明规则变化后日程是否应保留当地钟表时间。

审计日志、预订系统、令牌过期、账期、重复任务、年龄判断、遥测和用户界面都会遇到这些区别。已经完成的付款通常需要时间点。生日通常只需要日历日期，不带时间和时区。

每天 `09:00` 开门的商店需要当地时钟字段与指定时区。必须在五秒后超时的请求需要时长与单调时钟。一个泛化的 `timestamp` 字段无法同时说明这四种契约。

选择表示形式之前，先说清领域值：

| 领域事实 | 必须保留 | 不要擅自添加 |
| --- | --- | --- |
| 已记录事件 | 时间点与精度 | 宿主本地时区 |
| 生日或发票日期 | 日历字段 | 午夜或 UTC |
| 指定时区的预约 | 本地字段、时区与解析策略 | 把当前偏移当永久规则 |
| 一段时间后过期 | 起始时间点或单调截止值，以及时长 | 日历日语义 |
| 每月重复 | 日历规则；涉及时刻时还要保留时区 | 每月固定秒数 |

这种区分能避免有损往返。如果把生日变成 UTC 午夜，在 UTC 以西展示时可能得到前一天。如果纽约预约只保存 `-04:00`，这个值既无法应用冬季规则，也无法采用以后的时区数据库变更。

真正困难的不是格式化，而是决定转换上下文由谁提供，以及本地字段有歧义或无效时该怎么办。这个策略应属于数据契约，不能藏在碰巧负责解析的辅助函数里。

## 工作原理

### 时间点与民用字段

时间点本身没有年、月、小时或星期几。这些字段是通过日历和时区规则集得到的投影视图。UTC 是一种实用的零偏移投影，但 UTC 表示仍是时间点的显示方式，不是时间点中多存了一份状态。

民用时间的方向相反。年、月、日、时、分等字段描述日历和钟表显示的内容。要把它们解析为时间点，还需要日历、指定时区或固定偏移；遇到切换边界时，有时还要明确选择。

两个方向的行为不同：

| 操作 | 输入 | 输出 | 是否可能有歧义 |
| --- | --- | --- | --- |
| 投影 | 时间点与时区 | 本地日历字段与偏移 | 对已定义的规则集而言不会 |
| 解析 | 本地字段与时区 | 时间点 | 会，在空缺和重叠处发生 |
| 格式化 | 结构化时间值与区域设置 | 供人阅读的文本 | 不能作为安全解析格式 |
| 解析文本 | 契约规定的文本语法 | 结构化时间值 | 字段或偏移缺失时会有歧义 |

把时间点投影到时区只会产生一个结果，因为时间点已经确定应采用哪条规则。本地字段则可能解析出零个、一个或两个候选时间点。如果 API 只暴露 `local_time` 与 `zone`，却不说明解析策略，业务决定就仍是隐式的。

### 偏移与指定时区

`+08:00` 这样的偏移只描述与 UTC 的一个数值关系。它附在完整本地日期时间字段上时，足以标识一个时间点，却不能描述过去或未来的规则变化。

`Europe/Paris` 或 `America/New_York` 这样的 IANA 时区名标识一套持续维护的历史和规则。具体偏移取决于所考察的时间点。政府可以修改规则，所以已部署应用也会依赖某个时区数据库版本。

`CST` 这样的缩写不适合作为交换标识符。不同地区会复用同一组字母，短标签也可能随季节改变。需要地区规则时使用 IANA 标识符；传输时间点时则带上明确的数值偏移。

夏令时（daylight saving time）是规则变化的一个原因，却不是唯一原因。历史偏移变更与政府决策也会形成切换。代码应根据时区规则推理，不能假定每次切换都是熟悉的一小时季节调整。

### 空缺、重叠与 `fold`

时钟向前拨时，一段本地时间会被跳过。落在空缺中的本地值没有对应时间点。时钟向后拨时，一段时间会重复，其中每个本地值都对应两个时间点。

Python 的 `zoneinfo.ZoneInfo` 把 IANA 规则应用于感知型 `datetime` 对象。发生重叠时，`datetime.fold` 用 `0` 选择切换前偏移，用 `1` 选择切换后偏移。只有两种解释确实不同时，这个标记才有意义。

通过构造器或 `replace(tzinfo=...)` 附加 `ZoneInfo`，不会验证本地字段是否存在。即使字段位于空缺中，Python 也会创建感知型对象。如果输入起点是民用字段，就必须先规定空缺策略，再通过 UTC 往返验证，或者使用能直接暴露解析结果的日程库。

### 感知型值与简单型值

Python 把带有可用偏移信息的 `datetime` 称为感知型（aware），否则称为简单型（naive）。简单型值本身无法可靠承诺它表示 UTC、宿主本地时间还是尚未解析的民用值。含义只存在于外围代码中，很容易丢失。

获取当前 UTC 时间点时使用 `datetime.now(UTC)`，不要使用返回简单型结果且已经弃用的 `datetime.utcnow()`。在边界处把外部时间点解析为感知型值。只有日期的值用 `date` 保存；未解析的本地日程则使用明确包含时区和策略的结构。

`replace(tzinfo=zone)` 会给现有字段换标签，不会把一个时间点从某个时区移到另一个时区。`astimezone(zone)` 保持时间点不变，只改变投影字段，因此两个操作不能互换。

### 时长与日历运算

经过时长可以用秒、纳秒或 `timedelta` 表示，具体取决于系统所需精度。跨越地区时钟变化时，如果要增加恰好经过的 24 小时，应在 UTC 时间线上运算，再把结果投影到展示时区。

日历运算处理字段和规则。「明天中午」会推进本地日期并保留中午，即使时间线上只经过 23 或 25 小时。「一个月后」还需要溢出规则，因为目标月份可能没有原来的日期数字。

Python 算术有一条容易踩坑的同区规则。给本地感知型 `datetime` 加 `timedelta` 时，会保留同一 `tzinfo` 并进行字段运算；两个感知型值共享同一个 `tzinfo` 时，相减会忽略各自偏移。需求若是实际经过时间，应先把两个端点转为 UTC，或者相减各自的时间戳。

进程内超时不要使用挂钟读数。网络时间同步或操作员可能调整系统时间，使连续两次 UTC 读数发生跳跃。`time.monotonic()` 不会倒退，也没有民用时间含义，而区间测量恰好需要这种性质。

### 存储与边界

对于已记录事件，可以用明确规定格式且带已知偏移的 RFC 3339 文本，或者单位写入契约的整数纪元值跨服务传输。必须验证语法、范围、精度与单位。只叫 `timestamp` 的裸整数很容易产生秒与毫秒混淆。

如果后续行为取决于地区规则，就要单独保存指定时区。日历服务可能需要原始本地字段、IANA 时区、选定的重叠或空缺策略，以及解析后的时间点。同时保留时间点有利于排序和审计，又不会丢掉日程意图。

格式化属于展示边界。明确传入区域设置与时区，不要继承宿主默认值。格式化输出供人阅读，通常不是稳定的交换格式，也不应被反向解析成领域数据。

数据库类型各不相同。有的保留时间点，有的保留不带时区的字段，`timestamp` 之类名称的含义也取决于具体产品。把数据库值映射为语言类型前，应先阅读数据库类型与驱动转换的准确契约。

## 示例

下面的示例使用 Python 3.14 标准库中的 `datetime`、`zoneinfo` 和 `time` 模块。所有输出均由 Python 3.14.3 配合本地 IANA 时区数据实际生成。

### 投影一个时间点

发布时刻带有明确的 UTC 偏移，因此标识一个时间点。每次调用 `astimezone()` 都保持该时间点不变，只改变它的民用投影。

<!-- quick -->

```python
# file: instant_views.py
from datetime import datetime
from zoneinfo import ZoneInfo


release = datetime.fromisoformat("2026-11-01T05:30:00+00:00")

for zone_name in ["America/New_York", "Asia/Shanghai"]:
    local = release.astimezone(ZoneInfo(zone_name))
    print(f"{zone_name}: {local:%Y-%m-%d %H:%M %z} fold={local.fold}")

print(f"epoch seconds: {release.timestamp():.0f}")
```

```text
America/New_York: 2026-11-01 01:30 -0400 fold=0
Asia/Shanghai: 2026-11-01 13:30 +0800 fold=0
epoch seconds: 1793511000
```


<!-- /quick -->

纽约的 `01:30` 是时钟回拨当天的第一次出现，所以 `fold` 为 `0`。上海在这个时间点没有重叠。两行仍然都对应纪元秒 `1793511000`。

对于已加载的规则集，这个方向是确定的：先有时间点，再做投影。逆向解析则需要策略，因为经过一小时后，纽约会再次显示 `01:30`。

### 解析重复的本地时间

下面的程序构造同一组纽约钟表字段的两种解释。`fold` 改变所选偏移，因此也改变 UTC 时间点。

```python
# file: ambiguous_time.py
from datetime import UTC, datetime
from zoneinfo import ZoneInfo


new_york = ZoneInfo("America/New_York")
first = datetime(2026, 11, 1, 1, 30, tzinfo=new_york, fold=0)
second = datetime(2026, 11, 1, 1, 30, tzinfo=new_york, fold=1)

for candidate in [first, second]:
    print(
        f"fold={candidate.fold} "
        f"offset={candidate.utcoffset()} "
        f"utc={candidate.astimezone(UTC).isoformat()}"
    )

print(f"same wall fields: {first.replace(tzinfo=None) == second.replace(tzinfo=None)}")
print(f"elapsed: {(second.timestamp() - first.timestamp()) / 3600:.0f} hour")
```

```text
fold=0 offset=-1 day, 20:00:00 utc=2026-11-01T05:30:00+00:00
fold=1 offset=-1 day, 19:00:00 utc=2026-11-01T06:30:00+00:00
same wall fields: True
elapsed: 1 hour
```

这种特殊的负 `timedelta` 输出分别表示 UTC−04:00 与 UTC−05:00。移除 `tzinfo` 后，两组字段相等，但时间戳相差一小时。预订系统必须明确选择、拒绝或询问，不能直接采用库的默认值。

不能只根据小时推断 `fold`。它只有与具体日期和时区组合、且规则确实产生重叠时才有意义。如果以后重建必须回到同一次出现，就要持久化解析后的时间点或明确选择。

### 日历意图与经过时间

这个示例中的纽约在 3 月 8 日早晨向前拨钟。一项操作保留下一个日期的当地中午，另一项操作则推进恰好经过的 24 小时。

```python
# file: calendar_vs_elapsed.py
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo


new_york = ZoneInfo("America/New_York")
start = datetime(2026, 3, 7, 12, 0, tzinfo=new_york)

# 日历意图保留 12:00；经过时间意图保留 24 小时。
same_wall_time_tomorrow = start + timedelta(days=1)
after_twenty_four_hours = (
    start.astimezone(UTC) + timedelta(hours=24)
).astimezone(new_york)

print(f"start:    {start:%Y-%m-%d %H:%M %z}")
print(f"calendar: {same_wall_time_tomorrow:%Y-%m-%d %H:%M %z}")
print(f"elapsed:  {after_twenty_four_hours:%Y-%m-%d %H:%M %z}")
print(f"calendar seconds: {same_wall_time_tomorrow.timestamp() - start.timestamp():.0f}")
print(f"elapsed seconds:  {after_twenty_four_hours.timestamp() - start.timestamp():.0f}")
```

```text
start:    2026-03-07 12:00 -0500
calendar: 2026-03-08 12:00 -0400
elapsed:  2026-03-08 13:00 -0400
calendar seconds: 82800
elapsed seconds:  86400
```

日历结果只经过了 82,800 秒，因为本地时钟跳过一小时。经过时间结果显示为 `13:00`，比起始钟点晚一小时。两者没有哪个总是正确；具体需求决定选择。

代码用时间戳比较经过时间。如果改写为 `(after_twenty_four_hours - start).total_seconds()`，得到的会是同区钟表字段差，本例中这些显示字段相差 25 小时。这个结果回答了另一个问题。

### 构造单调截止时间

截止时间可以保存为单调时钟的一次读数。注入时钟能让测试取得准确读数，也不会误把日历时间戳当作区间控制依据。

```python
# file: monotonic_deadline.py
import time


class SequenceClock:
    def __init__(self, readings):
        self._readings = iter(readings)

    def __call__(self):
        return next(self._readings)


def make_deadline(timeout_seconds, clock=time.monotonic):
    expires_at = clock() + timeout_seconds

    def remaining():
        return max(0.0, expires_at - clock())

    return remaining


# 注入时钟，让截止时间测试保持确定性。
clock = SequenceClock([100.0, 101.25, 106.0])
remaining = make_deadline(5.0, clock)

print(f"remaining: {remaining():.2f}")
print(f"remaining: {remaining():.2f}")
```

```text
remaining: 3.75
remaining: 0.00
```

这些数值读数不能转换为日期，也不应作为通用时间戳序列化给另一个进程。它们只对当前运行时中兼容时钟测出的差值有意义。这种窄契约能防止民用时间校正延长或缩短超时。

`max()` 截断让已过期预算返回零，而不是负值。真实等待循环仍要把当前剩余预算传给每次阻塞操作，并在预算归零时停止。

## 陷阱

### 把简单型值当成 UTC

> **陷阱:** 生成的辅助函数解析 `2026-06-01T09:00:00`，随后假定简单型结果为 UTC。另一台机器却把相同字段当成本地时间，序列化时无声地改变了时间点。

**修复方法：** 对表示时间点的输入要求明确偏移，并在边界处转为感知型值。未解析的民用输入应单独建模，不能只靠注释或变量名说明其时区契约。

### 把重贴标签当成转换

> **陷阱:** `value.replace(tzinfo=ZoneInfo("UTC"))` 保留所有字段，只换上新标签。如果 `value` 描述巴黎钟表时间，这会悄悄创建另一个时间点。

**修复方法：** 要保持已有时间点时使用 `astimezone()`。只有明确要把字段解析成当地民用时间时才使用 `replace(tzinfo=...)`，随后还要处理空缺与重叠。

### 把偏移存成时区

> **陷阱:** 为纽约重复日程保存 `-04:00`，只记录了一次偏移，没有记录纽约规则。日程会在冬季偏移一小时，也无法适应以后的规则变更。

**修复方法：** 把 IANA 时区标识符与重复字段一起存储，并规定时区规则数据库升级如何影响未来日期。解析后的传输时间戳仍要带明确偏移，保证每次传输的时间点没有歧义。

### 让库替你决定切换策略

> **陷阱:** 给春季空缺中的 `02:30` 附加 `ZoneInfo`，得到的是对象，而不是错误。进入秋季重叠时，默认 `fold=0` 又会悄悄选中第一次出现。

**修复方法：** 通过 UTC 往返检测零个或两个候选项，再应用有名称的产品策略：拒绝、向前移动、选较早、选较晚，或者询问用户。为每个支持日程的时区测试真实切换点。

### 混用日历运算与经过时间运算

> **陷阱:** 同一个「增加一天」在一条路径中实现为固定秒数，在另一条路径中实现为同区字段运算。普通日期测试都能通过，时钟切换时却会分歧；按月运算还多了溢出问题。

**修复方法：** 用操作名称与测试写明语义选择，例如 `expires_after_hours` 或 `next_local_occurrence`。测试切换点、月末、闰日和反向移动时，同时断言当地字段与时间线差值。

### 使用挂钟计时

> **陷阱:** 超时逻辑计算 `datetime.now(UTC) - started_at`。时钟同步可能让这个来源前进或后退，造成过早、过晚或负数时长。

**修复方法：** 经过时间与截止值使用 `time.monotonic()` 或 `time.monotonic_ns()`。日志需要民用时间戳时另行记录 UTC 时间点，不要把单调时钟读数转换成日期。

<!-- deep -->

## 在不猜测的前提下解析本地时间

### 候选时间点

本地时间解析应被视为搜索候选时间点，不能只做字符串解析再贴上时区标签。首先取得已经验证的日历字段与指定时区。尝试每种受支持的重叠解释，把候选项通过 UTC 再投影回原时区；只有所有本地字段都保持不变时才保留。

普通时间的两个 `fold` 尝试可能归并为同一个时间点；按时间线值去重，保留一个候选项。空缺没有候选项，因为两次尝试都无法往返回请求字段。重叠则产生两个不同候选时间点，它们投影后的字段相同。

候选项数量为策略代码提供了清楚输入：

| 候选项数量 | 含义 | 可选策略 |
| --- | --- | --- |
| `0` | 本地时间不存在 | 拒绝，或按已说明规则移动 |
| `1` | 本地时间唯一 | 接受 |
| `2` | 本地时间重复 | 选较早、选较晚，或询问 |

「向前移动」仍需定义。它可以按偏移变化量移动，也可以选择空缺后的第一个有效时间点。遇到不寻常的历史切换时，两种策略会产生不同结果，所以操作必须说明自己实现哪一种。

### 相等与排序

感知型 `datetime` 并不表示无需检查契约就能安全比较。两个操作数拥有同一个 `tzinfo` 对象时，Python 比较会忽略 `tzinfo` 和 `fold`，所以示例中两个重复的 `01:30` 会比较为相等，即使它们表示不同时间点。它们的时间戳并不相等。

判断时间线身份时，应把有效值规范化为 UTC，或者比较契约规定的纪元单位。判断民用字段是否相同时，应在明确选择的时区中比较日期与时间字段。这是两个不同谓词，理应有不同名称。

审计事件排序需要时间点语义。同一个当地日期中展示的商店预约，可能需要民用顺序，并在重叠处增加出现次序作为同值条件。直接比较 datetime 会隐藏调用方真正需要的顺序。

### 精度、范围与时间尺度

纪元数值必须带单位。接近当前日期的十位十进制数常像秒，十三位数常像毫秒，但根据数量级猜测并不是验证。应在 schema 与字段名中声明单位，拒绝超出领域范围的值，并只转换一次。

精度也属于契约。Python `datetime` 保存微秒，数据库与协议保存的小数位可能更多或更少。把时间戳用于相等判断、签名、缓存键或幂等判断前，应先定义截断或舍入策略。

大多数民用应用 API，包括 Python `datetime`，都不能用字段值 `60` 表示闰秒。不同系统交换的 UTC 时间戳也可能采用已声明的平滑策略。需要科学时间尺度的系统必须明确命名，不能假定 Unix 风格秒数已经提供这种模型。

### 时区数据库变更

指定时区代表规则，而规则有版本。更新 tzdata 后，未来预约即使保存的民用字段与时区不变，解析出的时间点也可能改变。这可能是有意修正，也可能违反已经作出的承诺。

未来日程需要明确策略：按当前规则重新计算；在平台允许时固定规则集版本；或者保留此前解析的时间点，并在规则变化时显示冲突。正确选择取决于承诺究竟是「当地 `09:00`」还是「这个确切时间点」。

过去事件的时间点应在数据库更新后保持稳定。如果载入了修正后的规则，其历史展示偏移仍可能改变；审计系统若必须重现用户当时所见内容，还需要保留渲染记录或规则集来源。

### 重复日程是规则，不是秒数列表

重复日程包括字段、日历频率、例外，以及时刻相关时的指定时区与切换策略。过早展开太多日期，会让已生成时间点在规则变化后过时。太晚展开又可能无法满足提醒和容量规划。

应只生成有界时间窗口，保留重复规则，并明确重新生成行为。重叠可能产生两个相同格式化标签，因此去重必须使用日程出现标识，不能用标签。跳过的日程也要有可观察状态，不能悄悄消失。

按月和按年重复都需要溢出规则。1 月 31 日加一个月，可以截断到 2 月末、跳过 2 月，也可以拒绝该日程。2 月 29 日加一年也有同样的策略问题；泛化时长无法作答。

### 测试设计

时间测试应控制每项环境输入：当前时间点、区域设置、IANA 时区、需要可复现性时的 tzdata 来源，以及单调时钟读数。不要用被测辅助函数计算预期值。应采用独立计算的时间点或已发布的切换数据。

建立一个小型边界矩阵：

1. 一个普通且唯一的本地时间。
2. 一个落在真实空缺中的时间。
3. 真实重叠中的两次出现。
4. 一次月末或闰日日历移动。
5. 一个负数或已经过期的时长。

对每个已解析日程，断言保存的民用字段、时区标识符、所选策略、UTC 时间点与往返结果。对经过时间操作，除了展示端点，还要单独断言底层时长。这样能发现只在一种表示中看似正确、实际却回答错误问题的代码。

性质测试还可以加入实用不变量。把有效时间点投影到某时区，再用返回的 `fold` 解析这些字段，应在支持精度内还原原时间点。在时间线上先增加时长再减去同一时长，也应往返成功，除非明确遇到范围边界。

运行时诊断应记录足够的结构化上下文，以便重建决定：输入字段、时区标识符、偏移、`fold` 或有名称的解析策略、解析后的 UTC 时间点，以及可用时的 tzdata 来源。不能仅因为调度错误附带了用户自由文本，就把这些文本写入日志。

<!-- /deep -->

[检查点: foundations/dates-and-time](https://codewiki.com/zh/foundations/dates-and-time/#checkpoint)

## 延伸阅读

- [Python `datetime` 文档](https://docs.python.org/3.14/library/datetime.html)
- [Python `zoneinfo` 文档](https://docs.python.org/3.14/library/zoneinfo.html)
- [Python `time.monotonic()` 文档](https://docs.python.org/3.14/library/time.html#time.monotonic)
- [IANA 时区数据库理论](https://data.iana.org/time-zones/tzdb/theory.html)
- [RFC 3339：互联网中的日期与时间](https://www.rfc-editor.org/rfc/rfc3339.html)
