原来这才是 Python logging 模块的正确使用姿势!进阶高级开发必看

最近在参与我司的一个新项目的时候,让我负责配置可观测性相关的基础代码,看到其他同事的第一版代码搞了个全项目一起使用的 logger 变量,在所有项目代码内统一引入,进行统一的打印,突然感觉十分难受。

# 同事的第一版代码
logger = logging.getLogger("app")

# 所有模块都引用这个全局 logger
from utils import logger
logger.info("xxx")

这种写法让我产生了强烈的生理不适——日志真的应该是这么打的吗?于是研究了一下 Python logging 的最佳实践,结果发现了新大陆。

logging 模块的核心设计

在说最佳实践之前,先聊聊 logging 模块是怎么设计的。理解了这个,才能理解为什么很多"常见用法"其实是错误的。

logging 模块有五个核心组件:

1

1

1

*

1

*

Logger

+name: str

+level: int

+handlers: List[Handler]

+propagate: bool

+debug(msg)

+info(msg)

+warning(msg)

+error(msg)

Handler

+level: int

+formatter: Formatter

+emit(record)

Formatter

+fmt: str

+datefmt: str

+format(record)

Filter

+filter(record)

LogRecord

+name: str

+level: int

+message: str

+created: float

日志的流转过程是这样的:

Handler flow

Logger flow

用户代码中的日志调用,例如:logger.info(...)

logger 是否对本次调用 level 启用?

创建 LogRecord

挂载在 logger 上的 filter 是否拒绝该 record?

将 record 传递给当前 logger 的 handlers

当前 logger 的 propagate 是否为 true?

是否存在 parent logger?

将当前 logger 设为 parent logger

停止

层级结构中是否至少有一个 handler?

使用 lastResort handler

record 被传递给 handler

handler 是否对该 record 的 level 启用?

挂载在 handler 上的 filter 是否拒绝该 record?

emit(包含 formatting)

停止

关键点:

  1. Logger 有层级结构:root 是最顶层,app.userapp 的子 logger,app.user.auth 又是 app.user 的子 logger
  2. 默认传播机制:子 logger 的日志默认传播到父 logger(propagate=True
  3. Logger 继承父 logger 的 level:如果子 logger 没有设置 level,会从父 logger 继承

配置中的 class 字段

在 YAML 配置中,class 字段指定 Handler 的类,参数会传给构造函数:

handlers:
  console:
    class: logging.StreamHandler
    level: INFO
    formatter: simple
    stream: ext://sys.stdout  # 参数传给构造函数

这里的 ext://sys.stdout 是 logging 配置的特殊语法,表示引用标准输出流。

全项目一个 logger 错在哪

先看看常见的"全项目一个 logger"是怎么写的:

# utils.py
import logging
logger = logging.getLogger("app")
# user_service.py
from utils import logger

logger.info("用户登录")  # 来自同一个 logger
# order_service.py
from utils import logger

logger.info("创建订单")  # 还是同一个 logger

这种写法的问题是:

日志没有区分来源。当你想只看 user_service 的日志时,没办法过滤。你只能看到"所有模块混在一起"的日志。

耦合问题。所有模块都依赖 utils.py,模块间的依赖关系变得混乱。

推荐的写法:配置驱动

两种日志组织方式都是合理的

实际上,logging 的最佳实践有两种常见的组织方式,没有绝对优劣,按需选择即可:

方式写法特点
每模块独立 loggerlogger = logging.getLogger(__name__)无脑方便,利用默认传播机制配置简单
子系统级别 loggerlogger = logging.getLogger("app.user")按业务分组控制灵活,但需要额外设计

每模块独立 logger 的好处:由于 logger 默认传播和继承父 logger,只要配置顶级 logger(app)就能控制所有子 logger,配置并不复杂。很多项目(比如 Flask、Django)默认就是这么用的。

子系统级别 logger 的好处:按业务领域分组控制很方便,比如可以把 app.user 的日志单独输出到用户相关日志文件。但代码需要额外的设计和维护。

业界现状:常用库里两种用法都有。比如 uvicorn 用的是子系统级别,而很多小型项目直接用每模块独立 logger。没有一定之规,按需选择即可。

配置驱动的写法

# logging.yaml
version: 1
disable_existing_loggers: false

formatters:
  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.StreamHandler
    level: INFO
    formatter: default
  file:
    class: logging.handlers.RotatingFileHandler
    level: DEBUG
    formatter: detailed
    filename: app.log
    maxBytes: 10485760  # 10MB
    backupCount: 5

loggers:
  app:  # 顶级 logger
    level: DEBUG
    handlers: [console, file]
    propagate: false  # 配置了 handler 后建议设为 False
  app.user:
    level: DEBUG
    handlers: [file]
    propagate: false

root:
  level: WARNING
  handlers: [console]

加载配置:

import logging.config
import yaml

with open('logging.yaml', 'r') as f:
    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.py
import logging
logger = logging.getLogger(__name__)

logger.info("utils loaded")
# user_service.py
import logging
logger = logging.getLogger(__name__)  # "user_service"

logger.info("用户登录")
# order_service.py
import logging
logger = logging.getLogger(__name__)  # "order_service"

logger.info("创建订单")

配置起来也很简单,只要配置顶级 logger 就行:

root:
  level: INFO
  handlers: [console, file]

所有子 logger(user_serviceorder_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. 多进程写入同一个文件

使用 RotatingFileHandlerTimedRotatingFileHandler 时,多进程写入可能导致日志损坏。

解决:使用 ConcurrentRotatingFileHandler 或日志收集服务(如 Filebeat)。

4. 性能问题

日志字符串拼接在 DEBUG level 时也会执行,即使这条日志不会被记录:

logger.debug(f"用户数据: {expensive_function()}")  # expensive_function 总是会执行

解决:使用懒加载:

logger.debug("用户数据: %s", expensive_function())  # 仅在 DEBUG 开启时执行

总结

logging 模块的设计其实很优雅:层级结构 + 传播机制 + handler 解耦

全项目一个 logger 的做法,是没有理解这套设计的结果。正确做法是让每个模块用自己的 logger,利用默认的传播机制,配置集中在根 logger。

至于选择"每模块独立 logger"还是"子系统级别 logger",取决于项目规模和个人偏好。前者简单无脑,后者控制灵活。没有绝对的好坏之分。

关键的一点是:把日志配置抽离成配置文件(YAML 或 dictConfig),而不是在代码里硬编码 basicConfig。这样改了配置不用改代码,也方便在不同环境使用不同配置。


参考文档

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值