java百万级mysql数据导入excel(多线程)
大家好,最近写项目有一个mysql导入excel的需求。
那么今天从下面三个方面来实现这个需求,分别是白嫖navicat功能,poi,easyExcel
其中easyExcel方案会使用多线程加快导入导出效率
navicat
如果不是程序需求的话,可以通过navicat提供的《导出向导》实现。
这里可以选择各种格式的导入导出
选择要导出的表,并指定保存路径,点击《下一步》
选择要导出的字段,一般默认为全部字段
然后一直无脑下一步,点击开始,查看xlxs文件。
那如果想用程序语言在项目中如何实现呢?
poi
POI提供API给Java程序对Microsoft Office格式档案读和写的功能
官方地址:poi.apache.org/overview.ht...
API:poi.apache.org/apidocs/4.1...
maven引入依赖包
xml
<!-- https://mvnrepository.com/artifact/org.apache.poi/poi -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>4.1.2</version>
</dependency>
tips:HSSFWorkbook类 是poi对Microsoft Excel 2003 XLS的支持。也就是.xls文件,而现在 XSSFWorkbook类对.xlxs 进行支持。
所以一般选择4.12版本或之后。
由于easyExcel提供了更强大,更灵活的execl导入导出功能,基本项目里不怎么用poi,所以这里给出一个jdbc+poi来实现的源码。本人已经调试过没有问题。读者可以跟着源码debug一遍,不再赘述。
easyExcel
选择原因
**第一点:内存消耗提升。**由于poi在Excel解压缩时内存消耗依然很大,easyExcel重写底层excel解析,一个3M的excel用POI sax解析依然需要100M左右内存直接降为几M。
第二点:简单易用。easyExcel在上层做了模型转换的封装,让使用者更加简单方便
最近正在对easyExcel源码解析,可以关注我等待更新
github地址:github.com/alibaba/eas...
文档地址:easyexcel.opensource.alibaba.com/docs/curren...
快速上手
单线程写入excel
这里我将UserTest这个domain,通过它的mapper和mabatis的分页插件,按照pageSize=10000来分页查出,并将其write到一个.xlsx的一个个sheet中。
代码如下:
java
@SpringBootTest
public class WriteTest {
private static final Logger LOGGER = LoggerFactory.getLogger(WriteTest.class);
@Resource
private UserTestMapper userTestMapper;
@Test
void simpleWrite() throws IOException {
//单线程插入,每一页是一个sheet插入到一个.xlsx文件中
String fileName = "user.xlsx";
OutputStream outputStream = new FileOutputStream(fileName);
int pageSize=10000;
int pageNum=1;
List<UserTest> data;
int i=0;
long current=System.currentTimeMillis();
try (ExcelWriter excelWriter = EasyExcel.write(outputStream, UserTest.class).build()) {
do {
// 每次都要创建writeSheet 这里注意必须指定sheetNo 而且sheetName必须不一样
WriteSheet writeSheet = EasyExcel.writerSheet(i, "模板" + i).build();
// 分页去数据库查询数据 这里可以去数据库查询每一页的数据
PageHelper.startPage(pageNum,pageSize);
data = userTestMapper.selectByExample(new UserTestExample());
excelWriter.write(data, writeSheet);
pageNum++;
i++;
}while (data.size()>0);
}
long end=System.currentTimeMillis();
System.out.println("耗时:"+(end-current)+"ms");//10w => 2180ms 100w=>23550ms
outputStream.close();
}
结果:
注意这是在@SpringBootTest下。选择ioc注入单例mapper的方式来直接进行操作
这里使用PageHelper为mybatis分页插件
多线程写入excel
由于标题百万数据的量级,若单线程去消耗io速度太慢,可以通过多线程让cpu参与以加快性能。
思路:一百万条数据,分为100个线程去同时分页查,每一页10000条(也就是每个sheet一万条)。
然后等待所有线程查询完毕后,在main线程中调用easyExcel的api将其写入。
流程图:
代码:
java
@Test
void SimpleWriteByThread() throws IOException, InterruptedException {
/**
* 多线程插入:开启100个进程去同时分页查,然后放到一个map中,阻塞起来,然后main去遍历这个map,然后遍历
*/
long current=System.currentTimeMillis();
String fileName = "user.xlsx";
OutputStream outputStream = new FileOutputStream(fileName);
//记录<分页页数,对应数据>的map
Map<Integer, List<UserTest>> map = new HashMap<>();
//定义一个线程执行Service,100个固定的线程池。
ExecutorService executorService = Executors.newFixedThreadPool(100);
long count = userTestMapper.countByExample(new UserTestExample());
int pages=100;
int pageSize= (int) (count/pages);
//开启一个countDownLatch计数器,等到各线程执行完成后再执行main线程
CountDownLatch countDownLatch = new CountDownLatch(pages);
for (int i = 0; i < pages; i++) {
int currentI=i;
executorService.submit(() -> {
//每个线程分页查,并将data put到map中
PageHelper.startPage(currentI+1,pageSize);
List<UserTest> userTests = userTestMapper.selectByExample(new UserTestExample());
map.put(currentI,userTests);
countDownLatch.countDown();
});
}
countDownLatch.await();
//main唤醒,遍历map,将map的key作为sheet数,将value插入各sheet
try (ExcelWriter excelWriter = EasyExcel.write(outputStream, UserTest.class).build()){
for (Map.Entry<Integer,List<UserTest>> entry : map.entrySet()){
Integer pageNum = entry.getKey();
List<UserTest> pageData = entry.getValue();
WriteSheet writeSheet = EasyExcel.writerSheet(pageNum, "模板" + pageNum).build();
excelWriter.write(pageData, writeSheet);
}
}
long end=System.currentTimeMillis();
System.out.println("耗时:"+(end-current)+"ms");//10w => 2345ms 100w => 8809ms
outputStream.close();
}
Q:为什么每个线程分页查出来后就用EasyExcel.writerSheet存入呢?用ThreadLocal也行啊
A:easyExcel的bug,不能多线程异步同时写入execl
结果:上图一致。
单线程读出excel
由于read操作在本人项目没有体现,这里只给出简单实现。
EasyExcel采用监听机制,
当read到一条数据时,会调用invoke方法,在这里你可以进行数据库和逻辑操作,
当一个sheet读完后,会调用doAfterAllAnalysed方法
这里选择单线程批量插入。
代码:
java
@Test
void read1(){
long startTime = System.currentTimeMillis();
EasyExcel.read(fileName, UserTest.class, new UserTestListener(userTestMapper)).sheet().doRead();
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
LOGGER.info("Execution time of read1 method: {} milliseconds", duration);
}
//UserTestListener.java
@Slf4j
public class UserTestListener<U> implements ReadListener<UserTest> {
/**
* 每隔x条存储数据库,实际使用中可以100条,然后清理list ,方便内存回收
*/
private static final int BATCH_COUNT = 1000;
/**
* 缓存的数据
*/
private ThreadLocal<List<UserTest>> cachedDataList = ThreadLocal.withInitial(ArrayList::new);
private ExecutorService executorService = Executors.newSingleThreadExecutor();
/**
* 假设这个是一个DAO,当然有业务逻辑这个也可以是一个service。当然如果不用存储这个对象没用。
*/
private UserTestMapper userTestMapper;
private static AtomicInteger count=new AtomicInteger(1);
/**
* 如果使用了spring,请使用这个构造方法。每次创建Listener的时候需要把spring管理的类传进来
*
* @param userTestMapper
*/
public UserTestListener(UserTestMapper userTestMapper) {
this.userTestMapper = userTestMapper;
}
@Override
public void extra(CellExtra extra, AnalysisContext context) {
ReadListener.super.extra(extra, context);
}
/**
* 所有数据解析完成了 都会来调用
*
* @param context
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
saveData();
log.info("所有数据解析完成!");
}
@Override
public boolean hasNext(AnalysisContext context) {
return ReadListener.super.hasNext(context);
}
/**
* 加上存储数据库
*/
private void saveData() {
if(cachedDataList.get().size()!=0){
userTestMapper.saveBatchByNative(cachedDataList.get());
log.info("第"+count.incrementAndGet()+"次插入:"+cachedDataList.get().size()+"条数据");
}
cachedDataList.get().clear();
}
@Override
public void onException(Exception exception, AnalysisContext context) throws Exception {
ReadListener.super.onException(exception, context);
}
@Override
public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
ReadListener.super.invokeHead(headMap, context);
}
@Override
public void invoke(UserTest userTest, AnalysisContext analysisContext) {
log.info("解析到一条数据:{}", JSON.toJSONString(userTest));
cachedDataList.get().add(userTest);
if (cachedDataList.get().size() >= BATCH_COUNT) {
saveData();
// asyncSaveData();
}
}
}
多线程读出excel
在main线程中,创建100个线程去进行read操作,每个线程都会经历下面的流程:
解析到一条数据 => 达到batchSize后存入数据库 => 解析完成 => 输出日志
实例代码如下:
java
@Test
void read2() throws InterruptedException {
ArrayList<Callable<Object>> tasks = new ArrayList<>();
ExecutorService executorService = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
int finalI = i;
tasks.add(()->{
EasyExcel.read(fileName, UserTest.class, new UserTestListener(userTestMapper)).sheet(finalI).doRead();
return null;
});
}
executorService.invokeAll(tasks);
System.out.println("read2 main线程结束");
}
如果想再性能优化,可以选择在"达到batchSize后存入数据库 "这里想办法
比如创建的这100个子线程中,每一个都会解析 => 保存 => 然后再解析,一直到解析完成。
那其实可以在解析成功后,在这100个子线程中再各自去创建1个子线程去专门执行存入数据库的操作,这样效率最快。
实例代码如下:
java
//userTestListener.java中定义
private void asyncSaveData() {
if(cachedDataList.get().size()!=0){
executorService.execute(new saveTask(cachedDataList.get(),this));
cachedDataList.get().clear();
}
}
static class saveTask implements Runnable{
private List<UserTest> userTests;
private UserTestListener<UserTest> userTestListener;
public saveTask(List<UserTest> userTests, UserTestListener userTestListener) {
this.userTests = userTests;
this.userTestListener = userTestListener;
}
@Override
public void run() {
userTestListener.userTestMapper.saveBatchByNative(userTests);
log.info("第"+count.incrementAndGet()+"次插入:"+userTests.size()+"条数据");
}
}
注意:这样的效果不一定就好到哪里去,和机器cpu核心数和操作系统有关,但我实测是加快30%。
注意:对于上述定义的cachedDataList务必使用ThreadLocal包装,否则各线程会对其数据污染。
分析性能
这里给出笔者在插入单表100万数据导入导出的方案耗时记录
总结
本文介绍了3种mysql表导出excel的方式
- 如果不需要写程序实现则使用现成数据库连接工具,如navicat的导出功能
- 如果想理解excel和mysql表底层数据结构可以使用poi去实现(毕竟easyExcel的底层很多也是poi的东西
- 如果企业项目等建议使用easyExcel,开源,内存占用小,也较安全。