ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python时间处理完全指南:time与datetime模块核心转换与避坑

Python时间处理完全指南:time与datetime模块核心转换与避坑

Python里的time和datetime,几乎是我见过被混用得最厉害的一对标准库。很多人写了好几年代码,还是经常对着time.time()、time.localtime()、datetime.now()、strftime、strptime这些名字发呆,每次用到都要重新翻一遍文档,翻完还容易踩时区的坑。

复习这两个模块,我一直主张抓住一条主线:不管是time还是datetime,Python里所有时间处理本质上都在三种状态之间互相转换——时间戳、结构化时间对象、格式化字符串。你只要把这个三角关系刻在脑子里,剩下的事情全部是对号入座。

这篇复习笔记,是我自己每隔一段时间都会回来重看一遍的内容。它适合给准备面试的人、工作中频繁处理日志和数据的同学,以及那些“每次写时间代码都要现查资料”的Python使用者。

1. 先把时间表达的三种状态刻进脑子

1.1 为什么你总觉得这两个模块难记

说句实话,time和datetime难记,不是因为API多,而是因为绝大多数教材把它们分开讲,导致你脑子里同时存在两套坐标系。一套是C语言风格的时间表示(time模块),一套是面向对象风格的时间表示(datetime模块)。两套体系长得不一样,功能还互相重叠,你记起来当然一团浆糊。

打个比方。time模块更像是一个“老派工具箱”,里面装的都是跟操作系统底层打交道的东西——时间戳、CPU时钟、睡眠函数,很多实现直接就是C库的封装。datetime模块则是站在更高层次重新设计的“现代化工具”,提供date、time、datetime这些直观的对象,还自带日期算术。我见过不少资历不浅的Python开发者,候选人在面试里写时间处理写不利索,几乎全是卡在“不知道一条数据到底应该用哪种形式承载”。

有一个记忆框架,我用了很久,特别管用:任何时刻,时间数据在Python里一定是三种形态之一。

  • 时间戳(Timestamp):一个浮点数或整数,表示从1970年1月1日0点(UTC)到目标时刻经过的秒数。这是计算机最“喜欢”的形态,比较大小、存数据库、做减法都靠它。
  • 结构化对象(Struct/Time Object):把时间拆成年、月、日、时、分、秒、星期等字段的对象。time.localtime()返回time.struct_time,datetime.datetime.now()返回datetime对象。这是让人脑最舒服的形态。
  • 字符串:人类最终看到的形态,比如“2024-06-15 14:30:00”。这种形态适合展示和数据交换,但不能直接做计算。

你回忆一下自己踩过的坑,是不是基本都起源于这三种形态之间的转换没打通?格式化输出、时间加减、比较早晚、序列化存储,每一条路径都对应一组固定的函数,把它们焊死在脑子里,后面的事情全是重复劳动。

1.2 最容易搞混的一组概念:时刻、日期、时长

复习之前,还得先把三个日常概念在代码层面分清楚,不然下面讲什么你都会觉得绕。

第一是“时刻”,代码里对应的是绝对时间点。2024-06-15 14:30:00就是一个时刻,它是唯一的,不会因为你调用方式不同就变成另一个时刻。第二是“日期”,2024-06-15只精确到天,它本质上是一个“时刻的粗粒度表示”。第三是“时长”,对应timedelta,比如“3小时20分钟”,描述的是两个时刻之间的距离,而不是某个时刻本身。

很多人背了那么多API,真到用的时候还是乱,根源就是把“时刻”和“时长”搞混。比如有人要用datetime.now()去减一个字符串,第一反应不是先把字符串转成对象,而是直接把字符串丢进减法里报错。这就是没理清“结构化对象才能真正参与运算”这层基础逻辑。

2. time模块:老派但依然能打的底层工具箱

2.1 核心API一览:哪些现在还常用,哪些已经被取代

time模块整体上就是围绕时间戳和struct_time展开的。真正在业务代码里高频出现的函数,说实话就那么几个,我直接给你列一张表,标注清楚“你还应该不该用”。

