1. 日志不是打印,是给系统装黑匣子
很多人第一次接触 logging 模块,都是从print()升级过来的。代码跑得好好的,加几行 print 看看变量值,调试完再删掉,循环往复。直到某天线上服务半夜挂了,你打开终端只看到一片空白,或者更糟——满屏都是 print 输出,根本分不清哪条是正常流程、哪条是异常信号。这时候你才意识到,日志不是打印,它是系统的黑匣子。
logging 模块就是 Python 标准库给这个黑匣子提供的完整解决方案。它解决的核心问题有三个:第一,把日志从代码里解耦出来,不用再写一堆 print 然后手动清理;第二,给日志分级,让不同严重程度的信息走不同的通道;第三,支持多目标输出,同一份日志可以同时写到控制台、文件、网络端点,甚至通过邮件发出去。适合谁看?如果你写过超过两百行的 Python 脚本,或者维护过任何需要长期运行的服务,这篇内容就是给你准备的。哪怕你之前只用过logging.basicConfig()打打简单日志,后面关于 Logger 层级、Handler 组合、Formatter 定制的部分也会让你重新理解这个模块的设计意图。
我见过太多项目把 logging 用成了“高级 print”,配置写得随意,出了问题查日志像大海捞针。其实 logging 模块的架构非常清晰,四个核心组件各司其职,理解它们之间的关系之后,你就能像搭积木一样组合出适合自己项目的日志系统。下面我从一次真实的排查经历说起,带你把这套机制彻底吃透。
2. 从一次日志丢失事故拆解 logging 的四大组件
2.1 事故现场:为什么我的日志文件是空的
某次我负责的一个数据同步服务,部署到测试环境后运行了一整天,第二天同事问我:“你那个服务到底跑没跑?日志文件是空的。”我第一反应是不可能,代码里明明写了logging.info()。打开代码一看,配置是这样的:
import logging logging.basicConfig(level=logging.INFO, filename='sync.log') logger = logging.getLogger('sync') logger.info('同步任务开始')看起来没问题对吧?但实际运行时,basicConfig在模块导入阶段就被调用了,而getLogger('sync')拿到的是一个带名字的 logger。问题出在:basicConfig配置的是 root logger,而sync这个 logger 的级别默认是NOTSET,它会向上查找父级 logger 的级别。理论上应该能输出才对。但实际测试发现,如果项目中还有其他库提前调用了basicConfig或者配置了 root logger 的 handler,我这里的配置就会被覆盖或者忽略。
这就是典型的“配置顺序依赖”问题。logging 模块的组件之间是有层级关系的,不理解这个层级,配置就会互相打架。要彻底搞明白,得先把四个核心组件拎清楚。
2.2 Logger:日志的入口和层级树
Logger 是你代码里直接打交道的对象。每次调用logging.getLogger(name),你拿到的是一个 Logger 实例。这里有个关键设计:Logger 是有层级结构的,用点号分隔名字来体现父子关系。比如getLogger('app')是父,getLogger('app.db')是子,getLogger('app.db.query')是孙。所有 Logger 最终都挂在一个叫 root 的根 Logger 下面。
这个层级结构有什么用?主要影响两件事:级别继承和日志传播。当你创建一个新 Logger 时,如果不显式设置级别,它的有效级别会沿着层级向上查找,直到找到一个设置了具体级别的祖先。同样,一条日志记录产生后,会先交给当前 Logger 的 Handler 处理,然后默认向上传播给父级 Logger 的 Handler,一直到 root。这意味着如果你在 root 上挂了一个文件 Handler,所有子 Logger 的日志都会写进同一个文件,除非你手动关闭传播。
我踩过的一个坑是:在多个模块里分别getLogger(__name__),然后每个模块都调用了basicConfig。结果就是第一个模块的配置生效,后面的全被忽略,因为basicConfig只在 root 没有 Handler 时才起作用。正确做法是:在程序入口处统一配置一次,其他模块只负责获取 Logger 并打日志。
2.3 Handler:日志往哪里去
Handler 决定日志的输出去向。标准库提供了十几种 Handler,常用的有StreamHandler(输出到流,默认是 stderr)、FileHandler(输出到文件)、RotatingFileHandler(按大小切割)、TimedRotatingFileHandler(按时间切割)。一个 Logger 可以挂多个 Handler,同一条日志会被分发到所有 Handler 上。
这里有个容易混淆的点:Handler 自己也有级别。一条日志要能被某个 Handler 处理,必须同时满足两个条件:日志级别大于等于 Logger 的有效级别,且大于等于该 Handler 的级别。我见过有人把 Logger 级别设成 DEBUG,Handler 级别设成 ERROR,然后疑惑为什么 DEBUG 日志没输出。其实就是被 Handler 拦住了。
另一个实战经验:如果你同时挂了控制台 Handler 和文件 Handler,建议给它们设置不同的级别。比如控制台只输出 INFO 以上,方便日常观察;文件记录 DEBUG 以上,用于事后排查。这样既不会让终端刷屏,又保留了完整的调试信息。
2.4 Formatter:日志长什么样
Formatter 控制日志的最终格式。默认格式只输出消息本身,信息量太少。实际项目中至少要包含时间戳、日志级别、Logger 名称、行号。一个典型的格式字符串是这样的:
formatter = logging.Formatter( '%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s', datefmt='%Y-%m-%d %H:%M:%S' )%(levelname)-8s里的-8表示左对齐占 8 个字符宽度,这样不同级别的日志在视觉上能对齐,扫一眼就能定位。%(lineno)d记录行号,排查问题时能直接跳到代码位置。但要注意,行号有性能开销,高并发场景下如果日志量极大,可以考虑去掉。
Formatter 还可以自定义时间格式。默认的asctime是2024-01-15 10:30:45,123这种带毫秒的格式,如果不需要毫秒,用datefmt参数指定即可。另外,如果你想把日志输出成 JSON 格式方便日志系统采集,可以继承 Formatter 类重写format方法,把日志记录转成字典再序列化。
2.5 Filter:被低估的精细控制工具
Filter 是四个组件里最少被提及的,但它在复杂场景下非常有用。Filter 可以挂在 Logger 上,也可以挂在 Handler 上,用来决定一条日志记录是否被放行。它接收一个 LogRecord 对象,返回 True 或 False。
举个实际例子:项目里有个第三方库日志特别多,你只想看它的 WARNING 以上级别,但又不想改全局配置。可以写一个 Filter 挂在对应的 Handler 上,检查record.name是否以那个库的名字开头,如果是且级别低于 WARNING 就返回 False。这样就能精准过滤,不影响其他模块。
Filter 还能用来做上下文注入。比如在多线程环境下,你想在每条日志里带上当前线程 ID 或者请求 ID,可以在 Filter 里给 record 动态添加属性,然后在 Formatter 里引用这个属性。这种用法在 Web 框架的请求日志中很常见。
3. 配置方式的选择:代码配置、字典配置还是文件配置
3.1 三种配置方式的适用场景对比
logging 模块提供了三种配置途径,各有各的适用场景,选错了会让后期维护很痛苦。
| 配置方式 | 核心 API | 适用场景 | 主要缺点 |
|---|---|---|---|
| 代码配置 | basicConfig()/ 手动创建组件 | 小型脚本、快速原型 | 配置散落在代码中,修改需改代码 |
| 字典配置 | dictConfig() | 中大型项目、多环境切换 | 字典结构嵌套深,写错不易发现 |
| 文件配置 | fileConfig() | 传统项目、运维可编辑 | 格式老旧,不支持自定义 Filter 和复杂对象 |
我个人的选择习惯是:一次性脚本用basicConfig就够了;正式项目一律用dictConfig,因为字典可以直接从 YAML 或 JSON 文件加载,不同环境用不同配置文件,代码里只写一行dictConfig(config_dict)。fileConfig基于 configparser 格式,功能受限,新项目不建议用。
3.2 dictConfig 的实战配置模板
下面这份配置是我在多个项目中反复使用后沉淀下来的,直接可以抄作业:
import logging.config LOGGING_CONFIG = { 'version': 1, 'disable_existing_loggers': False, 'formatters': { 'standard': { 'format': '%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s', 'datefmt': '%Y-%m-%d %H:%M:%S' }, 'simple': { 'format': '%(levelname)s | %(message)s' } }, 'handlers': { 'console': { 'class': 'logging.StreamHandler', 'level': 'INFO', 'formatter': 'simple', 'stream': 'ext://sys.stdout' }, 'file': { 'class': 'logging.handlers.RotatingFileHandler', 'level': 'DEBUG', 'formatter': 'standard', 'filename': 'logs/app.log', 'maxBytes': 10 * 1024 * 1024, 'backupCount': 5, 'encoding': 'utf-8' } }, 'loggers': { 'app': { 'level': 'DEBUG', 'handlers': ['console', 'file'], 'propagate': False } }, 'root': { 'level': 'WARNING', 'handlers': ['console'] } } logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger('app')这份配置有几个关键点值得说明。disable_existing_loggers设为 False,意思是配置生效前已经创建的 Logger 不会被禁用,这在大型项目中很重要,因为有些库可能在导入时就创建了 Logger。propagate设为 False,防止app的日志向上传播到 root 导致重复输出。RotatingFileHandler的maxBytes和backupCount组合实现了日志轮转,10MB 一个文件,保留 5 个备份,总占用不超过 60MB。
3.3 多环境配置的切换策略
开发环境想要 DEBUG 级别、彩色控制台输出;测试环境想要 INFO 级别、文件加控制台;生产环境想要 WARNING 级别、只写文件、按天切割。这种需求用一份配置硬编码是满足不了的。
我的做法是把配置字典拆成基础部分和环境覆盖部分,用字典合并的方式组合。基础部分定义 formatters 和 handlers 的通用结构,环境部分覆盖 level、filename、handler 列表等字段。合并可以用递归函数实现,也可以直接用collections.ChainMap做浅合并。更简单的方案是准备多个 YAML 文件,启动时根据环境变量加载对应的文件。
注意:无论用哪种方式,都要确保日志目录存在。
FileHandler不会自动创建父目录,如果logs/目录不存在,程序启动就会报错。建议在配置加载前用os.makedirs('logs', exist_ok=True)确保目录就绪。
4. 日志切割与轮转:别让日志文件撑爆磁盘
4.1 按大小切割的陷阱与参数计算
RotatingFileHandler是最常用的切割方式,但它的行为有个容易误解的地方:切割发生在写入之前。也就是说,当文件大小已经达到maxBytes时,下一条日志写入前会触发轮转。这意味着实际文件大小可能略微超过maxBytes,超出量取决于单条日志的大小。
参数怎么定?假设你的服务每天产生约 500MB 日志,磁盘给日志分区留了 5GB。如果按大小切割,maxBytes设 50MB,backupCount设 20,总占用就是 50MB × 21 = 1050MB,约 1GB,留足了余量。但这样一天会产生 10 个文件,排查问题时需要跨多个文件搜索,不太方便。所以按大小切割更适合日志量波动大、单文件不宜过大的场景。
4.2 按时间切割的命名规则与清理逻辑
TimedRotatingFileHandler按时间间隔切割,支持秒、分、时、天、周等粒度。它的命名规则是在原文件名后追加时间后缀,比如app.log切割后变成app.log.2024-01-15。backupCount表示保留多少个历史文件,超出的会被自动删除。
这里有个细节:when='midnight'表示每天午夜切割,但切割后的文件后缀是前一天的日期。如果你在 1 月 16 日凌晨查看,会看到app.log.2024-01-15,这是正常的。interval参数配合when使用,比如when='H'、interval=6表示每 6 小时切割一次。
实际使用中我遇到过一个问题:服务在午夜时刻正好有大量日志写入,切割操作和写入操作竞争,偶尔会出现日志丢失。后来改成when='midnight'配合utc=True,让切割时间基于 UTC,避开了业务高峰,问题就没再出现。
4.3 日志压缩与归档的补充方案
标准库的 Handler 不支持自动压缩,但可以通过继承TimedRotatingFileHandler重写doRollover方法,在切割完成后调用gzip压缩旧文件。这样能节省大量磁盘空间,文本日志压缩比通常能达到 10:1 以上。
如果日志需要长期归档,更好的方案是切割后由外部脚本搬运到对象存储或归档目录。Handler 只负责本地切割,归档逻辑独立运行,两者解耦。这样即使归档脚本出问题,也不影响主服务的日志写入。
5. 多模块协作中的 Logger 命名与传播控制
5.1 用name自动构建层级树
在每个模块里用logger = logging.getLogger(__name__)是标准做法。__name__在包内模块中是带包路径的完整名称,比如myapp.services.sync,这样自动就形成了层级结构。根包myapp的 Logger 可以统一配置 Handler,子模块的日志会向上传播,不需要每个模块单独配置。
这种做法的好处是:你可以在配置里针对myapp.services设置 DEBUG 级别,而myapp.db保持 WARNING 级别,粒度非常细。排查某个子系统的问题时,临时调高对应 Logger 的级别即可,不用重启整个服务。
5.2 propagate 参数的实战影响
propagate默认是 True,意味着日志会向上传播到父级 Logger 的 Handler。这通常是你想要的,但有两种情况需要关掉它。
第一种是重复输出。如果你给子 Logger 和父 Logger 都配了控制台 Handler,一条日志会打印两次。第二种是第三方库的日志污染。有些库会创建自己的 Logger 并挂 Handler,如果不关掉传播,它们的日志会混进你的日志文件。
我的经验是:应用自己的 Logger 树,只在最顶层的应用 Logger 上挂 Handler,子 Logger 全部保持propagate=True,让日志自然向上汇聚。对于第三方库,在配置里把它们的 Logger 级别调高或者propagate设为 False,隔离掉不需要的日志。
5.3 跨模块日志追踪的上下文注入
微服务或异步任务中,一条请求可能经过多个模块,日志分散在不同 Logger 里。想在日志中串联同一个请求的所有记录,需要注入请求 ID。标准库没有内置这个功能,但可以用LoggerAdapter或者自定义 Filter 实现。
LoggerAdapter的用法是包装一个 Logger,在process方法里给消息添加上下文。但它的局限是只影响通过 Adapter 打的日志,如果代码里直接用了原始 Logger,上下文就丢了。更彻底的方式是用contextvars配合 Filter,在请求入口设置上下文变量,Filter 从变量里取值注入到每条日志记录中。这种方式对代码侵入小,适合在框架层面统一实现。
6. 性能考量:日志写多了也会拖慢服务
6.1 日志级别判断的开销
每次调用logger.debug()时,即使级别不够不会输出,方法调用本身和参数拼接仍然有开销。如果参数拼接很昂贵,比如logger.debug('result: %s', expensive_func()),expensive_func()会被执行,造成浪费。
正确的做法是用惰性求值:logger.debug('result: %s', expensive_func())这种写法,expensive_func()仍然会执行,因为参数在调用前就求值了。真正惰性的写法是logger.debug('result: %s', lambda: expensive_func()),但标准库不支持 lambda 延迟求值。所以更实际的做法是先判断级别:
if logger.isEnabledFor(logging.DEBUG): logger.debug('result: %s', expensive_func())这样在级别不够时直接跳过,避免了不必要的计算。
6.2 异步日志与队列处理
高并发场景下,日志写入可能成为瓶颈,尤其是文件 IO 和网络 IO。标准库提供了QueueHandler和QueueListener,可以把日志写入放到独立线程中处理。主线程只负责把日志记录放入队列,立即返回,不阻塞业务逻辑。
配置方式是在主线程挂QueueHandler,启动一个QueueListener监听队列,把日志分发给真正的 Handler。这样即使磁盘 IO 慢,也不会影响请求处理速度。代价是日志可能有轻微延迟,极端情况下进程崩溃时队列中未处理的日志会丢失。对于大多数业务场景,这个代价是可以接受的。
6.3 生产环境日志级别的选择
开发环境用 DEBUG,生产环境用 INFO 或 WARNING,这是常识。但“生产环境用 INFO”具体意味着什么?意味着所有 INFO 级别的日志都会写磁盘。如果你的代码里在每个循环里都打 INFO,日志量会爆炸。
我的原则是:INFO 级别只记录状态变更和关键业务节点,比如“服务启动”“配置加载完成”“任务开始/结束”“外部调用返回”。循环内部的逐条处理用 DEBUG,生产环境不输出。异常信息用 ERROR 或 EXCEPTION,确保一定会被记录。WARNING 用于可恢复的异常情况,比如重试成功、降级生效。
7. 几个让我印象深刻的踩坑记录
7.1 日志文件权限问题导致服务启动失败
有次部署到新环境,服务启动就报PermissionError,日志文件写不进去。排查发现是部署脚本用 root 创建了日志目录,但服务以普通用户运行,没有写权限。后来在部署流程里加了chown步骤,确保日志目录归属正确。这个坑的教训是:日志目录的权限要和运行服务的用户匹配,不能想当然。
7.2 多进程写入同一文件的混乱
用multiprocessing起多个进程,每个进程都配了FileHandler写同一个文件。结果日志内容交错,甚至出现半行日志。原因是多个进程同时操作同一个文件描述符,写入不是原子的。解决方案有两种:一是用ConcurrentRotatingFileHandler(需要额外安装),它用文件锁保证原子性;二是每个进程写独立文件,用进程 ID 区分文件名,后期再合并分析。我倾向于第二种,简单可靠。
7.3 日志格式中的异常堆栈丢失
logger.error('出错了')只会记录消息,不会记录异常堆栈。要记录堆栈,必须用logger.exception('出错了')或者在error方法里传exc_info=True。这个细节很容易忘,导致排查时只看到“出错了”三个字,完全不知道哪里出的错。我的习惯是在所有except块里统一用logger.exception(),确保堆栈不丢。
7.4 配置被第三方库覆盖
引入某个第三方库后,发现自己的日志格式变了。排查发现那个库在导入时调用了basicConfig,而我的配置在它之后才执行,导致我的配置被忽略。解决办法是在程序最入口处、导入任何第三方库之前就完成日志配置。如果做不到,就用dictConfig并设置disable_existing_loggers=False,强制覆盖已有配置。
8. 从标准库到结构化日志的演进思路
标准库的 logging 模块足够应对大多数场景,但当日志需要被采集系统消费时,纯文本格式就不够用了。结构化日志把每条记录输出成 JSON,字段固定,便于检索和聚合。
实现方式有两种:一是自定义 Formatter,把 LogRecord 的属性和消息转成 JSON 字符串;二是用structlog这类第三方库,它在标准库基础上提供了更友好的 API 和更丰富的上下文绑定能力。如果项目已经大量使用标准库 logging,我建议先用自定义 Formatter 过渡,成本最低。等团队习惯了结构化日志的查询方式,再考虑引入更重的方案。
自定义 JSON Formatter 的核心是重写format方法:
import json import logging class JsonFormatter(logging.Formatter): def format(self, record): log_data = { 'timestamp': self.formatTime(record), 'level': record.levelname, 'logger': record.name, 'message': record.getMessage(), 'module': record.module, 'line': record.lineno } if record.exc_info: log_data['exception'] = self.formatException(record.exc_info) return json.dumps(log_data, ensure_ascii=False)这个 Formatter 输出的每条日志都是合法 JSON,可以直接被日志采集工具解析。ensure_ascii=False保证中文正常显示,不会被转义成 Unicode 编码。
我在实际使用中发现,结构化日志最大的价值不是格式好看,而是让日志从“给人看”变成“给机器看”。一旦日志可以被程序解析,就能做实时告警、异常检测、调用链分析,这才是日志系统真正的威力所在。