13、文件分割实战:将一个大文件分割成多个小文件

文件分割,说白了就是把一个大文件切成几块。你想想看,日志文件动辄几个GB,编辑器打开都卡半天。或者你要把一个大文件通过U盘拷给别人,U盘格式是FAT32,单文件不能超过4GB。怎么办?

我早年做嵌入式开发时,经常要处理几十MB的日志文件。那时候调试工具简陋,根本打不开这么大的文件。后来我写了个小工具,把日志按时间戳切成小段,每个文件只存10分钟的数据。嗯,从那以后,排查问题效率高了不少。

13.1 核心思路:按大小切还是按行数切?

文件分割有两种主流策略:

  • 按字节大小切:每个小文件固定大小,比如10MB一个。适合二进制文件(图片、视频)。
  • 按行数切:每个小文件固定行数,比如1000行一个。适合文本文件(日志、CSV)。

我个人习惯用按大小切的方式,因为通用性更强。但如果你处理的是文本数据,按行切更符合直觉——毕竟你读日志时是按行看的。

核心原则:分割时不能丢失数据,也不能重复数据。每个字节只能属于一个小文件。

13.2 按字节大小分割的实现

先看一个最简单的实现。我们打开源文件,每次读固定大小的数据,写到新文件里。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define CHUNK_SIZE (10 * 1024 * 1024)  // 10MB

int split_by_size(const char *src_path, size_t chunk_size) {
    FILE *src = fopen(src_path, "rb");
    if (!src) {
        perror("打开源文件失败");
        return -1;
    }

    char buffer[8192];  // 8KB缓冲区
    char out_name[256];
    int part_num = 0;
    size_t bytes_written = 0;
    FILE *dst = NULL;

    while (1) {
        size_t bytes_read = fread(buffer, 1, sizeof(buffer), src);
        if (bytes_read == 0) break;

        // 需要新文件时创建
        if (dst == NULL || bytes_written >= chunk_size) {
            if (dst) fclose(dst);
            snprintf(out_name, sizeof(out_name), "%s.part%d", src_path, part_num++);
            dst = fopen(out_name, "wb");
            if (!dst) {
                perror("创建输出文件失败");
                fclose(src);
                return -1;
            }
            bytes_written = 0;
        }

        // 计算本次实际能写多少
        size_t to_write = bytes_read;
        if (bytes_written + to_write > chunk_size) {
            to_write = chunk_size - bytes_written;
        }

        fwrite(buffer, 1, to_write, dst);
        bytes_written += to_write;

        // 如果没读完,把剩余数据放回缓冲区?不,这里有个坑
        // 实际上我们读多了,需要处理剩余部分
        if (to_write < bytes_read) {
            // 把未写入的部分"退回"?标准C没有ungetc批量操作
            // 正确做法:用fseek回退
            fseek(src, -(long)(bytes_read - to_write), SEEK_CUR);
        }
    }

    if (dst) fclose(dst);
    fclose(src);
    printf("分割完成,共生成 %d 个文件\n", part_num);
    return 0;
}

注意:上面的代码有个隐藏问题。当读取的数据跨越文件边界时,我们用了fseek回退。但fseek对某些流(如管道、socket)不支持。生产环境中,我建议用双缓冲方案:先读满一个chunk,再写入,这样不需要回退。

13.3 按行数分割的实现

按行分割更简单。用fgets逐行读取,写到当前小文件,行数够了就换下一个。

int split_by_line(const char *src_path, int lines_per_file) {
    FILE *src = fopen(src_path, "r");
    if (!src) {
        perror("打开源文件失败");
        return -1;
    }

    char buffer[4096];
    char out_name[256];
    int part_num = 0;
    int line_count = 0;
    FILE *dst = NULL;

    while (fgets(buffer, sizeof(buffer), src)) {
        // 需要新文件时创建
        if (dst == NULL || line_count >= lines_per_file) {
            if (dst) fclose(dst);
            snprintf(out_name, sizeof(out_name), "%s.part%d", src_path, part_num++);
            dst = fopen(out_name, "w");
            if (!dst) {
                perror("创建输出文件失败");
                fclose(src);
                return -1;
            }
            line_count = 0;
        }

        fputs(buffer, dst);
        line_count++;
    }

    if (dst) fclose(dst);
    fclose(src);
    printf("分割完成,共生成 %d 个文件\n", part_num);
    return 0;
}

这段代码看起来简单,但有个细节:fgets读到一行时,如果行长度超过4096字节,会分多次读取。这时候行数计数就不准了。我曾经在解析一个CSV文件时踩过这个坑——某行数据特别长,结果被当成多行切开了。

避坑指南:如果你确定文件每行不超过4KB,上面的代码够用。否则,建议用fread自己解析换行符,或者用POSIX的getline函数(它能自动分配缓冲区)。

13.4 文件分割的完整流程图

下面这张图展示了文件分割的核心流程。我习惯先画清楚逻辑再写代码,这样不容易漏掉边界情况。

文件分割核心流程 打开源文件 选择分割策略:按大小 / 按行数 读取数据块 / 一行 达到分割条件? 创建新文件 写入当前文件 关闭所有文件,结束

13.5 生产环境中的改进点

上面给的代码能跑,但离生产环境还有距离。我总结了几点改进方向:

问题 改进方案 说明
缓冲区太小 使用mmap或大缓冲区(1MB+) 减少系统调用次数,提升吞吐量
文件名冲突 加入时间戳或UUID 避免多次运行覆盖已有文件
错误处理不完善 记录已分割的文件列表,支持断点续传 分割到一半程序崩溃,能从中断处继续
不支持大文件(>2GB) 使用64位文件接口(fseeko/ftello) 32位程序默认不支持超过2GB的文件

我的经验:有一次我在服务器上分割一个12GB的日志文件,用的就是上面那个简单版本。结果跑了半小时还没跑完——缓冲区只有8KB,磁盘IO成了瓶颈。后来改成1MB缓冲区,3分钟就搞定了。所以,缓冲区大小真的很关键。

13.6 分割后的文件如何合并

分割只是第一步,你肯定还需要合并回去。合并的逻辑更简单:按文件名顺序打开所有小文件,依次写入一个大文件。

int merge_files(const char *base_name, int part_count) {
    FILE *dst = fopen(base_name, "wb");
    if (!dst) {
        perror("创建目标文件失败");
        return -1;
    }

    char buffer[8192];
    char part_name[256];

    for (int i = 0; i < part_count; i++) {
        snprintf(part_name, sizeof(part_name), "%s.part%d", base_name, i);
        FILE *src = fopen(part_name, "rb");
        if (!src) {
            perror("打开分片文件失败");
            fclose(dst);
            return -1;
        }

        size_t bytes;
        while ((bytes = fread(buffer, 1, sizeof(buffer), src)) > 0) {
            fwrite(buffer, 1, bytes, dst);
        }

        fclose(src);
        // 合并后可以删除分片文件
        // remove(part_name);
    }

    fclose(dst);
    printf("合并完成\n");
    return 0;
}

合并时要注意顺序。如果分片文件是按part0、part1、part2命名的,直接按数字顺序合并就行。但如果你用了时间戳命名,记得先排序再合并。

一句话总结:文件分割的核心就是「读一块,写一块,满了换文件」。代码不难,但缓冲区大小、边界处理、错误恢复这些细节决定了代码能不能真正用到生产环境。

好了,文件分割就聊到这里。代码你拿去就能用,但建议根据实际场景调整缓冲区大小和分割策略。嗯,动手试试吧。


公众号:蓝海资料掘金营,微信deep3321