SpringBoot 实现 MySQL 百万级数据量导出并避免 OOM 的解决方案

737 阅读5分钟

一、需求分析

在进行百万级别的数据导出时,我们需要考虑以下几个方面的问题:

  1. 性能问题:对于百万级别的数据导出,性能是一个非常重要的问题。如果性能不够好,导出数据需要的时间就会很长,甚至可能会超时或者导致服务器崩溃。
  2. 内存问题:百万级别的数据导出很容易导致内存溢出(OOM)的问题,这对于系统的稳定性来说是一个很大的威胁。
  3. 用户体验问题:如果用户在导出数据时需要等待很长时间,或者遇到其他问题导致导出失败,这会影响用户的体验。因此,我们需要确保导出过程的流畅性和可靠性。

二、解决方案

为了解决上述问题,我们可以采取以下措施:

  • 分页查询:对于大数据量的导出,我们需要采取分页查询的方式,每次查询一定数量的数据并写入文件,避免一次性查询全部数据导致内存溢出。
  • 多线程处理:为了提高导出性能,我们可以采用多线程方式进行数据导出。
  • 增加缓冲区:在导出大量数据时,增加缓冲区大小也是一种有效的手段,可以避免频繁的IO操作,提高导出效率。
  • 分区导出:在多线程导出数据时,我们可以将数据按照分区进行划分,每个线程负责导出自己所处理的分区数据。这样可以进一步提高导出性能。同时,我们还需要注意线程池的设置,以充分利用系统资源,避免资源浪费。

下面我们来详细介绍如何实现以上措施。

1. 分页查询

在 SpringBoot 中,我们可以使用 MyBatis 或者 JPA 进行分页查询。以 MyBatis 为例,我们可以通过配置分页插件来实现分页查询,如下所示:

<!-- 配置分页插件 -->
<plugins>
  <plugin interceptor="com.github.pagehelper.PageInterceptor">
    <property name="dialect" value="mysql"/>
  </plugin>
</plugins>

在具体的 SQL 语句中,我们可以通过 LIMIT 和 OFFSET 关键字实现分页查询,如下所示:

SELECT * FROM table LIMIT #{offset}, #{pageSize}

其中,offset 表示起始行数,pageSize 表示每页显示的数据条数。我们可以通过设置这两个参数来实现分页查询。

对于大数据量的导出,我们需要将分页查询和文件写入结合起来,每次查询一定数量的数据并写入文件,然后再进行下一次查询,直到所有数据都被查询完毕。具体实现代码如下:

@Service
public class ExportService {

    private final MyBatisMapper myBatisMapper;

    public ExportService(MyBatisMapper myBatisMapper) {
        this.myBatisMapper = myBatisMapper;
    }

    /**
     * 导出数据到文件
     *
     * @param fileName 导出文件名
     * @param pageSize 每页数据条数
     */
    public void exportToFile(String fileName, int pageSize) throws IOException {
        File file = new File(fileName);
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) {
            int offset = 0;
            boolean hasMore = true;
            while (hasMore) {
                List<Data> dataList = myBatisMapper.queryData(offset, pageSize);
                if (dataList.isEmpty()) {
                    hasMore = false;
                } else {
                    for (Data data : dataList) {
                        writer.write(data.toString());
                        writer.newLine();
                    }
                    offset += pageSize;
                }
            }
        }
    }
}

在上述代码中,我们通过 MyBatisMapper 进行数据查询,每次查询 pageSize 条数据,并将数据写入文件。如果查询结果为空,说明所有数据已经导出完毕,此时将 hasMore 设置为 false,结束导出过程。

2. 多线程处理

为了提高导出性能,我们可以采用多线程方式进行数据导出。具体实现代码如下:

@Service
public class ExportService {

    private final MyBatisMapper myBatisMapper;

    public ExportService(MyBatisMapper myBatisMapper) {
        this.myBatisMapper = myBatisMapper;
    }

    /**
     * 导出数据到文件
     *
     * @param fileName 导出文件名
     * @param pageSize 每页数据条数
     * @param threadNum 线程数
     */
    public void exportToFile(String fileName, int pageSize, int threadNum) throws IOException, InterruptedException {
        File file = new File(fileName);
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) {
            ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < threadNum; i++) {
                futures.add(executorService.submit(() -> {
                    int offset = i * pageSize;
                    boolean hasMore = true;
                    while (hasMore) {
                        List<Data> dataList = myBatisMapper.queryData(offset, pageSize);
                        if (dataList.isEmpty()) {
                            hasMore = false;
                        } else {
                            for (Data data : dataList) {
                                writer.write(data.toString());
                                writer.newLine();
                            }
                            offset += pageSize * threadNum;
                        }
                    }
                }));
            }
            for (Future<?> future : futures) {
                future.get();
            }
            executorService.shutdown();
        }
    }
}