函数作用状态说明
time.time()返回当前时间戳(秒,浮点数)高频使用,性能好,跨平台稳定
time.localtime([secs])将时间戳转为本地时区的struct_time高频,默认取当前时间
time.gmtime([secs])将时间戳转为UTC时区的struct_time一般用datetime替代,部分场景仍需
time.mktime(t)将struct_time转回时间戳有替代方案,但底层逻辑要懂
time.strftime(fmt, t)将struct_time格式化为字符串高频,注意datetime也有同名方法
time.strptime(str, fmt)将字符串解析为struct_time低频,一般用datetime.strptime
time.sleep(secs)让程序暂停指定秒数高频,注意阻塞特性

time.time()得到的浮点数,在老的32位系统上曾经出现过“2038年问题”的隐患,现代Python跑在64位系统上基本不用操心了。time.clock()在Python 3.3之后被移除了,3.8之后彻底没了,测代码执行时长用time.perf_counter()或者time.process_time(),前者带睡眠时间,后者不带,选哪个看你到底想测“墙上时间”还是“纯CPU时间”。

2.2 time.sleep的正确打开方式:阻塞是它的性格

time.sleep()是唯一一个不做时间转换却总在业务代码里出现的函数。它的作用就是让当前线程挂起指定秒数,CPU让出来给别的线程用。这个函数在网络请求限速、重试退避、动画循环控制这些场景里极其常见。

写爬虫的人尤其要留意:不要每请求一次就sleep(1),这样太死板,一旦目标服务响应变慢,你的频率控制还是固定的。更专业的做法是用指数退避,第一次失败等1秒,第二次等2秒,第三次等4秒。这种设计比固定sleep容错性强得多,实际项目里省去无数因为“请求太频繁被封”而半夜爬起来改代码的麻烦。

time.sleep()底层调的是C库的休眠接口,它的实际挂起时间可能比参数略大,尤其在高负载机器上。所以一切对时间精度要求极高的逻辑,都不该建立在sleep的假设上。

2.3 struct_time到底长什么样

time.localtime()返回的struct_time是个类元组对象,你既可以用索引访问tm_year、tm_mon,也可以用属性访问。它包含九个字段:tm_year、tm_mon、tm_mday、tm_hour、tm_min、tm_sec、tm_wday、tm_yday、tm_isdst。

这里有两个新手最容易看懵的点:tm_wday是星期几,取值范围0到6,0表示周一;tm_yday是一年中的第几天,范围1到366。这两个字段不常用,但面试时经常被拿来当考点。我当年第一次看到tm_wday=2的时候还以为是周三,其实是周三没错,但0是周一的约定就是容易让人记混。

tm_isdst表示夏令时是否生效:0表示不生效,1表示生效,-1表示未知。如果你在全球分布式系统里处理日期逻辑,夏令时永远是噩梦级别的问题。后面讲时区坑的时候我会专门展开。

3. datetime模块:面向对象时代的正道

3.1 四个类的分工:date、time、datetime、timedelta

datetime模块把时间拆成四个类,分工明确,这才是它比time模块好用得多的根因。

datetime.date只管日期,年、月、日,没有时分秒。datetime.time只管时间,时、分、秒、微秒,没有日期。datetime.datetime是两者合体,既能表示日期也能表示时间,是实际业务里出现频率最高的一个。datetime.timedelta表示两个时间点之间的差值,可以是天、秒、微秒的任意组合,它不表示“哪天”,只表示“隔了多久”。

这里有个细节值得注意:time.time这个自带的小时区抽象在Python 3版本里是有tzinfo属性的,但你平时传进去的如果是个None,它就变成了一个“幼稚时间”(naive time),不带时区信息。后面讲时区优化的时候这个点会反复出现。

3.2 datetime对象的构造与常用方法

构造一个datetime对象最直接的方式是这样的:

from datetime import datetime dt = datetime(2024, 6, 15, 14, 30, 45, 123456) print(dt)

输出结果带着微秒:2024-06-15 14:30:45.123456。

这个构造逻辑很清晰:年、月、日是必填参数,时分秒默认0,微秒默认0。值得注意的一个细节是,datetime的tzinfo默认是None,也就是说构造出来的是不带时区信息的“幼稚对象”。如果你在搞跨时区业务,这个对象是不完整的。

