MyBatis大数据量批量插入或者更新(on duplicate key update)的详细说明以及项目碰坑记录

背景

项目需要把Excel导入,并且支持更新成果简介字段的值。这种Excel模版一共有9个,对应9大奖项,其中双创奖的导入模版如下:
在这里插入图片描述

数据库表设计

主表为t_awards,主键用的是UUID,主要存储合并单元格部分的数据(一级分类、二级分类、获奖年份、成果名称等),通过award_type区分不同奖项;详细表为t_awards_detail,主要存储非合并单元格数据(获奖单位、获奖个人、员工编号等),award_id对应t_awards表的主键。

t_awards表结构如下:
在这里插入图片描述
t_awards_detail表结构如下:
在这里插入图片描述
截图中橙色为主键、绿色为普通索引、红色为联合唯一索引。

实现逻辑

因为Excel中的数据没有唯一列,后端是通过唯一索引去做区分的。大致的实现逻辑如下:

  • 在Controller层通过EasyExcel读出Excel中的数据得到一个list、然后校验数据的合法性。
  • 在Service层先把list分组得到一个map,把map的键封装存入主表,把map的值封装存入详细表。
  • 导入的数据有时候会有几万条,这里使用的批量插入或更新on duplicate key update,避免对数据库进行多次的连接和断开操作。

on duplicate key update

说明

官网原话:

If you specify an ON DUPLICATE KEY UPDATE clause and a row to be
inserted would cause a duplicate value in a UNIQUE index or PRIMARY
KEY, an UPDATE of the old row occurs. For example, if column a is
declared as UNIQUE and contains the value 1, the following two
statements have similar effect:

翻译为中文:
如果指定ON DUPLICATE KEY UPDATE子句,并且要插入的行将导致唯一索引或主键中出现重复值,则会更新旧行。例如,如果列a声明为唯一且包含值1,则以下两条语句具有类似的效果:

1
2
3
INSERT INTO t1 (a,b,c) VALUES (1,2,3)
ON DUPLICATE KEY UPDATE c=c+1;
UPDATE t1 SET c=c+1 WHERE a=1;

If column b is also unique, the INSERT is equivalent to this UPDATE
statement instead:

翻译为中文:
如果列b也是唯一的,则INSERT相当于这个UPDATE语句:

1
UPDATE t1 SET c=c+1 WHERE a=1 OR b=2 LIMIT 1;

一句话总结就是"有就更新,没有就插入"。
官方文档还提到从MySQL 8.0.20开始,不推荐使用VALUES()来引入新行或者列,后续可能还会删除。
官方文档地址

与replace into对比

  • 相同点:插入数据的表必须有主键或者是唯一索引,否则会出现重复数据。
  • 不同点:replace into如果发现表中有相同数据(根据主键或者唯一索引判断)则先删除此行数据,然后再插入新的数据,所以on duplicate key update效率会更高一些。 如果没有相同数据,replace into和on duplicate key update都会插入新数据。

总结

  • 唯一索引不允许两行具有相同的索引值,但是可以都为NULL。所以,int类型可以给字段设置默认值1 varchar类型给字段设置默认值空串;如果已经有null值需要更新下数据。
  • on duplicate key update需要有在INSERT语句中有存在主键或者唯一索引的列,并且对应的数据已经在表中才会执行更新操作。而且如果要更新的字段是主键或者唯一索引,不能和表中已有的数据重复,否则插入更新都失败。
  • 不管是更新还是增加语句都不允许将主键或者唯一索引的对应字段的数据变成表中已经存在的数据。
  • 在并行且开启事务的时候使用on duplicate key update语句会出现死锁。
    参考链接

碰到的问题

  • 数据库设置了自增主键,在使用on duplicate key update进行批量插入的时候,有更新更新时间,影响行肯定大于1条。使用MyBatis的useGeneratedKeys="true"只有第1条数据返回了主键。使用replace into也有类似问题,有时候返回一个主键有时候返回两个主键,而且返回的主键可能是删除了的。
    在这里插入图片描述
    去掉on duplicate key update语句时可以返回所有主键的,不知道为啥。所以要做批量插入且更新的功能不要依赖数据库返回主键
  • 使用uuid作为主键的话,主键是通过Java代码生成,所以找不到主表哪些数据是更新了。目前的做法是不更新旧的详细表数据,也符合产品的需求。

相关代码

