登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python logging 怎样为异常记录 traceback 但避免重复输出

来源:17golang原创

时间:2026-10-24 22:25:17 181浏览 收藏

值班时最容易误判的一类日志问题是:异常明明只发生了一次,终端却连续出现两份甚至三份 traceback。通常不需要牺牲 traceback 来“消重”。正确做法是让异常在一个明确的处理器里用 logger.exception() 记录一次,再把 logger 层级、handler 数量和 propagate 配置整理成一条可解释的输出路径。

先记住这三个判断
  • logger.exception() 本身是 ERROR 级别,并会附加当前异常信息,只应在异常处理器中调用。
  • 同一异常既调用 logger.exception() 又调用 logger.error(..., exc_info=True),会得到两条记录。
  • 子 logger 和父 logger 同时挂 handler,且子 logger 保持传播时,同一个 LogRecord 也可能被输出多次。

先判断重复来自调用还是 logger 层级

Python logging 把 logger、handler、filter 和 formatter 分开设计。异常处理器负责创建记录,handler 负责把记录送到控制台或文件;logger 的层级传播则决定同一条记录是否继续交给父级 handler。排查时先问“记录创建了几次”,再问“这条记录经过了几个输出点”。

Python logging 异常处理器生成 LogRecord 后沿子 logger、父 logger 到达多个 handler 的关系说明图
图1:Python logging 的异常记录、层级传播与输出 handler 关系说明图,不是运行截图。

如果输出中 traceback 的首行、业务消息和时间都重复一致,优先检查 handler 或传播;如果每份消息上下文不同,才继续检查异常处理器是否被多个层级重复调用。官方文档也说明,给一个 logger 和它的祖先同时挂 handler,可能让同一条记录发出多次。

异常处理器里只选一种 traceback 写法

最小写法是把记录动作放在 except 块内:

import logging

logger = logging.getLogger(__name__)

def load_profile(user_id: str) -> dict:
    try:
        # 这里模拟一个可能失败的外部读取动作。
        return read_profile_from_store(user_id)
    except OSError:
        # exception 会以 ERROR 级别记录当前异常的 traceback。
        logger.exception("读取用户资料失败 user_id=%s", user_id)
        # 记录后按业务约定返回空结果,避免把同一异常再次记录。
        return {}

logger.exception() 是 logger.error() 的便捷形式,区别在于它会把异常信息加入日志,而且官方要求它只在异常处理器中使用。如果你需要动态指定级别,可以改成下面这种写法,但同一个 except 分支不要两种都调用:

try:
    result = parse_payload(raw_payload)
except ValueError:
    # exc_info=True 让 error 记录当前异常;这里不再额外调用 logger.exception。
    logger.error("请求体解析失败", exc_info=True)
    result = None  # 让调用方得到明确的失败结果。

重复输出时先数 handler,再看 propagate

模块级 logger 通常使用 logging.getLogger(__name__),消息会向父 logger 传播。最常见的重复配置是:应用 logger 挂了一个 FileHandler,root logger 又挂了一个 StreamHandler,而应用 logger 仍然允许传播。此时一条记录同时写文件并打印终端并不一定是错误;真正的问题是你是否又在同一目的地配置了第二个 handler,或是否把两份内容误当成两次异常。

def describe_logger_tree(logger: logging.Logger) -> list[tuple[str, str]]:
    # 只读取当前 logger 的直接 handler,便于排查重复挂载。
    rows = [(logger.name, type(handler).__name__) for handler in logger.handlers]
    # propagate 为真时,输出还可能来自父级;这里把信号单独列出。
    rows.append((logger.name, f"propagate={logger.propagate}"))
    return rows

检查表可以这样用:

现象优先检查处理方向
同一消息同时出现在文件和终端是否本来就配置了两个目的地如果这是预期,保留;否则删掉不需要的 handler
终端出现两份完全相同消息root 与子 logger 是否都挂了 StreamHandler只在一个层级保留控制台 handler
每次请求重载后多一份初始化函数是否反复 addHandler把配置移到应用启动处,或先清理已有 handler
子模块日志多一份子 logger 的 propagate 是否仍为 True集中由父级处理,或在子 logger 明确设置 False

用集中配置固定唯一输出路径

