You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

读取大型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!"

内存泄漏原因分析

  1. Tarfile模块的缓存机制:tarfile.open()默认会缓存所有已读取的TarInfo对象,即便用生成器逐个获取member.name,这些TarInfo实例也不会被垃圾回收,导致内存持续增长。这是核心问题——生成器并没有真正实现"惰性释放",tarfile内部会保留所有成员的引用。
  2. chunk写入的内存浪费:使用"\n".join(filenames)会一次性生成超大字符串,当chunk_size较大时,这个字符串会占用额外的大量内存。
  3. 手动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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 01:15:00