使用多线程+easyexcel实现异步号码导入
需求
最近项目需要实现excel文件上传批量导入号码
实现
通过多线程+easyexcel的方式实战一手。不多说,上代码,欢迎各位大佬指正。
环境
springboot 2.6.13
mybatis-plus 3.4.2
引入依赖
1. 父pom.xml控制版本
<properties>
<easyexcel.version>3.1.1</easyexcel.version>
<commons-io.version>2.7</commons-io.version>
</properties>
<!-- 引入easyexcel依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
<!--commons.io-->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
2. 子模块
<!-- 引入easyexcel依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
3 数据库表
号码表
CREATE TABLE `whitelist_numberdtl` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '号码id',
`work_box_id` bigint(20) unsigned DEFAULT NULL COMMENT '工具箱id',
`number` char(11) DEFAULT NULL COMMENT '号码',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `unq_workbox_num` (`work_box_id`,`number`) USING BTREE COMMENT '工具箱id号码唯一索引'
) ENGINE=InnoDB AUTO_INCREMENT=285 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='白名单号码详情表';
导入记录表
CREATE TABLE `import_num_record` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '号码导入记录id',
`work_box_id` bigint(20) unsigned DEFAULT NULL COMMENT '工具箱id',
`file_name` varchar(255) DEFAULT NULL COMMENT '上传文件名',
`status` tinyint(3) unsigned DEFAULT '0' COMMENT '导入状态(0导入中 1导入成功 2导入失败)',
`result` varchar(500) DEFAULT NULL COMMENT '导入结果',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`create_by` varchar(64) DEFAULT '' COMMENT '创建者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`update_by` varchar(64) DEFAULT '' COMMENT '更新者',
`del_flag` tinyint(4) DEFAULT NULL COMMENT '逻辑删除标志位',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='号码导入记录表';
3.1 controller接口
/**
* 导入号码
*
* @param file 文件
* @param id 工具箱id
* @param type 工具箱类型
* @return
*/
@SneakyThrows
@PostMapping("/importNum")
public AjaxResult importNum(MultipartFile file, Long id, Integer type) {
// 根据id找出工具箱规则,如果该规则导入状态为1,那么不能继续导入
EntWorkBox entWorkBox = entWorkBoxService.getById(id);
if (entWorkBox.getImportStatus() == 1) {
return AjaxResult.error(BizCodeEnum.IMPORTING_NUMBER.getCode(),
BizCodeEnum.IMPORTING_NUMBER.getMsg());
} else {
// 否则置为1后执行号码导入
entWorkBox.setImportStatus(1);
entWorkBoxService.updateById(entWorkBox);
// 执行号码导入
AjaxResult ajaxResult = entWorkBoxService.importNum(file, id, type);
return ajaxResult;
}
}
3.2 service
/**
* 导入号码
* @param file
* @param id
* @param type
* @return
*/
AjaxResult importNum(MultipartFile file, Long id, Integer type);
3.3 serviceImpl
@Override
public AjaxResult importNum(MultipartFile file, Long id, Integer type) {
// 根据类型调用各自的号码导入方法, 0 白名单 1 黑名单 2 空号
AjaxResult result = new AjaxResult();
switch (type) {
case 0:
result = whitelistNumberdtlService.importNum(file, id);
break;
case 1:
result = blacklistNumberdtlService.importNum(file, id);
break;
case 2:
result = vacantNumberdtlService.importNum(file, id);
break;
default:
break;
}
return result;
}
/**
* 批量导入号码
*
* @param file
* @param id
* @return
*/
@SneakyThrows(Exception.class)
@Override
public AjaxResult importNum(MultipartFile file, Long id) {
long start = System.currentTimeMillis();
// 启动线程池导入号码
ThreadPoolExecutor executor = threadPoolTaskExecutor.getThreadPoolExecutor();
CompletableFuture.runAsync(() -> {
EntWorkBox entWorkBox = entWorkBoxMapper.selectById(id);
// 导入号码结果记录
ImportNumRecord importNumRecord = new ImportNumRecord();
// 获取文件名
String filename = file.getOriginalFilename();
// 设置文件名
importNumRecord.setFileName(filename);
// 组装号码导入结果
StringBuilder result = new StringBuilder();
// 设置工具箱id
importNumRecord.setWorkBoxId(id);
importNumRecord.setStatus(0);
importNumRecordMapper.insert(importNumRecord);
//成功信息记录
StringBuilder successMsg = new StringBuilder();
//失败信息记录
StringBuilder failureMsg = new StringBuilder();
//成功条数
AtomicInteger successNum = new AtomicInteger();
//失败条数
AtomicInteger failureNum = new AtomicInteger();
// 查询现有号码集合
List<String> nums = whitelistNumberdtlMapper.getNumByWorkBoxId(id);
// EasyExcel读取excel
try {
EasyExcel.read(file.getInputStream(), WhitelistNumberdtl.class, new ReadListener<WhitelistNumberdtl>() {
private int count = 5000;
/**
* 存储所有非重复号码
*/
private Set<String> cacheDataSet = new HashSet<>();
/**
* 临时存储
*/
private List<WhitelistNumberdtl> cachedDataList = ListUtils.newArrayListWithExpectedSize(count);
/**
* 执行号码读取
*/
@Override
public void invoke(WhitelistNumberdtl data, AnalysisContext context) {
// 如果号码格式错误不予添加
if (!data.getNumber().matches(EntWorkBoxConstants.PATTERN_NUM)) {
// 失败号码数量+1,超过10个只显示10个,未超过则把错误信息拼接起来
failureNum.incrementAndGet();
if (failureNum.get() < 10) {
String msg = "<br/>" + "手机号 " + data.getNumber() + " 导入失败:号码格式有误!";
failureMsg.append(msg);
}
} else {
// 如果号码重复,不予重复添加
if (!nums.contains(data.getNumber()) && !cacheDataSet.contains(data.getNumber())) {
// set放入首次出现的号码
cacheDataSet.add(data.getNumber());
// 把每条号码记录存入list
data.setWorkBoxId(id);
data.setCreateTime(LocalDateTime.now());
// log.info("读取号码{}", data);
cachedDataList.add(data);
// 成功号码数量+1
successNum.incrementAndGet();
// 如果list超过2000,执行保存,完成后清空list缓存数据
if (cachedDataList.size() >= count) {
saveData();
cachedDataList = ListUtils.newArrayListWithExpectedSize(count);
}
} else {
// 失败号码数量+1,超过10个只显示10个,未超过则把错误信息拼接起来
failureNum.incrementAndGet();
if (failureNum.get() < 10) {
String msg = "<br/>" + "手机号 " + data.getNumber() + " 导入失败:号码已存在!";
failureMsg.append(msg);
}
}
}
}
/**
* 最后执行
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 避免所有数据都已经重复,执行空插入,出现sql错误
if (cachedDataList.size() > 0) {
// 保存不足5000条的剩余数据
saveData();
// 清空set
cacheDataSet = new HashSet<>();
}
}
/**
* 存储数据库
*/
private void saveData() {
log.info("{}条数据,开始存储数据库!", cachedDataList.size());
// 批量插入
whitelistNumberdtlMapper.insertBatch(cachedDataList);
for (WhitelistNumberdtl numberdtl : cachedDataList) {
String key = "WHITELIST_"+id+"_"+numberdtl.getNumber();
redisCache.setCacheObject(key, numberdtl.getNumber());
}
log.info("存储数据库/redis成功!");
}
}).sheet().doRead();
// 偏移在其实位置插入记录
successMsg.insert(0, "导入成功共 " + successNum + " 条,数据如下:" + "<br/>");
if (failureNum.get() > 10) {
failureMsg.append("<br/>" + ".....错误号码最多显示10条");
}
// 偏移在其实位置插入记录
failureMsg.insert(0, "导入失败共 " + failureNum + " 条,数据格式不正确,错误如下:");
importNumRecord.setResult(successMsg.toString() + failureMsg.toString());
importNumRecord.setStatus(1);
importNumRecord.setUpdateTime(LocalDateTime.now());
importNumRecordMapper.updateByRecord(importNumRecord);
} catch (Exception e) {
// 异常捕获
log.error("导入出错:{}", e, e.getMessage());
result.append("导入出错:{}" + e.getMessage());
importNumRecord.setStatus(2);
importNumRecord.setResult(result.toString());
importNumRecordMapper.updateByRecord(importNumRecord);
} finally {
// 出现异常,恢复导入号码状态 保证恢复导入状态
entWorkBox.setImportStatus(0);
entWorkBoxMapper.updateById(entWorkBox);
}
long end = System.currentTimeMillis();
log.info("任务执行结束,条数{} 耗时{}", (successNum.get() + failureNum.get()), end - start);
}, executor);
// 线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:暂停3秒钟线程
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
return AjaxResult.success("提交成功");
}
3.3 mapper
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.rc.business.mapper.ImportNumRecordMapper">
<update id="updateByRecord">
update import_num_record
set
work_box_id=#{workBoxId},
file_name=#{fileName},
status=#{status},
result=#{result},
update_time=#{updateTime}
where id=#{id}
</update>
<select id="getRecords" resultType="com.rc.business.domain.ImportNumRecord">
select
imr.id,
imr.work_box_id,
imr.file_name,
imr.status,
imr.result,
imr.create_time
from import_num_record imr
where imr.work_box_id=#{workBoxId}
order by imr.update_time desc
</select>
</mapper>
3.4 AjaxResult
import java.util.HashMap;
import java.util.Map;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.utils.StringUtils;
/**
* 操作消息提醒
*
* @author ruoyi
*/
public class AjaxResult extends HashMap<String, Object>
{
private static final long serialVersionUID = 1L;
/** 状态码 */
public static final String CODE_TAG = "code";
/** 返回内容 */
public static final String MSG_TAG = "msg";
/** 数据对象 */
public static final String result = "result";
/**
* 初始化一个新创建的 AjaxResult 对象,使其表示一个空消息。
*/
public AjaxResult()
{
}
/**
* 初始化一个新创建的 AjaxResult 对象
*
* @param code 状态码
* @param msg 返回内容
*/
public AjaxResult(int code, String msg)
{
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
}
/**
* 初始化一个新创建的 AjaxResult 对象
*
* @param code 状态码
* @param msg 返回内容
* @param data 数据对象
*/
public AjaxResult(int code, String msg, Object data)
{
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
if (StringUtils.isNotNull(data))
{
super.put(result, data);
}
}
/**
* 返回成功消息
*
* @return 成功消息
*/
public static AjaxResult success()
{
return AjaxResult.success("操作成功");
}
/**
* 返回成功数据
*
* @return 成功消息
*/
public static AjaxResult success(Object data)
{
return AjaxResult.success("操作成功", data);
}
/**
* 返回成功消息
*
* @param msg 返回内容
* @return 成功消息
*/
public static AjaxResult success(String msg)
{
return AjaxResult.success(msg, null);
}
/**
* 返回成功消息
*
* @param msg 返回内容
* @param data 数据对象
* @return 成功消息
*/
public static AjaxResult success(String msg, Object data)
{
return new AjaxResult(HttpStatus.SUCCESS, msg, data);
}
/**
* 返回错误消息
*
* @return
*/
public static AjaxResult error()
{
return AjaxResult.error("操作失败");
}
/**
* 返回错误消息
*
* @param msg 返回内容
* @return 警告消息
*/
public static AjaxResult error(String msg)
{
return AjaxResult.error(msg, null);
}
/**
* 返回错误消息
*
* @param msg 返回内容
* @param data 数据对象
* @return 警告消息
*/
public static AjaxResult error(String msg, Object data)
{
return new AjaxResult(HttpStatus.ERROR, msg, data);
}
/**
* 返回错误消息
*
* @param code 状态码
* @param msg 返回内容
* @return 警告消息
*/
public static AjaxResult error(int code, String msg)
{
return new AjaxResult(code, msg, null);
}
public Object getResult() {
return this.get(result);
}
public Object getCodeTag() {
return this.get(CODE_TAG);
}
}
后续优化:
1、当前号码校验方式:表数据带字符正则校验+表重复数据校验+库已存在数据校验,当表的量大时,数据校验慢—》测试,无数据导入十万条耗时13秒+,已存在十万条数据继续插入十万条耗时50秒+;
2、代码可以分块抽取出来