大文件排序优化实践

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

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

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

  

原文地址:https://www.cnblogs.com/yougewe/p/13799483.html