• 大文件排序优化实践


      在很多应用场景中,我们都会面临着排序需求,可以说是见怪不怪。我们也看过许多的排序算法:从最简单的冒泡排序、选择排序,到稍微好点的插入排序、希尔排序,再到有点理论的堆排序、快速排序,再到高级的归并排序、桶排序、基数排序。

      而实际工作中我们可能用到的排序有哪些呢?而且,大部分时序,相信大家都是使用一个现有库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("
    ".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("
    ".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("
    ".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);
        }
    }

      以上实现,还是非常棒的,供诸君参考。

      

  • 相关阅读:
    phpstorm+Xdebug断点调试PHP
    解决PHP curl https时error 77(Problem with reading the SSL CA cert (path? access rights?))
    Windows下安装并设置Redis
    PHP使用数据库的并发问题
    PHP 数组排序
    js 异常处理
    nginx php上传大文件的设置(php-fpm)
    第二章 基本数据类型:常见数据类型的方法
    第一章 python介绍、变量、数据类型、流程控制语句等
    python2.x和python3.x的区别
  • 原文地址:https://www.cnblogs.com/yougewe/p/13799483.html
Copyright © 2020-2023  润新知