日期与时间

区分时间点、民用时间、时区、时长与日历运算,保留转换所需的上下文,避免看似正确却含义模糊的时间计算。

难度 进阶 时长 标准深度约15分钟
版本 Python 3.14
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()都保持该时间点不变,只改变它的民用投影。

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}")
America/New_York: 2026-11-01 01:30 -0400 fold=0
Asia/Shanghai: 2026-11-01 13:30 +0800 fold=0
epoch seconds: 1793511000

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

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

解析重复的本地时间

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

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")
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小时。

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}")
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小时。这个结果回答了另一个问题。

构造单调截止时间

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

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}")
remaining: 3.75
remaining: 0.00

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

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

陷阱

把简单型值当成UTC

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

把重贴标签当成转换

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

把偏移存成时区

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

让库替你决定切换策略

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

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

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

使用挂钟计时

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

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

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

候选时间点

本地时间解析应被视为搜索候选时间点,不能只做字符串解析再贴上时区标签。首先取得已经验证的日历字段与指定时区。尝试每种受支持的重叠解释,把候选项通过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来源。不能仅因为调度错误附带了用户自由文本,就把这些文本写入日志。

延伸阅读

检查点

5个问题 · 1道输出预测题 · 1道找错题

复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?