
写Python的人迟早会跟日期和时间正面撞上。装完Python后第一个脚本多半是打印“Hello World”第二个脚本八成就是获取当前时间。麻烦的是真正做业务时你会发现时间这东西远没有print一下那么简单有时差、有时区、有格式化、有夏令时、有闰秒还有Windows和Linux表现各异的各种行为。我这些年处理过不少和日期时间相关的线上事故大多数都不是因为算法复杂而是在最简单的地方出了问题。这篇博客就把我积累的经验整理一遍覆盖time模块、datetime模块、时区处理、第三方库选型、常见坑位以及和命令行时间操作的配合场景。1. 为什么日期时间处理成了Python里的“老大难”1.1 混乱的起点时间戳、UTC与本地时间很多人的困惑是从“时间到底是什么”开始的。操作系统层面时间本质是一个浮点数表示从1970年1月1日0点0分0秒UTC到当前时刻经过的秒数这就是Unix时间戳。但人类习惯用的是另一套表达年月日时分秒、星期几、是不是夏令时。这两套体系之间需要转换而转换过程中一旦涉及本地时区就会引入大量“隐含假设”。Python的time模块默认操作的就是系统本地时间而datetime模块则希望你在“naive datetime”和“aware datetime”之间做出明确选择。所谓naive就是不带时区信息的本地时间它“假装”自己就是绝对时间aware则是绑定了timezonetzinfo对象的时间。这两个概念没搞清楚后面写出来的代码会有各种诡异的bug——最典型的就是服务器部署在不同时区同一段代码在不同机器上输出不同结果或者存入数据库的时间类型不匹配。1.2 两个内置模块的模糊分工Python标准库里负责时间的有time、datetime、calendar三个模块。time主打底层时间戳和struct_time互转datetime提供面向对象的时间运算calendar主要处理月份、星期的排列。实际业务里我的建议是能用datetime就不用time除非你在做性能监控或高频打点。datetime的可读性和操作性强太多了time模块保留下来更多是为了兼容老代码和系统调用层面的需求。不过time模块里有一个函数是datetime替代不了的time.time()它的精度高、开销小打点和性能统计都靠它。1.3 “cmd修改时间和日期”带来的启示系统时钟是地基很多人在Windows下会直接跑cmd命令调整系统的日期和时间比如手动改系统时间或者用w32tm /resync同步时间服务器。这里要明白一个关键点Python里所有时间相关函数最终读的都是操作系统时钟。你改了系统时间Python里datetime.now()的结果立刻跟着变。这件事的工程含义很重要如果你在开发环境手动改了系统时间测试代码那么Python时间相关的所有判断都会“错位”。我见过有人在本地把系统时间调到下个月测账单逻辑结果忘了改回来第二天提交代码时CI全部失败因为测试里断言“当月数据”的逻辑全被带偏了。所以在做自动化测试时不要依赖datetime.now()的真实值要么用freezegun这类库冻结时间要么把时间作为参数注入函数可测试性会好很多。2. time模块底层时间接口的正确打开方式2.1 从epoch到struct_timetime.time()和time.localtime()time模块最常用的组合是time.time()取时间戳然后用time.localtime()或time.gmtime()转成struct_time对象。struct_time本质上就是一个命名元组可以通过属性访问年、月、日、时、分、秒、星期等字段。import time # 当前时间戳单位秒浮点数 ts time.time() print(ts) # 1710000000.123456 # 转成本地时间的struct_time lt time.localtime(ts) print(lt.tm_year, lt.tm_mon, lt.tm_mday) print(lt.tm_hour, lt.tm_min, lt.tm_sec) # 转成UTC时间的struct_time gt time.gmtime(ts)注意time.gmtime()返回的是UTC的struct_timetime.localtime()返回的是本地时区的struct_time。如果你用time.mktime()把struct_time转回时间戳它始终会按本地时区解读而如果struct_time本来来自gmtime再用mktime就会多算/少算一个时区偏移。这就是让人头大的“时间戳双转换”问题后面第三节会详细讲。2.2 time.sleep()的精度陷阱time.sleep()是很多人天天用但未必了解精度的函数。在Windows上它的实际暂停精度受系统定时器影响大约在1毫秒到15毫秒之间波动在Linux上通常能精确到微秒级。如果你写一个高频采集脚本希望每0.01秒采一次数据在Windows上直接time.sleep(0.01)实测下来的间隔可能抖到15毫秒这会直接破坏采集频率。更稳妥的做法是记录“目标时间点”而不是“相对等待时间”import time period 0.01 next_time time.perf_counter() period while True: # 采集数据... next_time period delay next_time - time.perf_counter() if delay 0: time.sleep(delay) else: # 已经落后跳过等待 pass这段代码的核心思想是每次都按绝对时间轴计算下一次触发时间累积误差不会放大抖动也只会影响单次间隔不会越来越偏。2.3 性能场景下的替代思路如果你只是为了测量某段代码的执行耗时time.time()精度在部分平台上不够稳定官方推荐用time.perf_counter()或time.monotonic()。区别在于time.time()会被系统手动改时间或NTP校时影响可能在两次调用之间出现倒退而monotonic是单调时钟专门为“测量间隔”设计。import time start time.perf_counter() # 待测代码... elapsed time.perf_counter() - start print(f耗时: {elapsed:.6f}秒)用perf_counter做性能分析不要用它做“当前几点”的判断它没有绝对时间语义。这是我见到很多新手容易混用的地方。3. datetime模块日常业务的主力军3.1 datetime、date、time: 别用错类型datetime模块下有date、time、datetime、timedelta、timezone几个类。date只管年月日time只管时分秒微秒datetime是两者的组合。很多业务数据只需要日期比如账单日那就要用date而不是datetime否则后面比较“某天是否在范围内”时因为带时间导致边界判断出错。from datetime import date, datetime, time, timedelta, timezone d date(2024, 3, 15) dt datetime(2024, 3, 15, 14, 30, 0) t time(14, 30, 0) # datetime转date d2 dt.date() # date和time合并成datetime dt2 datetime.combine(d, t)3.2 strftime/strptime格式化读写法的一次性掌握格式化是重灾区常见错误%m是月份month%M是分钟minute%Y是四位年份%y是两位年份。拼错一个字符解析结果完全走样而且这类bug在代码评审里极难发现。常用的格式化指令我整理了一张表指令含义示例输出%Y四位年份2024%y两位年份24%m月份补零03%d日补零15%H24小时制小时14%I12小时制小时02%M分钟30%S秒05%f微秒6位123456%zUTC偏移0800%Z时区名称CST%a星期缩写Fri%A星期全称Friday%b月份缩写Mar%B月份全称March%j一年中的第几天075%U一年中的第几周周日为一周开始10%W一年中的第几周周一为一周开始11%x本地日期表示03/15/24%X本地时间表示14:30:05%%字面百分号%写入日志时我建议直接使用ISO 8601格式%Y-%m-%dT%H:%M:%S%z比如2024-03-15T14:30:050800。这个格式可排序、可解析、跨语言无歧义。Python 3.7提供了datetime.fromisoformat()来解析但注意它只支持有限格式对带毫秒和不同分隔符的字符串可能有问题。from datetime import datetime now datetime.now() print(now.strftime(%Y-%m-%dT%H:%M:%S%z)) text 2024-03-15T14:30:050800 dt datetime.fromisoformat(text)3.3 日期加减、月首月末、工作日推算timedelta可以处理天、秒、微秒的加减但不能直接“加一个月”或“加一年”因为月份天数不固定。这个限制让很多人烦尤其是做账期、续费周期、排班系统时。计算每月的第一天很简单把day替换成1。计算最后一天有两个思路一个是用calendar.monthrange另一个是“下个月第一天减去一天”。from datetime import date, timedelta import calendar d date(2024, 3, 15) # 当月第一天 first_day d.replace(day1) # 当月最后一天方法1直接查月历 last_day_num calendar.monthrange(d.year, d.month)[1] last_day_1 d.replace(daylast_day_num) # 当月最后一天方法2下月第一天减一天 if d.month 12: next_month_first date(d.year 1, 1, 1) else: next_month_first date(d.year, d.month 1, 1) last_day_2 next_month_first - timedelta(days1) assert last_day_1 last_day_2还需要处理“加一个月”的语义。比如1月31日加一个月合理结果是什么2月28日或闰年的2月29日但d.replace(monthd.month1)直接会抛ValueError。这时要么用dateutil.relativedelta要么自己写裁剪逻辑。我建议业务上明确规则后用自包含函数避免处处引入第三方库。工作日推算在排班、审批流里也常用。标准库里没有直接支持但可以用numpy.busday_offset或者在纯Python里循环判断weekday()是否小于5。注意法定节假日永远没法用内置库解决都得接日历服务或维护节假日表。4. 时区的坑naive和aware的生死线4.1 naive datetime为什么危险naive datetime不带时区信息存进MySQL的DATETIME字段后不同语言、不同服务器读取时会有各自解释。假设你的服务器在东八区datetime.now()是2024-03-15 14:30:00但数据库连接字符串里写了serverTimezoneUTC的ORM框架比如某些Java中间件它读出来可能被当作UTC时间再返回到东八区前端显示就成了22:30:00。Python这边同样会踩。用datetime.now()和datetime.utcnow()创建的同样是naive对象后者虽然字面是UTC时刻但没有任何标记说它是UTC你一旦把它当本地时间处理就多了一次时区偏移。正确的做法是做业务时统一使用aware datetime。获取当前时间就用from datetime import datetime, timezone # 明确标记为UTC时间 now_utc datetime.now(timezone.utc) # 明确标记为东八区时间 from datetime import timezone, timedelta tz_cst timezone(timedelta(hours8)) now_cst datetime.now(tz_cst)存储时统一存UTC或带偏移的时间展示时才转本地时区。这是跨时区系统唯一的稳妥方案。4.2 zoneinfo与pytz的选择pytz是第三方库历史悠久zoneinfo是Python 3.9进入标准库的模块直接读取系统时区数据库。新项目我推荐用zoneinfo不用装额外依赖。from datetime import datetime from zoneinfo import ZoneInfo tz_sh ZoneInfo(Asia/Shanghai) now datetime.now(tz_sh) print(now) # 2024-03-15 14:30:0508:00 # 本地时区的另一种获得方式 from datetime import datetime import time local_tz ZoneInfo(time.tzname[0]) # 不推荐时区名在不同系统差异大zoneinfo构造的tzinfo可以直接传入datetime构造器。pytz则有个历史包袱它同一个时区对象会被反复使用直接作为tzinfo传入datetime是错的必须用localize和normalize方法import pytz from datetime import datetime tz pytz.timezone(Asia/Shanghai) dt tz.localize(datetime(2024, 3, 15, 14, 30, 0)) print(dt)我现在遇到老项目里有一堆pytz.timezone(...)直接传给datetime构造器的代码这在某些夏令时切换的时区会算错但在Asia/Shanghai这种没有夏令时的地区反而测不出来算是隐性技术债。4.3 夏令时切换引发的“幽灵时间”对国内开发者来说夏令时像个“概念”因为我们不执行夏令时。但如果你服务海外用户必须面对。北美每年3月第二个周日和11月第一个周日切换。3月切换日凌晨2点跳到3点所以那个凌晨的2:00到2:59这个小时“不存在”11月切换日凌晨2点变回1点于是1:00到1:59这同一个小时会出现两次。用zoneinfo可以还原这两种情况from datetime import datetime from zoneinfo import ZoneInfo tz_ny ZoneInfo(America/New_York) # 不存在的时间Python会帮你推成3:00 nonexistent datetime(2024, 3, 10, 2, 30, tzinfotz_ny) print(nonexistent) # 2024-03-10 03:30:00-04:00 # 重复的时间Python默认取标准时偏移的那一次 ambiguous datetime(2024, 11, 3, 1, 30, tzinfotz_ny) print(ambiguous) # 2024-11-03 01:30:00-04:00业务上处理夏令时最省心的建议还是“内部一律UTC只在展示层转本地”因为UTC没有夏令时概念。要生成“本地时间段的开始和结束”时先按UTC构造再转目标时区不要试图手工计算偏移量。5. 第三方库的取舍dateutil、pendulum、arrow何时用5.1 什么时候不需要引入第三方库很多团队一上来就装arrow或pendulum但实际需求只是打印日志或存时间戳标准库两三行就能解决引入额外依赖反而增加维护成本和安全风险。我给的参考标准是只用当前时间、格式化、简单加减标准库足够。需要“加一个月”“加一年”、月末年末计算引入dateutil的relativedelta这个单点需求最轻量。需要解析各种乱七八糟的自然语言时间串比如yesterday、next friday、2024-03-15 14:30dateutil.parser常被用在这个场景但命中率有限复杂语义得靠自然语言解析库。需要打印“3小时前”“2天前”这种人类化时间差arrow和pendulum内置了humanize功能dateutil没直接提供。需要完整时区数据库且不便依赖系统时区库pendulum体积更大、功能更全但zoneinfo出现后它的优势缩水了。5.2 常用库的核心能力对比能力标准库 datetimedateutilarrowpendulum日期时间对象内置datetime扩增自定义Arrow对象兼容datetime相对日期加月/年不支持relativedeltashiftadd自然语言解析不支持parsergetparse时区支持zoneinfo可用tzfile可用内置内置人类化时间不支持不支持humanizediff_for_humans对标准datetime的侵入性无低高经常要调.datetime低arrow的问题在于它返回的Arrow对象不是datetime子类和老代码交互时经常要到处调.datetime或.naive容易漏转换。pendulum的DateTime倒是兼容datetime但整体体积偏大。如果你正维护一个需要多年演进的中大型服务我建议优先考虑datetime zoneinfo dateutil的组合避免把自己绑死在某个第三方生态上。5.3 我的选型建议日常业务我基本只用标准库datetime zoneinfo dateutil.relativedelta。日期解析用datetime.fromisoformat解析不了的再上dateutil.parser。这样依赖少、类型统一、排错链路短。举一个相对日期计算的例子给“当前时间”加“3个月”然后取当月第一天from datetime import datetime from dateutil.relativedelta import relativedelta from zoneinfo import ZoneInfo now datetime.now(ZoneInfo(Asia/Shanghai)) three_months_later now relativedelta(months3) month_first three_months_later.replace(day1) print(month_first.isoformat())这段代码在每月31号附近执行也不会抛异常relativedelta会自动处理月末裁剪语义明确代码也短。换成标准库要写不少分支。6. 一个真实场景从命令行时间操作到Python自动化脚本6.1 cmd和PowerShell里的时间操作 vs Python的对应关系搜索热词里出现“cmd修改时间和日期”说明很多人在Windows环境手动校时。cmd下直接运行time和date可以查看和设置系统时间PowerShell里用Get-Date更灵活。但手动改时间只是临时手段自动化场景下更适合的做法是先确保系统时间正确然后用Python脚本对时间数据做校验、转换、入库。我常用的自动化场景是从不同机器上抓取日志文件日志头部有各种格式的时间脚本统一解析后转成UTC时间戳再按小时聚合统计。这个场景第一步往往是先确认系统时间对不对# Windows下查看当前系统时间 date /t time /t # 通过w32tm触发一次时间同步 w32tm /resync# Linux下查看和同步时间 date timedatectl status把这些命令行操作封装到Python脚本里可以周期性地检查系统时间偏差。注意w32tm /resync需要管理员权限而且如果你的机器本来就在域环境里有自动同步策略不需要手动触发。这里不展开系统管理员那些事只强调一点Python脚本运行的机器系统时间必须可靠否则所有时间相关逻辑都会建立在错误地基上。6.2 解析自然语言时间dateutil.parser的威力与边界日志分析场景里经常要解析各种半结构化时间串这种场景标准库的strptime写到手酸。dateutil.parser.parse可以自动识别常见的日期时间格式甚至支持时区偏移、星期几等from dateutil import parser cases [ 2024-03-15 14:30:05, 15/Mar/2024:14:30:05 0800, March 15, 2024 2:30 PM, 20240315T143005Z, ] for text in cases: dt parser.parse(text) print(dt.isoformat())但parser不是银弹它的“智能”在某些场景会误判比如03/04/2024在它眼里默认是美国习惯解析成3月4日不是4月3日再比如它遇到缺失年份的字符串会默认取当前年份。生产环境中如果要精确解析已知格式还是要优先用strptime或fromisoformat把parser留给“格式不可控但大体类似”的日志清洗场景。6.3 日志时间格式的工程实践日志是时间处理最密集的场景这里给几个我自己沉淀的规则直接用就行日志文件里统一用ISO 8601格式带时区记录例如2024-03-15T14:30:05.12308:00。如果日志服务端要求UTC就统一存UTC如果要求本地时间也要把偏移写上去避免不同机器时区设置不同导致日志时间完全对不上。Python logging模块配置里可以通过formatter的datefmt和自定义converter来控制时间格式。import logging from datetime import datetime, timezone # 自定义一个带UTC时间的日志格式 class UTCFormatter(logging.Formatter): def formatTime(self, record, datefmtNone): dt datetime.fromtimestamp(record.created, tztimezone.utc) return dt.strftime(datefmt or %Y-%m-%dT%H:%M:%S%z) handler logging.StreamHandler() handler.setFormatter(UTCFormatter(fmt%(asctime)s %(levelname)s %(message)s)) logger logging.getLogger(demo) logger.addHandler(handler) logger.setLevel(logging.INFO) logger.info(订单创建成功)格式化后的日志可以把时间、级别、业务关键词串在一起方便ELK或第三方日志平台直接解析。7. 我踩过的五个时间处理坑附规避方案7.1 time.mktime与datetime.timestamp的时区差datetime.timestamp()会把aware datetime转换成时间戳但如果传入的是naive datetime它默认按本地时区解释而time.mktime()同样按本地时区解释struct_time。如果两个方向混用尤其是服务器时区不是东八区时时间戳就会差出一个偏移。我的规避原则是处理“存储时间戳”或“传输时间戳”这类跨系统数据时一律先把datetime转成带UTC时区的aware对象再调.timestamp()。from datetime import datetime, timezone # 反例naive对象调用timestamp按本地时区解释 naive datetime(2024, 3, 15, 14, 30, 0) ts naive.timestamp() # 正例明确标记为UTC再转时间戳 utc_dt datetime(2024, 3, 15, 14, 30, 0, tzinfotimezone.utc) ts utc_dt.timestamp()7.2 月份补零的惨痛教训有一次做月度报表导出的文件名逻辑是freport-{now.month}.xlsx结果1到9月生成的文件名是report-1.xlsx而不是report-01.xlsx排序时10月排在2月前面。这种补零问题看着小但汇入数据仓库后按文件名排序就乱套。建议无论是月份还是日期格式化时都用%m和%d或者手动拼f{now.month:02d}。7.3 数据库里存的datetime是naiveORM框架、数据库驱动、数据库字段类型三者对时区的处理不一致经常导致存进去的是UTC时间读出来显示成本地时间或者反过来。最稳妥的方案是规定“数据库统一存UTC时间戳或带时区的TIMESTAMPTZ”应用层在读出来之后立刻转成aware datetime再进入业务逻辑。如果用的是SQLite只有一个DATETIME字段存进去的基本就是字符串时区全靠约定。我用SQLite做本地原型时会自己在存取层加一个转换函数统一转ISO 8601字符串。7.4 “月初”到底是哪一天在跑月度账单时计算“本月1号”这个看似简单的需求有人会写datetime.now().replace(day1)这没问题但计算“上个月1号”如果写成today.replace(monthtoday.month - 1, day1)在1月就会抛异常。正确写法from datetime import datetime from zoneinfo import ZoneInfo now datetime.now(ZoneInfo(Asia/Shanghai)) # 上个月1号 if now.month 1: last_month_first now.replace(yearnow.year - 1, month12, day1) else: last_month_first now.replace(monthnow.month - 1, day1) # 下个月1号同理 if now.month 12: next_month_first now.replace(yearnow.year 1, month1, day1) else: next_month_first now.replace(monthnow.month 1, day1)或者直接上dateutil.relativedelta用now relativedelta(months-1)让库替你处理跨年问题。7.5 分布式系统的时间统一问题跨进程、跨机器的时间戳永远不要用本地时间做排序或对账。每台机器的时间源、时区设置都可能不同尤其云上实例如果NTP没有强制同步时间偏差可能到秒级这对分布式锁、消息幂等、日志排序都是致命的。我早期做的一个采集系统两台服务器时间差了2秒数据处理时把“后发生的事”排到了“先发生的事”前面对账结果一直对不上。后来统一了三件事所有时间戳用UTC、强制NTP同步、日志里输出带时区偏移的ISO字符串。问题才彻底消停。踩了这么多坑之后回头看日期时间处理的核心不是学几个API而是先定规矩数据内部统一用UTC、存储统一用时间戳或带时区类型、展示层才转本地、跨系统交互用ISO 8601。规矩定好了再复杂的场景也只是套模板的事。如果你正在接手一个时间处理混乱的老项目别急着重构全部代码先把数据流入口和出口的时间统一约定改掉效果往往立竿见影。