☰
Python日志记录最佳实践:从基础配置到生产排障实战
2026/9/28 14:15:10 网站建设 项目流程

写Python两年以上的朋友,大概率都有过这种体验:线上出了故障,打开日志文件,满屏INFO刷过去,真正想看的异常栈被冲得没影;或者自己写的脚本,三天后再看,完全想不起那些print到底在打印什么。Python日志记录(Logging)模块是标准库里最被低估的组件,把它的最佳实践用对,不是学会几个API就行,而是要想清楚日志到底要回答什么问题:系统是否正常、某个请求慢在哪一步、故障现场能不能快速回放。这篇文章是我最近一年在生产环境里反复调Logging总结出来的配置方案和排障经验,适合业务开发、脚本维护和刚接触Logging的新手直接抄作业。

1. 动工之前,先把三个设计问题想透

一开始学Logging,很多人就是照着文档抄一段basicConfig,能用就觉得“会了”。直到项目变大,才发现日志体系全是坑:重复日志、日志串台、级别失效、进程写文件互相覆盖。归根到底,是没把日志当成一个系统来设计,而只是当成print的替代品。

1.1 日志不是print的升级版

print和logging最本质的区别,是print只会把字符串打到标准输出,而logging把“信息产生”和“信息去往哪里”彻底拆开了。logging里有Logger、Handler、Formatter、Filter四个核心角色:Logger负责在哪一行代码记日志,Handler决定日志写到控制台还是文件还是远程服务,Formatter决定日志长什么样,Filter决定哪些日志能过闸。

这种分层带来的好处非常直接。你可以在开发环境把DEBUG级别输出到控制台,在生产环境只保留INFO甚至WARNING到文件,代码里一个级别都不用改;你可以给同一个Logger挂两个Handler,一个存完整日志文件,一个把ERROR级别以上同步推给监控系统。这些都是print做不到的。

我见过不少人用print做完临时调试,上线时懒得删或者不敢删,最后所有print全留在生产代码里,信息还带乱码和多余换行。print只适合写一次性脚本,或者调试的时候你根本不关心历史记录。凡是可能运行超过一天、需要回溯的代码,都应该用logging。

1.2 级别设计:每条日志都有自己的“交付优先级”

Logging自带五个级别,不是随便标个数字那么简单,而是对应了不同的处理路径:

级别典型场景生产环境默认处理方式
DEBUG变量值、函数进入退出、SQL语句等细节一般关闭,出问题时临时打开
INFO正常业务事件:订单创建、用户登录、任务完成写文件
WARNING有风险但能继续运行:接口超时自动重试、磁盘使用率临近阈值写文件,重点监控
ERROR业务异常、依赖服务不可用、代码里捕获到的异常写文件,并触发告警
CRITICAL整个模块或程序无法继续运行立即人工介入

一个常见的反模式,是习惯性用logger.info打调试信息,上线后又不敢改级别,导致INFO日志里充满琐碎细节,真正关键的业务事件全被淹没。要反过来定规则:INFO里只放“一个有业务语义、可以回看系统轨迹的事件”,DEBUG才放过程细节。比如“用户12345登录成功”是INFO,“请求头里的Authorization解析结果是什么”是DEBUG。

我自己的准则是:如果这一行日志对明天查问题有用,就用INFO或WARNING;如果只是让我写代码当时感觉踏实,就放DEBUG。生产环境默认INFO,但保留一个改造开关,出问题的时候能临时切到DEBUG而不需要重新发版。

1.3 一行日志的格式,决定排障速度

随手用默认格式打日志,会漏掉排障最需要的“上下文信息”。一段正经的日志格式至少应该包含:

  • 时间(精确到毫秒,且带时区或直接用UTC)
  • 日志级别
  • 来源模块和代码行号
  • 线程或进程标识
  • 核心业务追踪信息,比如request_id
  • 消息本体

我最终常用的文本格式是:

LOGGING_FORMAT = "%(asctime)s.%(msecs)03d %(levelname)-8s [%(name)s:%(lineno)d] [%(threadName)s] %(message)s"

这个格式看着简单,实际搜日志时就知道多好用了:grep "order_service.py"能快速定位模块;grep "ERROR"能过滤异常;按毫秒时间戳能还原请求在微秒级的时间线。日志时间里一定要带毫秒,否则两个请求并发的时候,你根本没法判断谁先谁后。

