读取大型tar归档文件时的内存泄漏问题排查
外部排序处理Tar归档文件名时的内存泄漏问题分析与解决
问题描述
我有一个30GB的tar归档文件,其中包含大量32KB的文件。需求是提取归档内的文件名列表,按从小到大排序后保存到磁盘,供其他程序处理。由于无法将完整文件名列表载入内存,选择使用外部排序算法,但运行代码时持续出现内存泄漏:生成第15个数据块后,内存占用高达6GB,不符合预期。即使将chunk_size调整为1000也无济于事。
归档内文件名示例
1740296766220.dump
1740296766320.dump
1740296766331.dump
1740296766339.dump
1740296766380.dump
1740296766391.dump
1740296766410.dump
实现代码
import tarfile import os import gc import heapq import tempfile def parse_filename(filename): """ Extracts the numeric part of the file name for sorting. """ try: return int(filename.split('.')[0]) except ValueError: return float('inf') def filename_generator(tar_path): """ A generator that lazily reads file names from a tar archive. """ with tarfile.open(tar_path, 'r') as tar: for member in tar: yield member.name def external_sort_tar(tar_path, output_file, chunk_size=1_000_000): temp_files = [] temp_dir = tempfile.mkdtemp() # Temporary directory for chunks print(temp_dir) def save_chunk(filenames, chunk_index): """ Sorts and saves chunks to a temporary file. """ temp_file = os.path.join(temp_dir, f"chunk_{chunk_index}.txt") filenames.sort(key=parse_filename) with open(temp_file, "w") as tf: tf.writelines("\n".join(filenames) + "\n") return temp_file filenames = [] chunk_index = 0 for name in filename_generator(tar_path): filenames.append(name) if len(filenames) >= chunk_size: # We limit the volume of the chunk temp_files.append(save_chunk(filenames, chunk_index)) filenames = [] gc.collect() chunk_index += 1 if filenames: temp_files.append(save_chunk(filenames, chunk_index)) # Multipath merging of sorted chunks with open(output_file, "w") as out: file_handles = [open(f, "r") for f in temp_files] out.writelines(heapq.merge(*file_handles, key=parse_filename)) # Closing and deleting temporary files for f in file_handles: f.close() for f in temp_files: os.remove(f) os.rmdir(temp_dir) external_sort_tar('example.tar', "tar.map", chunk_size=1000000)
测试归档生成脚本
#!/bin/bash # Ask the user for the number of files to generate (default: 10000) read -p "How many files to generate? (default: 10000): " FILE_COUNT FILE_COUNT=${FILE_COUNT:-10000} # Get the current Unix timestamp in milliseconds START_TIMESTAMP=$(date +%s%3N) # Create a temporary directory for the files TMP_DIR=$(mktemp -d) echo "Temporary directory created: $TMP_DIR" # Generate files with random data for ((i=0; i<FILE_COUNT; i++)); do FILENAME="$TMP_DIR/$((START_TIMESTAMP + i)).dump" head -c 100 /dev/urandom > "$FILENAME" done echo "$FILE_COUNT files created. Archiving..." # Create a tar archive without compression (files are placed directly in the root of the archive) ARCHIVE_NAME="dumps_$(date +%s).tar" tar -cf "$ARCHIVE_NAME" -C "$TMP_DIR" $(ls "$TMP_DIR") echo "Files added to archive: $ARCHIVE_NAME" # Remove the temporary directory rm -rf "$TMP_DIR" echo "Temporary files removed. Done!"
内存泄漏原因分析
- Tarfile模块的缓存机制:
tarfile.open()默认会缓存所有已读取的TarInfo对象,即便用生成器逐个获取member.name,这些TarInfo实例也不会被垃圾回收,导致内存持续增长。这是核心问题——生成器并没有真正实现"惰性释放",tarfile内部会保留所有成员的引用。 - chunk写入的内存浪费:使用
"\n".join(filenames)会一次性生成超大字符串,当chunk_size较大时,这个字符串会占用额外的大量内存。 - 手动GC的局限性:调用
gc.collect()只能回收Python层面的无用对象,但tarfile的内部缓存属于C扩展层面,手动GC无法强制清理这部分内存。
解决思路与优化代码
核心修复:关闭Tarfile的缓存
修改filename_generator函数,禁用tarfile的成员缓存,并手动删除已处理的TarInfo对象:
def filename_generator(tar_path): """ 惰性读取tar归档文件名,避免缓存所有TarInfo对象 """ with tarfile.open(tar_path, 'r', dereference=False, ignore_zeros=True) as tar: # 禁用tarfile的成员缓存 tar.members = None for member in tar: yield member.name # 手动删除当前member引用,帮助GC回收 del member
优化chunk写入逻辑
避免一次性生成大字符串,改为逐行写入:
def save_chunk(filenames, chunk_index): """ 排序并保存chunk到临时文件,逐行写入减少内存占用 """ temp_file = os.path.join(temp_dir, f"chunk_{chunk_index}.txt") filenames.sort(key=parse_filename) with open(temp_file, "w") as tf: for name in filenames: tf.write(f"{name}\n") return temp_file
合并阶段的内存优化
使用生成器处理排序键,避免重复计算,同时确保文件句柄安全管理:
# 多路径合并优化 from itertools import map def line_key(line): # 处理换行符,避免影响parse_filename name = line.rstrip('\n') return parse_filename(name) with open(output_file, "w") as out: # 为每个文件句柄创建带排序键的生成器 iterators = [] for f in temp_files: fh = open(f, 'r') # 转换为(排序键, 行内容)格式,供heapq.merge直接处理 iterators.append((map(lambda x: (line_key(x), x), fh), fh)) # 合并后写入结果文件 for _, line in heapq.merge(*(it for it, _ in iterators)): out.write(line) # 关闭所有文件句柄 for _, fh in iterators: fh.close()
其他调整
- 合理设置
chunk_size:无需刻意调小到1000,建议根据内存情况设置为50万-100万,平衡磁盘IO和内存占用。 - 移除不必要的
gc.collect():修复tarfile缓存问题后,Python的自动GC足以回收chunk列表的内存。
验证效果
修改后,tarfile不再缓存所有TarInfo对象,内存占用会稳定在单个chunk对应的文件名列表大小,处理30GB归档时内存占用可控制在几百MB以内,不会出现持续增长的情况。
内容的提问来源于stack exchange,提问作者stas stas
相关产品推荐
相关产品推荐

