
1. 为什么你的项目需要一个靠谱的日志系统刚入行那会儿我对日志这东西是真不上心。代码里到处散落着print(到这里了)、print(变量名)调试的时候满屏刷上线之后又得一行行删。直到有一次线上服务半夜出问题运维同事问我“有没有日志”我打开服务器一看控制台输出早就被冲没了那一刻才真正意识到没有日志等于闭着眼睛开车。Python 的logging模块就是来解决这个问题的。它是标准库自带的日志工具不用装任何第三方包开箱即用。核心能力说白了就几件事把程序运行过程中的信息按级别分类按格式输出按目的地分发——可以同时打到控制台、写进文件、甚至发到远程。它解决的痛点也很明确print只能输出到标准输出、没有时间戳、没有级别、没法统一开关、更没法做日志轮转而logging把这些全都管了。这篇文章适合谁看如果你写过脚本但日志全靠print如果你接手了一个日志乱成一锅粥的项目如果你想知道logging那套Logger、Handler、Formatter、Filter到底怎么串起来的那这篇就是写给你的。我会从设计思路讲到实操配置再到踩过的坑尽量把每个“为什么”都讲透让你看完能直接抄作业。2. 整体设计思路logging 的四大组件是怎么协作的2.1 先搞懂 Logger、Handler、Formatter、Filter 的分工很多人用logging觉得绕是因为没理清这四个角色的关系。我用一个生活化的类比来解释把日志系统想象成一家快递公司。Logger记录器是寄件人。你调用logger.info(...)就相当于把包裹交给快递。Logger 有个关键属性叫日志级别低于这个级别的消息它直接拒收连门都不出。Handler处理器是分拣中心。一个 Logger 可以挂多个 Handler每个 Handler 决定这条日志往哪送——一个送控制台一个送文件一个送错误专用文件。Handler 自己也有级别可以比 Logger 更严格。Formatter格式器是包装工人。它决定日志长什么样时间戳、级别、模块名、行号、消息内容全靠它排版。Filter过滤器是安检员。它能在日志进入 Handler 之前做更细粒度的拦截比如只放行某个模块的日志或者过滤掉包含敏感词的内容。理解了这个分工你就明白为什么有时候“日志没打出来”——可能是 Logger 级别太高拒收了也可能是 Handler 级别太高拦下了还可能是 Filter 给过滤了。排查的时候顺着这条链路一层层看就行。2.2 为什么推荐用模块级 logger 而不是 root logger新手最常见的写法是logging.info(xxx)这用的是root logger。它能跑但有个大坑root logger 是全局共享的你在这个模块改了它的配置整个程序所有模块都受影响。项目一大A 模块想要 DEBUG 级别B 模块只想要 WARNING用 root logger 根本没法分开控制。正确姿势是在每个模块顶部拿一个带名字的 loggerimport logging logger logging.getLogger(__name__)__name__会自动变成当前模块的完整路径比如myapp.services.user。这样做的好处是日志里能直接看出是哪个模块打的而且因为 logger 有层级继承机制myapp.services.user会继承myapp.services和myapp的配置你可以在顶层统一配置子模块自动生效需要单独调整时再覆盖。提示getLogger(__name__)是官方推荐的标准写法几乎每个成熟项目都这么干。养成习惯别再用 root logger 了。2.3 日志级别怎么选别把 DEBUG 当 INFO 用logging内置了五个常用级别从低到高是DEBUG INFO WARNING ERROR CRITICAL。设置级别为 INFO 时DEBUG 会被忽略INFO 及以上都会输出。选级别的核心原则是这条信息在什么场景下对谁有用。DEBUG开发调试用的细节比如“进入函数 X参数是 Y”。生产环境一般关掉因为量太大。INFO正常的业务流程节点比如“用户登录成功”“订单创建完成”。生产环境的主力级别。WARNING出现了异常但不影响继续运行比如“配置文件缺失使用默认值”“重试第 2 次”。ERROR功能出错了比如“数据库连接失败”“接口返回异常”。CRITICAL整个程序要挂了比如“磁盘写满服务即将停止”。我见过有人把每个函数入口都打成 INFO结果日志文件一天涨几个 G。级别用错比不用日志还糟。经验法则是生产环境默认 INFO需要排查问题时临时调到 DEBUG排查完调回去。3. 核心细节解析配置方式与关键参数3.1 两种配置方式basicConfig 和 dictConfig 怎么选logging有两种主流配置方式。第一种是logging.basicConfig()适合小脚本快速上手import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(name)s: %(message)s, datefmt%Y-%m-%d %H:%M:%S, )它简单但只能配置一次第二次调用基本无效而且没法精细控制多个 Handler。所以它只适合几十行的临时脚本。第二种是logging.config.dictConfig()用字典描述整个配置树适合正式项目import logging.config LOGGING_CONFIG { version: 1, disable_existing_loggers: False, formatters: { standard: { format: %(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s, datefmt: %Y-%m-%d %H:%M:%S, }, }, handlers: { console: { class: logging.StreamHandler, level: INFO, formatter: standard, stream: ext://sys.stdout, }, file: { class: logging.handlers.RotatingFileHandler, level: INFO, formatter: standard, filename: logs/app.log, maxBytes: 10 * 1024 * 1024, backupCount: 5, encoding: utf-8, }, }, root: { level: INFO, handlers: [console, file], }, } logging.config.dictConfig(LOGGING_CONFIG)dictConfig的优势在于配置和代码分离可以放在 YAML/JSON 文件里能精确控制每个 Handler 的级别、格式、目标disable_existing_loggers: False这个参数尤其重要它保证已经创建的 logger 不会被禁用避免第三方库的日志突然消失。注意disable_existing_loggers默认是True这会导致在dictConfig之前创建的 logger 全部失效。踩过这个坑的人不在少数务必显式设为False。3.2 Formatter 格式串里的字段哪些最值得放格式串决定了日志的可读性和可排查性。常用的字段我整理成一张表字段含义是否推荐%(asctime)s时间戳强烈推荐排查问题第一要素%(levelname)s级别名强烈推荐快速定位严重程度%(name)slogger 名字通常是模块路径推荐定位来源模块%(lineno)d行号推荐直接跳到出错代码%(funcName)s函数名可选调试时有用%(process)d进程 ID多进程场景推荐%(threadName)s线程名多线程场景推荐%(message)s日志内容必须有一个我常用的生产级格式串是这样的%(asctime)s [%(levelname)s] %(name)s:%(lineno)d [%(process)d] - %(message)s时间戳建议用datefmt指定成%Y-%m-%d %H:%M:%S默认格式带毫秒和时区看着累。行号%(lineno)d虽然有一点点性能开销但排查问题时能省下大量翻代码的时间非常值。3.3 日志轮转别让日志文件把磁盘撑爆日志文件无限增长是生产事故的常见诱因。logging.handlers提供了两种轮转 HandlerRotatingFileHandler按文件大小轮转。设maxBytes10*1024*102410MBbackupCount5意思是单个文件到 10MB 就切一个新文件最多保留 5 个备份超出的自动删掉。这样日志总量被控制在 60MB 左右。TimedRotatingFileHandler按时间轮转。设whenmidnight、interval1、backupCount30就是每天零点切一个文件保留 30 天。怎么选看你的日志量。如果一天日志量稳定且不大用按时间轮转方便按天查如果日志量波动大、可能瞬间暴涨用按大小轮转更安全。我一般两个都用按大小防止单文件过大按时间方便归档。file: { class: logging.handlers.TimedRotatingFileHandler, filename: logs/app.log, when: midnight, interval: 1, backupCount: 30, encoding: utf-8, formatter: standard, }提示encodingutf-8一定要加。Windows 上默认编码可能是 GBK中文日志会乱码甚至报错这个坑我踩过不止一次。4. 实操过程从零搭一套可用的日志系统4.1 第一步目录结构与配置分离正式项目我建议把日志配置单独放一个文件比如log_config.py或logging.yaml。目录结构大致这样myproject/ ├── myproject/ │ ├── __init__.py │ ├── main.py │ ├── services/ │ │ └── user.py │ └── log_config.py ├── logs/ │ └── .gitkeep └── requirements.txtlogs/目录要提前建好logging不会自动创建目录文件 Handler 找不到路径会直接抛异常。放个.gitkeep保证空目录能进版本库。日志文件本身要加进.gitignore别把日志提交上去。4.2 第二步写一个可复用的配置函数在log_config.py里封装一个初始化函数在程序入口调用一次import os import logging.config def setup_logging(log_dirlogs, levelINFO): os.makedirs(log_dir, exist_okTrue) config { version: 1, disable_existing_loggers: False, formatters: { standard: { format: %(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s, datefmt: %Y-%m-%d %H:%M:%S, }, simple: { format: %(levelname)s - %(message)s, }, }, handlers: { console: { class: logging.StreamHandler, level: level, formatter: standard, stream: ext://sys.stdout, }, file: { class: logging.handlers.RotatingFileHandler, level: level, formatter: standard, filename: os.path.join(log_dir, app.log), maxBytes: 10 * 1024 * 1024, backupCount: 5, encoding: utf-8, }, error_file: { class: logging.handlers.RotatingFileHandler, level: ERROR, formatter: standard, filename: os.path.join(log_dir, error.log), maxBytes: 10 * 1024 * 1024, backupCount: 5, encoding: utf-8, }, }, root: { level: level, handlers: [console, file, error_file], }, } logging.config.dictConfig(config)这里我特意加了第三个 Handlererror_file它只收 ERROR 及以上级别。这样排查严重问题时直接看error.log不用在几万行 INFO 里翻找效率提升非常明显。4.3 第三步在业务代码里正确使用 logger配置好之后业务模块里这样用import logging logger logging.getLogger(__name__) def create_order(user_id, amount): logger.info(开始创建订单 user_id%s amount%s, user_id, amount) try: # 业务逻辑 result do_something(user_id, amount) logger.info(订单创建成功 order_id%s, result[order_id]) return result except Exception: logger.exception(订单创建失败 user_id%s, user_id) raise这里有两个关键点。第一用%s占位符而不是 f-string。logger.info(x%s, x)只有在日志真正要输出时才会做字符串拼接而logger.info(fx{x})无论级别是否开启都会先拼好字符串白白浪费性能。第二异常用logger.exception()它会自动把 traceback 一起记下来比logger.error()强太多。4.4 第四步验证配置是否生效配置完别急着跑业务先写个测试确认每个 Handler 都工作正常import logging from myproject.log_config import setup_logging setup_logging() logger logging.getLogger(test) logger.debug(这条不该出现因为级别是 INFO) logger.info(这是一条 INFO 日志) logger.warning(这是一条 WARNING 日志) logger.error(这是一条 ERROR 日志) try: 1 / 0 except ZeroDivisionError: logger.exception(捕获到除零异常)跑完之后检查控制台有没有输出 INFO 及以上logs/app.log里有没有全部记录logs/error.log里是不是只有 ERROR 和异常。三个都对配置就算通了。5. 常见问题与排查技巧实录5.1 日志重复输出两遍是怎么回事这是最高频的问题。现象是控制台每条日志出现两次。原因通常是你既调用了basicConfig()或给 root 加了 Handler又给自定义 logger 加了 Handler而 logger 默认会向父级传播propagateTrue导致同一条日志被 root 的 Handler 又处理了一遍。解决办法有两个要么给自定义 logger 设logger.propagate False要么干脆别给自定义 logger 单独加 Handler统一在 root 上配置。我推荐后者配置集中不容易乱。5.2 日志文件里中文乱码前面提过根因是文件编码。FileHandler默认用系统编码Windows 上就是 GBK。解决方法是所有文件 Handler 都显式加encodingutf-8。如果已经产生了乱码文件用iconv或编辑器转码即可但新日志一定要从配置上根治。5.3 多进程写同一个日志文件会丢日志RotatingFileHandler不是进程安全的。多个进程同时往一个文件写轮转的时候可能互相覆盖导致日志丢失。常见方案有三种一是每个进程写自己的文件文件名带 PID二是用QueueHandlerQueueListener让一个独立线程统一写三是用支持并发写的第三方 Handler。中小项目用第一种最简单直接filenameflogs/app_{os.getpid()}.log。5.4 第三方库日志太吵怎么压requests、urllib3这些库默认会打不少 INFO 甚至 DEBUG 日志。压制的办法是单独调高它们的级别logging.getLogger(urllib3).setLevel(logging.WARNING) logging.getLogger(requests).setLevel(logging.WARNING)在dictConfig里也可以用loggers段统一配置比在代码里零散设置更清晰。5.5 常见问题速查表现象可能原因解决方向日志完全不输出级别设太高 / Handler 没挂上检查 logger 和 handler 的 level日志重复两遍propagate 导致重复处理设propagateFalse或统一在 root 配置中文乱码文件编码非 UTF-8Handler 加encodingutf-8日志文件不生成目录不存在提前os.makedirs多进程丢日志Handler 非进程安全分文件写或用队列第三方库刷屏库自身日志级别低单独调高级别提示排查日志问题时先用logging.getLogger().handlers打印当前所有 Handler再用logger.getEffectiveLevel()看实际生效级别比盲目改配置快得多。6. 几个让日志真正好用的进阶技巧6.1 用 extra 给日志加结构化字段纯文本日志排查起来还是靠眼睛。logging支持通过extra参数往日志里塞自定义字段logger.info(用户下单, extra{user_id: 123, order_id: A001})配合格式串里的%(user_id)s就能把业务字段直接打进日志行。更进一步可以自定义一个 JSON Formatter把整条日志输出成 JSON方便被日志采集系统解析。这在需要做日志分析、告警的场景下非常有用。6.2 用 Filter 做敏感信息脱敏日志里不小心打出手机号、身份证号是常见的安全隐患。写一个 Filter在filter()方法里对record.msg做正则替换把敏感字段打码再挂到 Handler 上。这样无论业务代码怎么写出口处统一脱敏比在每个打日志的地方手动处理可靠得多。6.3 上下文追踪给每条日志带上请求 ID在 Web 服务里一次请求可能经过十几个函数日志散落各处。用contextvars存一个 request_id再写个 Filter 把它注入到每条日志里就能把同一次请求的所有日志串起来。排查线上问题时拿到 request_id 一搜整条链路清清楚楚。这个技巧在微服务架构里几乎是标配。6.4 性能考量别在热路径上打重日志日志本身有开销尤其是写文件和格式化。在高频调用的函数里打 DEBUG 日志即使级别关着函数调用和参数检查也有成本。如果确实需要在热路径记录可以用logger.isEnabledFor(logging.DEBUG)先判断或者把重日志改成采样记录。我见过因为日志打太猛导致接口延迟翻倍的案例这个坑值得警惕。7. 我踩过的坑和一点个人体会说几个文档里不会写、但实际项目中一定会遇到的教训。第一日志配置一定要在程序最早期初始化最好在main函数第一行。我遇到过配置写在某个模块里结果另一个模块先被导入并打了日志那些日志全走了默认配置格式乱七八糟。第二别在日志里做字符串运算logger.info(result: str(expensive_call()))这种写法即使级别关着expensive_call()也照跑不误性能杀手。第三日志级别要能动态调整生产环境出问题时不可能重启服务改级别可以用信号或者配置中心热更新级别这个能力关键时刻能救命。logging这个模块初看有点繁琐但一旦理清 Logger、Handler、Formatter、Filter 这条链路你会发现它设计得相当灵活。我的建议是小脚本用basicConfig快速搞定正式项目一律上dictConfig把配置和代码分开Handler 至少配控制台、全量文件、错误文件三个。日志这东西平时觉得是负担出事的时候就是唯一的救命稻草。花半天时间把它搭好后面能省下无数个加班的夜晚。