高频方法就那么几个:now()返回当前本地时间,utcnow()在Python 3.12已经被标记为过时了,建议用datetime.now(timezone.utc)替代;fromtimestamp()把时间戳转为本地时间,utcfromtimestamp()转成UTC时间;.strftime()把对象格式化为字符串,.strptime()把字符串解析为对象;.date()取日期部分,.time()取时间部分,.replace()可以只改部分字段,.timestamp()直接转成时间戳。

now()和today()在绝大多数场景下行为一致,区别只在于today()是纯本地时间,不带时区,now()可以对tz传参获得带时区的当前时间。我翻了源码,today()本质上会调用now(),所以别纠结选哪个,统一用now()就好。

3.3 timedelta的运算规则:时间和时间的加减法

timedelta在业务代码里简直是无处不在。它内部只存三样东西:天(days)、秒(seconds)、微秒(microseconds)。你构造它的时候可以传weeks、days、hours、minutes、seconds、milliseconds、microseconds,内部会统一折算成天、秒、微秒三件套。

from datetime import datetime, timedelta start = datetime(2024, 6, 15, 8, 0) end = datetime(2024, 6, 15, 18, 30) gap = end - start print(gap) # 10:30:00 print(gap.total_seconds()) # 37800.0

两个datetime对象相减,得到的是timedelta;datetime加减timedelta,得到的是新的datetime。这条运算规则是整套时间计算的基础。你处理“订单超过24小时未支付就自动关闭”这类需求,本质上就是拿“现在”减去“创建时间”,然后跟timedelta(hours=24)比大小,没有第二个思路。

这里有一个特别容易翻车的点:timedelta不能做跨月跨年的加减。你想算“3个月后是哪天”,直接加timedelta(days=90)是不对的,因为3个月不等于固定天数。这种需求你必须自己写逻辑处理,或者借助第三方库。Python标准库里至今没有原生的“月份加减”能力,这是我每次复习都会在笔记本上标注一遍的“坑位”。

3.4 常用格式化串对照表,背下来能省半小时

格式化字符串是时间处理里最容易出低级错误的地方。%Y和%y不一样,%H和%I不一样,%p表示AM/PM,很多人写日志格式化时把%H写成%h,报错半天找不到原因。这里放一份常用的对照表,建议打印出来贴在工位旁边:

格式符含义示例
%Y四位年份2024
%y两位年份24
%m两位月份06
%d两位日期15
%H24小时制小时14
%I12小时制小时02
%M分钟30
%S秒45
%f微秒(6位数字)123456
%pAM/PMPM
%a英文星期缩写Sat
%A英文星期全称Saturday
%b英文月份缩写Jun
%B英文月份全称June
%j一年中的第几天167

%f这个格式符极其有用,生成日志文件名、做幂等键、做缓存时间戳的时候,带上微秒能极大降低并发冲突概率。我写过很多“按时间分片的临时文件”,文件名里带上微秒,多进程环境下也几乎不会重名。

4. 三种状态互相转换:你真正要练到肌肉记忆的代码

4.1 字符串转为datetime:业务数据进来的第一道门

我做过好几个数据处理项目,几乎每一个的起点都是“把字符串时间解析成对象”。日志文件、Excel表格、接口返回的JSON,里面全是字符串,不转成对象什么都干不了。

from datetime import datetime s = "2024-06-15 14:30:45" dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S") print(dt)

strptime就是“string parse time”的缩写,重点是第二个参数必须跟字符串的格式完全匹配。它是逐字符匹配的,多一个空格少一个空格都会直接抛异常。我踩过最蠢的坑,是解析"2024/06/15"的字符串却写了"%Y-%m-%d"的格式串,报错之后才反应过来分隔符是斜杠不是横杠。

面对来源混杂的数据,更推荐的方法是先把格式统一,再做解析。我习惯写个小函数,把常见格式都试一遍:

from datetime import datetime def parse_flexible(s): for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): try: return datetime.strptime(s, fmt) except ValueError: continue raise ValueError(f"无法解析时间字符串: {s}")

