首页 / 资讯中心 / 文章详情

Python日志分割:多进程安全切割与滚动Handler实战

Python日志分割:多进程安全切割与滚动Handler实战 ★ FEATURED ARTICLE
1. 这个需求到底在解决什么问题1.1 日志分割的场景与痛点我最近在一个内部服务项目里帮同事收拾了一堆日志问题核心就一句话Python 环境下按大小、按时间分割日志并且要在多进程场景下保证安全。这个需求看起来平平无奇但你真要跑到生产环境去踩一遍会发现坑远比想象中多。很多团队一开始都只是调用一下自带的RotatingFileHandler日志少的时候风平浪静一旦并发上来、日志量上来错行、丢日志、重命名冲突全来了而且这些问题不是必现是概率性出现排查起来极其恶心。先明确一下“日志分割”到底是要解决什么。它的核心诉求其实有两个一是控制单个日志文件体积避免日志文件疯狂膨胀最终打爆磁盘或者让tail都变得卡顿二是便于归档和检索按时间切出来的日志文件能对应到业务日期出问题的时候能快速定位到对应时段的日志。按大小切适合日志格式统一、检索靠grep的场景按时间切适合需要按自然日、自然小时回溯的场景。它们并不互斥实际工程中常常是组合使用。但分割本身并不难难的是多进程。大多数 Python 服务都不是单进程跑的multiprocessing起的多个 worker、Nginx 背后的多个 uWSGI/gunicorn worker、或者你自己用进程池并发处理任务这些进程如果共享同一个日志文件就必然碰到“同一时刻多个进程写同一个文件”的竞争问题。Python 的 logging 模块默认提供的处理器几乎都没有处理好这件事。1.2 多进程场景的真正难点很多人觉得写日志不就是往文件里 append 一行吗文件系统难道不会处理并发写这里要分开看。单行日志的写入在极短的内容、恰好不超过 PIPE_BUF、且是简单 append 写的情况下Linux 下多次 write 可能会保持原子性但这对日志完全不可靠因为你的日志行不一定短并且 logging 内部有format、stream.write等多个环节。更致命的是滚动rollover操作。所谓的滚动就是当文件达到阈值或者跨过时间点后把正在写的app.log重命名为app.log.1再新建一个空的app.log继续写。这一步在单进程里没任何问题但在多进程里就变成了灾难性的竞态进程 A 判断当前文件超过了maxBytes准备执行重命名进程 B 同时也在写这个文件它持有的还是旧文件的文件描述符进程 A 把旧文件改名成app.log.1并打开新文件进程 B 可能继续往旧的app.log.1里写也可能因为路径已经被改动而写进了错误的地方更严重的是两个进程同时执行重命名时后执行的会覆盖先执行的日志直接丢。所以“多进程安全配置”并不是一句空话它要求你对“写入”和“滚动”这两个操作同时加跨进程互斥保护。接下来我先把 Python 自带的两个分割器讲透再给出经过实测的多进程方案。1.3 这篇文章适合谁如果你是刚接触 Python 日志的小白建议把自带的RotatingFileHandler和TimedRotatingFileHandler的用法吃透然后照着第 3 章的“进程锁方案”抄一个能用的自定义 Handler。如果你已经写过一段时间日志但被多进程丢日志折磨过可以直接跳到第 3、4 章看实测过的方案和压测脚本。涉及的核心代码我都放在正文里复制改改就能用。2. 先吃透 Python 自带的两种分割器2.1 按大小分割RotatingFileHandler 的原理与核心参数logging.handlers.RotatingFileHandler是 Python 官方提供的最基础的按大小分割方案用法非常简单import logging from logging.handlers import RotatingFileHandler handler RotatingFileHandler( filenameapp.log, maxBytes10 * 1024 * 1024, # 10MB backupCount5, encodingutf-8, ) formatter logging.Formatter(%(asctime)s [%(levelname)s] %(name)s: %(message)s) handler.setFormatter(formatter)这段配置表达的意思很直白日志写到app.log当这个文件达到 10MB 时自动滚动成app.log.1旧的app.log.1变成app.log.2依次类推最多保留 5 个备份文件最多也就是 60MB 的日志占用。原理层面RotatingFileHandler内部维护着一个maxBytes和backupCount。每次emit一条日志之前它会调用shouldRollover判断是否需要滚动。判断逻辑是把当前文件指针移到文件末尾看文件大小加上当前这条日志格式化后的长度如果大于等于maxBytes就触发滚动。滚动时执行doRollover先关闭当前文件流然后依次把app.log.4重命名为app.log.5、app.log.3变成app.log.4……最后把当前文件app.log重命名为app.log.1再重新打开一个新的app.log写入。这里有几个容易忽略的细节第一maxBytes设置为 0 时不会滚动。很多人误以为设成 0 表示不限制大小实际上等于关闭了按大小滚动。所以想让这个 Handler 生效必须给一个正数。第二backupCount为 0 时不保留任何备份。滚动时只是关闭再重新打开文件并不改名旧内容会继续被追加表面上看起来像是“没有滚动”。想让日志文件真正归档backupCount至少是 1。第三文件命名后缀默认从.1开始递增。如果想改成.log.20250601这种带日期的格式需要重写rotation_filename和rotator方法。这在日常排障中非常有用因为你能直接看出某个大文件是哪天的日志。不过自定义命名后要格外注意清理逻辑这一点在按时间分割里更突出后面会讲到。2.2 按时间分割TimedRotatingFileHandler 的刻度与时区细节按时间分割用的是logging.handlers.TimedRotatingFileHandler典型配置如下from logging.handlers import TimedRotatingFileHandler handler TimedRotatingFileHandler( filenameapp.log, whenD, # 每天切一次 interval1, # 间隔是 1 天 backupCount7, # 保留一周 encodingutf-8, utcFalse, # 使用本地时间而不是 UTC )when参数决定分割周期常用的取值我整理成了一张表取值含义说明S秒一般只用于测试或极端场景M分钟适合短周期采集任务H小时适合高频业务日志D天按自然日滚动但注意是相对启动时间W0-W6周一至周日每周指定某一天滚动interval只能是 1midnight每天零点更符合自然日切割习惯这里有个非常关键的坑whenD并不等价于“每天凌晨 0 点切一次”。它的含义是“从 Handler 创建的时间开始每经过 24 小时切一次”。如果你的服务是下午 3 点启动的那它会在每天下午 3 点切文件而不是午夜。想要真正按自然日切割要么用whenmidnight要么在 Python 3.7 以上使用atTime参数指定每天的具体切割时刻比如from datetime import time handler TimedRotatingFileHandler( filenameapp.log, whenD, interval1, backupCount30, atTimetime(hour0, minute0, second0), )时区同样是个隐藏问题。utcTrue时滚动时间严格按 UTC 计算utcFalse默认时按本地时区计算。如果你的服务器时区是 UTC8而你在电脑上看到的归档文件名是app.log.2025-06-01_00-00-00别惊讶这通常是因为用了本地时间。判断是否加了utcTrue的一个笨办法是看归档文件名的日期和你的业务时间是否一致。另外TimedRotatingFileHandler还有一个删除旧文件的逻辑需要注意。它默认只删除“与自己命名规律匹配”的归档文件匹配规则基于extMatch正则表达式。如果你改写了文件名后缀比如把默认的app.log.2025-06-01_00-00-00改成app.20250601.log那么getFilesToDelete可能识别不出来旧文件就会一直堆积最终打爆磁盘。这个我在第 5 章还会详细讲。2.3 内置分割器的三个通病自带 Handler 在单进程里算是靠谱的但只要你一上多进程问题就接踵而来。我结合自己的实测总结出三个通病通病一日志行交错。多个进程同时 write 同一个文件时因为没有锁一条日志可能写到一半被另一条日志插入最终形成INFO worker-1 task 123startedINFO worker-2 task 456 started这种谁也看不明白的错行。日志量大时交错频率会直线上升。通病二滚动竞态导致日志丢失。这是最严重的。两个进程同时检测到“该滚动了”同时执行os.rename后执行的会把先执行的文件覆盖掉或者一个进程已经在写新文件另一个进程还在向旧文件描述符写数据最终你发现某个时间段的日志凭空消失。通病三状态不一致。内置的TimedRotatingFileHandler在滚动时会记录下一次滚动时间nextRollover这个状态只存在于当前进程内存里。当多个进程各自维护一份 Handler 实例时它们的“下一次滚动时间”可能不一样就会出现部分进程已经滚动、部分进程还在写旧文件的混乱局面。搞清楚这三个通病你就能明白为什么“多进程安全配置”不能靠简单加两行配置解决必须从 Handler 的实现层面动手。3. 多进程安全三种主流方案选型3.1 方案对比独立文件、进程锁、队列中转面对多进程共享日志的需求业界大致有三种方案。它们的适用场景差异很大我先把对比列出来再展开讲重点方案实现成本日志统一性性能开销适用场景每个进程写独立文件低差多个日志文件低进程之间日志天然独立可接受按进程查带进程文件锁的自定义 Handler中好统一写一个文件高每条日志都有锁开销中小日志量、进程数不多QueueHandlerQueueListener中央写入中高好统一写一个文件低日志异步入队大日志量生产环境、多进程并发高独立文件方案其实最省心。比如你在每个 worker 进程里给filename加上进程号日志就自然分隔开了。缺点是排障时需要同时打开多个文件跨进程追踪同一个请求会比较痛苦所以它更适合“日志可以按进程切分”的场景。进程锁方案是指所有进程仍然写入同一个日志文件但在“写入”和“滚动”之前先抢占同一个文件锁保证同一时间只有一个进程在操作日志文件。这个方案对现有代码改动小也是我在中小项目里最常用的一招。队列中转方案是所有进程把日志事件塞进一个multiprocessing.Queue然后由专门的日志进程或日志线程统一从队列取出日志并写入文件。因为写入只发生在单一声明主体中滚动和写入天然互斥完全消除竞争。这套方案是目前生产环境大面积采集日志的推荐姿势但会引入队列维护成本和日志进程的生命周期管理问题。下面把两个可行方案的具体实现都展示出来。你可以根据实际情况选择。3.2 用文件锁保护滚动的自定义 Handler 实现先给结论如果你只是希望“多进程同时写一个日志文件并正确滚动”那么一个带文件锁的 Handler 就够用了不需要引入队列和额外进程。这里的核心是利用操作系统提供的文件锁原语在 Linux 下我用的是fcntl.flockWindows 下可以换成msvcrt或者等价锁实现。下面是完整代码import os import time import logging import fcntl from logging.handlers import RotatingFileHandler class LockedRotatingFileHandler(RotatingFileHandler): 支持多进程安全的按大小分割日志 Handler。 思路所有进程共享同一个锁文件写入和滚动前先抢锁。 抢到锁的进程才允许操作日志文件其他进程在此等待。 def __init__(self, filename, modea, maxBytes0, backupCount0, encodingNone, delayFalse, lock_pathNone): super().__init__( filename, modemode, maxBytesmaxBytes, backupCountbackupCount, encodingencoding, delaydelay, ) # 锁文件单独放避免滚动重命名时把锁文件一起改掉 if lock_path is None: lock_path filename .lock self.lock_path lock_path self._lock_fd None def _acquire_lock(self): # 锁文件以追加模式打开保证文件存在即可内容不重要 self._lock_fd open(self.lock_path, a) fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX) def _release_lock(self): if self._lock_fd is not None: fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_UN) self._lock_fd.close() self._lock_fd None def emit(self, record): try: self._acquire_lock() try: if self.shouldRollover(record): self.doRollover() msg self.format(record) stream self.stream stream.write(msg self.terminator) self.flush() except (KeyboardInterrupt, SystemExit): raise except Exception: self.handleError(record) finally: self._release_lock()代码里最关键的一点是把“判断是否滚动”和“真正写入”这两步放在同一个锁保护范围内。如果只锁写入、不锁滚动两个进程依然可能一个刚滚动完、另一个拿着旧状态继续写所以必须整体锁住。这里有个细节值得展开为什么锁文件要和日志文件分开因为我一开始偷懒用日志文件本身加锁结果doRollover会把日志文件改名锁的状态就乱了。后来把锁单独放到app.log.lock问题迎刃而解。类似的 Handler 设计锁文件永远不参与滚动只承担跨进程同步作用。紧接着还要处理按时间分割的多进程安全版本。直接继承TimedRotatingFileHandler即可from logging.handlers import TimedRotatingFileHandler class LockedTimedRotatingFileHandler(TimedRotatingFileHandler): 支持多进程安全的按时间分割日志 Handler。 核心思路和 LockedRotatingFileHandler 完全一致。 def __init__(self, filename, whenD, interval1, backupCount0, encodingNone, delayFalse, utcFalse, atTimeNone, lock_pathNone): super().__init__( filename, whenwhen, intervalinterval, backupCountbackupCount, encodingencoding, delaydelay, utcutc, atTimeatTime, ) if lock_path is None: lock_path filename .lock self.lock_path lock_path self._lock_fd None def _acquire_lock(self): self._lock_fd open(self.lock_path, a) fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX) def _release_lock(self): if self._lock_fd is not None: fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_UN) self._lock_fd.close() self._lock_fd None def emit(self, record): try: self._acquire_lock() try: if self.shouldRollover(record): self.doRollover() msg self.format(record) stream self.stream stream.write(msg self.terminator) self.flush() except (KeyboardInterrupt, SystemExit): raise except Exception: self.handleError(record) finally: self._release_lock()两个 Handler 的代码高度相似唯一的区别在于shouldRollover的判断逻辑。这里我不做二次封装直接拆成两个类原因很简单继承关系清晰出问题的时候排查更容易。如果你觉得重复代码多可以把锁的逻辑抽成一个 Mixin 类就看团队风格了。3.3 用 QueueListener 做中央日志写入带锁方案有一个明显短板每条日志都要做一次跨进程加锁日志量越大锁竞争越激烈。如果你每秒要写几千条日志那么所有进程都在排队等锁日志写入会成为性能瓶颈。这种场景我推荐用队列中转方案。Python 官方其实已经给了很好的脚手架核心就是logging.handlers.QueueHandler和logging.handlers.QueueListener。子进程只需要把日志对象扔进队列真正写文件的只有一个主体。结构如下import multiprocessing as mp import logging from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler def init_logger_queue(queue, log_file): 创建一个队列监听器在单进程内统一写文件。 # 写文件的 handler 只属于监听线程/进程不会被子进程直接使用 file_handler RotatingFileHandler( log_file, maxBytes100 * 1024 * 1024, backupCount10, encodingutf-8, ) file_handler.setFormatter(logging.Formatter( %(asctime)s [%(processName)s] %(levelname)s %(name)s: %(message)s )) listener QueueListener(queue, file_handler) listener.start() return listener def setup_worker_logging(queue): 在每个 worker 进程里只需要把日志塞进队列。 handler QueueHandler(queue) root logging.getLogger() root.handlers.clear() root.addHandler(handler) root.setLevel(logging.INFO)主进程在启动 worker 之前先创建一个multiprocessing.Queue然后调用init_logger_queue启动监听器。每个 worker 进程调用setup_worker_logging(queue)把自己的 root logger 挂上QueueHandler。之后所有进程产生的日志都进入同一个队列由监听器所在的进程统一出队、统一写文件。因为写文件操作只在这一个监听器里发生滚动再也不会产生竞态。队列方案的另一个好处是日志写入变成了异步操作。worker 进程把日志丢进队列就可以继续干别的活不需要等待磁盘写入完成日志量大时性能优势非常明显。代价是要注意队列积压问题如果日志生产速度长期高于队列消费速度队列内存占用会持续增长。所以生产环境里我会监控队列大小一旦积压超过阈值就报警同时在日志 Handler 里加一个“丢弃策略”兜底避免把业务进程的内存打爆。4. 落地实操从零搭建一套多进程安全的日志系统4.1 初始化步骤与参数设计理论讲完该上手了。我按自己实际搭建日志系统时的顺序理出一个可复现的落地流程分五步第一步量化日志量决定滚动策略。先估算业务高峰期每秒会产生多少行日志、每行大约多少字节。比如某采集服务高峰期每秒 500 行每行 200 字节那就是每秒 100KB一小时大约 360MB一天大约 8.6GB。这时候如果按大小切单文件 100MB 意味着一天切 86 个文件如果按时间切一天一个文件正好。我的建议是日志量相对稳定就按时间切每天一个文件配backupCount30保留一个月日志量波动大就在时间切割的同时叠加大小限制防止某个异常高峰把单文件撑到几个 GB。第二步确定文件命名和归档规则。归档文件名最好带上日期或小时比如app.log.2025-06-01这样排障时拿到文件名就知道对应的时段。如果用按大小滚动默认的app.log.1、app.log.2不带时间信息排障不太友好你可以重写rotation_filename加后缀如果用按时间滚动默认命名已经带了时间戳基本够用。第三步选择多进程方案。进程数少于 10、每秒日志量在几百条以内用第 3.2 节的带锁 Handler 完全没问题进程数多或者日志量很大直接用QueueHandlerQueueListener。这里没必要过度设计日志量小还上队列就是给自己找麻烦。第四步把 Handler 组装成统一的日志配置。我习惯用logging.config.dictConfig管日志而不是在代码里手动创建一堆 Handler。为了方便理解这里给出一份针对自定义带锁 Handler 的配置示例你把LockedRotatingFileHandler放到log_config.py文件里然后在dictConfig里按下面这样引用import logging.config LOGGING_CONFIG { version: 1, disable_existing_loggers: False, formatters: { standard: { format: %(asctime)s [%(process)d] [%(threadName)s] %(levelname)s %(name)s: %(message)s, datefmt: %Y-%m-%d %H:%M:%S, } }, handlers: { file_rotate: { class: log_config.LockedRotatingFileHandler, filename: /var/log/service/app.log, maxBytes: 104857600, # 100MB backupCount: 10, encoding: utf-8, formatter: standard, } }, root: { level: INFO, handlers: [file_rotate], }, } logging.config.dictConfig(LOGGING_CONFIG)如果你选的是时间切割把class换成LockedTimedRotatingFileHandler再配置when、interval、atTime即可 handlers: { file_rotate: { class: log_config.LockedTimedRotatingFileHandler, filename: /var/log/service/app.log, when: midnight, interval: 1, backupCount: 30, encoding: utf-8, formatter: standard, } }第五步明确日志初始化时机。这一步最容易翻车。如果你的服务用了multiprocessing.Process启动子进程建议在子进程入口函数里调用统一初始化函数让每个进程都持有自己的 Handler 实例。如果你用了fork方式启动子进程父进程日志配置好的情况下子进程会继承但继承的是父进程的文件描述符和状态滚动状态容易错乱所以我也建议在子进程里重新初始化一遍宁可多花一点点开销换来状态的确定性。4.2 混合切割按时间 按大小同时生效有一部分场景不仅希望每天一个日志文件还要求单文件不能超过某个体积避免单个文件过大导致检索困难。这种需求可以写一个混合 Handler触发条件是两个条件取“或”import time from datetime import datetime from logging.handlers import RotatingFileHandler class DailyAndSizeRotatingHandler(LockedRotatingFileHandler): 混合切割 Handler 1. 每天到达设定时刻时强制滚动 2. 单文件达到 maxBytes 时也强制滚动。 def __init__(self, filename, maxBytes0, backupCount0, encodingNone, whenmidnight, at_timeNone): super().__init__(filename, maxBytesmaxBytes, backupCountbackupCount, encodingencoding) self._next_day self._calc_next_rollover(at_time) def _calc_next_rollover(self, at_time): now datetime.now() # 简化实现凌晨 0 点为例 next_time now.replace(hour0, minute0, second0, microsecond0) if next_time now: next_time next_time.replace(daynow.day 1) return time.mktime(next_time.timetuple()) def shouldRollover(self, record): if super().shouldRollover(record): return True return time.time() self._next_day def doRollover(self): super().doRollover() self._next_day self._calc_next_rollover(None)这里我只是展示一个非常朴素的思路。真正的生产代码里日期推进逻辑、时区处理、命名规则都要按业务调整。不建议把这套复杂逻辑写歪了简单场景优先保证一种切割方式可靠复杂场景再叠加。4.3 多进程压力测试脚本写完 Handler 不压测等于没写。我一般用下面这个小脚本模拟 8 个进程同时写日志并控制单文件在 1MB 左右就滚动用来验证日志是否丢失、是否错行import logging import multiprocessing as mp import os def worker_write(worker_id, log_path, total2000): logger logging.getLogger(fworker-{worker_id}) handler LockedRotatingFileHandler( log_path, maxBytes1024 * 1024, # 1MB 滚动一次 backupCount5, encodingutf-8, ) logger.addHandler(handler) logger.setLevel(logging.INFO) for i in range(total): logger.info(fworker-{worker_id} write-message-{i:06d}) handler.close() if __name__ __main__: log_path stress_test.log if os.path.exists(log_path): os.remove(log_path) processes [ mp.Process(targetworker_write, args(i, log_path, 3000)) for i in range(8) ] for p in processes: p.start() for p in processes: p.join() # 统计主日志文件与备份文件里的有效行数 total_lines 0 for fname in [log_path] [f{log_path}.{i} for i in range(1, 6)]: if os.path.exists(fname): with open(fname, encodingutf-8) as f: total_lines sum(1 for line in f if line.strip()) expected_lines 8 * 3000 print(f期望行数: {expected_lines}, 实际行数: {total_lines}) assert total_lines expected_lines, 存在日志丢失!这个脚本的核心逻辑是8 个进程总共写入 24000 条日志单文件 1MB 一直滚动最后统计主文件和 5 个备份文件里的总行数如果少于 24000 就是有日志丢了。我在实际测试中不加锁的内置 Handler 在这个压测下几乎必然出现丢行加了锁之后跑了很多轮都是精确相等的。5. 真实环境中的问题排查记录5.1 高频问题错行、丢行、重名冲突日志出现错行特征很典型一行日志里夹杂着另一个进程的半句话比如INFO worker-1 task started [INFO worker-2] task done。这种问题通常是没有跨进程锁直接用了内置的FileHandler。排查时先确认是不是真的多进程共写一个文件再确认是否用了我们上面说的带锁 Handler。如果你确认加了锁还错行那大概率是锁的范围没覆盖全比如滚动和写入分别锁了两次中间被其他进程插进来。丢行的问题要区分两种情况。一种是滚动瞬间丢的表现为备份文件齐全但总行数少了这通常是滚动竞态导致的按 3.2 节的方案锁住doRollover即可解决。另一种是磁盘满了丢的日志文件写入失败这时候logging会调用handleError默认只是打印到stderr你不会在日志文件里看到任何痕迹。所以生产环境建议配一个独立的错误监控或者给handleError加报警逻辑不然磁盘写满时你连报警日志都看不到。重名冲突主要出现在按大小滚动时。多个进程同时把app.log.1重命名为app.log.2如果app.log.2本来就存在某些实现会直接覆盖导致日志丢失。锁方案能从根本上避免两个进程同时进入滚动逻辑所以这个问题不需要单独处理。5.2 时间分割的失效与清理 bug我踩过最典型的一个坑是TimedRotatingFileHandler在某些版本下按时间滚动可能不触发。原因出在shouldRollover的判断逻辑上它比较的是record的创建时间和nextRollover。如果你的进程长时间没有日志输出等下一次日志进来时当前时间已经远超过nextRollover理论上应该滚动但它内部把“检查”建立在“下一次滚动时间由当前时间推算”的机制上一旦长时间静默滚动状态的推进就可能出问题。解决方案是如果要求严格的准点切割在业务代码里用独立调度器触发一个定时 tick或者干脆用QueueListener 外部定时任务重开日志文件比依赖TimedRotatingFileHandler更可靠。清理旧日志失效的问题也很常见。默认情况下TimedRotatingFileHandler的清理逻辑只能识别默认命名格式的归档文件。比如app.log.2025-06-01_00-00-00能被认出来但如果你改成了app-20250601.log或者大小分割和时间分割混用backupCount大概率不会按预期清理。我遇到过一次线上日志目录涨到 200GB 的事件排查下来就是因为业务方自定义了归档命名但没重写getFilesToDelete。后来的处理方式有两种要么统一用标准命名要么额外写一个清理脚本按文件修改时间和保留天数定期清理。5.3 若干工程经验经验一日志格式里一定要加进程号和线程号。多进程场景下没有进程号的日志几乎没法排查。推荐在 formatter 里用%(asctime)s [%(process)d] [%(threadName)s] %(levelname)s %(name)s: %(message)s这样即使日志文件是乱的你也能根据进程号快速区分是哪条链路打出来的。经验二锁文件独立于日志文件。这个我前面提过再强调一次。锁文件千万别复用日志文件本身否则滚动重命名时锁会乱掉而且锁文件的内容对排查没价值反而可能让你误判日志文件大小。经验三定时检查归档目录大小。无论你用的是哪种 Handler都建议在监控里加上“日志目录总大小”和“归档文件数量”两个指标。日志分割再怎么设计也挡不住某些异常逻辑疯狂打日志。我习惯每天凌晨检查一次日志目录超过阈值就报警避免磁盘写满拖垮整个服务。经验四QueueHandler方案里要关注队列积压。队列不是无限大的日志生产速度超过消费者时要么丢弃日志要么阻塞业务。二者的取舍取决于业务对日志完整性的要求。我通常在QueueHandler外层套一个“队列满则丢弃并打印到 stderr”的兜底逻辑避免把业务进程拖死。日志丢了可以事后排查业务自己挂掉就是事故了。经验五不要把日志文件路径硬编码在代码里。日志路径应该来自环境变量或配置文件尤其多进程部署时不同节点可能映射不同的日志目录。我参与的一个跨平台系统里日志路径曾经硬编码成/tmp下的固定路径结果多个服务节点同时写同一个文件错行问题比多进程还严重。改成按主机名或节点标识分目录之后世界清净了。结尾一点补充这篇文章里我给的带锁 Handler 是偏 Linux 的因为生产环境绝大多数服务都跑在 Linux 上。如果你必须在 Windows 上跑把fcntl.flock替换成msvcrt.locking就行思路完全一致。至于方案选择我个人在实际项目中更偏爱QueueHandlerQueueListener因为它把“滚动”和“写入”都收拢到单进程里天然避免各种竞态性能也好。但如果只是临时脚本或者 worker 数量很少的简单任务带锁 Handler 完全够用没必要引入队列。项目日志这块最好在初期就把规则定清楚等日志量爆发再改切割策略那才真的是边跑边换轮子谁改谁知道难受。
阅读完成 · 觉得有帮助?
咨询建站