Service层的主要代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Override
public int doubleInnovation(List<ImportInnovationField> list) {
String userName = sessionUserApi.getSessionUser().getUsername();
// 1、对list根据多字段进行分组,得到map
Map<String,List<ImportInnovationField>> map = list.stream().collect(
Collectors.groupingBy(
field -> field.getTopClass()+CommonConstant.SEPARATOR_B+field.getSecondClass()+CommonConstant.SEPARATOR_B+field.getAwardYear()+CommonConstant.SEPARATOR_B+field.getAwardProduct()+CommonConstant.SEPARATOR_B+field.getBriefIntroduction()
));
// 2、循环map,把map键插入主表,返回主表主键id;把id封装到map值里面,批量插入从表
Gson gson = new Gson();
List<Awards> awardsList = new ArrayList<>();
List<AwardsDetail> detailList = new ArrayList<>();
for(Map.Entry<String,List<ImportInnovationField>> entrySet: map.entrySet()){
// 2.1 封装Awards并加入awardsList
String[] strs = entrySet.getKey().split(CommonConstant.SEPARATOR_B);
Awards awards = new Awards();
awards.setId(UUID.randomUUID().toString());
awards.setAwardType(AwardsEnum.AwardTypeEnum.INNOVATION_AND_ENTREPRENEURSHIP.getCode());
awards.setTopClass(strs[0]);
awards.setSecondClass(strs[1].equals(CommonConstant.NULL)?null:strs[1]);
awards.setAwardYear(strs[2]);
awards.setAwardProduct(strs[3]);
awards.setBriefIntroduction(strs[4].equals(CommonConstant.NULL)?null:strs[4]);
BigDecimal awardMoney = new BigDecimal(0);
for(ImportInnovationField field: entrySet.getValue()){
awardMoney = awardMoney.add(field.getMoney());
}
awards.setAwardMoney(awardMoney);
awards.setCreateUser(userName);
awards.setLastUpdateUser(userName);
awardsList.add(awards);
// 2.2 封装detailList
for(ImportInnovationField field: entrySet.getValue()){
AwardsDetail awardsDetail = gson.fromJson(gson.toJson(field),AwardsDetail.class);
awardsDetail.setAwardId(awards.getId());
awardsDetail.setCompanyType(setCompanyType(awardsDetail.getCompany()));
awardsDetail.setCreateUser(userName);
awardsDetail.setLastUpdateUser(userName);
detailList.add(awardsDetail);
}
}
// 3、 批量插入成果
batchInsertAwards(awardsList,detailList);
// 4、记录日志
List<String> businessIdList = awardsList.stream().map(Awards::getId).collect(Collectors.toList());
logService.batchInsertLog(businessIdList,"导入获奖成果",LogEnum.TypeEnum.AWARDS.getCode());
return 1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 批量插入获奖成果
private void batchInsertAwards(List<Awards> awardsList,List<AwardsDetail> detailList){
// 1、 把map键批量插入主表,若存在则更新成果简介等信息
try {
awardsImportMapper.batchInsertAwards(awardsList);
}catch (Exception e){
log.error("AwardsImportServiceImpl批量插入主表失败:"+e);
throw new CommonsException(MessageCode.Param_Error, CommonConstant.INSERT_TO_MAIN_TABLE_FAIL,e);

}
// 2、 把map的值批量插入从表
try {
awardsImportMapper.batchInsertAwardsDetail(detailList);
}catch (Exception e){
log.error("AwardsImportServiceImpl批量插入详细表失败:"+e);
throw new CommonsException(MessageCode.Param_Error, CommonConstant.INSERT_TO_FROM_TABLE_FAIL,e);
}
// 3、删除从表数据
// 3.1 从主表中查询出最近5分钟所有id
List<String> idList = awardsImportMapper.queryIdList();
// 3.2 从详细表中查询出最近5分钟所有awardId
List<String> awardIdList = awardsImportMapper.queryAwardIdList();
// 3.3 找出详细表比主表多的awardId(差集awardIdList-idList)用来删除从表数据
List<String> deleteIdList = awardIdList.stream().filter(item -> !idList.contains(item)).collect(toList());
// 3.4 删除详细表数据
if(CollUtil.isNotEmpty(deleteIdList)){
try {
awardsImportMapper.batchDeleteDetail(deleteIdList);
}catch (Exception e){
log.error("AwardsImportServiceImpl批量删除从表失败:"+e);
throw new CommonsException(MessageCode.Param_Error, CommonConstant.INSERT_TO_MAIN_TABLE_FAIL,e);
}
}
}

Dao层主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# id是Java代码生成的UUID,on duplicate key update可以更新主键或者唯一键的值但是不能和已有的数据重复
<insert id="batchInsertAwards">
insert into t_awards(
id, award_type, top_class, second_class, award_year,
award_product, brief_Introduction,award_money, innovation_points, application_effect,
display_diagram, project_data,patent_no, patent_type, apply_date,
organizer,create_user, create_time, last_update_user,update_time
)
values
<foreach collection="list" item="item" separator=",">
(
#{item.id},#{item.awardType},ifnull(#{item.topClass},''),ifnull(#{item.secondClass},''),#{item.awardYear},
ifnull(#{item.awardProduct},''),#{item.briefIntroduction},#{item.awardMoney},#{item.innovationPoints},#{item.applicationEffect},
#{item.displayDiagram},#{item.projectData},ifnull(#{item.patentNo},''),#{item.patentType},#{item.applyDate},
#{item.organizer},#{item.createUser},now(),#{item.lastUpdateUser},now()
)
</foreach>
on DUPLICATE key update brief_Introduction = VALUES(brief_Introduction),organizer = VALUES(organizer),
patent_type = VALUES(patent_type),apply_date = VALUES(apply_date),update_time = now()
</insert>

参考链接1
参考链接2
参考链接3