
做了这么多年 Python我几乎在每个项目里都要被人问一遍日志到底怎么打日志模块不就是 print 吗说实话Python 日志模块标准库里的 logging是那种你觉得自己会了一上生产就翻车的典型代表。它功能强大但默认行为、层级关系、Handler 机制这些细节如果不搞清楚轻则日志重复输出看得人头晕重则线上出问题的时候啥也查不到。这篇文章就把我这些年用 logging 攒下来的经验一次讲透从原理到实操、从入门到项目级配置顺便把那些文档里不会写、只有踩过坑才知道的细节一并分享出来。不管你是在写爬虫脚本、量化交易策略还是维护一个长期跑在服务器上的服务这篇都值得你花十分钟读完。1. 为什么说日志模块是每个 Python 项目的刚需1.1 从一次线上事故说起先讲个真实经历。早几年我做一个数据采集服务每天定时从外部接口拉数据、清洗、入库。当时为了图省事全项目都用 print 输出关键信息想着反正跑得好好的看控制台就够了。结果有一天凌晨任务悄悄失败但脚本本身没退出进程僵在那里。第二天我打开终端控制台早就被刷屏刷得干干净净之前的输出全没了唯一的线索是屏幕上最后几行无关紧要的调试信息。那次排查花了整整一个上午最后靠手动重跑加断点才定位到问题。从那以后我意识到一件事程序的运行轨迹不能依赖一个会丢、会刷屏、无法分级、无法持久化的 print。生产环境里日志就是程序的黑匣子它必须可靠、可查、可分级、可轮转。这也正是 Python 日志模块存在的意义。1.2 print 为什么撑不住场子print 最大的问题不是不能用而是它没有结构。想一想你平时用 print 打日志会遇到什么没有级别区分调试信息、警告、错误全混在一起日志一多根本没法筛。没有时间戳出问题了你不知道这条日志是几点打的前后顺序全靠肉眼猜。输出目标单一只能写控制台想同时写文件、写远程、按天切分print 全做不到。无法追溯上下文print 不知道这条日志是哪个模块、哪个函数、哪个线程打出来的多线程一跑日志乱成一锅粥。无法动态控制想临时把调试日志打开看看只能改代码重新跑在生产环境里这几乎是不可接受的。所以说print 适合写临时脚本、适合调试代码但绝对不适合作为一个正经项目的日志方案。python 日志模块logging就是冲着解决这堆问题来的。1.3 标准库 logging 到底解决了什么问题logging 是 Python 官方钦定的日志标准库它的设计思路非常成熟核心解决四件事第一分级过滤。DEBUG、INFO、WARNING、ERROR、CRITICAL 五级你可以给不同模块、不同场景设置不同阈值不需要的级别直接不进输出。第二多渠道输出。同一个日志事件可以同时写到控制台、文件、邮件、网络服务。Handler 机制让一条日志多处接收变得非常自然。第三结构化格式。时间、模块名、函数名、行号、线程号想打什么就打什么格式完全可控。第四可靠与可扩展。支持日志轮转按大小、按时间、支持自定义 Filter、支持配置化驱动。一个日志系统该有的东西它基本都有了。而且它是标准库不需要 pip install 任何东西跨平台、性能稳定、社区资料多。选它作为默认方案几乎不会出错。2. 先搞明白日志模块的四件套2.1 Logger日志记录的入口Logger 是日志系统的入口你在代码里调用的logger.info(...)、logger.error(...)都是它的方法。logger 实例通过logging.getLogger(名字)获取这个名字可以带点号比如myapp.module1点号会形成层级关系。这里有个关键机制Logger 之间是有父子关系的。名字是myapp的 logger 是myapp.module1的父 logger。子 logger 处理完日志后如果自己的propagate传播开关开着日志事件会继续往上层传一层一层传到最后那个 root logger。很多新手第一次写日志重复就是因为没搞懂这个传播机制。你给myapp配了个 FileHandler又在myapp.module1里也配了个 StreamHandler结果一条日志被打了两次甚至三次。这个点后面我专门讲怎么排查。2.2 Handler日志要去哪儿Handler 决定了日志最终写到哪。标准库自带的已经足够用Handler用途StreamHandler输出到流默认是控制台 stderrFileHandler输出到单个文件RotatingFileHandler按文件大小轮转比如单个文件超过 1MB 就自动切割TimedRotatingFileHandler按时间轮转比如每天、每小时切一个新文件NullHandler什么都不干主要是库作者用来吞掉日志用的每个 Logger 可以挂多个 Handler日志事件会同时交给所有 Handler 处理。比如开发环境挂一个控制台 Handler生产环境换成一个文件 Handler代码里的 logger 调用完全不用改。2.3 Formatter日志长什么样Formatter 决定日志的排版。最常用的占位符就这些%(asctime)s 时间默认格式是 2025-01-01 12:00:00,123 %(name)s logger 名字 %(levelname)s 日志级别比如 INFO %(message)s 日志正文 %(filename)s 打日志的文件名 %(lineno)d 打日志的行号 %(funcName)s 打日志的函数名 %(threadName)s 线程名我给生产项目用的标准格式一般是fmt %(asctime)s [%(levelname)s] %(name)s:%(filename)s:%(lineno)d - %(message)s这个格式包含了时间、级别、来源、位置四要素。出了错看一眼日志就知道是哪个文件的哪一行打出来的省去了大量猜测时间。你还可以加%(process)d打进进程号多进程部署的时候特别有用。2.4 Filter日志的守门员Filter 能力容易被忽略但用好了非常给力。它是日志事件在进入 Handler 之前的守门员可以按级别、按模块名、甚至按自定义规则决定放行还是拦截。比如你只想让某个 logger 的错误日志发邮件但不想让 INFO 日志满天飞就可以给邮件 Handler 挂一个 Filter只放行 ERROR 级别以上的记录。这个机制在项目大了以后做分级告警非常有用。我自己的做法是给日志系统加一个自定义 Filter用来标记哪些日志属于需要人工处理的级别配合告警 Handler 一起用效率很高。3. 从能跑到好用logging 基础配置实操3.1 最小可用配置basicConfig先看一段最基础的配置代码零基础上手足够用import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.StreamHandler(), logging.FileHandler(app.log, encodingutf-8), ] ) logger logging.getLogger(demo) logger.info(服务启动) logger.debug(这条不会显示因为级别是 INFO) logger.error(出错了但程序还能跑)basicConfig里几个关键参数说明一下level全局最低日志级别低于这个级别的日志直接丢弃。format日志格式。handlers传入 Handler 列表控制台和文件双写。如果你忘了传 handlers默认就只有一个 StreamHandler。filename/filemode如果嫌麻烦可以直接用filenameapp.log替代 handlers 里的 FileHandler。需要特别注意的是basicConfig只在 root logger 首次配置时生效。如果代码里已经有人创建过 logger 并且做了配置之后你再调用basicConfig很可能没有任何效果。这也是项目里常见的一个坑。3.2 记录一条完整的日志日志不只是打个字符串那么简单。实际开发中我推荐你养成几个习惯第一使用 f-string 之外的方式记录上下文或者至少在格式化时保持清晰。logger.info(用户 %s 下单成功订单号 %s, user_id, order_id)这种写法比f用户 {user_id} 下单成功更省性能因为 logging 会等确认这条日志真的需要记录时才做格式化。虽然现在 Python 的 f-string 已经很成熟但大量日志场景下用参数化写法是一个良好的习惯。第二记录异常时用 exc_info。捕获异常后用logger.exception(xxx)或logger.error(xxx, exc_infoTrue)日志里会带上完整的堆栈信息。这条太重要了很多人只记一句请求失败回头根本不知道失败在哪一行。看一个实际例子import logging logger logging.getLogger(order) try: result 1 / 0 except ZeroDivisionError: logger.exception(计算订单金额失败订单号%s, order_id)logger.exception就等价于logger.error(..., exc_infoTrue)它会自动带上当前异常的堆栈。线上排查问题堆栈信息就是救命稻草。第三善用stack_infoTrue。它能把调用栈也打进日志就算没有异常也能看到这条日志是被谁调用的。虽然会稍微多耗一点性能但在需要追溯调用链的时候非常好用。3.3 日志轮转磁盘不是无限大的日志如果不做轮转跑一个月可能就是几个 GB甚至几十 GB磁盘撑爆是迟早的事。标准库提供了两种轮转方案。按大小轮转用RotatingFileHandlerfrom logging.handlers import RotatingFileHandler handler RotatingFileHandler( app.log, maxBytes10 * 1024 * 1024, # 10MB backupCount5, # 保留5个备份 encodingutf-8 ) handler.doRollover()这样每个文件最多 10MB超过就自动切下一个最多保留 5 份历史文件app.log.1、app.log.2……。对于日常服务已经非常够用。按时间轮转用TimedRotatingFileHandlerfrom logging.handlers import TimedRotatingFileHandler handler TimedRotatingFileHandler( app.log, whenmidnight, # 每天零点切分也可以写 H、D、W0 backupCount30, encodingutf-8 )生产项目我更推荐按时间轮转为主。因为排查问题的时候我经常需要精确知道昨天中午到下午两点之间发生了什么按天切割的日志文件定位起来非常方便。按大小切割适合单个日志异常巨大的场景但时间维度不直观。4. 项目级标准姿势用配置驱动日志4.1 为什么要放弃 basicConfigbasicConfig只适合小型脚本和快速验证。项目一旦进入模块化开发、多人协作、多环境部署的阶段日志配置就应该和代码逻辑分离。理由很简单不同环境日志策略不一样本地开发要控制台测试环境要文件加控制台生产环境可能还要按天轮转加告警总不能每个环境改一遍代码。配置统一放一处运维可以调不用理解代码。日志格式、Handler、Filter 都在配置里一目了然评审和排查都方便。标准库官方推荐的方案是logging.config.dictConfig。它用一个字典通常从 YAML 或 JSON 文件加载描述整个日志系统包括 Logger、Handler、Formatter、Filter 之间的关系。4.2 dictConfig 配置示例下面这份配置是我在多个项目里用过的模板你可以直接抄走改一改# logging_config.yaml version: 1 disable_existing_loggers: false formatters: standard: format: %(asctime)s [%(levelname)s] %(name)s:%(filename)s:%(lineno)d - %(message)s access: format: %(asctime)s [%(levelname)s] %(message)s handlers: console: class: logging.StreamHandler level: DEBUG formatter: standard stream: ext://sys.stdout file_info: class: logging.handlers.TimedRotatingFileHandler level: INFO formatter: standard filename: logs/app.log when: midnight backupCount: 30 encoding: utf-8 file_error: class: logging.handlers.TimedRotatingFileHandler level: ERROR formatter: standard filename: logs/error.log when: midnight backupCount: 90 encoding: utf-8 loggers: myapp: level: INFO handlers: [console, file_info, file_error] propagate: false root: level: WARNING handlers: [console]然后用代码加载import logging.config import yaml with open(logging_config.yaml, r, encodingutf-8) as f: config yaml.safe_load(f) logging.config.dictConfig(config) logger logging.getLogger(myapp)这里有些细节想强调一下disable_existing_loggers: false这一行千万不能少。我见过很多项目配置文件里没写这个结果代码里已经定义好的其他 logger 莫名其妙不输出日志了。原因是 dictConfig 默认会把已存在的非 root logger 全部禁用这坑栽过的人非常多。propagate: false也很关键。我给myapp这个 logger 显式配置了 Handler就不再需要往上传播给 root logger否则会重复输出。这个语义你在配置里写清楚比在代码里排查半天要省事得多。4.3 代码里的正确打开方式配置归配置代码里的写法也有讲究。我推荐一个约定每个模块顶部统一logging.getLogger(__name__)然后在模块内使用这个 logger。# order_service.py import logging logger logging.getLogger(__name__) def create_order(order_id, amount): logger.info(开始创建订单order_id%s, amount%s, order_id, amount) ...__name__是模块的完整路径名比如myapp.services.order_service。这样从日志里就能直接看出是哪条业务链路、哪个模块、哪个类打出来的信息。配合前面的配置只要 logger 名字以myapp开头就自动并入统一的 Handler 管理完全不用每个文件手动配 handler。这个写法的好处是以后你想单独把某个子模块的日志级别调高、调低或者在某个模块上单独挂 Handler只需要在配置文件的 loggers 里加一段名字即可代码一行都不用动。5. 日志模块高频踩坑与排查实录5.1 同一个项目日志重复输出这是被问得最多的问题没有之一。症状是一条日志在控制台打了两次甚至三次一次是当前 logger 的 Handler 打的一次是 root logger 的 Handler 打的。原因就是前面说的propagate机制。子 logger 处理完日志后如果没关传播事件会继续往父级传如果你的子 logger 和 root logger 都挂了 Handler等于一条日志被处理了两遍。解决办法很简单二选一给子 logger 设置propagate false切断向父级传播。不让 root logger 挂 Handler所有输出交给具体子 logger。如果你用 dictConfig在对应 logger 里写propagate: false即可。如果你在代码里搞手动执行logger.propagate False也能生效。排查这类问题可以先在代码里临时给 logger 加一个自定义 Filter给每条日志打个记号看看它经过了几道 Handler很快就能定位。5.2 控制台中文乱码Windows 控制台和 Linux 终端对编码的处理不一样Windows 的 cmd 和 PowerShell 默认可能是 GBK而你的日志是 UTF-8于是中文变成一坨乱码。解决手段有几个给 FileHandler 加encodingutf-8这是最基本的一步。很多人的日志文件也是乱码就是漏了这个参数。控制台乱码可以在 Windows 上设置环境变量PYTHONIOENCODINGutf-8或在代码里sys.stdout.reconfigure(encodingutf-8)按需处理。还有一个思路日志文件全部用 UTF-8控制台用于人工查看时再去调整终端编码这样最稳。我个人的习惯是日志文件统一 UTF-8控制台输出主要用于开发调试如果遇到乱码就手动调整终端不把代码写死成 GBK避免部署到 Linux 上再出问题。5.3 日志文件不见了常见于两种场景。一种是你配置了多级目录比如logs/app.log但logs目录不存在FileHandler 无法创建文件。解决办法很简单加载配置前先os.makedirs(logs, exist_okTrue)。另一种更隐蔽你写了一个程序平时print都能看到但把basicConfig放到了if __name__ __main__:之外的某个模块里结果别的模块提前创建了自己的 logger你的配置又没生效日志就悄悄消失了。建议在最靠近程序入口的位置加载配置并且用logging.config.dictConfig而不是basicConfig后者更容易出现静默失效的问题。5.4 打日志拖慢程序日志本身是有性能开销的尤其是大量高频日志。每秒打几千条 INFO磁盘 IO 分分钟成为瓶颈。如果你发现自己写了一个循环里有日志可以先评估这个日志有没有必要每条都打。常见的优化姿势if logger.isEnabledFor(logging.DEBUG): logger.debug(处理第 %s 行数据原始内容%s, i, raw)先判断当前是否启用了 DEBUG 级别再决定要不要构造这条日志。因为logger.debug的入参也可能是一个复杂的表达式如果不先判断级别表达式的计算开销也是实打实的。另一个思路是调整级别。生产环境的 INFO 日志尽量精简把高频变化的调试内容放在 DEBUG需要排查问题时再临时把某个模块的级别调低而不是让代码里塞满高消耗日志。5.5 第三方库日志刷屏怎么办你会经常遇到这种场景你在调requests、urllib3、pandas它们底层也可能往日志系统里写东西一旦你配置了 root logger它们的信息也跟着出来了有时候刷得比自己的业务日志还多。处理方式很经典在配置里把它们单独压下去logging.getLogger(urllib3).setLevel(logging.WARNING) logging.getLogger(requests).setLevel(logging.WARNING) logging.getLogger(pandas).setLevel(logging.WARNING)或者干脆在配置文件的 loggers 里单独声明loggers: urllib3: level: WARNING requests: level: WARNING pandas: level: WARNING注意一点Python 官方对库作者的约定是第三方库的 logger 默认应该只挂 NullHandler不主动往用户屏幕上输出任何东西。你看到第三方库的日志通常是因为你在 root 级别开了 DEBUG或者这个库自己把 logger 的 level 调了。遇到刷屏先别急按需压低对应 logger 的级别即可。6. 结合真实场景爬虫和量化交易里的日志设计6.1 爬虫任务日志怎么分层爬虫脚本动辄跑几小时、几天如果日志设计不合理中途出问题根本无从下手。我建议按任务粒度拆分日志文件而不是所有页面日志堆在一起。常用的方式是给每个任务类型单独建 loggerlogger logging.getLogger(crawler.task_detail) logger.info(开始抓取详情页product_id%s, pid) logger.warning(页面解析失败product_id%s稍后重试重试次数%s, pid, retry) logger.error(连续重试 3 次仍失败product_id%s转入错误队列, pid, exc_infoTrue)同时把日志分成运行进度文件和错误明细文件两类。进度文件只记录 INFO 级别错误文件只记录 ERROR 级别。这样每天查看进度、发现问题就扫一眼 error.log不用在大文件里反复 grep。这里的重点在于日志不应该是事后被动排查工具而应该是任务过程的实时仪表盘。如果你发现自己每次都要靠日志抓瞎那说明日志结构本身需要重新设计。补充一个合规提醒写爬虫时注意目标站点的 robots 协议和访问频率数据采集要尊重版权不要用暴力抓取破坏别人服务。日志设计帮你在低风险、合规的范围内把任务跑得更稳。6.2 量化策略日志怎么记录交易信号量化交易策略里日志的作用不仅是排查问题更是复盘依据。每一笔交易信号、下单参数、回测结果都应该有据可查。我的做法是分三层记录信号产生层记录策略产生了什么信号用的什么参数、什么时间、基于什么数据快照。比如logger.info(产生买入信号symbol%sprice%sconfidence%s策略版本%s, symbol, price, confidence, strategy_version)执行结果层记录下单/模拟下单的结果成功与否、延迟、滑点预估。这部分信息要和信号层关联起来不然复盘时一条信号对应不上订单。绩效快照层按固定周期比如每天收盘后记录账户净值、持仓、收益率等指标。这些日志未来可以直接拉出来做数据可视化用 pandas 读进去就是一张复盘表。量化场景的日志有个额外要求时间精度。%(asctime)s默认精确到毫秒如果策略是高频的建议在 Formatter 里加%(msecs)d或者在关键事件自行记录time.time()的原始时间戳避免因为日志排队而造成时间误差。7. 我的一些额外心得最后再分享几个很多人不会注意但非常实用的细节。第一必留一个启动日志。程序启动时把自己的版本号、运行环境、关键配置项打出来。这样每次排查问题先看启动日志就知道当前跑的是哪套代码配置对不对避免对着旧版本日志分析半天。这个习惯在部署频繁的项目里价值巨大。第二留意disable_existing_loggers的坑。前面说过配置里disable_existing_loggers: false这一行一定要写。这个配置项常年坑人你在模块里先getLogger创建了 logger再到入口做dictConfig如果配置里没有设置 false 或 true 的明确值默认行为 Python 3.10 之前是禁用已存在的 logger之后就变了。为了代码在不同版本上行为一致请显式声明。第三生产环境给文件权限留条路。用TimedRotatingFileHandler按天切分日志时如果每次切换都通过logging内部去创建新文件有时候会因为权限问题失败导致后续日志全部丢失。稳妥的做法是在日志目录上安排好写权限并在程序里对日志目录做检查、创建动作。第四不要把日志和告警混为一谈。日志解决的是记录告警解决的是主动通知。很多人想在日志模块里实现打了 ERROR 就发邮件这个能实现但别过度。日志库里塞太多邮件通知、短信通知实际运行时会拖慢主流程而且网络一抖动日志模块自己先挂了。我通常的做法是日志只做可靠落盘告警交给独立的监控进程去消费日志文件。职责分清楚系统才稳定。第五别迷信第三方日志库。我现在也偶尔会用 loguru 这类库它确实简洁好用但标准库 logging 的优势在于零依赖、官方维护、生态兼容性最好。很多框架内部直接用 logging你自己再引入一套日志体系容易出现两套日志互不相通的局面。我的建议是先在标准库上建立一套成熟的配置模板如果你的场景实在需要更高级的格式化、关联 ID 追踪等功能再去考虑扩展方案而不是一上来就替换标准库。我在实际操作中最深的体会是日志模块本身的代码量很少真正值钱的是仔细规划知道你有哪些日志来源知道每种日志该流向哪里知道出了问题第一步从哪里查起。这套思路建立起来你的 Python 项目才算真正具备了可观测性。下次项目里再有人想用 print 对付日志的时候这篇文章就是你的回答。提示上面这套配置思路不仅适用于标准库你在学习任何 Python 日志库比如 loguru、structlog时设计目标和结构模型都差不多的把标准库吃透后面看别的库都是小菜一碟。