Python logging 日志不输出?根源在 propagate 这条链上

调试的时候最让人抓狂的不是报错,是没报错也没输出 。你在代码里规规矩矩写了 logging.info("start task"),运行完控制台干干净净;换成 print 立刻就有了。这时候大多数人会怀疑自己记错了 API,其实 API 没记错,是 logging 的设计和你想的不一样。

这篇文章把我这几年在项目里反复遇到的 6 个原因按出现频率排一遍,每个都给出复现代码和修法,最后给一份可以直接抄的 dictConfig 配置。

一、先对齐一个认知:logging 不是"打印",是一条流水线

logging.info() 这条调用背后有四个对象在协作:

  • Logger :你调用的入口,有名字、有层级(a.b 是 a 的子级)
  • Handler:决定日志去哪(控制台、文件、网络)
  • Formatter:决定日志长什么样
  • Filter:决定哪些日志能过

日志从 logger 出来,先比级别,再交给本 logger 上的所有 handler,然后沿着名字往上**冒泡(propagate)**给父 logger 的 handler,一直冒到 root。

绝大多数的"不输出"和"重复输出",都出在级别和冒泡这两个环节。理解这一句就够了:logger 决定要不要记,handler 决定往哪记,propagate 决定要不要往上报。

二、坑 1:默认级别是 WARNING,info 和 debug 被静默丢弃

这是新手第一坑。root logger 的默认级别是 WARNING,你没配过级别,info 和 debug 会被直接丢掉,不会有任何提示。

python 复制代码
import logging
logging.info("解析完成")      # 什么都不出现
logging.warning("字段缺失")    # 出现

修法只有一行,但要注意调用的是模块级函数时改的是 root:

python 复制代码
logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s %(levelname)s %(name)s %(message)s")
logging.info("解析完成")   # 现在有了

三、坑 2:basicConfig 只在 root 没有 handler 时才生效

这条最隐蔽。basicConfig 里有一句判断:如果 root logger 已经有 handler 了,它什么也不做,直接 return 。而很多第三方库在 import 的时候就会调 basicConfig 或自己挂 handler。

于是就出现了这种顺序依赖:

python 复制代码
import some_third_party_lib   # 它偷偷配置过 root
import logging
logging.basicConfig(level=logging.INFO)   # 静默失效
logging.info("还是不输出")

我的判断是:在任何工程代码里都别依赖 basicConfig ,直接用 dictConfig 显式声明,顺序依赖就消失了。库作者才该用 NullHandler,业务代码该自己说了算。

四、坑 3:日志写在了子 logger 上,配置却配在了 root 上

模块里推荐写法是 logger = logging.getLogger(__name__),这样日志自带模块路径,非常好定位。但如果你给 root 挂了文件 handler,而子 logger 的级别设得比 root 高,日志在子 logger 这一关就被拦了,压根传不到 root 的 handler。

python 复制代码
log = logging.getLogger("app.pipeline")
log.setLevel(logging.WARNING)      # 这里就把 info 掐了
log.info("这条永远到不了 root 的 handler")

排查方式很直接,打一张表看每个 logger 的真实状态:

python 复制代码
for name in ("", "app", "app.pipeline"):
    lg = logging.getLogger(name)
    print(f"{name or 'root':<15} level={logging.getLevelName(lg.level)} "
          f"handlers={len(lg.handlers)} propagate={lg.propagate}")

五、坑 4:propagate 引发的"不输出"和"重复输出"

propagate=False 会切断向上冒泡。这条在容器里特别常见:uvicorn、gunicorn、celery 都会接管 root logger,有些框架为了不把业务日志刷两遍,会把业务 logger 的 propagate 关掉,或者反过来给 root 加一个 handler 导致你的日志被打印两次。

  • 现象 A:Docker 里 docker logs 看不到业务日志 → 大概率是某个父级 propagate=False 或者框架把 root 的 handler 摘了。
  • 现象 B:同一条日志在控制台出现两次 → 子 logger 和 root 各有一个 handler,且 propagate=True(默认)。

