《Python编程入门》11.2 logging 与结构化日志

print 无法分级、无法定向、无法关闭。本节讲清 logging 的 Logger/Handler/Formatter/Filter 架构、层级传播与 basicConfig 的局限,用 dictConfig 写完整配置,示范 % 延迟格式化、exc_info 异常日志、自定义 JSON Formatter 与日志轮转。

本节目标:掌握 logging 的四件套架构与层级传播,用 dictConfig 写出生产级配置,并把日志输出成机器可解析的 JSON。
适用版本:Python 3.12+(实测 3.14.6)

11.2 logging 与结构化日志

上一节我们处理了「时间」,而日志恰好是「带时间戳、按级别输出的事件流」。本节把程序从 print 的原始阶段升级到可配置、可分级、可采集的日志系统——这也是后面写命令行工具、Web 服务时绕不开的基础设施。

11.2.1 为什么不用 print,以及五个级别

print 有三个致命短板:不能分级(调试信息与错误混在一起)、不能定向(全部挤进 stdout,污染管道输出)、不能关闭(上线后只能靠改代码删掉)。logging 一次解决全部问题,而且它是标准库,零依赖。日志分五个级别,数值越大越严重:

级别数值用途
DEBUG10排查用的细节
INFO20正常运行的关键事件
WARNING30默认最低级别,可能有问题
ERROR40出错了,但程序还能跑
CRITICAL50严重错误,程序可能无法继续

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 与命令行工具 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时