如果你用的是分布式或微服务架构,纯文本格式排障还是会累。更推荐结构化日志,把关键字段用JSON输出,方便日志采集系统直接索引和聚合。后面我单独讲怎么做。

2. 配置方案选择:从BasicConfig到结构化配置

Logging的配置方式有好几种,选错后面会非常痛苦。我的建议是:脚本用最简单的方式,项目一律用dictConfig,别在代码里手工addHandler满天飞。

2.1 适合小脚本的最简配置

临时脚本、单文件工具,用basicConfig就够了,几行代码解决问题:

import logging # 在脚本入口处,只调用一次 logging.basicConfig( level=logging.DEBUG, format="%(asctime)s %(levelname)-8s [%(name)s:%(lineno)d] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=[ logging.StreamHandler(), logging.FileHandler("script.log", encoding="utf-8"), ], )

注意,basicConfig只能配置根Logger,而且不能“重复调用生效”。如果你在模块A里调一次,模块B里再调一次,第二次可能完全没用。特别提醒,Python 3.8之后basicConfig支持force=True,但脚本里别滥用。

2.2 为什么现实项目要改用DictConfig

项目一旦有多个模块、多个Logger、多个Handler,再用basicConfig就只能眼睁睁看着代码里到处logging.getLogger(...).addHandler(...)。这种写法导致的结果是:日志配置被拆散到几十个文件里,谁也看不出全局配置长什么样。

dictConfig把整个日志配置集中定义在一个字典里,结构清晰,还能用YAML或JSON外置。而且它可以配置Logger层级关系、Formatter、Filter、Handler的参数,统一管理,真正做到“配置归配置,代码归代码”。

一个典型的dictConfig结构:

import logging from logging.config import dictConfig LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "standard": { "format": "%(asctime)s.%(msecs)03d %(levelname)-8s " "[%(name)s:%(lineno)d] [%(threadName)s] %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S", }, }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "DEBUG", "formatter": "standard", "stream": "ext://sys.stdout", }, "file": { "class": "logging.handlers.RotatingFileHandler", "level": "INFO", "formatter": "standard", "filename": "app.log", "maxBytes": 10 * 1024 * 1024, "backupCount": 5, "encoding": "utf-8", }, }, "root": { "handlers": ["console", "file"], "level": "INFO", }, } dictConfig(LOGGING_CONFIG)

这里有个关键开关:disable_existing_loggers必须设为False。默认值是True,它的意思是:当dictConfig执行时,会把之前已经创建好的Logger全部禁用。后果非常隐蔽——某个库在import阶段提前创建了自己的Logger,等你之后调用dictConfig,那个Logger就“死”了,日志全部静默。生产环境里90%的“日志莫名消失”都跟这个有关,先把这条记在笔记里。

2.3 按环境拆分配置的常见写法

同一个项目,开发环境想看到DEBUG,生产环境的日志级别和信息量都不一样。最省事的做法是维护一个“基础配置字典”,再按环境override:

import os from logging.config import dictConfig def load_logging_config() -> dict: env = os.getenv("APP_ENV", "dev").lower() base_config = { "version": 1, "disable_existing_loggers": False, "formatters": { "json": { "format": '{"time": "%(asctime)s", "level": "%(levelname)s", ' '"logger": "%(name)s", "lineno": "%(lineno)d", ' '"message": "%(message)s"}', }, "standard": { "format": "%(asctime)s.%(msecs)03d %(levelname)-8s " "[%(name)s:%(lineno)d] [%(threadName)s] %(message)s", }, }, "handlers": { "console": { "class": "logging.StreamHandler", "formatter": "standard", }, "json_file": { "class": "logging.handlers.RotatingFileHandler", "filename": "app.log", "maxBytes": 10 * 1024 * 1024, "backupCount": 5, "encoding": "utf-8", "formatter": "json", }, }, "root": { "handlers": ["console", "json_file"], "level": "INFO", }, } if env == "dev": base_config["root"]["level"] = "DEBUG" base_config["handlers"]["console"]["level"] = "DEBUG" base_config["handlers"]["json_file"]["level"] = "DEBUG" else: base_config["root"]["level"] = "INFO" base_config["handlers"]["console"]["level"] = "INFO" base_config["handlers"]["json_file"]["level"] = "INFO" return base_config dictConfig(load_logging_config())

如果更喜欢YAML,在项目配置文件旁边放一个logging.yaml,用yaml.safe_load()加载后传给dictConfig即可。但注意,YAML天然不适合放复杂的Handler类名和引用,所以遇到要加过滤器、参数比较复杂的情况,直接用Python字典更省心。