对应修法:

python 复制代码
log = logging.getLogger("app")
log.handlers.clear()          # 先清掉可能重复挂上的 handler
log.propagate = False         # 自己全权负责,不往 root 冒泡
log.addHandler(logging.StreamHandler())

六、坑 5:多进程写同一个日志文件

用 FileHandler 让 4 个 gunicorn worker 同时写一个 app.log,日志会交叉覆盖、偶尔整段丢失。这不是 logging 的 bug,是多进程追加写没有跨进程锁。

正确做法是换 RotatingFileHandler(只解决单进程内的文件轮转),或者干脆让每个进程只往 stdout 写、由容器/日志采集器统一收集。我自己在生产环境一律选后者------应用只管往标准输出写,落盘和轮转交给基础设施,省心且不会丢。

七、坑 6:f-string 写进了 logger 调用里

这个不影响输出与否,但值得一起改:

python 复制代码
logging.debug(f"请求参数 {payload}")          # 哪怕级别不够,字符串也已经拼好了
logging.debug("请求参数 %s", payload)          # 延迟到真正要输出时才格式化

在高 QPS 的接口里,前一种写法会在你完全不需要日志的时候白白做几千次字符串拼接。logging 的 %s 惰性格式化就是为这件事设计的。

八、一份可以直接抄的配置

python 复制代码
import logging.config

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,   # 关键:别把第三方库的 logger 全禁了
    "formatters": {
        "std": {"format": "%(asctime)s %(levelname)-8s %(name)s | %(message)s"},
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "std",
            "stream": "ext://sys.stdout",
        },
    },
    "loggers": {
        "app": {"level": "DEBUG", "handlers": ["console"], "propagate": False},
    },
    "root": {"level": "WARNING", "handlers": ["console"]},
}

logging.config.dictConfig(LOGGING)

这里有个容易被忽略的参数 disable_existing_loggers,它默认是 True,会把配置之前已存在的 logger 全部禁用------这也是"配完之后 requests、sqlalchemy 的日志全没了"的经典成因。工程里一律显式写 False。

排查清单(按顺序走一遍,五分钟内能定位)

  1. print 有而 logging 没有 → 先看级别(坑 1)
  2. 级别对但仍无输出 → 打印各层 logger 的 level / handlers / propagate(坑 3、4)
  3. 本地有、容器里没有 → 查框架的 root logger 接管与 propagate(坑 4)
  4. 输出两遍 → 父子各挂了 handler,清 handler 或关 propagate(坑 4)
  5. 文件日志缺段 → 多进程写同一文件(坑 5)
  6. 配完第三方日志消失 → disable_existing_loggers(第八节)

最后留一句我的经验:日志问题几乎从不发生在 logger 调用那一行,而发生在"谁配置过它、什么时候配置的"。把配置集中到程序入口一次搞定,后面就再没踩过。

你遇到过哪种最离谱的 logging 不输出?欢迎在评论区贴现象,我按上面清单帮你定位。

相关推荐
ZealSinger1 小时前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go
喜欢打篮球的普通人1 小时前
MiniMind 学习笔记(十):优化器、学习率和数据设置——训练稳定性的三块基石
笔记·python·学习
醇氧1 小时前
uvicorn 详细介绍
linux·运维·python·python3.11
丹宇码农2 小时前
Go 与 Python 协程(Coroutine)对比演示项目
开发语言·python·golang
w***48822 小时前
SpringBoot整合easy-es
spring boot·后端·elasticsearch
codists2 小时前
lexeme和token
python
dpharness2 小时前
踩完 dsh-ads 的四个坑,我说说虚构排名该怎么看
后端
预知同行2 小时前
深入解析 AI 应用可观测性:OpenTelemetry GenAI 规范下的调用链追踪与 Token 成本治理
后端·架构
天赐范式2 小时前
天赐范式第182天:让变异开始存活——主线重启与变异存续条件
python·ar模型·数字生命·天赐范式·动态运行时·稳态方差·变异存续