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 用户登",后面就断了。排查了半天,才发现是日志写入时被另一个进程打断了。
隔离的做法其实很简单:
- 每个进程一个日志文件:比如
app_1.log、app_2.log,文件名里带上进程ID - 使用进程名称或ID作为日志器名称:这样即使写到同一个地方,也能区分来源
- 通过队列集中处理:所有进程把日志发到队列,由一个专门的进程写入
我的建议:开发阶段用每个进程独立文件,方便调试。生产环境用队列集中写入,保证顺序和完整性。
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 总结与避坑指南
好了,咱们把多进程日志的基础捋了一遍。核心就三点:
- 进程间日志隔离——每个进程独立初始化日志器,或者用进程ID区分
- 共享文件的问题——写入冲突、句柄继承、缓冲区丢失,这三个坑一定要避开
- 队列方案——用
QueueHandler+QueueListener,是目前最稳妥的做法
我曾经在一个金融项目里,因为没处理好共享日志的问题,导致交易日志丢失了十几条。虽然最后数据没丢,但被领导叫去喝茶的滋味可不好受。从那以后,我对多进程日志就格外小心。
避坑指南:
- 永远不要在子进程中直接使用父进程创建的
FileHandler - 如果非要用共享文件,记得加文件锁(
fcntl.flock或portalocker) - 日志格式里一定要带上进程ID(
%(process)d),方便排查 - 生产环境建议用
concurrent-log-handler这类专门处理并发日志的库
下一章咱们会深入讲QueueHandler和QueueListener的细节,以及如何做日志的聚合分析。到时候我会分享一个我在电商平台用过的实战方案,保证让你大开眼界。