本节目标:掌握 logging 的四件套架构与层级传播,用 dictConfig 写出生产级配置,并把日志输出成机器可解析的 JSON。
适用版本:Python 3.12+(实测 3.14.6)
11.2 logging 与结构化日志
上一节我们处理了「时间」,而日志恰好是「带时间戳、按级别输出的事件流」。本节把程序从 print 的原始阶段升级到可配置、可分级、可采集的日志系统——这也是后面写命令行工具、Web 服务时绕不开的基础设施。
11.2.1 为什么不用 print,以及五个级别
print 有三个致命短板:不能分级(调试信息与错误混在一起)、不能定向(全部挤进 stdout,污染管道输出)、不能关闭(上线后只能靠改代码删掉)。logging 一次解决全部问题,而且它是标准库,零依赖。日志分五个级别,数值越大越严重:
| 级别 | 数值 | 用途 |
|---|---|---|
DEBUG | 10 | 排查用的细节 |
INFO | 20 | 正常运行的关键事件 |
WARNING | 30 | 默认最低级别,可能有问题 |
ERROR | 40 | 出错了,但程序还能跑 |
CRITICAL | 50 | 严重错误,程序可能无法继续 |
root logger 默认级别是 WARNING,所以不配置时 info 和 debug 根本不会输出。当 root 没有任何 handler 时,WARNING 及以上会落到一个叫 lastResort 的兜底 handler 上,格式固定为 WARNING:root:消息。
11.2.2 Logger 层级与传播(propagate)
Logger 用点号命名,天然形成一棵树:app 是 app.db 的父节点。子 logger 处理完记录后,默认会向上「传播」给祖先的 handler(propagate=True):
import logging
parent = logging.getLogger("app")
child = logging.getLogger("app.db")
parent.setLevel(logging.DEBUG)
child.setLevel(logging.DEBUG)
h = logging.StreamHandler()
h.setFormatter(logging.Formatter("[%(name)s] %(levelname)s %(message)s"))
parent.addHandler(h)
child.warning("经 propagate 交给 app 的 handler")
child.propagate = False
child.warning("这条不会出现(child 自己没有 handler)")
parent.warning("parent 自己的仍然出现")
[app.db] WARNING 经 propagate 交给 app 的 handler
这条不会出现(child 自己没有 handler)
[app] WARNING parent 自己的仍然出现
注意 app.db 自己一个 handler 都没有,却能输出——靠的就是向上传播(child.handlers 为空、propagate 为 True)。setLevel 则只作用于本层。
11.2.3 basicConfig 的局限
basicConfig 是入门用的快捷方式,但它只能配置 root,且只在 root 没有 handler 时生效一次。这里有个隐蔽的坑:
import logging
logging.warning("先来一条 warning") # 模块级函数会隐式调用 basicConfig()
logging.basicConfig(level=logging.DEBUG,
format="%(levelname)s:%(name)s:%(message)s")
logging.debug("我本想让这行出现")
print("root handlers:", logging.getLogger().handlers)
print("root level :", logging.getLogger().level)
WARNING:root:先来一条 warning
root handlers: [<StreamHandler <stderr> (NOTSET)>]
root level : 30
模块级的 logging.warning() 在 root 没有 handler 时会悄悄帮你调一次 basicConfig()。等你自己再调 basicConfig(level=DEBUG) 时,root 已经有 handler 了,于是这次调用被完全忽略,debug 依旧不输出。结论:basicConfig 只适合最简脚本,正经项目一律用 dictConfig。
11.2.4 Handler / Formatter / Filter 三件套
- Handler:决定日志「往哪去」(控制台、文件、网络)。
- Formatter:决定日志「长什么样」。
- Filter:决定日志「哪些能通过」。
import logging
logger = logging.getLogger("demo")
logger.setLevel(logging.DEBUG)
logger.propagate = False
class OnlyErrorFilter(logging.Filter):
def filter(self, record):
return record.levelno >= logging.ERROR
fh = logging.FileHandler("demo.log", encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s %(message)s"))
ch = logging.StreamHandler()
ch.setFormatter(logging.Formatter("[console] %(levelname)s %(message)s"))
ch.addFilter(OnlyErrorFilter()) # 控制台只放 ERROR 以上
logger.addHandler(fh)
logger.addHandler(ch)
logger.debug("调试信息只进文件")
logger.error("错误同时进文件和控制台")
[console] ERROR 错误同时进文件和控制台
文件 demo.log 里两条都在;%(levelname)-8s 的 -8 表示左对齐补到 8 字符宽。logger = logging.getLogger("demo") 这行也说明:要拿到一个可配置的 logger,用 getLogger(name),而不是直接用模块级的 logging.info()。
11.2.5 dictConfig:生产级配置
logging.config.dictConfig 用一个字典描述整棵 logger 树,是项目里的标准做法:
import logging
import logging.config
LOGGING = {
"version": 1,
"disable_existing_loggers": False, # 别关掉别的库已经建好的 logger
"formatters": {
"standard": {"format": "%(asctime)s %(levelname)s %(name)s: %(message)s"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "standard"},
"file": {"class": "logging.handlers.RotatingFileHandler", "filename": "app.log",
"maxBytes": 5_000_000, "backupCount": 3, "encoding": "utf-8",
"formatter": "standard"},
},
"root": {"level": "INFO", "handlers": ["console", "file"]},
"loggers": {"urllib3": {"level": "WARNING"}}, # 降噪:第三方库调高到 WARNING
}
logging.config.dictConfig(LOGGING)
logging.getLogger("app").info("服务已启动")
2026-10-09 07:38:02,104 INFO app: 服务已启动
disable_existing_loggers: False 很关键——它保证在 dictConfig 之前已经创建的 logger 不会被禁用。
11.2.6 库代码的约定:getLogger(name)
如果你在写供别人 import 的库,规则只有一条:取 logger、记日志,但不加 handler、不设 level。让使用方在入口统一配置:
# mylib.py(库代码)
import logging
logger = logging.getLogger(__name__) # 名字自动是 "mylib"
def process(x):
logger.info("处理 %r", x)
if x < 0:
logger.warning("收到负数 %r", x)
return x * 2
应用入口只需 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s"),再调用 process(21) 与 process(-5),输出为:
INFO mylib: 处理 21
INFO mylib: 处理 -5
WARNING mylib: 收到负数 -5
getLogger(__name__) 让日志自动带上模块名,定位问题一目了然;库不擅自加 handler,则把「输出到哪里」的决定权完全交给应用。
11.2.7 % 延迟格式化
写 logger.info("x=%s", x) 而不是 logger.info(f"x={x}"),这不只是风格问题,而是性能问题:
import logging
class Heavy:
calls = 0
def __str__(self):
Heavy.calls += 1
return "<heavy>"
log = logging.getLogger("perf")
log.setLevel(logging.INFO)
log.addHandler(logging.StreamHandler())
obj = Heavy()
log.debug("值 = %s", obj) # 被过滤 -> 不做 % 格式化
log.info("值 = %s", obj) # 通过 -> 才格式化
print("调用次数:", Heavy.calls) # 1
log.debug(f"值 = {obj}") # f-string 先执行,被过滤也白算
print("再调用:", Heavy.calls) # 2
值 = <heavy>
调用次数: 1
再调用: 2
用 %s 占位时,只有记录真的要输出时才会去拼接字符串;f-string 则在调用 logger 之前就把对象转好了,级别不够也白白执行一遍。注意延迟的是格式化,不是参数求值——expensive() 这样的函数调用无论如何都会先跑。
11.2.8 异常日志:exc_info 与 logger.exception
在 except 块里记日志,一定要带上堆栈,否则你只看到「失败了」,看不到「为什么失败」:
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("exc")
try:
1 / 0
except ZeroDivisionError:
log.exception("除法失败") # 等价于 log.error("除法失败", exc_info=True)
ERROR 除法失败
Traceback (most recent call last):
File "example.py", line 7, in <module>
1 / 0
~~^~~
ZeroDivisionError: division by zero
(上面的路径为便于阅读省去了目录前缀。)logger.exception() 只能在 except 块里用(它会自动抓当前异常);在别处要用 logger.error(..., exc_info=True)。
11.2.9 结构化日志:自定义 JSON Formatter
标准库没有内置的 JSON 日志格式,但它提供了足够灵活的 Formatter,自己写一个只要几行。这样输出的每行都是 JSON,能被 Loki、ELK 等系统直接索引:
import json
import logging
import logging.config
class JsonFormatter(logging.Formatter):
def format(self, record):
payload = {
"time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for key in ("request_id", "user_id"):
if hasattr(record, key): # 取出 extra 传入的字段
payload[key] = getattr(record, key)
return json.dumps(payload, ensure_ascii=False)
logging.config.dictConfig({
"version": 1,
"formatters": {"json": {"()": JsonFormatter}},
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json"}},
"root": {"level": "INFO", "handlers": ["console"]},
})
logging.getLogger("app.orders").info(
"订单创建", extra={"request_id": "req-123", "user_id": 42})
{"time": "2026-10-09T07:37:10+0800", "level": "INFO", "logger": "app.orders", "message": "订单创建", "request_id": "req-123", "user_id": 42}
配置里 "()": JsonFormatter 表示「用这个可调用对象构造」;业务字段通过 extra={...} 传进来,在 Formatter 里用 getattr 取出。若不想手写,也可用第三方 python-json-logger,但标准库方案零依赖。
11.2.10 日志轮转与 fileConfig
日志文件不能无限增长,用 RotatingFileHandler 按大小轮转:
import logging
from logging.handlers import RotatingFileHandler
log = logging.getLogger("rot")
log.setLevel(logging.INFO)
h = RotatingFileHandler("rot.log", maxBytes=200, backupCount=2, encoding="utf-8")
h.setFormatter(logging.Formatter("%(message)s"))
log.addHandler(h)
for i in range(30):
log.info("第 %02d 行:一条刚好超过阈值的日志消息", i)
目录里生成的日志文件: ['rot.log', 'rot.log.1', 'rot.log.2']
当前 rot.log 行数: 2
写满 maxBytes 就滚动一次,保留 backupCount 个备份,最旧的被丢弃。按时间轮转则用 TimedRotatingFileHandler(如 when="midnight" 每天一个文件)。
除了字典,标准库还支持 INI 文件配置,适合运维直接改配置文件的场景:
[loggers]
keys=root,app
[handlers]
keys=console
[formatters]
keys=simple
[logger_root]
level=WARNING
handlers=console
[logger_app]
level=DEBUG
handlers=console
qualname=app
propagate=0
[handler_console]
class=StreamHandler
formatter=simple
args=(sys.stdout,)
[formatter_simple]
format=%(levelname)s %(name)s: %(message)s
用 logging.config.fileConfig("log.ini") 加载即可。三套配置方式(basicConfig / dictConfig / fileConfig)中,dictConfig 表达力最强,是首选。
小结
logging用级别(DEBUG→CRITICAL)、Handler、Formatter、Filter 取代print;root 默认级别是WARNING。- Logger 按点号分层,子 logger 通过
propagate把记录交给祖先的 handler;setLevel只管本层。 basicConfig只在 root 无 handler 时生效一次,模块级logging.warning()会隐式调用它——正经项目用dictConfig。- 库代码只
logging.getLogger(__name__),不加 handler、不设 level;用%s占位做延迟格式化。 - 异常日志用
logger.exception()或exc_info=True;JSON 日志可自定义Formatter;文件日志用RotatingFileHandler轮转。
日志解决的是「程序运行时发生了什么」,而下一节我们要解决「程序怎么接收外部指令」——命令行参数解析。想先看调试与可观测性的完整工具箱,可延伸阅读 Python 调试与日志工程 。
阅读导航:上一节:datetime、zoneinfo 与时区 · 下一节:argparse 与命令行工具 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。