3、多进程日志基础:multiprocessing模块、进程间日志隔离、共享日志文件的问题

好,咱们进入正题。多进程日志,说白了就是让你的程序在同时跑多个进程时,日志还能整整齐齐、不丢不乱。这活儿看着简单,坑可不少。我当年第一次做分布式爬虫,就栽在这上面了——日志文件里全是乱码和覆盖,排查问题比写代码还痛苦。

3.1 multiprocessing模块:Python多进程的基石

Python的multiprocessing模块,是咱们做多进程日志的起点。它跟threading不一样——线程共享内存,进程各自独立。这意味着每个进程都有自己的内存空间、文件描述符,当然,也有自己的日志流。

先看个最简单的例子:

from multiprocessing import Process
import logging

def worker(name):
    logger = logging.getLogger(name)
    logger.setLevel(logging.INFO)
    handler = logging.FileHandler(f'worker_{name}.log')
    handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s'))
    logger.addHandler(handler)
    logger.info(f'进程 {name} 启动')

if __name__ == '__main__':
    processes = []
    for i in range(3):
        p = Process(target=worker, args=(i,))
        processes.append(p)
        p.start()
    for p in processes:
        p.join()

这段代码每个进程写自己的日志文件,互不干扰。但问题来了——如果我想把所有日志写到一个文件里呢?

核心要点:每个进程独立初始化日志器,是进程间日志隔离的最简单方式。但共享文件时,事情就复杂了。

3.2 进程间日志隔离:为什么需要?怎么做?

为什么要隔离?你想想看,如果三个进程同时往一个文件里写日志,会发生什么?

  • 日志交错:进程A写到一半,进程B插进来写,结果一行日志被切成两半
  • 缓冲区混乱:每个进程有自己的缓冲区,刷盘时机不同,日志顺序乱掉
  • 文件句柄冲突:多个进程同时打开同一个文件,操作系统会怎么处理?

我遇到过最离谱的一次,是生产环境上两个进程同时写同一个日志文件,结果文件里出现了半行日志——"2024-01-15 10:30:45,123 INFO 用户登",后面就断了。排查了半天,才发现是日志写入时被另一个进程打断了。

隔离的做法其实很简单:

  1. 每个进程一个日志文件:比如app_1.logapp_2.log,文件名里带上进程ID
  2. 使用进程名称或ID作为日志器名称:这样即使写到同一个地方,也能区分来源
  3. 通过队列集中处理:所有进程把日志发到队列,由一个专门的进程写入

我的建议:开发阶段用每个进程独立文件,方便调试。生产环境用队列集中写入,保证顺序和完整性。

3.3 共享日志文件的问题:三个大坑

好,现在咱们聊聊共享日志文件。很多人图省事,让所有进程写同一个文件。嗯,这里要注意,坑很深。

坑一:写入冲突与数据损坏

Python的logging模块默认不是线程安全的,更不是进程安全的。多个进程同时write()同一个文件,操作系统会尽力保证每个write()是原子的——但前提是你写的数据小于PIPE_BUF(通常是4096字节)。一旦日志超过这个大小,就会被拆成多次写入,冲突就来了。

# 错误示范:多个进程共享同一个FileHandler
import logging

logger = logging.getLogger('shared')
handler = logging.FileHandler('shared.log')  # 所有进程都用这个
logger.addHandler(handler)

# 进程A和进程B同时写,日志就乱了

坑二:文件句柄的继承问题

fork创建子进程时,子进程会继承父进程的文件描述符。这意味着父进程打开的日志文件句柄,子进程也能用。但问题在于——它们共享同一个文件偏移量!

我曾经踩过这个坑:父进程写了一条日志,文件指针移到了100字节处。子进程接着写,它以为文件指针在100,但实际上父进程又写了一条,指针到了200。结果子进程的日志覆盖了父进程的日志。排查时发现日志文件里全是乱码,气得我差点砸键盘。

警告:使用fork创建进程时,一定要在子进程中重新打开日志文件句柄,不要继承父进程的。否则文件偏移量共享,日志必乱。

坑三:日志丢失与缓冲区问题

每个进程都有自己的缓冲区。进程A写了一条日志,还在缓冲区里没刷盘,进程B也写了一条。如果此时进程A崩溃了,它缓冲区里的日志就丢了。更麻烦的是,进程B的日志可能因为进程A的崩溃而受到影响。

解决方案其实不复杂:

  • 设置flush=True,每次写入都刷盘(但性能会下降)
  • 使用RotatingFileHandler时注意加锁
  • 最好用QueueHandler + QueueListener的组合

3.4 实战:一个安全的共享日志方案

说了这么多问题,那到底怎么解决?我个人习惯用multiprocessing.Queue来做日志中转。所有子进程把日志消息丢到队列里,主进程或者一个专门的日志进程负责写入文件。

from multiprocessing import Process, Queue
import logging
import logging.handlers

def worker(queue, name):
    # 每个进程用QueueHandler,把日志发到队列
    handler = logging.handlers.QueueHandler(queue)
    logger = logging.getLogger(f'worker_{name}')
    logger.addHandler(handler)
    logger.setLevel(logging.INFO)
    logger.info(f'进程 {name} 开始工作')

def log_listener(queue):
    # 专门的日志写入进程
    handler = logging.FileHandler('aggregated.log')
    handler.setFormatter(logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    ))
    while True:
        try:
            record = queue.get()
            if record is None:  # 收到None信号就退出
                break
            logger = logging.getLogger(record.name)
            logger.addHandler(handler)
            logger.handle(record)
        except Exception:
            import sys
            print('日志写入异常', file=sys.stderr)

if __name__ == '__main__':
    queue = Queue()
    listener = Process(target=log_listener, args=(queue,))
    listener.start()
    
    workers = []
    for i in range(3):
        p = Process(target=worker, args=(queue, i))
        workers.append(p)
        p.start()
    
    for p in workers:
        p.join()
    
    queue.put(None)  # 通知监听进程退出
    listener.join()

这个方案的好处:

  • 所有日志按顺序写入,不会交错
  • 子进程崩溃不影响日志完整性
  • 可以统一控制日志格式和输出目标
  • 性能好,队列操作比文件IO快得多

3.5 总结与避坑指南

好了,咱们把多进程日志的基础捋了一遍。核心就三点:

  1. 进程间日志隔离——每个进程独立初始化日志器,或者用进程ID区分
  2. 共享文件的问题——写入冲突、句柄继承、缓冲区丢失,这三个坑一定要避开
  3. 队列方案——用QueueHandler + QueueListener,是目前最稳妥的做法

我曾经在一个金融项目里,因为没处理好共享日志的问题,导致交易日志丢失了十几条。虽然最后数据没丢,但被领导叫去喝茶的滋味可不好受。从那以后,我对多进程日志就格外小心。

避坑指南:

  • 永远不要在子进程中直接使用父进程创建的FileHandler
  • 如果非要用共享文件,记得加文件锁(fcntl.flockportalocker
  • 日志格式里一定要带上进程ID(%(process)d),方便排查
  • 生产环境建议用concurrent-log-handler这类专门处理并发日志的库

下一章咱们会深入讲QueueHandlerQueueListener的细节,以及如何做日志的聚合分析。到时候我会分享一个我在电商平台用过的实战方案,保证让你大开眼界。