日志文件管理:文件滚动策略与日志分割归档

日志文件如果不加管理,一天就能撑爆磁盘。我见过最夸张的一次,一个 Java 应用一天写了 12GB 日志,直接把测试环境的根分区塞满了。嗯,从那以后,日志滚动和归档就成了我项目里的标配。

说白了,日志滚动就是「写到一定大小或时间,自动换一个新文件写」。归档则是把旧日志打包压缩,省空间、方便查。这两件事在 Python 里用 RotatingFileHandler 就能搞定。

RotatingFileHandler 的核心机制

Python 的 logging.handlers.RotatingFileHandler 支持两种滚动触发条件:

  • 按文件大小滚动:单个日志文件达到指定字节数,就切分
  • 按备份数量保留:保留最近 N 个文件,旧的自动删除

举个例子,我习惯这样配置:

import logging
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler(
    filename='app.log',
    maxBytes=10 * 1024 * 1024,  # 10MB
    backupCount=5,
    encoding='utf-8'
)

handler.setFormatter(logging.Formatter(
    '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
))

logger = logging.getLogger('my_app')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)

这段代码的意思是:每个日志文件最大 10MB,超过就切。保留最近 5 个备份文件,最旧的那个会被自动删掉。你想想看,如果日志每天写 50MB,那磁盘上最多也就保留 60MB 左右的数据,非常可控。

滚动后的文件命名规则:

原始文件始终叫 app.log。第一次滚动后,app.log 被重命名为 app.log.1,新内容写入新的 app.log。第二次滚动后,app.log.1 变成 app.log.2,app.log 变成 app.log.1,依此类推。数字越大,文件越旧。

TimedRotatingFileHandler:按时间滚动

有些场景下,按大小滚动不够用。比如合规要求日志必须按天归档,或者你想在凌晨低峰期统一切分。这时候用 TimedRotatingFileHandler:

from logging.handlers import TimedRotatingFileHandler

handler = TimedRotatingFileHandler(
    filename='app.log',
    when='midnight',   # 每天午夜切分
    interval=1,
    backupCount=30,    # 保留最近30天
    encoding='utf-8'
)

when 参数支持多种粒度:

参数值 含义 典型场景
'S' 每秒 调试、性能测试
'M' 每分钟 短时压力测试
'H' 每小时 高频交易日志
'D' 每天 通用业务日志
'W0' 每周一 周报统计日志
'midnight' 每天午夜 合规归档

我的经验:生产环境我一般用 when='midnight' 配合 backupCount=30。这样每天一个文件,查某一天的日志直接看文件名就知道是哪天,不用猜。而且凌晨切分不影响业务高峰。

日志归档:压缩与清理

光滚动还不够。日志文件如果不压缩,几十个文件堆在那也挺占地方。我个人习惯在滚动后做两件事:

  1. 压缩旧日志:用 gzip 把已滚出的文件打包
  2. 定期清理:删除超过保留期限的归档文件

Python 标准库没有直接提供压缩归档的 Handler,但我们可以自己写一个简单的包装:

import gzip
import os
import shutil
from logging.handlers import RotatingFileHandler

class CompressedRotatingHandler(RotatingFileHandler):
    def doRollover(self):
        # 先执行父类的滚动逻辑
        super().doRollover()
        # 然后压缩刚滚出的文件
        for i in range(self.backupCount, 0, -1):
            src = f"{self.baseFilename}.{i}"
            dst = f"{src}.gz"
            if os.path.exists(src) and not os.path.exists(dst):
                with open(src, 'rb') as f_in:
                    with gzip.open(dst, 'wb') as f_out:
                        shutil.copyfileobj(f_in, f_out)
                os.remove(src)

这段代码在每次滚动后,把滚出的 .log.1、.log.2 等文件压缩成 .log.1.gz。压缩比通常在 5:1 到 10:1 之间,效果很明显。

我曾经踩过的坑:压缩操作本身会消耗 CPU 和磁盘 IO。如果日志量特别大(比如每秒几百条),滚动时再压缩可能导致日志写入短暂阻塞。我的建议是:把压缩放到单独的线程或定时任务里做,别在 Handler 的 doRollover 里同步执行。

日志分割与归档的完整流程

下面这张图展示了从日志写入到最终归档的完整链路:

日志滚动与归档完整流程 应用写入日志 app.log(当前文件) 触发滚动条件 app.log → app.log.1 新建 app.log app.log.1.gz 滚动条件:文件大小超过 maxBytes | 时间到达指定间隔 | 手动调用 doRollover()

生产环境配置建议

我在多个线上项目里总结了一套比较稳的配置参数,分享给你参考:

场景 滚动策略 maxBytes / interval backupCount 归档方式
开发调试 按大小 5MB 3 不压缩
低流量业务 按天 midnight 30 gzip 压缩
高流量业务 按大小 + 按天 100MB + midnight 7 gzip 压缩 + 冷存储
合规审计 按天 midnight 365 gzip 压缩 + 加密归档

避坑指南:我曾经在线上把 backupCount 设成 0,结果日志文件滚动后直接删除了旧文件,没有保留任何备份。排查问题时发现历史日志全没了,只能靠监控曲线猜问题。所以 backupCount 至少设成 1,除非你确定不需要回溯。

多进程环境下的滚动问题

如果你的应用是多进程的(比如 Gunicorn、uWSGI),多个进程同时写同一个日志文件,滚动时可能会出问题。Python 的 RotatingFileHandler 不是线程安全的,更不是进程安全的。

我推荐两种方案:

  • 方案一:每个进程写独立的日志文件,文件名带上进程 ID 或序号。比如 app_worker_1.log、app_worker_2.log。这样各滚各的,互不干扰。
  • 方案二:使用 ConcurrentRotatingFileHandler(第三方库 concurrent-log-handler),它通过文件锁保证多进程安全滚动。
# 方案一示例:按进程 ID 分文件
import os
import logging

worker_id = os.getpid()
handler = RotatingFileHandler(
    filename=f'app_worker_{worker_id}.log',
    maxBytes=10 * 1024 * 1024,
    backupCount=3
)

特别注意:多进程共用同一个日志文件时,千万不要在滚动时做「先删旧文件再重命名」的操作。两个进程同时滚动,可能一个进程把另一个进程刚滚出的文件删了。我遇到过这种问题,排查了整整一天才发现是滚动冲突。

日志归档的冷存储策略

对于需要长期保留的日志(比如合规要求保留 180 天),滚动到本地的文件不可能一直存着。我的做法是:

  1. 本地保留最近 7 天的日志(按天滚动,backupCount=7)
  2. 每天凌晨用 cron 任务把前一天的日志压缩后上传到对象存储(S3、OSS 等)
  3. 对象存储上按年月日建目录,比如 logs/2025/03/21/app.log.gz
  4. 设置对象存储的生命周期策略,180 天后自动删除

这样本地磁盘压力很小,历史日志又能随时从对象存储拉回来查。嗯,这个方案我在好几个项目里都用过,稳得很。