大文件排序优化实践
在很多应用场景中,我们都会面临着排序需求,可以说是见怪不怪。我们也看过许多的排序算法:从最简单的冒泡排序、选择排序,到稍微好点的插入排序、希尔排序,再到有点理论的堆排序、快速排序,再到高级的归并排序、桶排序、基数排序。
而实际工作中我们可能用到的排序有哪些呢?而且,大部分时序,相信大家都是使用一个现有库API直接就完成了排序功能。所以,讲真,大家还真不一定会很好排序。
不过本文的目的不是基础排序算法,而是如何处理数据量的文件的内容排序问题?
1. 多大的文件算大文件?
多大的文件算大文件?这是个定义的问题,当我每天处理的都是几百几千的数据,那么我遇到几万的数据后,我可以认为这个数据量是大的。
但总体来说,我们还是需要定义一个量级的,不然无法评估处理能力问题。
比如我们定义超过200M的文件算大文件可以不?我们定义超过5000w行的数据算大文件可以不?
好了,基于这样大的数据量的时候,也许我们就不能简单的调用几个库函数就解决问题了,至少你load到内存也将存在一定的风险了。
所以,是时候想想优化的事了!
2. 如何利用好现有的工具?
针对一些问题,我们可以自己做,也可以找别人帮忙做。具体谁来做,这是个问题!
比如,你自己花个一两天的时间,写了个排序算法,搞定了。但是,你能保证你的稳定性吗?你能经受住生产环境复杂的环境考验吗?
再比如,你可以现有的工具进行操作,如果有人提供了稳定的api函数供调用的话,你可以这么干。如果你的服务是跑在linux环境下,那么,我们有必要试一下系统提供的排序功能。 sort . 这工具绝对是经过无数的考验的,可以放心使用。它也有丰富的参数供选择,这对我们的日常工作非常有帮助,但对于一个普通的排序也许我们并不需要。
比如最简单的,自然排序:
sort 1-merged.txt -o 1-sorted.txt
就可以将文件排好序了。但是当数据非常大的时候,比如我使用 7000w+ 的行数(约1.2G)进行排序时,就花费了 6min+ . 也许是我硬件不怎么好,但是实际上它就是会很慢啊!
$ time sort 1-merged.txt -o 1-sorted.txt real 8m5.709s user 25m19.652s sys 0m4.523s
这种性能,在当今大数据横行的时代,基本就是胎死腹中了。大数据应对的都是TB/PB 级别的数量,而我们仅为GB级并且没有做其他业务就已经耗费了这么长时间,这是没办法继续了。让我进一步深入。
看到文档里有说,系统本地化配置影响排序,实际就是存在一个编解码的问题,它会依据本地的配置来进行转换字符然后再进行排序。这个开销可是不小哦,比如我们设置都是中文环境。而要去除这个影响,则可以使用添加 LC_ALL=C 之后就会使用原始的值进行排序,具体影响就是省去转换编码的开销了。那么,我们用这个参数试试。
*** WARNING *** The locale specified by the environment affects sort order. Set LC_ALL=C to get the traditional sort order that uses native byte values. $ time LC_ALL=C sort 1-merged.txt -o 1-sorted.txt real 2m52.663s user 2m7.577s sys 0m5.146s
哇,从8分钟降到了3分钟,虽然不是数量级的提升,但至少下降了一半以上的时间消耗,还是非常可观的。到这个地步,也许能满足我们的场景了。
但是,请注意一个问题,这里的 LC_ALL=C 之后,就会使用默认的逻辑进行处理了,那么,会有什么影响呢?实际上就是一些本地相关的东西,就会失效了。
最直接的,就是中文处理的问题了,比如我有一个文件内容是这样的:
床前明月光,
疑是地上霜。
举头望明月,
低头思故乡。
天子呼来不上船,
自称臣是酒中仙。
红酥手,
黄藤酒,
满城春色宫墙柳。
那么,我们使用 LC_ALL=C 设置来排序后,将会得到如下结果:
$ LC_ALL=C sort 1.txt -o 1-s1.txt $ cat 1-s1.txt 举头望明月, 低头思故乡。 天子呼来不上船, 满城春色宫墙柳。 疑是地上霜。 红酥手, 自称臣是酒中仙。 黄藤酒, 床前明月光,
额,看不懂啥意思?中文咋排序的我也给整忘了(而且各自机器上得到的结果也可能不一样)。好吧,没关系,我们去掉 LC_ALL=C 来看看结果:
$ sort 1.txt -o 1-s1.txt $ cat 1-s1.txt 床前明月光, 低头思故乡。 红酥手, 黄藤酒, 举头望山月, 满城春色宫墙柳。 天子呼来不上船, 疑是地上霜。 自称臣是酒中仙。
这下看懂了吧,这是按照拼音顺序来排序的。所以,你说 LC_ALL=C 重不重要,如果没有本地化设置,很多东西都是不符合情理的。所以,有时候我们还真不能这么干咯。
如果真想这么干,除非你确认你的文件里只有英文字符符号和数字,或者是 ASCII 的127 个字符。
3. 绕个路高级一下
前面的方法,不是不能解决问题,而是不能解决所有问题。所以,我们还得继续想办法。想想当下对大文件的处理方式都有哪些?实际也不多,并行计算是根本,但我们也许做不了并行计算,但我们可以拆分文件嘛。一个文件太大,我们就文件拆小排序后再合并嘛!就是不知道性能如何?
split -l 100000 -d ../1-merged.txt -a 4 sp_; for file in sp_*.txt; do; sort -o $file sorted_$file; done; sort -m sp_*.txt -o targed.txt; # 一行化后的格式 $ time for file in sp_*; do sort -o sorted_$file $file; done; sort -m sorted_* -o targetd.txt; real 12m15.256s user 10m11.465s sys 0m18.623s # 以上时间仅是单个文件的排序时间还不算归并的时间,下面这个代码可以统一计算 $ time `for file in sp_1_*; do sort $file -o sorted_$file; done; sort -m sorted_* -o targetd.txt;` real 14m27.643s user 11m13.982s sys 0m22.636s
看起来切分小文件后,排序太耗时间了,看看能不能用多进程辅助下!(所以最终我们还是回到了并行计算的问题上了)
# shell 异步运行就是在其后面添加 & 就可以了, 但是最后的归并是同步的. $ time `split -l 100000 -d ../1-merged.txt -a 4 sp_ ; for file in sp_* ; do {sort $file -o $file} &; done; wait; sort -m sp_* -o target.txt ; ` # 多处计时监控 $ time `time split -l 100000 -d ../1-merged.txt -a 4 sp_; time for file in sp_1_*; do { sort $file -o $file } & ; done; time wait; time sort -m sp_* -o target.txt;` # 以上报错,因为命令行下不允许使用 & 操作, 只能自己写shell脚本,然后运行了 # sort_merge.sh time split -l 100000 -d ../1-merged.txt -a 4 sp_; i=0 for file in sp_*; do { #echo "sort -o $file $file"; sort -o $file $file; } & done; time wait; time sort -m sp_* -o target.txt; # 以上脚本的确是会以异步进行排序,但会开启非常多的进程,从而导致进程调度繁忙,机器假死 # 需要修复下 # sort_merge.sh split_file_prefix='sp_' rm -rf ${split_file_prefix}*; time split -l 1000000 -d ../short-target.csv -a 4 ${split_file_prefix}; i=0 for file in ${split_file_prefix}*; do { sort -o $file $file; } & # 每开5个进程,就等一下 (( i=$i + 1 )) b=$(( $i % 5 )) if [ $b = 0 ] ; then # 小优化: 只要上一个进程退出就继续,而不是等到所有进程退出再继续 time wait $! # time wait fi; done; time wait; time sort -m ${split_file_prefix}* -o target.txt; # 以上运行下来,耗时9min+, 比未优化时还要差, 尴尬! real 9m54.076s user 19m1.480s sys 0m36.016s
看起来没啥优势啊, 咋整? 咱们试着调下参试试!
# 1. 将单个文件设置为50w行记录, 耗时如下: # 额, 没跑完, 反正没啥提升, 单个文件排序由之前的2s左右, 上升到了11s左右 # 2. 将单个设置为20w行记录试试: # 单个文件排序上升到了4.xs, 也不理想啊; real 9m2.948s user 21m25.373s sys 0m27.067s
增加下并行度试试!
# 增加并行度到10 real 9m3.569s user 21m4.346s sys 0m27.519s # 单文件行数 50w, 并行10个进程 real 8m12.916s user 21m40.624s sys 0m20.988s
看起来效果更差了,或者差不多. 难道这参数咋调整也没用了么? 算了, 不搞了.
4. 换个性能好的机器试试
前面的机器太差了,也没了信心。干脆换一个机器试试(与此同时我们应该得出一个诊断,排序是非常耗资源的,你应该要考虑其影响性问题,比如如何分配资源,出现异常情况如何处理,而不要为了这一小功能的调优而让整个应用处理危险之中)。下面我们直接进入优化参数环节:(仅为理论调优,实际应用请当心)
# 单文件50w行, 5进程 real 5m6.348s user 5m38.684s sys 0m44.997s # 单文件100w行, 5进程 real 2m39.386s user 3m34.682s sys 0m23.157s # 单文件100w行, 10进程 real 2m22.223s user 3m41.079s sys 0m25.418s # 以上结论是行数更容易影响结果, 因排序是计算型密集型任务, 进程数也许等于CPU核数比较优的选择 # 不过也有一个干扰项:即文件的读取写入是IO开销,此时2倍以上的CPU核数进程可能是更好的选择 # 单文件100w行, 10进程, 7100w总数(1.6G) # 使用原始排序 sort real 6m14.485s user 5m10.292s sys 0m13.042s # 使用 LC_ALL=C 测试结果 real 2m1.992s user 1m18.041s sys 0m11.254s # 使用分治排序, 100w行, 10进程 real 2m53.637s user 4m22.170s sys 0m29.979s
好吧,linux的优化估计只能到这里了。总结: 1. LC_ALL=C 很好用;2. 并行优化很困难。
5. 自行实现大文件排序
看起来shell帮不了太多忙了,咋整?用java实现?线程池可以很好利用队列先后问题;但到底有多少优势呢?试试就试试!
多线程可以很方便地并行,比如在分片文件的同时,其他线程就可以同进行排序了,也许等分片完成,排序就ok了呢!
简单时序可表示为: split拆分线程拆分文件 -> file1拆得一个个文件 -> submitSortTask(file1) 立即提交排序任务-> sort(file1) -> submitMergeTask(file1) 立即提交归并任务 -> wait (所有merge都完成)。也就是说所有过程都并行化了或者管道化了(pipeline),拆分一个文件就可以进行排序文件,排序完一个文件就可以合并一个文件。
线程模型是这样的: 1个拆分线程 -> n个排序线程 -> 1个归并线程;任务执行完成的前提是:n个排序线程完成 + n个归并任务完成;
大体思路是这样,优势如何得看数据说话。但我想还可以优化的是,归并线程是否可以维护一个指针,代表最后一次插入的结果,以便可以快速比较插入;(这可能有点困难,另外就是归并都要写新文件,这里可能是个瓶颈点,因为如果有1000次归并,就会存在读写大文件的过程,如果前面的文件分片存在速度慢的问题,那么此处的反复写更是一个瓶颈点,即使是用linux的sort进行归并也要花2min+,更不用说一次次地写读了)
代码实现:(以下实现的归并任务没有并行,而是简单化一次合并,时间复杂度为 O(m*n),其中m为分片文件个数;)(文件分片时间复杂度为 O(n))(小文件排序基于Arrays.sort(), 时间复杂有点复杂,暂且定为O(n*logn) 吧)
import lombok.extern.log4j.Log4j2; import org.apache.commons.io.FileUtils; import java.io.*; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; @Log4j2 public class BigFileSortHelper { /** * 排序分片线程池 */ private static final ExecutorService sortSplitThreadPool = Executors.newFixedThreadPool(5, new NamedThreadFactory("Sort-Thread-")); /** * 归并线程池 */ private static final ExecutorService mergeThreadPool = Executors.newSingleThreadExecutor( new NamedThreadFactory("Merge-Thread-")); /** * 排序大文件(正序) * * @param filePath 文件路径 * @throws IOException 读取写入异常抛出 */ public static void sortBigFile(String filePath) throws IOException { // split -> f1 -> submit(f1) -> sort(f1) -> merge(f1) -> wait (所有merge都完成) int perFileLines = 20_0000; List<SplitFileDescriptor> splitFiles = new ArrayList<>(); String tmpDir = "/tmp/sort_" + System.currentTimeMillis(); try(FileReader reader = new FileReader(new File(filePath))) { BufferedReader bufferedReader = new BufferedReader(reader); String lineData; SplitFileDescriptor splitFile = null; AtomicLong lineNumberCounter = new AtomicLong(0); while ((lineData = bufferedReader.readLine()) != null) { if(lineNumberCounter.get() % perFileLines == 0) { submitSortTask(splitFile); splitFile = rolloverSplitFile(splitFile, lineNumberCounter.get(), perFileLines, tmpDir); splitFiles.add(splitFile); } writeLine(splitFile, lineData, lineNumberCounter); } // 提交最后一个分片文件排序 submitSortTask(splitFile); for (SplitFileDescriptor sp : splitFiles) { try { sp.getFuture().get(); } catch (Exception e) { log.error("排序分片文件结果异常", e); } } List<String> subFilePathList = splitFiles.stream() .map(SplitFileDescriptor::getFullFilePath) .collect(Collectors.toList()); mergeSortedFile(subFilePathList, "/tmp/merge_" + System.currentTimeMillis() + ".txt"); FileUtils.deleteQuietly(new File(tmpDir)); } } /** * 排序指定文件 * * @param originalFilePath 要排序的文件 * @return 排序后的文件位置(可为原地排序) * @throws IOException 读取写入异常时抛出 */ private static String sortFile(String originalFilePath) throws IOException { List<String> lines = FileUtils.readLines( new File(originalFilePath), "utf-8"); lines.sort(String::compareTo); FileUtils.writeLines(new File(originalFilePath), lines); return originalFilePath; } /** * 滚动生成一个新的分片文件 * * @param lastFile 上一个输出的分片文件 * @param currentLineNum 当前总记录行数 * @param perFileLines 单文件可容纳行数 * @param tmpDir 存放临时文件的目录(分片文件) * @return 分片文件的信息 * @throws IOException 文件打开异常抛出 */ private static SplitFileDescriptor rolloverSplitFile(SplitFileDescriptor lastFile, long currentLineNum, int perFileLines, String tmpDir) throws IOException { if(lastFile != null) { lastFile.close(); } int splitFileNo = (int) (currentLineNum / perFileLines); String formattedFileName = String.format("sp_%04d", splitFileNo); return SplitFileDescriptor.newSplit(tmpDir, formattedFileName); } /** * 提交排序任务 * * @param splitFile 单个小分片文件实例 */ private static void submitSortTask(SplitFileDescriptor splitFile) { if(splitFile == null) { return; } Future<?> sortFuture = sortSplitThreadPool.submit(() -> { try { sortFile(splitFile.getFullFilePath()); } catch (IOException e) { log.error("排序单文件时发生了异常" + splitFile.getFullFilePath(), e); } }); splitFile.setFuture(sortFuture); } /** * 合并有序文件 * * @param splitFilePathList 子分片文件列表 * @param outputPath 结果文件存放路径 * @throws IOException 读写异常时抛出 */ public static long mergeSortedFile(List<String> splitFilePathList, String outputPath) throws IOException { List<BufferedReader> bufferedReaderList = new ArrayList<>(splitFilePathList.size()); splitFilePathList.forEach(r -> { FileReader reader = null; try { reader = new FileReader(new File(r)); BufferedReader buffFd = new BufferedReader(reader); bufferedReaderList.add(buffFd); } catch (FileNotFoundException e) { log.error("文件读取异常", e); } }); String[] onlineDataShards = new String[bufferedReaderList.size()]; int i = 0; for ( ; i < bufferedReaderList.size(); i++ ) { BufferedReader reader = bufferedReaderList.get(i); onlineDataShards[i] = reader.readLine(); } String lastLineData = null; AtomicLong lineNumCounter = new AtomicLong(0); try(OutputStream targetOutput = FileUtils.openOutputStream(new File(outputPath))) { while (true) { int minIndex = 0; for (int j = 1; j < onlineDataShards.length; j++) { // 最小的文件已被迭代完成 if(onlineDataShards[minIndex] == null) { minIndex = j; continue; } // 后一文件已被迭代完成 if(onlineDataShards[j] == null) { continue; } if (onlineDataShards[minIndex].compareTo(onlineDataShards[j]) > 0) { minIndex = j; } } // 所有文件都已迭代完成 if(onlineDataShards[minIndex] == null) { break; } String minData = onlineDataShards[minIndex]; // 去重 if(!minData.equals(lastLineData)) { writeLine(targetOutput, minData, lineNumCounter); lastLineData = minData; } // 迭代下一行 onlineDataShards[minIndex] = bufferedReaderList.get(minIndex).readLine(); } } for (BufferedReader reader : bufferedReaderList) { reader.close(); } return lineNumCounter.get(); } /** * 写单行数据到输出流 */ private static void writeLine(SplitFileDescriptor splitFile, String lineData, AtomicLong lineNumCounter) throws IOException { if(splitFile == null) { throw new RuntimeException("分片文件为空"); } OutputStream outputStream = splitFile.getOutputStream(); writeLine(outputStream, lineData, lineNumCounter); } /** * 写单行数据到输出流 */ private static void writeLine(OutputStream outputStream, String lineData, AtomicLong lineNumCounter) throws IOException { outputStream.write(lineData.getBytes()); outputStream.write("\n".getBytes()); lineNumCounter.incrementAndGet(); } /** * 分片文件描述 */ private static class SplitFileDescriptor { String subFileName; String fullFilePath; OutputStream outputStream; Future<?> future; public SplitFileDescriptor(String mainDir, String subFileName) throws IOException { this.subFileName = subFileName; if(!mainDir.endsWith("/")) { mainDir += "/"; } this.fullFilePath = mainDir + subFileName; this.outputStream = FileUtils.openOutputStream(new File(fullFilePath)); } public static SplitFileDescriptor newSplit(String mainDir, String subFileName) throws IOException { return new SplitFileDescriptor(mainDir, subFileName); } public void close() throws IOException { outputStream.close(); } public void setFuture(Future<?> future) { this.future = future; } public String getSubFileName() { return subFileName; } public void setSubFileName(String subFileName) { this.subFileName = subFileName; } public String getFullFilePath() { return fullFilePath; } public void setFullFilePath(String fullFilePath) { this.fullFilePath = fullFilePath; } public OutputStream getOutputStream() { return outputStream; } public void setOutputStream(OutputStream outputStream) { this.outputStream = outputStream; } public Future<?> getFuture() { return future; } } /** * 简单命命名线程生成工厂类 */ private static class NamedThreadFactory implements ThreadFactory { private AtomicInteger counter = new AtomicInteger(0); private String threadNamePrefix; public NamedThreadFactory(String prefix) { this.threadNamePrefix = prefix; } @Override public Thread newThread(Runnable r) { return new Thread(r, threadNamePrefix + counter.incrementAndGet()); } } }
老实说,个人觉得思路还算不差,而且这段代码看起来还是不错的。
但是效果如何? 我用 无言以对 来回答,分片阶段就跟蜗牛一样,真的是性能差得不行,具体数据就不说了,反正比原始的 sort 性能还要差。虽然还有很多优化的地方,比如使用nio,mmap。。。 但终究太费力。
6. 基本内存分片的快速排序实现
有一个巧用,可以直接将读取的分片数据直接丢到排序线程池排序,然后再写文件,这样减少了写分片与重新读分片的io消耗,铁定能提升不少性能,这是为性能的铤而走险,其风险点是内存数据过大将带来不可逆转不可抗力!
完善点的实现如下:(使用内存排序,nio读取文件)
import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; import org.apache.commons.io.LineIterator; import java.io.*; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; @Slf4j public class BigFileSortHelper { /** * 排序分片线程池 */ private static final ExecutorService sortSplitThreadPool = Executors.newFixedThreadPool(5, new NamedThreadFactory("Sort-Thread-")); /** * 归并线程池 */ private static final ExecutorService mergeThreadPool = Executors.newSingleThreadExecutor( new NamedThreadFactory("Merge-Thread-")); /** * 排序大文件(正序) * * @param filePath 文件路径 * @throws IOException 读取写入异常抛出 */ public static String sortBigFile(String filePath) throws IOException { // split -> f1 -> submit(f1) -> sort(f1) -> merge(f1) -> wait (所有merge都完成) long startTime = System.currentTimeMillis(); String sortedFilePath = "/tmp/merge_" + startTime + ".txt"; String tmpDir = "/tmp/sort_" + startTime; // splitBigFileAndSubmitSortUseBufferReader(filePath, tmpDir); List<SplitFileDescriptor> splitFiles = splitBigFileAndSubmitSortUseApacheIoItr(filePath, tmpDir); log.info("分片流程处理完成, 分片文件个数:{}, 耗时:{}ms", splitFiles.size(), (System.currentTimeMillis() - startTime)); for (SplitFileDescriptor sp : splitFiles) { try { sp.getFuture().get(); } catch (Exception e) { log.error("排序分片文件结果异常", e); } } log.info("所有子分片文件排序完成, 耗时:{}ms", (System.currentTimeMillis() - startTime)); List<String> subFilePathList = splitFiles.stream() .map(SplitFileDescriptor::getFullFilePath) .collect(Collectors.toList()); // long totalLines = mergeSortedFileUseBufferReader(subFilePathList, sortedFilePath); long totalLines = mergeSortedFileUseApacheIoItr(subFilePathList, sortedFilePath); log.info("文件归并完成, 总写入行数:{}, 耗时:{}ms", totalLines, (System.currentTimeMillis() - startTime)); boolean delSuccess = FileUtils.deleteQuietly(new File(tmpDir)); if(!delSuccess) { log.warn("清理文件夹失败:{}", tmpDir); } return sortedFilePath; } /** * 使用bufferReader读取大文件内容并分片并提交排序线程 * * @param filePath 大文件路径 * @param tmpDir 切分的小文件路径 * @return 分片好的文件信息列表 * @throws IOException 读取异常抛出 */ private static List<SplitFileDescriptor> splitBigFileAndSubmitSortUseBufferReader(String filePath, String tmpDir) throws IOException { int perFileLines = 20_0000; List<SplitFileDescriptor> splitFiles = new ArrayList<>(); String lineData; SplitFileDescriptor splitFile = null; AtomicLong lineNumberCounter = new AtomicLong(0); // 使用bufferdReader读取文件分片 try(FileReader reader = new FileReader(new File(filePath))) { BufferedReader bufferedReader = new BufferedReader(reader); while ((lineData = bufferedReader.readLine()) != null) { if(lineNumberCounter.get() % perFileLines == 0) { submitSortTask(splitFile); splitFile = rolloverSplitFile(splitFile, lineNumberCounter.get(), perFileLines, tmpDir); splitFiles.add(splitFile); } writeLine(splitFile, lineData, lineNumberCounter); } } // 提交最后一个分片文件排序 submitSortTask(splitFile); return splitFiles; } /** * 读取大文件分片并提交排序(使用apache io组件实现) * * @see #splitBigFileAndSubmitSortUseBufferReader(String, String) */ private static List<SplitFileDescriptor> splitBigFileAndSubmitSortUseApacheIoItr(String filePath, String tmpDir) throws IOException { int perFileLines = 20_0000; List<SplitFileDescriptor> splitFiles = new ArrayList<>(); String lineData; SplitFileDescriptor splitFile = null; AtomicLong lineNumberCounter = new AtomicLong(0); // 使用apache io 类库读取文件分片, 比直接使用 bufferedReader 性能好 LineIterator lineItr = FileUtils.lineIterator(new File(filePath)); try { while (lineItr.hasNext()) { lineData = lineItr.nextLine(); if(lineNumberCounter.get() % perFileLines == 0) { submitSortTask(splitFile); splitFile = rolloverSplitFile(splitFile, lineNumberCounter.get(), perFileLines, tmpDir); splitFiles.add(splitFile); } writeLine(splitFile, lineData, lineNumberCounter); } } finally { LineIterator.closeQuietly(lineItr); } // 提交最后一个分片文件排序 submitSortTask(splitFile); return splitFiles; } /** * 排序指定文件 * * @param splitFile 要排序的分片文件 * @return 排序后的文件位置(可为原地排序) * @throws IOException 读取写入异常时抛出 */ private static String sortSplitFile(SplitFileDescriptor splitFile) throws IOException { String originalFilePath = splitFile.getFullFilePath(); List<String> lines = splitFile.readLineDataBuffer(); if(lines == null) { lines = FileUtils.readLines( new File(originalFilePath), "utf-8"); } lines.sort(String::compareTo); FileUtils.writeLines(new File(originalFilePath), lines); return originalFilePath; } /** * 滚动生成一个新的分片文件 * * @param lastFile 上一个输出的分片文件 * @param currentLineNum 当前总记录行数 * @param perFileLines 单文件可容纳行数 * @param tmpDir 存放临时文件的目录(分片文件) * @return 分片文件的信息 * @throws IOException 文件打开异常抛出 */ private static SplitFileDescriptor rolloverSplitFile(SplitFileDescriptor lastFile, long currentLineNum, int perFileLines, String tmpDir) throws IOException { if(lastFile != null) { lastFile.close(); } int splitFileNo = (int) (currentLineNum / perFileLines); String formattedFileName = String.format("sp_%04d", splitFileNo); return SplitFileDescriptor.newSplit(tmpDir, formattedFileName); } /** * 提交排序任务 * * @param splitFile 单个小分片文件实例 */ private static void submitSortTask(SplitFileDescriptor splitFile) { if(splitFile == null) { return; } Future<?> sortFuture = sortSplitThreadPool.submit(() -> { try { sortSplitFile(splitFile); } catch (IOException e) { log.error("排序单文件时发生了异常" + splitFile.getFullFilePath(), e); } }); splitFile.setFuture(sortFuture); } /** * 合并有序文件 * * @param splitFilePathList 子分片文件列表 * @param outputPath 结果文件存放路径 * @throws IOException 读写异常时抛出 */ public static long mergeSortedFileUseBufferReader(List<String> splitFilePathList, String outputPath) throws IOException { List<BufferedReader> bufferedReaderList = new ArrayList<>(splitFilePathList.size()); splitFilePathList.forEach(r -> { FileReader reader = null; try { reader = new FileReader(new File(r)); BufferedReader buffFd = new BufferedReader(reader); bufferedReaderList.add(buffFd); } catch (FileNotFoundException e) { log.error("文件读取异常", e); } }); String[] onlineDataShards = new String[bufferedReaderList.size()]; int i = 0; for ( ; i < bufferedReaderList.size(); i++ ) { BufferedReader reader = bufferedReaderList.get(i); onlineDataShards[i] = reader.readLine(); } String lastLineData = null; AtomicLong lineNumCounter = new AtomicLong(0); try(OutputStream targetOutput = FileUtils.openOutputStream(new File(outputPath))) { while (true) { int minIndex = 0; for (int j = 1; j < onlineDataShards.length; j++) { // 后一文件已被迭代完成 if(onlineDataShards[j] == null) { continue; } // 最小的文件已被迭代完成 if(onlineDataShards[minIndex] == null) { minIndex = j; continue; } if (onlineDataShards[j].compareTo(onlineDataShards[minIndex]) < 0) { minIndex = j; } } // 所有文件都已迭代完成 if(onlineDataShards[minIndex] == null) { break; } String minData = onlineDataShards[minIndex]; // 去重 if(!minData.equals(lastLineData)) { writeLine(targetOutput, minData, lineNumCounter); lastLineData = minData; } // 迭代下一行 onlineDataShards[minIndex] = bufferedReaderList.get(minIndex).readLine(); } } for (BufferedReader reader : bufferedReaderList) { reader.close(); } return lineNumCounter.get(); } /** * 合并有序文件(使用 apache-io 的 lineIterator) * * @see #mergeSortedFileUseBufferReader(List, String) */ public static long mergeSortedFileUseApacheIoItr(List<String> splitFilePathList, String outputPath) throws IOException { List<LineIterator> bufferedReaderList = new ArrayList<>(splitFilePathList.size()); splitFilePathList.forEach(r -> { try { bufferedReaderList.add( FileUtils.lineIterator(new File(r))); } catch (IOException e) { log.error("文件读取异常", e); } }); String[] onlineDataShards = new String[bufferedReaderList.size()]; int i = 0; for ( ; i < bufferedReaderList.size(); i++ ) { LineIterator reader = bufferedReaderList.get(i); onlineDataShards[i] = reader.nextLine(); } log.info("准备merge文件个数:{}", bufferedReaderList.size()); String lastLineData = null; int lastLineFd = -1; // 第二大的文件,用于二次快速比较大小 int lastSecondBigLineFd = -1; AtomicLong lineNumCounter = new AtomicLong(0); try(OutputStream targetOutput = FileUtils.openOutputStream(new File(outputPath))) { while (true) { int minIndex = 0; String lastSecondBigLineData = lastSecondBigLineFd == -1 ? null : onlineDataShards[lastSecondBigLineFd]; // 比上一次第二小的值还小,则就是当前的最小值没错了 // 第二小的值不那么好找,预留,待后续再完善吧 String newReadLineData = lastLineFd == -1 ? null : onlineDataShards[lastLineFd]; if(newReadLineData != null && (newReadLineData.equals(lastLineData) || (lastSecondBigLineData != null && newReadLineData.compareTo(lastSecondBigLineData) <= 0))) { minIndex = lastLineFd; } else if (lastSecondBigLineData != null) { minIndex = lastSecondBigLineFd; lastSecondBigLineFd = -1; } else { // 重新搜索最小值对应文件 List<Integer> swappedFds = new ArrayList<>(); for (int j = 1; j < onlineDataShards.length; j++) { // 后一文件已被迭代完成 String curShardLineData = onlineDataShards[j]; if(curShardLineData == null) { continue; } // 最小的文件已被迭代完成 if(onlineDataShards[minIndex] == null) { minIndex = j; continue; } if (curShardLineData.compareTo(onlineDataShards[minIndex]) < 0) { swappedFds.add(minIndex); minIndex = j; } // 与上一次最小一样,就不要再往下比较了,是最小没错 if(onlineDataShards[minIndex].equals(lastLineData)) { break; } } } lastLineFd = minIndex; // 所有文件都已迭代完成 if(onlineDataShards[minIndex] == null) { break; } String minData = onlineDataShards[minIndex]; // 去重 if(!minData.equals(lastLineData)) { writeLine(targetOutput, minData, lineNumCounter); log.info("write lineData: " + minData); lastLineData = minData; } // 迭代下一行 LineIterator fdItr = bufferedReaderList.get(minIndex); if(fdItr.hasNext()) { onlineDataShards[minIndex] = fdItr.nextLine(); continue; } onlineDataShards[minIndex] = null; } } int closedFileNum = 0; for (LineIterator reader : bufferedReaderList) { LineIterator.closeQuietly(reader); if(reader != null) { closedFileNum++; } } log.info("关闭分片文件个数:{}", closedFileNum); return lineNumCounter.get(); } /** * 写单行数据到输出流 */ private static void writeLine(SplitFileDescriptor splitFile, String lineData, AtomicLong lineNumCounter) throws IOException { if(splitFile == null) { throw new RuntimeException("分片文件为空"); } splitFile.writeLine(lineData); lineNumCounter.incrementAndGet(); } /** * 写单行数据到输出流 */ private static void writeLine(OutputStream outputStream, String lineData, AtomicLong lineNumCounter) throws IOException { outputStream.write(lineData.getBytes()); outputStream.write("\n".getBytes()); lineNumCounter.incrementAndGet(); } /** * 分片文件描述 */ private static class SplitFileDescriptor { String subFileName; String fullFilePath; OutputStream outputStream; Future<?> future; /** * 用于存放缓冲数据 */ List<String> lineDataBuffer; public SplitFileDescriptor(String mainDir, String subFileName) throws IOException { this.subFileName = subFileName; if(!mainDir.endsWith("/")) { mainDir += "/"; } this.fullFilePath = mainDir + subFileName; } public static SplitFileDescriptor newSplit(String mainDir, String subFileName) throws IOException { return new SplitFileDescriptor(mainDir, subFileName); } public void close() throws IOException { outputStream.close(); } public void setFuture(Future<?> future) { this.future = future; } public String getFullFilePath() { return fullFilePath; } public void writeLine(String lineData) throws IOException { if(lineDataBuffer == null) { lineDataBuffer = new ArrayList<>(); } lineDataBuffer.add(lineData); // outputStream.write(lineData.getBytes()); // outputStream.write("\n".getBytes()); } public Future<?> getFuture() { return future; } /** * 读缓冲数据(单向不可逆) */ public List<String> readLineDataBuffer() { List<String> buf = lineDataBuffer; resetBuffer(); return buf; } /** * 重置缓冲, 避免内存溢出 */ void resetBuffer() { lineDataBuffer = null; } } /** * 简单命命名线程生成工厂类 */ private static class NamedThreadFactory implements ThreadFactory { private AtomicInteger counter = new AtomicInteger(0); private String threadNamePrefix; public NamedThreadFactory(String prefix) { this.threadNamePrefix = prefix; } @Override public Thread newThread(Runnable r) { return new Thread(r, threadNamePrefix + counter.incrementAndGet()); } } }
具体就是,使用apache的io包进行文件读取(底层基于nio),另外,将大文件分片的结果优先写到分片缓冲中,直接丢入排序线程,排序非常快。所以当分片完成时,基本上排序也就完成了。而归并的过程,则是一个插入排序的过程,消耗也主要文件读取io,使用一个lastLineData作为去重的实现,在内容重复度很高时,该操作非常有用。上面的优化,基本可以提供4倍左右的性能,还是不错的。就是会存在一定风险:如当io足够快时,很可能排序线程就跟不上,从而导致内存撑爆了;另外如果外部请求排序的任务较多时,也会导致内容耗光,这都是极其危险的。
单元测试如下:
@Slf4j public class BigFileSortHelperTest { @Test public void testSortFile1() throws IOException { long startTime = System.currentTimeMillis(); log.info("start sort process."); String sortedFilePath = BigFileSortHelper.sortBigFile("D:\\cygwin64\\home\\Administrator\\1-merged.txt"); log.info("sortedFilePath:" + sortedFilePath); log.info("costTime: " + (System.currentTimeMillis() - startTime) + "ms"); } @Test public void testMergeFunc() throws IOException { long startTime = System.currentTimeMillis(); String splitFilePath = "/tmp/sort_1602922717865"; Collection<File> files = FileUtils.listFiles(new File(splitFilePath), TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE); List<String> splitFileList = files.stream().map(File::getPath).collect(Collectors.toList()); long totalLines = BigFileSortHelper.mergeSortedFileUseApacheIoItr( splitFileList, "/tmp/merge0.txt"); log.info("merge costTime:{}, totalLines:{} ", System.currentTimeMillis() - startTime, totalLines); Assert.assertEquals("去重后的文件行数不对", 33, totalLines); } }
以上实现,还是非常棒的,供诸君参考。