原来这才是 Python logging 模块的正确使用姿势!进阶高级开发必看
- logging 模块的核心设计
- 配置中的 class 字段
- 全项目一个 logger 错在哪
- 推荐的写法:配置驱动
- 两种日志组织方式都是合理的
- 配置驱动的写法
- 为什么 propagate 要设为 False
- 全项目一个 logger 的正确改法
- 常见坑
- 1. 重复 handler
- 2. level 继承的坑
- 3. 多进程写入同一个文件
- 4. 性能问题
- 总结
最近在参与我司的一个新项目的时候,让我负责配置可观测性相关的基础代码,看到其他同事的第一版代码搞了个全项目一起使用的 logger 变量,在所有项目代码内统一引入,进行统一的打印,突然感觉十分难受。
# 同事的第一版代码logger=logging.getLogger("app")# 所有模块都引用这个全局 loggerfromutilsimportlogger logger.info("xxx")这种写法让我产生了强烈的生理不适——日志真的应该是这么打的吗?于是研究了一下 Python logging 的最佳实践,结果发现了新大陆。
logging 模块的核心设计
在说最佳实践之前,先聊聊 logging 模块是怎么设计的。理解了这个,才能理解为什么很多"常见用法"其实是错误的。
logging 模块有五个核心组件:
日志的流转过程是这样的:
关键点:
- Logger 有层级结构:root 是最顶层,
app.user是app的子 logger,app.user.auth又是app.user的子 logger - 默认传播机制:子 logger 的日志默认传播到父 logger(
propagate=True) - Logger 继承父 logger 的 level:如果子 logger 没有设置 level,会从父 logger 继承
配置中的 class 字段
在 YAML 配置中,class字段指定 Handler 的类,参数会传给构造函数:
handlers:console:class:logging.StreamHandlerlevel:INFOformatter:simplestream:ext://sys.stdout# 参数传给构造函数这里的ext://sys.stdout是 logging 配置的特殊语法,表示引用标准输出流。
全项目一个 logger 错在哪
先看看常见的"全项目一个 logger"是怎么写的:
# utils.pyimportlogging logger=logging.getLogger("app")# user_service.pyfromutilsimportlogger logger.info("用户登录")# 来自同一个 logger# order_service.pyfromutilsimportlogger logger.info("创建订单")# 还是同一个 logger这种写法的问题是:
日志没有区分来源。当你想只看user_service的日志时,没办法过滤。你只能看到"所有模块混在一起"的日志。
耦合问题。所有模块都依赖utils.py,模块间的依赖关系变得混乱。
推荐的写法:配置驱动
两种日志组织方式都是合理的
实际上,logging 的最佳实践有两种常见的组织方式,没有绝对优劣,按需选择即可:
| 方式 | 写法 | 特点 |
|---|---|---|
| 每模块独立 logger | logger = logging.getLogger(__name__) | 无脑方便,利用默认传播机制配置简单 |
| 子系统级别 logger | logger = logging.getLogger("app.user") | 按业务分组控制灵活,但需要额外设计 |
每模块独立 logger的好处:由于 logger 默认传播和继承父 logger,只要配置顶级 logger(app)就能控制所有子 logger,配置并不复杂。很多项目(比如 Flask、Django)默认就是这么用的。
子系统级别 logger的好处:按业务领域分组控制很方便,比如可以把app.user的日志单独输出到用户相关日志文件。但代码需要额外的设计和维护。
业界现状:常用库里两种用法都有。比如 uvicorn 用的是子系统级别,而很多小型项目直接用每模块独立 logger。没有一定之规,按需选择即可。
配置驱动的写法
# logging.yamlversion:1disable_existing_loggers:falseformatters:default:format:'%(asctime)s - %(name)s - %(levelname)s - %(message)s'detailed:format:'%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'handlers:console:class:logging.StreamHandlerlevel:INFOformatter:defaultfile:class:logging.handlers.RotatingFileHandlerlevel:DEBUGformatter:detailedfilename:app.logmaxBytes:10485760# 10MBbackupCount:5loggers:app:# 顶级 loggerlevel:DEBUGhandlers:[console,file]propagate:false# 配置了 handler 后建议设为 Falseapp.user:level:DEBUGhandlers:[file]propagate:falseroot:level:WARNINGhandlers:[console]加载配置:
importlogging.configimportyamlwithopen('logging.yaml','r')asf:config=yaml.safe_load(f)logging.config.dictConfig(config)为什么 propagate 要设为 False
当一个 logger 配置了 handler 后,建议把propagate设为False。否则会出现重复日志:
# 如果 propagate=True(默认)app.info("msg")# 会打印两次# 第一次:app 的 handler 打印# 第二次:root 的 handler 打印(因为传播到 root 了)全项目一个 logger 的正确改法
回到开头的问题,正确的做法是每个模块用自己的 logger:
# utils.pyimportlogging logger=logging.getLogger(__name__)logger.info("utils loaded")# user_service.pyimportlogging logger=logging.getLogger(__name__)# "user_service"logger.info("用户登录")# order_service.pyimportlogging logger=logging.getLogger(__name__)# "order_service"logger.info("创建订单")配置起来也很简单,只要配置顶级 logger 就行:
root:level:INFOhandlers:[console,file]所有子 logger(user_service、order_service)的日志都会自动传播到 root logger 被处理。
常见坑
1. 重复 handler
如果父 logger 和子 logger 都配置了 handler,且propagate=True,日志会打印多次。
解决:要么子 logger 的propagate=False,要么子 logger 不配 handler。
2. level 继承的坑
子 logger 没有设置 level 时,会从父 logger 继承。如果父 logger 设置了level=DEBUG,子 logger 默认也是 DEBUG。
解决:明确设置每个 logger 的 level。
3. 多进程写入同一个文件
使用RotatingFileHandler或TimedRotatingFileHandler时,多进程写入可能导致日志损坏。
解决:使用ConcurrentRotatingFileHandler或日志收集服务(如 Filebeat)。
4. 性能问题
日志字符串拼接在DEBUGlevel 时也会执行,即使这条日志不会被记录:
logger.debug(f"用户数据:{long_string}")# 字符串拼接总是会执行解决:使用懒加载:
logger.debug("用户数据: %s",long_string)# 仅在 DEBUG 开启时执行拼接总结
logging 模块的设计其实很优雅:层级结构 + 传播机制 + handler 解耦。
全项目一个 logger 的做法,是没有理解这套设计的结果。正确做法是让每个模块用自己的 logger,利用默认的传播机制,配置集中在根 logger。
至于选择"每模块独立 logger"还是"子系统级别 logger",取决于项目规模和个人偏好。前者简单无脑,后者控制灵活。没有绝对的好坏之分。
关键的一点是:把日志配置抽离成配置文件(YAML 或 dictConfig),而不是在代码里硬编码basicConfig。这样改了配置不用改代码,也方便在不同环境使用不同配置。
参考文档:
- Python Logging HOWTO
- Python logging 模块文档