在上述代码中,我们通过 ExecutorService 创建了一个线程池,其中线程数由参数 threadNum 指定。对于每个线程,我们将查询起始行数设置为 i * pageSize,并在每次查询时将 offset 增加 pageSize * threadNum,从而实现多线程并发查询和写入文件。需要注意的是,我们使用了 Future 来等待所有线程执行完毕。

3. 增加缓冲区

在导出大量数据时,内存容易不够用,导致 OutOfMemoryError 错误。为了避免这种情况,我们可以增加缓冲区大小,减少写入文件的次数。具体实现代码如下:

@Service
public class ExportService {

    private final MyBatisMapper myBatisMapper;

    public ExportService(MyBatisMapper myBatisMapper) {
        this.myBatisMapper = myBatisMapper;
    }

    /**
     * 导出数据到文件
     *
     * @param fileName 导出文件名
     * @param pageSize 每页数据条数
     * @param threadNum 线程数
     * @param bufferSize 缓冲区大小
     */
    public void exportToFile(String fileName, int pageSize, int threadNum, int bufferSize) throws IOException, InterruptedException {
        File file = new File(fileName);
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(file), bufferSize)) {
            ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < threadNum; i++) {
                futures.add(executorService.submit(() -> {
                    int offset = i * pageSize;
                    boolean hasMore = true;
                    while (hasMore) {
                        List<Data> dataList = myBatisMapper.queryData(offset, pageSize);
                        if (dataList.isEmpty()) {
                            hasMore = false;
                        } else {
                            for (Data data : dataList) {
                                writer.write(data.toString());
                                writer.newLine();
                            }
                            offset += pageSize * threadNum;
                        }
                    }
                }));
            }
            for (Future<?> future : futures) {
                future.get();
            }
            executorService.shutdown();
        }
    }
}

在上述代码中,我们在创建 BufferedWriter 时,通过 bufferSize 指定了缓冲区大小。这样可以减少写入文件的次数,降低了内存的使用。

4. 分区导出

当数据量非常大时,即使使用了多线程和缓冲区,导出数据仍然可能会非常耗时。这时候,我们可以将数据分区导出,每个线程只处理一部分数据,从而提高导出性能。具体实现代码如下:

@Service
public class ExportService {

    private final MyBatisMapper myBatisMapper;

    public ExportService(MyBatisMapper myBatisMapper) {
        this.myBatisMapper = myBatisMapper;
    }

    /**
     * 导出数据到文件
     *
     * @param fileName 导出文件名
     * @param pageSize 每页数据条数
     * @param threadNum 线程数
     * @param bufferSize 缓冲区大小
     * @param partitionNum 分区数
     */
    public void exportToFile(String fileName, int pageSize, int threadNum, int bufferSize, int partitionNum) throws IOException, InterruptedException {
        File file = new File(fileName);
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(file), bufferSize)) {
            ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
List<Future<?>> futures = new ArrayList<>();
         int totalCount = myBatisMapper.countData();
         int pageSizePerPartition = (int) Math.ceil((double) pageSize * threadNum / partitionNum);
         int totalPageNum = (int) Math.ceil((double) totalCount / pageSize);
         int pageNumPerPartition = (int) Math.ceil((double) totalPageNum / partitionNum);
         for (int partitionIndex = 0; partitionIndex < partitionNum; partitionIndex++) {
             int startPageNum = partitionIndex * pageNumPerPartition + 1;
             int endPageNum = Math.min((partitionIndex + 1) * pageNumPerPartition, totalPageNum);
             futures.add(executorService.submit(() -> {
                 for (int pageNum = startPageNum; pageNum <= endPageNum; pageNum++) {
                     int offset = (pageNum - 1) * pageSize;
                     boolean hasMore = true;
                     while (hasMore) {
                         List<Data> dataList = myBatisMapper.queryData(offset, pageSizePerPartition);
                         if (dataList.isEmpty()) {
                             hasMore = false;
                         } else {
                             for (Data data : dataList) {
                                 writer.write(data.toString());
                                 writer.newLine();
                             }
                             offset += pageSizePerPartition * threadNum;
                         }
                     }
                 }
             }));
         }
         for (Future<?> future : futures) {
future.get();
}
executorService.shutdown();
}
}
}

在上述代码中,我们首先计算出每个分区需要处理的页数 pageNumPerPartition。然后,根据分区数 partitionNum 和总页数 totalPageNum,计算出每个分区需要处理的起始页码和结束页码。

接下来,我们在循环中,以每个分区需要处理的页码为循环变量,查询数据并写入文件。由于每个分区只处理自己需要处理的数据,所以导出速度会得到显著提升。