接手任意一个Python项目,我习惯先看三样东西:依赖清单、测试目录、日志配置。日志往往是最不被重视的一环,但线上出问题之后,真正能救你的往往只有它。这篇文章专门聊python系统日志写入配置这件事,从logging模块的工作原理讲起,覆盖最小可用配置、多模块组织、dictConfig统一管理、常见翻车点,以及生产环境常用的结构化日志方案。适合刚接触logging的新手,也适合已经配置过但总在重复踩坑的开发者。读完你至少能回答一个问题:我的项目日志现在到底写进了哪里,又该怎么改。
1. 为什么日志要靠logging而不是print
1.1 print日志的三个硬伤
先说说我用print时代经历过的事。当时在小项目里排查一个定时任务,代码里全是print,看起来没什么问题。结果任务部署到后台用systemd托管之后,print的内容全部进了journal,等我真的需要回溯三天前的运行状态时,日志早就被系统轮转清理掉了。那一刻我才意识到,print不能算日志,它只是一个临时输出工具。
print的第一个硬伤是没有级别。DEBUG、INFO、WARNING、ERROR这些概念完全不存在,所有输出一视同仁。线上正常跑时大量无关信息刷屏,真正出故障时却连最基础的错误码都没有被单独标记。第二个硬伤是没有上下文信息。print出来的只是字符串本身,没有时间戳、没有模块名、没有代码行号。一旦出现问题,你需要在代码里到处翻,才能判断这段输出来自哪里。第三个硬伤是去向不可控。print永远只往标准输出走,没法同时做到控制台、文件、远程采集各自一份,更没法方便地轮转切割。
而logging从设计上就是把这三件事分开的。它能按级别过滤,能自动附加时间、进程号、代码位置,能让同一条日志同时写进文件和控制台,还支持轮转、压缩、远程传输。用一个比喻来说:print是随手写在便签纸上的字,logging是分门别类归档的档案库。正经的程序逻辑不排斥print做临时调试,但如果你的项目要靠print来排查事故,那维护成本会非常高。
1.2 logging的组件模型:Logger、Handler、Formatter、Filter
很多教程一上来就讲basicConfig,导致很多人以为logging就是一行配置的事,后续加需求时就开始乱改全局变量。实际上logging是一个组件化的系统,核心就四样东西:
- Logger:日志的入口,代码里调用的对象,负责产生日志记录。它决定了这条日志叫什么名字、属于哪个级别。
- Handler:日志的去向,负责把记录发送到控制台、文件、网络等目标。一个Logger可以挂多个Handler。
- Formatter:日志的排版,负责把记录格式化成字符串。时间怎么显示、要不要带行号,都是它管。
- Filter:日志的筛子,负责在进入Handler之前做额外的过滤控制,比级别过滤更灵活。
理解了这个模型,很多配置问题会迎刃而解。比如最常见的"日志重复输出"问题,根因往往是同一批记录通过多个路径进入了同一个输出目标。再比如"文件里没有日志"问题,往往不是没产生记录,而是Logger没有挂任何Handler,或者Handler被某个配置覆盖了。
我用一个生活类比来帮你记:Logger是快递单上的寄件人信息,Handler是快递线路——既走陆地也走航空,Formatter是快递箱子上的标签样式,Filter则是安检口,不是所有包裹都能上车。你只需要在入口处调用logger.info("..."),后面的路全由这套流水线决定。
2. 日志写入文件的最小可用配置
2.1 从basicConfig到三件套
第一次写日志配置,绝大多数人都是从basicConfig开始的。它能用一行代码解决"写文件"这个最朴素的需求:
import logging logging.basicConfig( filename="app.log", level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", encoding="utf-8" ) logging.info("应用启动")这段配置做了四件事:把日志写入app.log文件,只记录INFO及以上级别,规定文本格式,并指定编码。对于脚本和一次性任务,这个配置够用了。但如果你直接把它放进Web服务里,很快会踩到第一个坑:basicConfig默认往root logger上挂Handler,而且只有在你没有配置过其他Handler时才生效。一旦某个第三方库在工作过程中调用了basicConfig,或者你自己在另外的模块里又执行了一次,前一个配置就可能被静默覆盖。
所以在正式项目里,我几乎不用basicConfig做主配置,而是手动组装Logger、Handler、Formatter三件套。下面是一个写文件的最小示例:
import logging logger = logging.getLogger("myapp") logger.setLevel(logging.DEBUG) file_handler = logging.FileHandler("myapp.log", encoding="utf-8") file_handler.setLevel(logging.INFO) formatter = logging.Formatter( "%(asctime)s | %(levelname)-8s | %(name)s | %(lineno)d | %(message)s" ) file_handler.setFormatter(formatter) logger.addHandler(file_handler)这段代码看起来比basicConfig啰嗦,但每个组件独立,后续想加控制台Handler、加轮转、加过滤器,都是插入一个新对象的事,不需要推翻重来。
2.2 格式串里的门道:asctime、levelname、name之外还有什么
格式串是Formatter的核心,但很多人只会照抄%(message)s。要写出一条适合排查问题的日志,至少要包含这几类信息:
| 占位符 | 含义 | 典型值 |
|---|---|---|
| %(asctime)s | 日志产生时间 | 2025-01-15 10:00:00,123 |
| %(levelname)s | 日志级别 | INFO、ERROR |
| %(name)s | Logger名称 | myapp.service |
| %(filename)s | 源文件名 | server.py |
| %(lineno)d | 代码行号 | 128 |
| %(funcName)s | 函数名 | handle_request |
| %(process)d | 进程ID | 4321 |
| %(thread)d | 线程ID | 123456 |
组合方式按团队习惯来,我自己常用的格式是:
%(asctime)s | %(levelname)-8s | %(process)d | %(name)s:%(lineno)d | %(message)s-8s的作用是左对齐并补齐宽度,让多行日志级别对齐,肉眼扫起来舒服一些。时间格式通过datefmt控制,我建议保留到秒加毫秒,否则排查慢接口时很难确认耗时波动。
格式串里有一个容易被忽略的参数是style,默认是%风格,如果你更喜欢{风格,可以这样写:
logging.Formatter( "{asctime} | {levelname} | {name} | {message}", style="{" )两种风格没有本质差异,全团队统一就好,最怕一半代码用%一半用{}。
2.3 文件轮转:RotatingFileHandler和TimedRotatingFileHandler怎么选
日志文件最忌讳无限增长。单机小应用可能一个月才几百MB,表面无感,但等你磁盘被撑满时,系统已经难以正常工作了。logging标准库提供了两种轮转方案,我建议从一开始就选好一种,而不是等到出问题再补。
按大小轮转用RotatingFileHandler:
from logging.handlers import RotatingFileHandler rotating_handler = RotatingFileHandler( "myapp.log", maxBytes=10 * 1024 * 1024, # 10MB backupCount=5, encoding="utf-8" )达到10MB后,当前文件会变成myapp.log.1,新的日志写入新的myapp.log,最多保留5份备份。它适合日志产生比较均匀、单条大小可预期的服务。
按时间轮转用TimedRotatingFileHandler:
from logging.handlers import TimedRotatingFileHandler timed_handler = TimedRotatingFileHandler( "myapp.log", when="midnight", backupCount=7, encoding="utf-8", utc=False )每天零点切割一份,保留7天。这里有个细节:如果某天服务在零点前宕机了,重启后日志会继续写进当前文件,不会自动补一份过去时间的日志。所以它依赖的是运行时持续在线,不是磁盘上的文件时间。
选择原则很简单:追求容量可控用大小轮转,追求时间范围清晰用时间轮转。更严格的做法是把轮转外包给系统logrotate,Python只负责打开文件。这时候不要用普通FileHandler,要用WatchedFileHandler,它监听文件是否被外部改名,指针变化后自动重新打开新文件,和logrotate配合得很丝滑。
3. 多模块场景下的Logger组织方式
3.1 getLogger("module.sub")的命名规则
项目一旦拆成多模块,最常见的问题就是:为什么我在这里写的logger.info没有输出?为什么别的模块的日志跑到了我的文件里?
这两个问题的答案都在Logger的命名树上。logging.getLogger(name)并不是随意取一个字符串,而是按照点号分隔的层级关系组织的。比如getLogger("myapp")是getLogger("myapp.api")的父Logger。子Logger默认会把日志记录传递给父Logger,最终传到root logger。
所以在每个模块里,我几乎总是这样开头:
import logging logger = logging.getLogger(__name__)__name__在模块里就是模块的完整导入路径,比如myapp.api.server。这套命名规则有天然的好处:你可以针对某个子模块单独调级别,也可以在父Logger上统一挂Handler,让所有子模块自动继承输出能力。
举例来说,你想让myapp.api这个模块打印更多调试信息,但整个项目仍然保持INFO:
parent = logging.getLogger("myapp.api") parent.setLevel(logging.DEBUG)其他模块不用改任何代码,只改这一行就生效,这在排查某个子系统时非常方便。
3.2 重复日志的源头:propagate
多模块配置最容易踩的雷,是同样的日志出现两遍。排查思路通常不是"代码打了两遍",而是同一条日志记录从不同的Logger路径流向了同一个Handler。
看这个场景:你在root logger上挂了文件Handler,又在某个模块的Logger上挂了一个文件Handler,同时该Logger没有关闭propagate。那么这条消息会先从当前Logger的Handler输出一次,再往上传播给root,又从root的Handler输出一次。实际效果就是重复。
标准库提供了一个属性来控制传播:
module_logger = logging.getLogger("myapp.api") module_logger.propagate = False设置为False后,当前Logger处理完就不会再向上传递。但我提醒一句:开propagate=False前要确认当前Logger真的挂了Handler,否则这条日志会直接消失。更推荐的做法不是到处关propagate,而是在根级别统一配置Handler,所有子Logger只负责产生日志,不自己挂输出,这样传播链路就非常干净。
3.3 一次初始化全局生效的模块化写法
有了上面的理论基础,我习惯把日志初始化单独放进一个模块,避免每个模块各配一套。比如新建logging_setup.py:
import logging from logging.handlers import RotatingFileHandler def setup_logging() -> None: root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) handler = RotatingFileHandler( "myapp.log", maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" ) handler.setFormatter(logging.Formatter( "%(asctime)s | %(levelname)-8s | %(process)d | %(name)s:%(lineno)d | %(message)s" )) root_logger.addHandler(handler)然后在程序入口,比如main开头或WSGI模块导入时调用一次:
from logging_setup import setup_logging setup_logging()这样做的前提是:Logger对象按名字缓存,同一个名字在进程内永远返回同一个实例。所以后续任何模块里使用getLogger(__name__),都会自动挂到root的Handler上。只要初始化发生在第一条业务日志之前,全局日志就是通的。
这里有个容易被忽略的问题:如果第三方库或者你自己在setup_logging之前就调用了basicConfig,之后再调用addHandler,可能有两套Handler同时工作。所以入口处的初始化顺序很重要,我通常在import阶段就完成,而不是等业务逻辑跑到一半才配置。
4. 用dictConfig把日志配置从代码里抽出来
4.1 为什么我用dictConfig而不是手动接线
手动组装Logger和Handler有个隐藏负担:代码量和Handler数量成正比。当你需要控制台、文件、错误单独文件、轮转、过滤器同时存在时,手写接线会变得长而难读。这时候就该换成logging.config.dictConfig。
dictConfig的核心思路是,把所有Logger、Handler、Formatter、Filter的定义放进去一个字典或YAML/JSON文件里,一次性应用。这样做的好处有三个:配置可被别的环境复用;配置能独立于代码修改;新同事看配置比看接线代码更直观。
我第一次用dictConfig是因为某项目的日志规则频繁变化——一会要加JSON输出,一会要加采样,每次改完都要重新发布代码。后来把配置抽到YAML里,运维同学自己就能调,开发只留了一个重新加载的接口。
4.2 一份可以抄走的生产配置模板
先给一份我常用的配置模板,不想看解释的话可以直接照抄:
import logging.config LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "standard": { "format": "%(asctime)s | %(levelname)-8s | %(process)d | %(name)s:%(lineno)d | %(message)s" }, "json": { "format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s" } }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "DEBUG", "formatter": "standard", "stream": "ext://sys.stdout" }, "file_info": { "class": "logging.handlers.RotatingFileHandler", "level": "INFO", "formatter": "standard", "filename": "logs/myapp.log", "maxBytes": 10 * 1024 * 1024, "backupCount": 5, "encoding": "utf-8" }, "file_error": { "class": "logging.handlers.RotatingFileHandler", "level": "ERROR", "formatter": "standard", "filename": "logs/error.log", "maxBytes": 10 * 1024 * 1024, "backupCount": 5, "encoding": "utf-8" } }, "root": { "level": "INFO", "handlers": ["console", "file_info", "file_error"] } } logging.config.dictConfig(LOGGING_CONFIG)几个关键点值得说明。disable_existing_loggers如果设置为True,会在执行时关闭所有之前存在的非root Logger,很多库的内部Logger会被殃及,导致它们的日志消息被丢弃。所以我要求自己这块一律为False。
ext://sys.stdout表示引用标准库的sys.stdout对象,字符串里的sys会被解释成Python模块。用这个写法比直接写"stream": "sys.stdout"更可靠,因为后者可能被当成字符串路径来解析。
把ERROR单独引到一个文件里,是我个人的强烈习惯。线上查问题时,最常见的动作不是翻全量日志,而是只看ERROR文件。你只需要tail -f error.log,就能快速定位这批失败请求里有没有重复出现的异常模式。
4.3 运行时改级别:dictConfig的局限与rescan方案
dictConfig适合在进程启动时做一次性配置,但Web服务经常需要在运行中临时调高某个模块的日志级别,比如怀疑某个接口有隐藏bug,想临时打开DEBUG。如果你直接执行logging.config.dictConfig(new_config),它会按新配置重建框架,之前手动挂上的Filter或Handler可能会被清掉,副作用比预期大。
我目前的做法是保留一个change_level(logger_name, level)入口:
def change_level(logger_name: str, level: int) -> None: logger = logging.getLogger(logger_name) logger.setLevel(level)只改级别,不动Handler接线。这样做既快速又能热生效,绝大多数排查场景已经够用。真正需要完整重载配置的时候,建议用信号或管理命令触发,把配置文件重新读取一次再走dictConfig,并且处理好外部Handler的关闭,避免句柄泄漏。
5. 实战里最容易翻车的六个坑
5.1 Windows下编码炸裂
文件写入编码问题在我接手某跨平台系统时集中爆发过一次。开发在Windows上跑,日志文件是默认的GBK编码,部署到Linux服务器后编码变成UTF-8,两者的中文日志互相乱码。更糟糕的是,如果写入的异常信息里包含特殊字符,而FileHandler没有指定encoding,Windows下极容易触发UnicodeEncodeError,进程会异常中断。
解决办法就是从第一步就统一指定编码。FileHandler、RotatingFileHandler都支持encoding="utf-8"参数。basicConfig里也可以传encoding="utf-8"。不要依赖系统默认编码,这是我在每个平台的踩坑总结。
5.2 日志时间永远差8小时
容器和云服务器默认时区往往是UTC,你的业务时间是东八区。如果你只配置了%(asctime)s而没有指定时区,日志时间会比实际时间晚8个小时,排查问题时会把时间线完全对不上。
标准Formatter不直接支持时区参数,可以用一个自定义Formatter解决:
import time from logging import Formatter class BeijingTimeFormatter(Formatter): converter = time.localtimeconverter指向time.localtime后,Formatter内部就会试用本地时间,而不再用UTC。更通用的做法是让容器启动时设置环境变量TZ,但进程一旦已经启动再修改就不生效了。我个人偏向在代码里自定义Formatter,因为这样不依赖外部环境。
5.3 异常堆栈打不出来
初学者常犯的一个错误是:
try: risky() except Exception: logger.error("something wrong")输出一个干巴巴的字符串,堆栈完全丢失,排查完全无从下手。正确的写法有两种:
logger.error("something wrong", exc_info=True)或者只在异常处理分支里用:
logger.exception("something wrong")logger.exception本质就是error(..., exc_info=True),它会自动把当前异常堆栈格式化进日志。问题是很多人不知道这个方法的存在,或者把logger.exception用在了没有异常的地方,结果堆栈信息为None,啥也没打出来。
5.4 轮转失败与句柄释放
RotatingFileHandler在Windows上有一个经典翻车场景:日志文件正被其他进程占用时,轮转要删除旧文件会失败,轻则日志中断,重则直接抛异常。这种情况常见于用Excel打开日志文件看内容,或者杀毒软件扫描把文件锁住了。
另一个经典问题是多进程共用同一个日志文件。每个进程有自己的文件指针,轮转时进程A把myapp.log改名为myapp.log.1,进程B还握着旧文件的句柄继续写,写进去的内容就像丢进黑洞一样找不回来了。解决方案有两种:一是让每个进程写独立的日志文件,文件名带进程号或实例ID;二是弱化进程内轮转,直接把日志交给外部logrotate,Python侧用WatchedFileHandler。
5.5 日志重复输出
重复输出的根源在第三节讲过,这里补充一个基于dictConfig的具体例子。如果你在root上挂了控制台Handler,又在某个自定义Logger上挂了同一个控制台类Handler,那这个Logger产生的日志会输出两次。排查时先看Logger的propagate值,再看Handler列表里是不是存在重复引用。最彻底的办法是建立"根统一输出、子Logger只设级别"的约定,从结构上消灭重复。
5.6 高并发下的性能损耗
日志本身是一种IO操作。在高并发下,每条请求打两三行INFO,就可能让你的接口QPS掉一截。标准库的Handler在写文件时是同步的,每个日志调用都可能触发一次磁盘写入。
如果性能吃紧,我建议两步走。第一步先降低输出量,把高频率且低价值的日志级别调成DEBUG,并且生产环境不要开。第二步再考虑异步:用QueueHandler和QueueListener,把日志生产放入队列,由独立线程批量写文件。这个方案不需要替换业务代码里任何logger调用,只要在配置阶段替换Handler挂载方式。
6. 结构化日志:把文本变成可检索字段
6.1 一行文本日志为什么不够
文本日志人类看起来友好,但机器不友好。当服务规模变大,你需要按错误级别过滤、按请求ID关联上下文、按时间范围聚合计数时,一行一行的字符串解析非常痛苦,还容易漏字段。
结构化日志的核心思想是输出键值对,或者直接输出JSON。每一行都包含同样的字段,采集端可以自动解析,监控系统可以按字段检索,排障时只需要按trace_id一筛,整条请求链路就都出来了。
6.2 用JSONFormatter输出结构化字段
标准库没有内置JSONFormatter,但实现一个并不难。我用的版本:
import json import logging class JsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: log_data = { "time": self.formatTime(record), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "module": record.module, "line": record.lineno } if record.exc_info: log_data["exc_info"] = self.formatException(record.exc_info) return json.dumps(log_data, ensure_ascii=False)然后挂到Handler上:
json_handler = logging.FileHandler("myapp.json.log", encoding="utf-8") json_handler.setFormatter(JsonFormatter())输出效果类似这样:
{"time": "2025-03-01 10:00:00,123", "level": "INFO", "logger": "myapp.api", "message": "order created", "module": "orders.py", "line": 42}注意ensure_ascii=False很重要,否则中文会变成\uXXXX转义序列,虽然信息不丢,但肉眼排查和采集端解析都不方便。
6.3 日志采样与细节打磨
结构日志也不是越全越好。某些访问日志每秒上千条,每条都落盘,很快把磁盘和采集带宽打满。对这类低价值高频率日志,我习惯加采样逻辑:
import random _should_sample = random.random() < 0.1 if _should_sample: logger.info(json.dumps({"event": "request", "url": url, "cost_ms": cost}))采样率根据量级调,但是ERROR和WARNING这类需要全量保留的日志,绝不能采样。这个坑一旦踩了,线上出问题时你会发现关键的ERROR记录恰好被随机丢掉了。
在把结构化日志接入采集系统时,还要注意字段类型别随意改变。同一个字段,这次是字符串,下次是数字,会让下游的索引和数据清洗很头疼。从第一次设计日志字段开始,就要把类型定死,这是结构化日志方案能不能真正落地的前提。
7. 一点个人实践心得
日志配置这件事,看起来只是几行初始化代码,但它和项目稳定性的关系远超想象。我实践下来最受益的一条准则是:在写业务代码之前,先想清楚日志策略。级别怎么分、文件怎么切、保留多久、ERROR是否需要单独落盘,这些决策在代码膨胀之后会越来越难改,不如一开始就定下来。
另一个有用的习惯是给每一条日志想想"五年后的我会靠它查出什么问题"。如果一条日志没有回答任何潜在问题的能力,它就只是噪音。排查问题时我也常常发现,真正关键的记录往往出现在那些当时以为啰嗦的细节里。
最后分享一个很实用的技巧:周期性自检日志系统。每隔一段时间,人为制造一个ERROR级别的异常,确认它如期出现在对应日志文件里。这个动作看起来多余,但它能提前暴露句柄泄漏、文件权限、轮转异常这类隐患。我就是靠这个习惯,在几次真实事故之前早早发现了日志系统本身的问题。日志系统不保证你能看清所有现场,但它保证你在需要时有一个可以相信的现场。