4.2 datetime转为字符串:输出前的最后一站

strftime跟strptime就是一对相反的活儿。“string format time”,把对象变成人看的字符串。

from datetime import datetime dt = datetime.now() s = dt.strftime("%Y-%m-%d %H:%M:%S") print(s)

这里有个小坑值得提醒:time模块有同名的strftime函数,参数格式是time.strftime(format, t),而datetime对象的方法是dt.strftime(format)。两个签名不一样,经常有人把两个模块的函数混着调用,一会儿传元组一会儿传对象,报错后一头雾水。你要记住:对象的就调方法,模块的就传参数。

4.3 datetime和timestamp互转:跨系统传输的万能语言

时间戳是跨系统、跨语言传输时间的最稳妥形态。不同编程语言、不同库对字符串格式支持程度不一样,但时间戳是全世界都认的“世界语”。

from datetime import datetime dt = datetime(2024, 6, 15, 14, 30) ts = dt.timestamp() print(ts) dt2 = datetime.fromtimestamp(ts) print(dt2)

dt.timestamp()在tzinfo为空的情况下,会默认假设你这个对象是本地时间,然后把它换算成UTC时间戳。这个行为的本质是:时间戳永远以UTC为基准,所以任何一个幼稚对象在转时间戳时都会被自动套上本地时区的假设。如果你构造对象时其实想表达的是UTC时间,这个假设会让你平白无故偏移好几个小时。

避免这个坑的办法是构造对象时就指定tzinfo=timezone.utc:

from datetime import datetime, timezone dt_utc = datetime(2024, 6, 15, 14, 30, tzinfo=timezone.utc) print(dt_utc.timestamp())

这两个时间戳的差值,恰好等于你本地时区跟UTC的偏移秒数。时区的坑,提前在这一步堵住,后面大风大浪都跟你没关系。

4.4 一条完整的转换链:从日志字符串到时间差

把前面几种转换串成一条链路,你就知道这些知识点到底怎么配合了。

from datetime import datetime log_start = "2024-06-15 08:00:00" log_end = "2024-06-15 18:30:00" start = datetime.strptime(log_start, "%Y-%m-%d %H:%M:%S") end = datetime.strptime(log_end, "%Y-%m-%d %H:%M:%S") gap = end - start print(f"耗时: {gap.total_seconds()} 秒") print(f"耗时: {gap.seconds} 秒(忽略整天)")

这个例子里最值得注意的细节是gap.seconds和gap.total_seconds()的区别。gap.seconds只返回“不足一天”的那部分秒数,也就是说如果时间差是2天3小时,gap.seconds只有3小时对应的秒数10800,而不是整个时间差。total_seconds()才是完整的秒数。凡是计算超时时长、计费时长、排队时长的场景,请一律用total_seconds(),这个坑我亲眼见过同事在月度报表里算错了整整两天的数据。

5. 时区与夏令时:复习这两个模块最大的拦路虎

5.1 什么是“幼稚时间”,为什么它如此危险

datetime对象如果tzinfo是None,就叫做“幼稚时间”(naive time)。它不是没有任何时区,而是“不知道自己在哪个时区”。这种对象存储在数据库里,一旦系统部署到另一个时区的机器上,或者数据要在多个国家的服务器之间流转,就会出现“你以为你存的是北京时间,别人读出来以为是纽约时间”的灵异事件。

我在一家做跨时区协同的项目里见过最典型的案例:核心交易系统记录订单创建时间,代码里写的是datetime.now(),然后直接入库。后来服务器迁移到另一个时区的机房,凌晨两点的订单全被记成了前一天晚上九点,整整歪了5个小时。那一次事故之后,团队定了一条铁律:所有涉及存储的时间,一律先转成UTC时间再存,展示的时候再转回本地时区。这套思路你在MongoDB、PostgreSQL、MySQL里都能通用,只是不同数据库的落地方式略有区别。

5.2 用zoneinfo正确做带时区的时间处理

Python 3.9起标准库引入了zoneinfo,终于可以不用第三方库就做正经的IANA时区解析。比如说你要处理纽约时间:

