基于SpringBoot+FastExcel的百萬級數(shù)據(jù)導(dǎo)入導(dǎo)出完整方案
本文將詳細(xì)介紹在 Spring Boot 3.5.11 + JDK17 + MySQL 環(huán)境下,使用 FastExcel 實現(xiàn)百萬級數(shù)據(jù)的 導(dǎo)入 和 導(dǎo)出。內(nèi)容包括:
- 數(shù)據(jù)庫表設(shè)計及百萬測試數(shù)據(jù)生成
- 環(huán)境配置與依賴
- 百萬數(shù)據(jù)導(dǎo)入(多線程批量插入)
- 百萬數(shù)據(jù)導(dǎo)出(多線程分頁查詢 + 單線程寫入)
- 線程池、阻塞隊列、CountDownLatch 的設(shè)計思路與原理
- 接口測試方法
一、項目初始化
1.1 創(chuàng)建 Spring Boot 項目
使用 Spring Initializr 創(chuàng)建項目,選擇:
- Spring Boot 3.5.11
- Java 17
- 依賴:Web、MyBatis Framework、MySQL Driver、Lombok
1.2 添加依賴(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.11</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>excel-batch-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Plus (簡化數(shù)據(jù)庫操作,非必需但推薦) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.9</version>
</dependency>
<!-- MySQL 驅(qū)動 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- FastExcel (若無法獲取,可用 EasyExcel 替代,API 類似) -->
<dependency>
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
<version>1.0.0</version> <!-- 請使用官方最新版本 -->
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- HikariCP 連接池 (Spring Boot 默認(rèn)) -->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
二、數(shù)據(jù)庫準(zhǔn)備
2.1 創(chuàng)建圖書表
CREATE DATABASE IF NOT EXISTS `excel_demo` DEFAULT CHARACTER SET utf8mb4;
USE `excel_demo`;
CREATE TABLE `book` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主鍵ID',
`title` VARCHAR(200) NOT NULL COMMENT '書名',
`author` VARCHAR(100) NOT NULL COMMENT '作者',
`price` DECIMAL(10,2) NOT NULL COMMENT '價格',
`publish_date` DATE NOT NULL COMMENT '出版日期',
`isbn` VARCHAR(20) NOT NULL COMMENT 'ISBN編號',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '創(chuàng)建時間'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圖書表';
2.2 生成百萬測試數(shù)據(jù)(用于導(dǎo)出測試)
-- 存儲過程:插入指定行數(shù)的隨機數(shù)據(jù)
DELIMITER $$
CREATE PROCEDURE insert_book_data(IN num_rows INT)
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE batch_size INT DEFAULT 1000;
DECLARE total_batches INT;
DECLARE current_batch INT DEFAULT 0;
SET total_batches = CEIL(num_rows / batch_size);
START TRANSACTION;
WHILE current_batch < total_batches DO
SET i = 0;
WHILE i < batch_size AND (current_batch * batch_size + i) < num_rows DO
INSERT INTO book (title, author, price, publish_date, isbn) VALUES (
CONCAT('Book_', current_batch * batch_size + i),
CONCAT('Author_', FLOOR(RAND() * 1000)),
ROUND(RAND() * 100, 2),
DATE_ADD('2000-01-01', INTERVAL FLOOR(RAND() * 8000) DAY),
CONCAT('978-', LPAD(FLOOR(RAND() * 1000000000), 9, '0'))
);
SET i = i + 1;
END WHILE;
COMMIT;
START TRANSACTION;
SET current_batch = current_batch + 1;
END WHILE;
COMMIT;
END$$
DELIMITER ;
-- 調(diào)用存儲過程插入 100 萬條數(shù)據(jù)
CALL insert_book_data(1000000);
三、實體類與 Mapper
3.1 實體類Book
package com.gxa.testexport.pojo.entity;
import cn.idev.excel.annotation.ExcelProperty;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
@TableName("book")
public class Book {
@TableId(type = IdType.AUTO)
// 不添加 @ExcelProperty,表示忽略 Excel 中的 id 列
private Long id;
@ExcelProperty(index = 1) // Excel 第2列(標(biāo)題行后)
private String title;
@ExcelProperty(index = 2) // Excel 第3列
private String author;
@ExcelProperty(index = 3) // Excel 第4列
private BigDecimal price;
@ExcelProperty(index = 4) // Excel 第5列
private LocalDate publishDate;
@ExcelProperty(index = 5) // Excel 第6列
private String isbn;
}
注意:若您的 Excel 中不包含 id 列,請移除 id 字段上的 @ExcelProperty,并調(diào)整其他字段的 index(從 0 開始)。本例假設(shè) Excel 包含所有 6 列。意思就是添加 了@ExcelProperty,你的Excel就不能有id列。
3.2 Mapper 接口
package com.example.excel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.excel.entity.Book;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface BookMapper extends BaseMapper<Book> {
/**
* 分頁查詢圖書(用于導(dǎo)出)
*/
@Select("SELECT id, title, author, price, publish_date, isbn FROM book ORDER BY id LIMIT #{offset}, #{limit}")
List<Book> selectPage(@Param("offset") long offset, @Param("limit") int limit);
/**
* 查詢總記錄數(shù)(用于導(dǎo)出)
*/
@Select("SELECT COUNT(*) FROM book")
long countAll();
/**
* 批量插入(用于導(dǎo)入)
* MyBatis Plus 提供了 saveBatch 方法,此處也可以自定義批量插入 SQL 提升性能
*/
// 使用 MyBatis Plus 的 saveBatch 即可,此處不需要額外定義
}
四、配置文件 application.yml
spring:
servlet:
multipart:
max-file-size: 100MB # 最大上傳文件大小
max-request-size: 100MB
datasource:
url: jdbc:mysql://localhost:3306/excel_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
username: root
password: yourpassword
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 30 # 根據(jù)線程數(shù)適當(dāng)調(diào)大,避免連接不夠
mybatis-plus:
configuration:
# 開啟 MyBatis 批量提交重寫,提升批量插入性能
rewriteBatchedStatements: true
global-config:
db-config:
# 開啟批量提交
insert-batch-size: 2000
server:
port: 8080
logging:
level:
com.example.excel.mapper: debug # 可選,查看 SQL
五、百萬數(shù)據(jù)導(dǎo)入功能
5.1 設(shè)計思路
- 讀取:使用 FastExcel 的監(jiān)聽器模式(SAX 方式),逐行讀取 Excel,避免內(nèi)存溢出。
- 批處理:每讀取一定數(shù)量(如
BATCH_SIZE = 2000)的記錄,封裝成一個插入任務(wù)提交給線程池。 - 線程池:多個插入任務(wù)并行執(zhí)行,提高數(shù)據(jù)庫寫入速度。
- 阻塞隊列:如果讀取速度遠(yuǎn)快于插入速度,可先將任務(wù)放入隊列,由線程池消費(此處我們直接提交任務(wù),利用線程池的任務(wù)隊列)。
- CountDownLatch:主線程等待所有插入任務(wù)完成后再返回結(jié)果。
- 事務(wù):每個批次使用獨立的事務(wù),避免長事務(wù)。
5.2 導(dǎo)入 Service 接口
package com.example.excel.service;
import org.springframework.web.multipart.MultipartFile;
public interface BookImportService extends IService<Book> {
/**
* 從 Excel 文件導(dǎo)入圖書數(shù)據(jù)
* @param file 上傳的 Excel 文件
* @return 導(dǎo)入結(jié)果統(tǒng)計
*/
ImportResult importBooks(MultipartFile file);
// 導(dǎo)入結(jié)果內(nèi)部類
@Data
@AllArgsConstructor
@NoArgsConstructor
class ImportResult {
private long totalRows; // 總處理行數(shù)
private long successRows; // 成功插入行數(shù)
private long failedRows; // 失敗行數(shù)
private long costTimeMillis; // 耗時
// 構(gòu)造方法、getter/setter 省略(使用 Lombok 或手動添加)
}
}
5.3 導(dǎo)入 Service 實現(xiàn)類
package com.example.excel.service.impl;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.context.AnalysisContext;
import cn.idev.excel.event.AnalysisEventListener;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.excel.entity.Book;
import com.example.excel.mapper.BookMapper;
import com.example.excel.service.BookImportService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
@Service
@Slf4j
public class BookImportServiceImpl extends ServiceImpl<BookMapper, Book> implements BookImportService {
@Autowired
private BookMapper bookMapper;
@Autowired
private PlatformTransactionManager transactionManager;
// 線程池配置:IO密集型任務(wù),核心線程數(shù)適當(dāng)調(diào)大
private static final int CORE_POOL_SIZE = 10;
private static final int MAX_POOL_SIZE = 20;
private static final int QUEUE_CAPACITY = 100;
private static final long KEEP_ALIVE_TIME = 60L;
// 每批插入的數(shù)據(jù)量
private static final int BATCH_SIZE = 2000;
@Override
public ImportResult importBooks(MultipartFile file) {
long startTime = System.currentTimeMillis();
ImportResult result = new ImportResult();
// 創(chuàng)建線程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy() // 當(dāng)線程池滿時,由讀取線程執(zhí)行插入,起到限流作用
);
// 用于統(tǒng)計成功/失敗行數(shù)
AtomicLong successCount = new AtomicLong(0);
AtomicLong failedCount = new AtomicLong(0);
// CountDownLatch 用于等待所有插入任務(wù)完成
// 注意:任務(wù)數(shù)量未知,無法在讀取前確定。因此采用動態(tài)增加計數(shù)的方式。
// 可以使用 AtomicInteger 記錄提交任務(wù)數(shù),再配合 CountDownLatch 的變種?簡單起見,我們使用一個計數(shù)器 + 等待所有任務(wù)完成的機制:
// 方案1:使用一個 List<Future> 收集所有提交的任務(wù),最后遍歷 future.get() 等待完成。
// 方案2:使用 CountDownLatch,但需要提前知道任務(wù)數(shù)??梢栽谧x取過程中先計數(shù)任務(wù)數(shù),但這樣需要兩次讀取?不可取。
// 此處采用方案1:提交任務(wù)時獲得 Future,最后等待所有 Future 完成。
List<Future<?>> futures = new CopyOnWriteArrayList<>();
// 讀取監(jiān)聽器
AnalysisEventListener<Book> listener = new AnalysisEventListener<Book>() {
private final List<Book> batch = new ArrayList<>(BATCH_SIZE);
@Override
public void invoke(Book book, AnalysisContext context) {
batch.add(book);
if (batch.size() >= BATCH_SIZE) {
// 提交插入任務(wù)
List<Book> toInsert = new ArrayList<>(batch);
batch.clear();
Future<?> future = executor.submit(() -> insertBatch(toInsert, successCount, failedCount));
futures.add(future);
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 處理剩余不足一批的數(shù)據(jù)
if (!batch.isEmpty()) {
List<Book> toInsert = new ArrayList<>(batch);
batch.clear();
Future<?> future = executor.submit(() -> insertBatch(toInsert, successCount, failedCount));
futures.add(future);
}
}
};
try {
// 開始讀取 Excel
EasyExcel.read(file.getInputStream(), Book.class, listener).sheet().doRead();
// 等待所有插入任務(wù)完成
for (Future<?> future : futures) {
try {
future.get(); // 阻塞直到任務(wù)完成,若任務(wù)拋出異常,此處會拋出 ExecutionException
} catch (ExecutionException e) {
log.error("插入任務(wù)執(zhí)行異常", e.getCause());
// 異常已在線程內(nèi)記錄,此處可累加失敗計數(shù)(但已在線程內(nèi)記錄,可忽略)
}
}
} catch (IOException e) {
log.error("讀取 Excel 文件失敗", e);
throw new RuntimeException("文件讀取失敗", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("等待插入任務(wù)被中斷", e);
} finally {
// 關(guān)閉線程池
executor.shutdown();
}
long endTime = System.currentTimeMillis();
result.setTotalRows(successCount.get() + failedCount.get());
result.setSuccessRows(successCount.get());
result.setFailedRows(failedCount.get());
result.setCostTimeMillis(endTime - startTime);
log.info("導(dǎo)入完成,總行數(shù):{},成功:{},失?。簕},耗時:{}ms",
result.getTotalRows(), result.getSuccessRows(), result.getFailedRows(), result.getCostTimeMillis());
return result;
}
/**
* 批量插入數(shù)據(jù),使用編程式事務(wù),每個批次獨立事務(wù)
*/
private void insertBatch(List<Book> books, AtomicLong successCount, AtomicLong failedCount) {
if (books.isEmpty()) {
return;
}
// 定義事務(wù)
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
def.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT);
TransactionStatus status = transactionManager.getTransaction(def);
boolean success = false;
try {
// 使用 MyBatis Plus 的批量插入方法(需注意 MP 默認(rèn)批量插入是循環(huán)單條,可開啟 rewriteBatchedStatements 優(yōu)化)
// 或者自定義 XML 使用 foreach 批量插入。此處用 MP 的 saveBatch,需在配置中開啟批量提交。
this.saveBatch(books); // 假設(shè)使用 MP 的 Service 或 Mapper,此處僅為示例,實際需注入 IService
// 若使用純 MyBatis,可自定義 mapper 方法批量插入
transactionManager.commit(status);
success = true;
successCount.addAndGet(books.size());
log.debug("批量插入成功,數(shù)量:{}", books.size());
} catch (Exception e) {
transactionManager.rollback(status);
failedCount.addAndGet(books.size());
log.error("批量插入失敗,數(shù)量:{},錯誤:{}", books.size(), e.getMessage());
}
}
}
注意:上述代碼中 this.saveBatch(books) 是 MyBatis Plus 的批量插入方法,需要注入 IService<Book> 或者使用 SqlSession 直接批量操作。為簡化,可以注入 com.baomidou.mybatisplus.extension.service.IService,或者自定義 Mapper 實現(xiàn)批量插入。這里為了代碼簡潔,假設(shè)已注入 BookMapper 并且 MP 的 saveBatch 可用(需要 BookMapper 繼承 BaseMapper,但 saveBatch 是 IService 的方法,需要注入 ServiceImpl)。實際使用時可以注入 com.baomidou.mybatisplus.extension.service.IService<Book>。
5.4 導(dǎo)入 Controller
package com.example.excel.controller;
import com.example.excel.service.BookImportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/import")
public class ImportController {
@Autowired
private BookImportService bookImportService;
@PostMapping("/books")
public BookImportService.ImportResult importBooks(@RequestParam("file") MultipartFile file) {
return bookImportService.importBooks(file);
}
}
六、百萬數(shù)據(jù)導(dǎo)出功能
(保留之前導(dǎo)出設(shè)計,但稍作整理,確保與導(dǎo)入風(fēng)格一致)
6.1 導(dǎo)出 Service 接口
package com.example.excel.service;
import com.baomidou.mybatisplus.extension.service.IService;
import jakarta.servlet.http.HttpServletResponse;
public interface BookExportService extends IService<Book> {
/**
* 導(dǎo)出百萬圖書數(shù)據(jù)到 Excel
* @param response HTTP響應(yīng)
*/
void exportMillionBooks(HttpServletResponse response);
}
6.2 導(dǎo)出 Service 實現(xiàn)類
package com.gxa.testexport.service.impl;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.excel.mapper.BookMapper;
import com.example.excel.pojo.entity.Book;
import com.example.excel.service.BookExportService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
@Service
@Slf4j
public class BookExportServiceImpl extends ServiceImpl<BookMapper, Book> implements BookExportService {
@Autowired
private BookMapper bookMapper;
// 線程池參數(shù)(根據(jù)服務(wù)器核心數(shù)和IO等待時間調(diào)整)
private static final int CORE_POOL_SIZE = 10;
private static final int MAX_POOL_SIZE = 20;
private static final int QUEUE_CAPACITY = 100;
private static final long KEEP_ALIVE_TIME = 60L;
// 每批查詢數(shù)量(根據(jù)數(shù)據(jù)庫性能和內(nèi)存調(diào)整)
private static final int BATCH_SIZE = 5000;
// 數(shù)據(jù)緩沖隊列(生產(chǎn)者-消費者模式)
private final BlockingQueue<List<Book>> dataQueue = new LinkedBlockingQueue<>(QUEUE_CAPACITY);
// 所有查詢?nèi)蝿?wù)完成標(biāo)志
private volatile boolean queryFinished = false;
// 用于捕獲寫線程異常
private final AtomicReference<Exception> writerException = new AtomicReference<>();
@Override
public void exportMillionBooks(HttpServletResponse response) {
// 設(shè)置響應(yīng)頭
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("百萬圖書導(dǎo)出", StandardCharsets.UTF_8)
.replaceAll("\\+", "%20");
response.setHeader("Content-disposition",
"attachment;filename*=utf-8''" + fileName + ".xlsx");
// 獲取總記錄數(shù)
long totalCount = bookMapper.countAll();
log.info("總記錄數(shù):{}", totalCount);
if (totalCount == 0) {
throw new RuntimeException("無數(shù)據(jù)可導(dǎo)出");
}
// 計算總頁數(shù)
int totalPages = (int) Math.ceil((double) totalCount / BATCH_SIZE);
CountDownLatch queryLatch = new CountDownLatch(totalPages); // 等待所有查詢?nèi)蝿?wù)完成
// 創(chuàng)建線程池(IO密集型,適當(dāng)調(diào)大線程數(shù))
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy() // 當(dāng)線程池滿時由調(diào)用線程執(zhí)行(限流)
);
long startTime = System.currentTimeMillis();
try {
// 啟動消費者線程(單線程寫Excel),傳入 response
Thread writerThread = new Thread(() -> writeDataToExcel(response), "Excel-Writer");
writerThread.start();
// 提交生產(chǎn)者任務(wù)(多線程分頁查詢)
for (int page = 0; page < totalPages; page++) {
int offset = page * BATCH_SIZE;
executor.submit(() -> {
try {
List<Book> books = bookMapper.selectPage(offset, BATCH_SIZE);
// 放入隊列,如果隊列滿則阻塞
dataQueue.put(books);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("查詢?nèi)蝿?wù)被中斷", e);
} finally {
queryLatch.countDown(); // 無論成功失敗,都必須 countDown
}
});
}
// 等待所有查詢?nèi)蝿?wù)完成
queryLatch.await();
// 所有查詢已完成,設(shè)置完成標(biāo)志
queryFinished = true;
// 等待寫線程結(jié)束
writerThread.join();
// 檢查寫線程是否有異常
Exception ex = writerException.get();
if (ex != null) {
throw new RuntimeException("寫Excel過程中發(fā)生異常", ex);
}
long endTime = System.currentTimeMillis();
log.info("導(dǎo)出成功,耗時:{} ms", endTime - startTime);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("導(dǎo)出被中斷", e);
throw new RuntimeException("導(dǎo)出被中斷", e);
} finally {
executor.shutdown();
}
}
/**
* 消費者:從隊列獲取數(shù)據(jù)并寫入Excel(單線程)
* @param response HTTP響應(yīng),用于獲取輸出流
*/
private void writeDataToExcel(HttpServletResponse response) {
OutputStream outputStream = null;
cn.idev.excel.ExcelWriter writer = null;
try {
outputStream = response.getOutputStream();
// 構(gòu)建ExcelWriter(注意:不要用 try-with-resources 自動關(guān)閉流,需手動控制順序)
writer = EasyExcel.write(outputStream, Book.class).build();
WriteSheet writeSheet = EasyExcel.writerSheet("圖書數(shù)據(jù)").build();
while (true) {
// 從隊列取數(shù)據(jù),超時1秒,避免一直阻塞導(dǎo)致無法檢測 finished
List<Book> batch = dataQueue.poll(1, TimeUnit.SECONDS);
if (batch != null) {
// 寫入一批數(shù)據(jù)
writer.write(batch, writeSheet);
} else if (queryFinished && dataQueue.isEmpty()) {
// 所有查詢已完成且隊列為空,退出循環(huán)
break;
}
// 如果隊列為空但查詢尚未完成,繼續(xù)循環(huán)等待
}
// 所有數(shù)據(jù)寫入完成,必須調(diào)用 finish 來刷新并關(guān)閉內(nèi)部資源
writer.finish();
// 此時可以安全關(guān)閉輸出流(但 finish 內(nèi)部可能已關(guān)閉,此處可不顯式關(guān)閉)
outputStream.flush();
} catch (Exception e) {
log.error("寫Excel過程中出錯", e);
// 記錄異常,供主線程判斷
writerException.set(e);
// 如果writer已創(chuàng)建但未finish,嘗試finish以釋放資源(避免內(nèi)存泄漏)
if (writer != null) {
try {
writer.finish();
} catch (Exception ex) {
log.error("關(guān)閉ExcelWriter時出錯", ex);
}
}
// 不要拋出RuntimeException,因為是在子線程中,異常通過 writerException 傳遞
} finally {
// 確保流關(guān)閉(如果未關(guān)閉)
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
log.error("關(guān)閉輸出流時出錯", e);
}
}
}
}
}
6.3 導(dǎo)出 Controller
package com.example.excel.controller;
import com.example.excel.service.BookExportService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/export")
public class ExportController {
@Autowired
private BookExportService bookExportService;
@GetMapping("/books")
public void exportBooks(HttpServletResponse response) {
bookExportService.exportMillionBooks(response);
}
}
七、關(guān)鍵設(shè)計解析
7.1 為什么使用線程池?
- 導(dǎo)入:數(shù)據(jù)庫插入是 IO 密集型操作,多線程并發(fā)插入可以充分利用數(shù)據(jù)庫連接和磁盤 IO,大幅提升吞吐量。
- 導(dǎo)出:數(shù)據(jù)庫查詢同樣是 IO 密集型,多線程分頁查詢可以縮短數(shù)據(jù)獲取時間,配合單線程寫入,實現(xiàn)高效導(dǎo)出。
7.2 線程池參數(shù)選型
| 參數(shù) | 值 | 說明 |
|---|---|---|
| corePoolSize | 10 | 核心線程數(shù),根據(jù) CPU 核心數(shù)和 IO 等待時間估算,通常設(shè)為 CPU 核心數(shù) * (1 + 平均等待時間/計算時間),這里簡單設(shè)為 10 |
| maximumPoolSize | 20 | 最大線程數(shù),避免創(chuàng)建過多線程導(dǎo)致數(shù)據(jù)庫連接耗盡 |
| keepAliveTime | 60s | 空閑線程存活時間 |
| workQueue | LinkedBlockingQueue(100) | 有界隊列,防止任務(wù)無限堆積導(dǎo)致內(nèi)存溢出 |
| rejectedExecutionHandler | CallerRunsPolicy | 當(dāng)線程池和隊列都滿時,由提交任務(wù)的線程(如讀取 Excel 的線程)執(zhí)行插入,實現(xiàn)自然限流 |
7.3 為什么用 CountDownLatch?
- 導(dǎo)入:需要等待所有插入任務(wù)完成后才能返回導(dǎo)入結(jié)果。由于任務(wù)數(shù)量在讀取過程中動態(tài)產(chǎn)生,我們使用
List<Future>來等待每個任務(wù)完成,效果等同于 CountDownLatch。 - 導(dǎo)出:需要等待所有分頁查詢?nèi)蝿?wù)結(jié)束,才能設(shè)置
finished = true,從而讓寫線程正確退出。CountDownLatch 初始化為總頁數(shù),每個查詢?nèi)蝿?wù)完成時調(diào)用countDown(),主線程await()等待所有查詢完成。
7.4 為什么用阻塞隊列?
- 導(dǎo)入:雖然我們直接提交插入任務(wù)給線程池,但線程池內(nèi)部有阻塞隊列,起到緩沖作用。讀取線程不會阻塞,除非線程池隊列滿(此時 CallerRunsPolicy 讓讀取線程自己插入,相當(dāng)于阻塞了讀?。?。
- 導(dǎo)出:使用
BlockingQueue作為數(shù)據(jù)通道,生產(chǎn)者(查詢線程)將數(shù)據(jù)放入隊列,消費者(寫線程)從隊列取出。當(dāng)寫速度慢于查詢速度時,隊列會填滿,生產(chǎn)者put()阻塞,自動調(diào)節(jié)生產(chǎn)速度,防止內(nèi)存溢出。
7.5 事務(wù)處理
- 導(dǎo)入:每個批次使用獨立事務(wù),避免長事務(wù),且失敗批次不會影響其他批次。使用編程式事務(wù)精細(xì)控制。
- 導(dǎo)出:只讀操作,無需事務(wù)。
7.6 數(shù)據(jù)庫連接池配置
- 必須確保
maximum-pool-size大于等于最大線程數(shù),否則線程會因獲取不到連接而阻塞或超時。本例中最大線程 20,連接池設(shè)為 30 比較穩(wěn)妥。
7.7 批量插入性能優(yōu)化
- 使用
rewriteBatchedStatements=true讓 MySQL 驅(qū)動將多條 insert 合并成一條,大幅提升批量插入性能。 - MyBatis Plus 的
saveBatch默認(rèn)也是循環(huán)單條,需要配置rewriteBatchedStatements才能發(fā)揮批量效果。也可以自定義 XML 使用<foreach>拼接成一條 insert 語句,但要注意 SQL 長度限制。
八、接口測試
8.1 導(dǎo)入測試
使用 Postman 或前端上傳文件:
- URL:
http://localhost:8080/import/books - Method: POST
- Body: form-data,key 為
file,選擇包含百萬數(shù)據(jù)的 Excel 文件(可用導(dǎo)出功能先生成一個)
返回示例:
{
"totalRows": 1000000,
"successRows": 1000000,
"failedRows": 0,
"costTimeMillis": 189786
}
- 189,786 毫秒(約 3.16 分鐘)的百萬數(shù)據(jù)導(dǎo)入速度在多數(shù)業(yè)務(wù)場景下屬于良好表現(xiàn),足以滿足日常需求。
- 優(yōu)化建議(導(dǎo)入速度要看硬件或優(yōu)化):
- 增大批量大小:嘗試將 BATCH_SIZE 提升至 5000~10000,觀察數(shù)據(jù)庫響應(yīng)。
- 使用多線程并發(fā)插入:確保數(shù)據(jù)庫連接池支持并發(fā),并注意線程數(shù)不宜超過 CPU 核心數(shù)的 2~3 倍。
- 使用原生批量插入語句:如
INSERT INTO table VALUES (...), (...), ...,代替框架的逐條轉(zhuǎn)換。
注意:本例子導(dǎo)入的Excel可以帶id列
8.2 導(dǎo)出測試
瀏覽器或 Postman 訪問:http://localhost:8080/export/books,將下載名為“百萬圖書導(dǎo)出.xlsx”的文件。
- 導(dǎo)出用時40076 ms ≈ 40秒,處理 1,000,000 條數(shù)據(jù),平均每秒處理約 25,000 條。40秒處理百萬數(shù)據(jù),在大多數(shù)業(yè)務(wù)場景下屬于非???/strong>。
注意:導(dǎo)出Excel的ID不是按順序?qū)С龅?,可以使用對ID列使用排序來觀察是否連續(xù)。
8.3 監(jiān)控
- 查看日志,觀察耗時和線程運行情況。
- 使用 JConsole 或 VisualVM 觀察內(nèi)存、線程狀態(tài)。
- 數(shù)據(jù)庫連接池監(jiān)控(如 Hikari 的 metrics)。
九、總結(jié)
本文完整實現(xiàn)了基于 Spring Boot 3.5.11 + FastExcel 的百萬數(shù)據(jù)導(dǎo)入導(dǎo)出功能:
- 導(dǎo)入:利用監(jiān)聽器逐行讀取,多線程批量插入,獨立事務(wù),確保性能和可靠性。
- 導(dǎo)出:多線程分頁查詢 + 阻塞隊列 + 單線程寫入,內(nèi)存友好且高效。
以上就是基于SpringBoot+FastExcel的百萬級數(shù)據(jù)導(dǎo)入導(dǎo)出完整方案的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot FastExcel百萬級數(shù)據(jù)導(dǎo)入導(dǎo)出的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Mybatis -如何處理clob類型數(shù)據(jù)
這篇文章主要介紹了Mybatis 如何處理clob類型數(shù)據(jù)的操作,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-06-06
Swagger2配置Security授權(quán)認(rèn)證全過程
這篇文章主要介紹了Swagger2配置Security授權(quán)認(rèn)證全過程,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-03-03
JAVA基于SnakeYAML實現(xiàn)解析與序列化YAML
這篇文章主要介紹了JAVA基于SnakeYAML實現(xiàn)解析與序列化YAML,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-12-12
JAVA發(fā)送HTTP請求的多種方式詳細(xì)總結(jié)
目前做項目中有一個需求是這樣的,需要通過Java發(fā)送url請求,查看該url是否有效,這時我們可以通過獲取狀態(tài)碼來判斷,下面這篇文章主要給大家介紹了關(guān)于JAVA發(fā)送HTTP請求的多種方式總結(jié)的相關(guān)資料,需要的朋友可以參考下2023-01-01
SpringBoot中間件ORM框架實現(xiàn)案例詳解(Mybatis)
這篇文章主要介紹了SpringBoot中間件ORM框架實現(xiàn)案例詳解(Mybatis),本篇文章提煉出mybatis最經(jīng)典、最精簡、最核心的代碼設(shè)計,來實現(xiàn)一個mini-mybatis,從而熟悉并掌握ORM框架的涉及實現(xiàn),需要的朋友可以參考下2023-07-07