如果应用需要同时写控制台和文件,可以把两个目的地都声明在一次配置中,再决定是否让应用 logger 继续向 root 传播。下面的模板把 handler 绑定到名为 service 的 logger,并用 propagate=False 切断 root 的第二条路径;这样 traceback 仍保留,但不会因为 root 还有同类 handler 而重复。

Python dictConfig 集中配置应用 logger、异常记录、控制台 handler、文件 handler、Formatter 和 propagate False 的结构图
图2:集中配置下的 logger、handler 与传播边界结构图,不是运行截图。
from logging.config import dictConfig

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "standard": {
            # %(exc_text)s 由 formatter 在有异常信息时补充 traceback。
            "format": "%(asctime)s %(levelname)s %(name)s %(message)s"
        }
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "standard",
        },
        "file": {
            "class": "logging.FileHandler",
            "level": "ERROR",
            "filename": "service.log",
            "encoding": "utf-8",
            "formatter": "standard",
        },
    },
    "loggers": {
        "service": {
            "level": "INFO",
            "handlers": ["console", "file"],
            # 由 service 自己负责两个目的地,避免再交给 root 重复处理。
            "propagate": False,
        }
    },
}

def configure_logging() -> None:
    # 只在应用启动阶段调用一次,避免热加载时不断新增 handler。
    dictConfig(LOGGING)

这里的两个 handler 是两个明确的输出目的地,不是重复记录:INFO 及以上进入控制台,ERROR 及以上额外进入文件。若你只想让父 logger 统一管理,则可以移除 service 的 handler 配置并保留默认传播;不要同时保留两套策略。

回滚路径:从复杂配置退回最小单点

线上日志突然翻倍时,先不要大范围修改业务异常处理。可以暂时回滚到“一个应用 logger、一个控制台 handler、一次 logger.exception()”的最小路径,再逐项恢复文件、JSON formatter 或第三方 handler。这样能把问题拆成“记录重复”与“输出重复”两类。

import logging

def minimal_logging() -> logging.Logger:
    # basicConfig 只负责 root 的一次性基础配置,适合故障回退。
    logging.basicConfig(
        level=logging.INFO,
        format="%(levelname)s %(name)s %(message)s",
    )
    # 返回模块 logger,让业务代码不直接依赖 root 级函数。
    return logging.getLogger("service")

如果回退后仍重复,问题大概率不在 formatter,而在同一异常分支被多次记录、多个 handler 没有清理,或上层框架已经替你配置了日志。此时保留一份真实 traceback,记录当前 logger 的 handler 列表和传播状态,再恢复正式配置。

告警确认与复盘清单

修复后不要只看“行数变少了”。一次异常应仍然能在预期目的地找到消息、异常类型和 traceback;同时,正常 INFO 日志不应因为文件 handler 的级别设置而意外消失。值班确认可以按下面顺序完成:

  1. 确认异常分支只调用了一次 logger.exception() 或一次带 exc_info=True 的记录方法。
  2. 列出应用 logger 与 root 的直接 handler,确认同一目的地没有重复实例。
  3. 确认 propagate 的选择与 handler 所在层级一致,不要一边集中处理一边让子 logger 再向 root 复制。
  4. 确认日志轮转、文件权限和编码配置没有把同一条记录重新转发。
  5. 在复盘中记录触发异常的业务边界、最终保留的 traceback 位置和回滚方式。

常见问题

logger.error 和 logger.exception 有什么区别? 两者都可以记录 ERROR;logger.exception() 会自动附加当前异常信息,并且应该只在 except 中使用。普通错误消息不需要 traceback 时,用 logger.error() 更清楚。

设置 propagate=False 会不会删除 traceback? 不会。它只阻止记录继续交给父 logger 的 handler;当前 logger 自己挂的 handler 仍可正常格式化并输出异常信息。

为什么 basicConfig 看起来没有生效? 基础配置通常只在 root 尚未有 handler 时起作用。若框架或前一次初始化已经配置过 handler,再调用它不会自动替换现有配置,应把正式配置放到明确的启动阶段。

文件和终端各有一份算重复吗? 不一定。如果两个目的地是有意配置的,这是一次记录的两种投递;只有同一目的地重复出现,或业务告警按记录条数计费时,才需要进一步收敛路径。

小结:保留 traceback 的关键是使用正确的异常记录方式,避免重复输出的关键是只保留一条清晰的 logger-handler 责任链。先判断调用次数,再检查 handler 和 propagate,最后用集中初始化固定配置,排障会比盲目删除 traceback 稳定得多。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>