from datetime import datetime, timezone from zoneinfo import ZoneInfo dt_ny = datetime(2024, 6, 15, 9, 0, tzinfo=ZoneInfo("America/New_York")) dt_utc = dt_ny.astimezone(timezone.utc) print(dt_ny) print(dt_utc)

6月15日纽约是夏令时期间,UTC比纽约快4小时,所以dt_utc会显示13:00。如果你用的是旧式的固定偏移timezone(timedelta(hours=-5)),夏令时来了你就会算错一小时。用ZoneInfo("America/New_York")而不是写死偏移,才是正经做法。

zoneinfo依赖系统的时区数据库。Windows上的Python可能缺少IANA时区数据库,需要安装tzdata这个包来补全。这个细节如果你在Windows上开发、在Linux服务器上部署,就很容易出现“本地能跑,线上报ZoneInfoNotFoundError”的尴尬。

5.3 夏令时的历史遗留坑

夏令时不只是“少睡一小时”的日常事件,它对程序来说是实打实的“时间墙”。每年3月和11月,美国、欧洲等地的时钟会突然跳变一小时。这意味着某一天的凌晨2点到3点可能根本不存在,或者某一天的凌晨1点到2点会出现两次。

写一个“每天凌晨2点执行任务”的定时程序,在夏令时切换的那一天就可能出问题:要么任务跳过一次,要么任务执行两次。Python的datetime在做算术的时候默认会帮你处理部分歧义,但如果你用的是旧式固定偏移,这一小时就是绕不过去的坎。我的经验是:跟“每天固定几点”相关的逻辑,不要直接做本地时间的add/subtract运算,要么统一在UTC上算完再转回本地,要么引入专门的调度库。把它当作常识记下来。

6. 动手写过的代码里,最值得说给后来人的几件事

6.1 线上事故教会我的:不要存本地时间

接着前面那个例子再展开一下。我遇到的另一个问题,是数据库里既有本地时间的字段,又有UTC时间的字段,业务代码里一会儿用now()一会儿用utcnow(),导致同一条记录里的两个时间字段差了好几个小时。排查这种问题最痛苦,因为日志里的时间戳看着是对的,但跟订单的实际发生时间就是差了几小时。

后来我们统一成一套规则:落库一律用UTC,展示时在入口处统一转本地。代码里跟外界打交道的地方只认时间戳或者带时区的字符串。这个规则听起来简单,但能坚持执行,能避免的项目灾难比任何花哨的时间库都值钱。

还有一个细节:用datetime.now(timezone.utc)代替已过时的utcnow()。utcnow()返回的是不带时区标记的“伪UTC时间”,它虽然值上是UTC,但tzinfo是空的。一旦你对它调用astimezone(),Python会先假设它是本地时间再转换,结果就会出错。这是Python 3.12把它标记为过时的直接原因之一。

6.2 线程安全与并发场景下的时间处理

多线程程序里,如果多个线程同时对同一个时间字符串做strptime解析,不会有问题,因为两个模块的底层调用本身是线程安全的。但如果你自己写了缓存的全局时间戳,就要小心了:一个线程更新了它,另一个线程读到的可能是旧值,从而产生一两秒的误差。

我在爬虫项目里常做的是:任务启动时取一次时间戳,作为整批任务的“批次ID”,而不是每个请求都现场取时间。这样做一是为了日志聚合方便,二是避免大量线程同时调用系统时间接口产生微小差异。小技巧,但在并发量上千的时候能省很多对账的麻烦。

6.3 性能优化:大批量时间转换的取舍

strptime是出了名的慢,在大批量日志解析场景里,它能成为性能瓶颈。我处理过单日几千万条日志的项目,一开始对每条日志都调datetime.strptime,结果发现解析耗时占据了整个ETL流程的40%以上。

解法有两个方向。第一个是尽量用批量解析,比如使用向量化数据处理工具,让底层C代码帮你干活,不要一条条在Python层解析。第二个是如果能保证字符串格式绝对统一,例如机器生成的固定长度日志,可以直接切片字典解析,把字符串的第0到4位直接int()了,速度比strptime快一个数量级。但这种方案代码比较丑,不到万不得已不建议用。

