
063、datetime 时间处理实战时区、DST、时间戳、格式化的 10 个暗坑上周五凌晨两点我被生产环境的报警电话吵醒。一个定时任务本该在每天凌晨1点执行结果那天硬是没跑。查日志发现代码里用datetime.now()获取当前时间然后判断是否到了执行窗口——问题出在那天正好是夏令时切换日凌晨2点变成了3点now()返回的时间直接跳过了1点这个时刻。这种坑我踩过不下十次。今天把 datetime 模块里最要命的10个暗坑全扒出来每个都是真金白银换来的教训。坑1datetime.now()返回的是本地时间但本地时间会变很多人写代码习惯用datetime.now()获取当前时间然后存数据库、写日志、做定时判断。这个函数返回的是系统本地时间但本地时间不是稳定的——夏令时切换、时区政策调整比如俄罗斯2014年、朝鲜2015年改时区都会让这个时间突然“跳变”。# 别这样写依赖本地时间做业务逻辑fromdatetimeimportdatetime nowdatetime.now()# 这里踩过坑夏令时切换时直接跳过1小时# 正确做法始终用UTC时间做内部逻辑fromdatetimeimportdatetime,timezone utc_nowdatetime.now(timezone.utc)# UTC时间不会受任何时区政策影响生产环境里所有服务之间的时间通信、定时任务调度、日志时间戳一律用UTC。只在展示给用户看的时候才转成用户所在时区。坑2datetime.utcnow()返回的是 naive datetime这个函数名字带utc很多人以为它返回的是带时区信息的UTC时间。实际上它返回的是一个没有时区信息的 naive datetime 对象。fromdatetimeimportdatetime utc_naivedatetime.utcnow()# 这里踩过坑这个对象没有tzinfoprint(utc_naive.tzinfo)# 输出 None# 如果你把这个时间存到数据库另一个服务读出来默认当成本地时间处理直接差8小时正确做法是显式指定时区fromdatetimeimportdatetime,timezone utc_awaredatetime.now(timezone.utc)# 带时区信息不会产生歧义坑3时间戳的起点不是1970年1月1日这个坑比较隐蔽。timestamp()方法返回的是从1970-01-01 UTC开始的秒数但有个前提——你的系统支持这个时间范围。fromdatetimeimportdatetime,timezone# 在Windows上这个会报错early_datedatetime(1900,1,1,tzinfotimezone.utc)try:tsearly_date.timestamp()# Windows上ValueError: year is out of rangeexceptValueErrorase:print(fWindows不支持1900年的时间戳:{e})Windows的mktime实现只支持1970年之后的时间。如果你要处理历史日期别用timestamp()直接用字符串存储或者用toordinal()。坑4夏令时切换导致的时间不存在或重复这是最坑爹的。每年3月和11月北美地区切换夏令时。3月第二个周日凌晨2点变成3点2点到3点这个小时直接消失。11月第一个周日凌晨2点变回1点1点到2点这个小时出现两次。fromdatetimeimportdatetime,timedelta,timezoneimportpytz easternpytz.timezone(US/Eastern)# 2023年3月12日凌晨2点不存在try:dteastern.localize(datetime(2023,3,12,2,30))print(dt)# 这里会报错NonExistentTimeErrorexceptpytz.exceptions.NonExistentTimeError:print(这个时间不存在被夏令时跳过了)# 2023年11月5日凌晨1点30分出现两次try:dteastern.localize(datetime(2023,11,5,1,30))print(dt)# 这里会报错AmbiguousTimeErrorexceptpytz.exceptions.AmbiguousTimeError:print(这个时间出现两次无法确定是哪个)处理方案用is_dst参数明确指定夏令时状态或者干脆全部用UTC时间存储展示时再转。坑5pytz的localize和replace不是一回事很多人用pytz时习惯用replace(tzinfopytz.timezone(Asia/Shanghai))来设置时区。这是错的。importpytzfromdatetimeimportdatetime shanghaipytz.timezone(Asia/Shanghai)# 错误做法replace不会处理夏令时wrongdatetime(2023,6,1,12,0).replace(tzinfoshanghai)print(wrong)# 时区偏移可能不对因为pytz的时区对象是Lazy的# 正确做法用localizerightshanghai.localize(datetime(2023,6,1,12,0))print(right)# 正确处理了时区偏移pytz的时区对象是历史时区数据库同一个时区在不同历史时期可能有不同的偏移量。localize会查历史数据replace只是简单赋值。坑6strftime的%Z和%z输出可能为空如果你用 naive datetime 调用strftime%Z和%z会输出空字符串。fromdatetimeimportdatetime naivedatetime(2023,6,1,12,0)print(naive.strftime(%Y-%m-%d %H:%M:%S %Z))# 输出: 2023-06-01 12:00:00# 注意最后是空的没有时区信息# 带时区信息才能正确输出fromdatetimeimporttimezone awaredatetime(2023,6,1,12,0,tzinfotimezone.utc)print(aware.strftime(%Y-%m-%d %H:%M:%S %Z))# 输出: 2023-06-01 12:00:00 UTC日志里如果用了%Z但没给时区信息排查问题时看到的时间没有时区标记很容易误判。坑7timedelta不能正确处理夏令时timedelta只是简单的加减秒数不会考虑夏令时切换。fromdatetimeimportdatetime,timedeltaimportpytz easternpytz.timezone(US/Eastern)dteastern.localize(datetime(2023,3,12,1,30))# 夏令时切换前# 加1天next_daydttimedelta(days1)print(next_day)# 2023-03-13 01:30:00-04:00# 看起来没问题但如果你加的是24小时next_24hdttimedelta(hours24)print(next_24h)# 2023-03-13 02:30:00-04:00# 因为夏令时切换实际只过了23小时处理跨时区的时间计算用dateutil的relativedelta或者pytz的normalize方法。坑8datetime.fromtimestamp的默认时区是本地时区这个函数接收一个时间戳返回一个 datetime 对象。如果不指定时区返回的是本地时间的 naive datetime。importtimefromdatetimeimportdatetime tstime.time()# 不指定时区local_naivedatetime.fromtimestamp(ts)# 这里踩过坑不同机器结果不同# 指定UTCutc_awaredatetime.fromtimestamp(ts,tzdatetime.timezone.utc)# 如果你在服务器AUTC8上生成时间戳在服务器BUTC0上解析# 不指定时区的话两个服务器解析出来的时间不一样跨服务器部署时一定要显式指定时区或者统一用datetime.utcfromtimestamp加replace(tzinfotimezone.utc)。坑9ISO 8601 格式解析的兼容性问题Python 3.7 之后datetime.fromisoformat可以解析 ISO 8601 格式但有个限制——它只支持 Python 自己的isoformat()输出格式不是完整的 ISO 8601。fromdatetimeimportdatetime# Python 3.7 支持dtdatetime.fromisoformat(2023-06-01T12:00:0000:00)print(dt)# 正常# 这个格式在ISO 8601中是合法的但Python不支持try:dtdatetime.fromisoformat(2023-06-01T12:00:00Z)# Z表示UTCexceptValueError:print(Python不支持Z后缀需要手动替换)# 正确做法先替换Ziso_str2023-06-01T12:00:00Zifiso_str.endswith(Z):iso_striso_str[:-1]00:00dtdatetime.fromisoformat(iso_str)第三方库dateutil.parser.isoparse支持完整的 ISO 8601生产环境建议用这个。坑10datetime对象的比较和哈希Naive datetime 和 aware datetime 不能直接比较会抛出 TypeError。fromdatetimeimportdatetime,timezone naivedatetime(2023,6,1,12,0)awaredatetime(2023,6,1,12,0,tzinfotimezone.utc)try:resultnaiveaware# TypeError: cant compare offset-naive and offset-aware datetimesexceptTypeError:print(不能直接比较需要统一)# 正确做法统一成一种naive_utcnaive.replace(tzinfotimezone.utc)print(naive_utcaware)# 正常比较更隐蔽的是如果你把 naive 和 aware 混在一起放进集合或者作为字典键哈希值不同会导致查找失败。个人经验建议内部存储一律用UTC时间戳float不要存 datetime 对象。时间戳是绝对的不受任何时区政策影响。数据库字段用BIGINT存毫秒数。展示层再转时区。用户看到的时间根据他的时区设置转。后端代码里永远不要出现datetime.now()全部用datetime.now(timezone.utc)。定时任务用 cron 表达式 UTC。不要用schedule库的every().day.at(01:00)这种写法因为夏令时切换时1点可能不存在或者出现两次。用 UTC 时间的 cron 表达式比如0 1 * * *表示 UTC 时间1点。测试时一定要覆盖夏令时切换日。每年3月和11月的那几天写单元测试验证你的时间逻辑。我见过太多系统在切换日挂掉。用pendulum替代datetime。如果你还在用原生的datetimepytz建议换成pendulum。它的 API 设计更合理自动处理夏令时支持add(days1)和add(hours24)的区别而且性能比pytz好。日志时间戳用 ISO 8601 格式 时区偏移。比如2023-06-01T12:00:0008:00不要用2023-06-01 12:00:00这种没有时区信息的格式。排查问题时时区信息能救命。那次凌晨的报警最后定位到的问题就是datetime.now()在夏令时切换日跳过了执行窗口。修复方案很简单所有定时任务的时间判断改成 UTC展示时再转本地时间。从那以后我再也没在时间问题上栽过跟头。