如何实现:Excel批量导入几十万用户数据至数据库?
需求描述
生产项目当中管理员需要使用**Excel将大量的用户信息导入至数据库**当中。现网反馈导入速度过慢,需对原有导入逻辑进行代码优化。
问题描述
走读原有项目代码,定位到导入速度过慢问题归为如下几类:
- Excel文件读取:读取采用旧版本的POI实现,逐行进行数据合法性校验,降低文件读取速度;
- 数据校验:用户信息唯一性判断,用户所属用户组信息判断等数据校验时,数据库查询次数过多(导入50w用户就需要查询50w次数据库);
- 数据入库:逐行插入数据库,与数据库交互次数过多;
解决方案
1.采用开源项目EasyExcel进行数据文件读取和错误文件生成,读取速度效果明显,支持xls、xlsx文件自动识别。
2.优化原有数据校验逻辑,将需要判断的数据加载至内存,在内存中完成导入数据的校验工作。
3.采用并行批量导入数据库的方式,加快入库速度。
代码实现
1. 导入相关依赖
<!-- poi -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<!-- easyexcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>2.2.6</version>
</dependency>
2. 实现监听器
package com.test.common.utils;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.capitek.modules.mgmt.entity.ImportUser;
import com.capitek.modules.mgmt.entity.UserAutzEntity;
import com.capitek.modules.mgmt.entity.UserGroupsEntity;
import com.capitek.modules.mgmt.service.UserGroupsService;
import com.capitek.modules.sys.entity.SysUserEntity;
import com.capitek.modules.sys.service.SysUserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
// 有个很重要的点 DemoDataListener 不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去
public class ImportUserDataListener extends AnalysisEventListener<ImportUser> {
private Logger logger = LoggerFactory.getLogger(getClass());
/**
* 每隔规定数量存一次储数据库,然后清理list
*/
private static final int BATCH_COUNT = 100000;
private List<ImportUser> impUserlist = new ArrayList<ImportUser>();
private List<ImportUser> impVaildUserlist = new ArrayList<ImportUser>();
private List<ImportUser> impErrorUserlist = new ArrayList<ImportUser>();
private List<UserAutzEntity> impUserAutzlist = new ArrayList<UserAutzEntity>();
private List<String> permGroupIdList = new ArrayList<>();
private Map<String, String> groupNameIdMap = new HashMap<String, String>();
private Map<String, Integer> usernameRepeat = new HashMap<String, Integer>();
private Map<String, Integer> ipAddrRepeat = new HashMap<String, Integer>();
public ImportUserDataListener() {
}
/**
* 每次创建Listener的时候需要把spring管理的类传进来
*/
public ImportUserDataListener(UserGroupsService userGroupsService, SysUserService sysUserService) {
//查詢所有的用戶組名称和用戶組ID,用于之后的数据校验
List<UserGroupsEntity> groups = userGroupsService.queryAllGroupName();
logger.info("查詢所有的用戶組名称和用戶組ID,用于之后的数据校验!");
for (UserGroupsEntity ug : groups) {
groupNameIdMap.put(ug.getGroupName(), ug.getGroupId());
}
//查询当前登录用户的用户组权限
logger.info("查询当前登录用户的用户组权限!");
SysUserEntity loginUser = ShiroUtils.getUserEntity();
if(!loginUser.getUserId().equals(Constant.SUPER_ADMIN)){
System.out.println("loginUser.getUserId():"+loginUser.getUserId());
permGroupIdList = sysUserService.selectGroupIdList( loginUser.getUserId());
}
}
/**
* 每解析一条数据,调用此方法
* 优化参考:解析数据的同时不要添加任何的判断逻辑,先将数据存入内存,之后进行数据筛选工作
*/
@Override
public void invoke(ImportUser user, AnalysisContext context) {
//将合法有效的用户存入内存
impUserlist.add(user);
}
/**
* 所有数据解析完成之后会调用次方法
* 优化建议:此处完成数据校验、数据入库、错误用户信息文件生成等工作
* @param context
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
logger.info("数据读取完成!共读取数据:{}条。", impUserlist.size());
logger.info("开始校验数据合法性!");
/**
* 遍历内存中的用户数据对象,进行必要的数据校验
*/
for (ImportUser user : impUserlist) {
//TODO:完善并检查属性判断逻辑是否完整
//判断用户名
if (user.getUserName() == null) {
user.setErrorCause("用户名不能为空");
impErrorUserlist.add(user);
continue;
}
//判断导入文件中的用户名username是否重复
if (usernameRepeat.containsKey(user.getUserName())) {
user.setErrorCause("用户名重复,请重新填写");
impErrorUserlist.add(user);
continue;
} else {
usernameRepeat.put(user.getUserName(), 1);
}
//判断导入文件中的静态IP是否重复
if (user.getFramedIpAddress() != null) {
if (ipAddrRepeat.containsKey(user.getFramedIpAddress())) {
user.setErrorCause("静态IP重复,请重新填写");
impErrorUserlist.add(user);
continue;
} else {
ipAddrRepeat.put(user.getFramedIpAddress(), 1);
}
}
//判断导入的用户组是否存在
if (user.getUserGroup() == null || user.getUserGroup().length() == 0) {
user.setGroupId("default");//默认用户组
} else {
if (!groupNameIdMap.containsKey(user.getUserGroup())) {
user.setErrorCause("用户指定的组不存在");
impErrorUserlist.add(user);
continue;
}
user.setGroupId(groupNameIdMap.get(user.getUserGroup()));
}
//判断当前登录账户是否有权限插入填写的用户组
if (!permGroupIdList.isEmpty() && !permGroupIdList.contains(user.getGroupId())) {
user.setErrorCause("当前管理员没有对此用户组的操作权限");
impErrorUserlist.add(user);
continue;
}
//设置用户主键ID标识
user.setUserId(java.util.UUID.randomUUID().toString().replace("-", ""));
//存储通过数据校验的合法用户
impVaildUserlist.add(user);
}
logger.info("数据合法性校验完毕!有效数据{}条,非法数据{}条.", impVaildUserlist.size(), impErrorUserlist.size());
//并行批量插入数据库
logger.info("开始插入数据");
//注意此处使用了lambda语法,java8以上支持
Insert2DBConsumer.insertData(impVaildUserlist(), ImportUserDataListener::insertUserToDBByJdbc);
logger.info("数据成功入库");
//生成导入错误文件
if (!impErrorUserlist.isEmpty()) {
recordErrors();
}
//TODO:清理集合中元素,释放内存
impErrorUserlist.clear();
impVaildUserlist.clear();
impUserlist.clear();
usernameRepeat.clear();
ipAddrRepeat.clear();
logger.info("释放批量导入临时占用内存,导入结束!");
}
/**
* 利用原生JDBC批量存储至数据库
*/
private void insertUserToDBByJdbc(List<ImportUser> impVaildUserlist) {
// 利用JDBC批量导入数据库,注意主库与备库的事务控制
Connection connection = null;
PreparedStatement cmd = null;
private final static String columnStr = "USER_ID,USER_NAME,GROUP_ID,USER_STATUS,CALLING_ID,USER_PASSWORD,CREATE_DATE,EXPIRE_DATE,"
+ "FULL_NAME,CONTACT_ADDR,CONTACT_POSTCODE,CONTACT_EMAIL,CONTACT_TEL,CONTACT_PHONE";
try {
Class.forName("oracle.jdbc.driver.OracleDriver");
connection = DriverManager.getConnection("jdbc:oracle:thin:@192.16.31.25:1521:orcl1", "root", "root");
connection.setAutoCommit(false);
cmd = connection.prepareStatement("insert into USERS(" + columnStr + ") values(?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
for(UsersEntity user: importUsers){
try {
cmd.setString(1, user.getUserId());
cmd.setString(2, user.getUserName());
cmd.setString(3, user.getGroupId());
cmd.setInt(4, user.getUserStatus());
cmd.setString(5, user.getCallingId());
cmd.setString(6, user.getUserPassword());
cmd.setString(7, user.getCreateDate());
cmd.setString(8, user.getExpireDate());
cmd.setString(9, user.getFullName());
cmd.setString(10, user.getContactAddr());
cmd.setString(11, user.getContactPostcode());
cmd.setString(12, user.getContactEmail());
cmd.setString(13, user.getContactTel());
cmd.setString(14, user.getContactPhone());
cmd.addBatch();
} catch (SQLException e) {
e.printStackTrace();
}
connection.commit();
}
} catch (Exception e) {
e.printStackTrace();
try {
if (connection != null) {
connection.rollback();
}
} catch (SQLException e1) {
e1.printStackTrace();
}
} finally {
// 关闭连接
try {
if (cmd != null) {
cmd.close();
}
if (connection != null) {
connection.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
logger.info("存储数据库成功!");
}
/**
* 生成错误文件
*/
private void recordErrors() {
logger.info("正在生成错误文件,无效数据共计{}条。"+impErrorUserlist.size());
String fileName = "G:\\工作目录\\NMCP\\ErrorUsers" + System.currentTimeMillis() + ".xlsx";
// 这里 需要指定写用哪个class去写,然后写到第一个sheet,名字为模板 然后文件流会自动关闭
EasyExcel.write(fileName, ImportUser.class).sheet("模板").doWrite(impErrorUserlist);
logger.info("错误文件生成结束!");
}
}
3. 并行插入方法类
package com.test.common.utils;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* 功能:利用并行流快速插入数据
*
*/
public class Insert2DBConsumer {
/**
* 根据数据库性能调整分批次导入数据的数据条数
*/
private final static int SIZE = 3000;
/**
* 调整并发线程数
*/
static {
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "4");
}
/**
* 分批次调用JDBC插入数据库
*
* @param list 插入的有效数据集合
* @param consumer Java8消费型方法定义,直接使用 类名::方法名 引用的方式
* @param <T> 插入的数据类型
*/
public static <T> void insertData(List<T> list, Consumer<List<T>> consumer) {
if (list == null || list.size() < 1) {
return;
}
List<List<T>> streamList = new ArrayList<>();
for (int i = 0; i < list.size(); i += SIZE) {
int j = Math.min((i + SIZE), list.size());
List<T> subList = list.subList(i, j);
streamList.add(subList);
}
streamList.parallelStream().forEach(consumer);
}
}
4. Excel文件读取
/**
* <p>1. 创建excel对应的实体对象 参照{@link ImportUser}
* <p>2. 由于默认一行行的读取excel,所以需要创建excel一行一行的回调监听器,参照{@link IhmportUserDataListener}
* <p>3. 直接读即可
*/
String fileName = "F://usersdata20w.xlsx";
// 需要指定读用ImportUser.class去读,然后读取第一个sheet 文件流会自动关闭
EasyExcel.read(fileName, ImportUser.class, new ImportUserDataListener()).sheet().doRead();
最后说明
Mybatis也可以实现数据的批量插入方式,参考如下语法:
<insert id="insertList">
insert into table(colom1, colom2)
values
<foreach collection="list" item="item" index="index" separator=",">
( #{item.colom1}, #{item.colom2})
</foreach>
</insert>
经过实际测试,导入相同量的数据,原生JDBC方式的导入速度较快与Mybatis的实现方式。