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