3. 生产级实操:完整项目里的Logging接入方式

配置写好了,接入姿势同样重要。这一节给出一套能直接落地的方案:模块命名、Request ID追踪、日志轮转。

3.1 模块内Logger的命名和传播

写业务模块时,永远不要用logging.getLogger()无参数的形式取Logger,那样拿到的是根Logger,会污染整个应用的日志。正确姿势是:

import logging logger = logging.getLogger(__name__)

__name__会带上完整模块路径,比如order.service、user.api。这是日志命名的核心:Logger的天然结构就是包结构树。通过dictConfig配置里loggers字段,可以针对某个包统一调整级别,非常灵活。

举个例子,项目里接入了第三方库requests,它自带大量WARNING日志,想把它们压掉,只需要在配置里:

"loggers": { "requests": { "level": "ERROR", "propagate": False, } }

propagate=False的意思是,这个Logger生成的日志不再向上传给父Logger。这样就能精准地控制第三方库的日志,而不是全局关掉,非常实用。

模块内用Logger时,还涉及一个容易混淆的点:子Logger没有设置Handler时,日志会传播到父Logger,由父Logger的Handler输出。所以不必在每个模块里都挂Handler,一般只要在根或包级Logger上挂Handler,子Logger自然就“继承”了输出通道。反过来,如果你在模块里手工addHandler,又没关propagate,日志会被父级Handler再写一遍,就是重复日志的典型来源,后面第4节细讲。

3.2 为每个请求生成跟踪ID

微服务或Web应用里,排障最大的障碍是“难以把一条用户请求在多个模块/多个服务产生的日志串起来”。解决方案是给每次请求生成一个唯一的request_id,并让它自动出现在这一请求期间产生的所有日志里。

Python标准库里,contextvars更适合做这件事,比threading.local或logging.Filter单独用干净得多:

import logging import uuid from contextvars import ContextVar request_id_var: ContextVar[str] = ContextVar("request_id", default="-") class RequestIdFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: record.request_id = request_id_var.get() return True

在Web框架(比如FastAPI或Flask)的入口中间件里设置这个变量:

@app.middleware("http") async def add_request_id(request, call_next): request_id = uuid.uuid4().hex[:12] token = request_id_var.set(request_id) try: response = await call_next(request) response.headers["X-Request-ID"] = request_id return response finally: request_id_var.reset(token)

然后在日志格式里加上%(request_id)s,就能实现一整个请求链路内的日志自动携带同一个ID。以后用grep "a1b2c3d4e5f6" app.log,这个请求从入口到DB调用,所有日志全部浮出水面。

这个方案能跑通的原理是:ContextVar在异步任务里会自动随上下文传递;只要中间件里设置好,由这个请求触发的协程任务里的日志记录都能读到同一个值。如果在普通线程池场景里,记得去给每个线程也设置request_id,或者在任务创建时把值传进去,否则线程里打日志会拿到默认的-。

3.3 文件轮转和日志清理策略

日志文件如果不做容量控制,跑一个月能吃到几十GB磁盘。最直接的方案是用RotatingFileHandler:

"file": { "class": "logging.handlers.RotatingFileHandler", "filename": "logs/app.log", "maxBytes": 50 * 1024 * 1024, # 单文件最多50MB "backupCount": 10, # 保留10个备份文件 "encoding": "utf-8", "delay": True, }

当单个日志文件超过maxBytes,RotatingFileHandler会把它重命名为app.log.1,再创建新的app.log。backupCount控制保留多少份历史文件,超过就自动删除。这套逻辑很适合单机部署,你的磁盘占用最多大概是maxBytes * (backupCount + 1),心里有数就好。

如果日志量更大,TimedRotatingFileHandler可以按天或按小时切片。但我要提醒一个坑:TimedRotating切文件时,如果应用里有另一个进程还持有旧文件句柄,会切不干净。配合日志采集系统时,也可以不落盘,直接输出到stdout,由容器平台统一采集归档。

还有一个容易忽略的问题:logging.handlers下面有不少Handler支持delay=True。设成True可以延迟创建文件,直到第一条日志真正写入时才打开文件,避免程序启动时无权写文件直接抛异常。很多脚本不知道这个参数,启动即崩溃,非常可惜。

4. 高频问题与排查技巧

这部分都是实操中几乎必踩的坑,我把症状、原因和解法整理成速查表,方便遇到问题时直接对照。

4.1 日志重复输出:多半是Handler挂多了

症状:控制台里同一行日志出现了两遍甚至三遍。

最常见原因有两个。第一,在模块里自己addHandler以后,又没有把propagate设为False,于是日志先被模块里自己挂的Handler输出一次,又被根Logger的Handler输出一次。第二,dictConfig运行了多次,但disable_existing_loggers=False,旧Handler没被清掉,新Handler又叠加上来。

排查方法:

  • 在出问题的那一段代码临时加一行print(logger.handlers, logger.parent),看Handler挂载情况。
  • 把根Logger的Handler列表打出来:logging.getLogger().handlers。
  • 给模块Logger加上propagate=False,确认是否还有重复。

治本方案是:配置统一收敛到dictConfig里,业务模块代码只负责getLogger(__name__),绝不手工addHandler。真需要在某个模块单独加Handler时,也把propagate=False写死。

4.2 中文乱码和编码设置

老生常谈,但每次写文件日志都会遇到。Windows控制台默认编码可能是GBK,Linux是UTF-8,而FileHandler默认不指定编码时,会跟随系统区域设置。日志文件一旦出现中文乱码,后面查问题时会直接疯掉。

所以文件Handler必须显式指定:

"handlers": { "file": { "class": "logging.handlers.RotatingFileHandler", "encoding": "utf-8", "errors": "replace", } }

errors="replace"的意思是,当遇到无法编码的字符时用?代替,而不是直接抛异常中断程序。线上日志如果因为某条消息里嵌了特殊字符导致写入异常,整个日志链路都可能挂掉,设置成replace是更稳妥的兜底方案。

4.3 多进程日志丢失与资源竞争

单进程里用logging没问题,但Gunicorn多worker、多进程跑同一个FileHandler时就开始出妖蛾子:两个进程同时写一个文件,日志行会互相穿插、偶发丢失,甚至拖慢请求。文件写入不是原子的,你看到的往往是两行日志拼成半行。

常见做法有三种:

  • 每个进程写自己的日志文件,文件名带PID或worker编号。简单可靠,但排障时要拼多个文件。
  • 用QueueHandler+QueueListener,把日志先送到一个独立线程再写文件。这个方案在单进程内有效,多进程之间还需要借助外部队列(如Redis、RabbitMQ)。
  • 直接输出到stdout/stderr,交给supervisor或容器平台统一收集。微服务场景下我强烈推荐这个,最简单也最无副作用。

如果你用的是multiprocessing自己写多进程任务,官方文档也推荐各个子进程创建自己的Logger,避免共用文件。记住:不要让多个进程直接共享同一个文件Handler去写同一个文件。

4.4 日志的“高并发”问题:异步Handler

日志量大了之后,同步写文件也会拖慢业务线程。解决办法是QueueHandler+QueueListener,把日志推进内存队列,由后台线程批量写入:

import os import queue import logging from logging.handlers import QueueHandler, QueueListener log_queue = queue.Queue(-1) queue_handler = QueueHandler(log_queue) console = logging.StreamHandler() console.setFormatter(logging.Formatter("%(asctime)s %(levelname) %(message)s")) queue_listener = QueueListener(log_queue, console, respect_handler_level=True) queue_listener.start() root_logger = logging.getLogger() root_logger.handlers = [] root_logger.addHandler(queue_handler) root_logger.setLevel(logging.INFO)

这样业务线程只需要往内存Queue里塞日志,耗时几乎可以忽略;后台的QueueListener线程负责真正写文件。respect_handler_level=True会让监听器参考Handler自己的级别去过滤日志,避免日志被双重过滤丢失。

还要注意:程序退出前最好调用queue_listener.stop(),确保Queue里还没写完的日志全部落盘,否则最后几条日志可能在进程退出时丢失。

异步日志适合那种“日志量很大,但可允许最多延迟几百毫秒”的场景。如果追求日志不丢不漏,就用同步Handler配合高性能磁盘,别盲目异步。


最后再分享两个我一直在用的经验:日志是给未来的自己看的,写的时候多问一句“三个月后,我看到这条日志能不能定位问题?”能,就留;不能,就删。另外,每次上线前我都会花十分钟在测试环境故意制造一个异常,然后顺着日志从入口走到故障点,确认日志链路完整。这套习惯比任何日志框架的“最佳实践”都更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询