还有一点,strftime的性能也不便宜。如果只是给日志打时间戳,直接用time.strftime的C底层实现往往比datetime.strftime略快一点,但优势不大。真正该花时间的,是让代码逻辑减少无谓的时间格式转换,比如本来可以传递一个对象就传递对象,不要为了好看每层都转成字符串再转换回来。

6.4 数据交换中的时间表示约定

跟第三方系统对接时间数据,最容易出现的纠纷就是“格式约定不清晰”。我参与过的接口联调,不止一次因为“一端传时间戳,一端以为传的是字符串”而互相甩锅。后来我们统一在接口文档里写明:接口内部一律用ISO 8601格式字符串,序列化为UTC时间,带时区后缀Z。这样大家一看到2024-06-15T08:00:00Z就知道这是UTC时间,不会有歧义。

在内部系统之间传递,直接用时间戳更稳妥。没必要为了好看转来转去,越转越容易出错。时间戳是数字,比较大小、存数据库、做序列化都毫无压力,唯一的代价是可读性差一点。你可以让展示层去处理可读性,不要让数据层迁就可读性。

7. 站在实操角度,给时间处理补上最后几块拼图

7.1 性能优先时的备选方案

标准库的time和datetime是通用方案,绝大多数场景够用。但如果你对性能有极致要求,或者你需要复杂的日历逻辑、工作日计算、节假判断,标准库就没那么顺手了。业内最常用的第三方库是某开源日历库和某日期处理库,前者专精日历算术,后者API设计极其顺手,几乎成了通用日期处理的社区标准替代。

我的态度是:面试和基础项目请老老实实用标准库,毕竟那是你安身立命的基本功;生产环境的大型项目,该引入第三方库就引入,不要为了“少装一个依赖”硬扛着用标准库写出绕来绕去的代码。

7.2 单元测试里测时间逻辑的正确姿势

时间相关的代码,特别容易写出“今天能跑通,明天跑不通”的测试用例。因为datetime.now()是动态的,你的断言如果写死了具体时刻,凌晨跑和中午跑结果必然不一样。

两个常用思路:一是把“获取当前时间”抽象成一个函数或依赖注入,测试时注入固定时间;二是用模拟工具把datetime.now()打桩成固定值。第二个方案更简单粗暴,但要注意不要模拟过头,把datetime类的其他行为也一并模拟掉了,那样测试就失真了。

写测试之前,先想清楚哪些逻辑真正依赖“当前时间”,哪些不依赖。真正该测的是时间转换逻辑:给一个固定的时间戳,转出来的字符串是否匹配预期;给一个固定字符串,解析出的对象字段是否正确;两个固定时间的差值是否精确。这些才是不随时间漂移、值得反复回归测试的内容。

7.3 我自己的时间处理护身符清单

最后分享几条我自己写代码时贴在注释里的规矩,是踩过无数坑之后浓缩出来的:

  • 业务代码里禁止直接调用datetime.now()超过三次以上,统一封装成now_str()之类的工具函数,方便以后改全局时区策略。
  • 落库时间一律存UTC。读取展示时,在离用户最近的那一层做时区转换。
  • 所有时间字符串解析,一律显式指定格式串,禁止依赖默认格式。默认格式在不同平台上行为有差异,确定性是最重要的。
  • 涉及月份加减,先问自己能不能不用timedelta硬算;如果不能,仔细写清楚进位和溢出逻辑。
  • 日志文件命名、缓存键名、幂等键里要用微秒,用%f,别只用秒,并发场景下秒级重复概率远超你的想象。

回头再看这篇文章写的内容,别把它当教程背,把它当“查漏补缺的清单”用。你如果看完能自己画一遍三种状态的转换图,能现场写出来字符串和时间戳互转的四行代码,能说出为什么不要存本地时间,那这一遍复习就算真正到位了。

Python的时间处理多年没大改,但只要系统在跑,这些细节就会一直纠缠你。把这些规则焊在脑子里,比临时查一百遍文档都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进