调试的时候最让人抓狂的不是报错,是没报错也没输出 。你在代码里规规矩矩写了 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。
排查清单(按顺序走一遍,五分钟内能定位)
print有而logging没有 → 先看级别(坑 1)- 级别对但仍无输出 → 打印各层 logger 的 level / handlers / propagate(坑 3、4)
- 本地有、容器里没有 → 查框架的 root logger 接管与
propagate(坑 4) - 输出两遍 → 父子各挂了 handler,清 handler 或关 propagate(坑 4)
- 文件日志缺段 → 多进程写同一文件(坑 5)
- 配完第三方日志消失 →
disable_existing_loggers(第八节)
最后留一句我的经验:日志问题几乎从不发生在 logger 调用那一行,而发生在"谁配置过它、什么时候配置的"。把配置集中到程序入口一次搞定,后面就再没踩过。
你遇到过哪种最离谱的 logging 不输出?欢迎在评论区贴现象,我按上面清单帮你定位。