前段时间收到一个优惠券兑换码的需求:管理后台针对一个优惠券发起批量生成兑换码,这些兑换码可以导出分发到各个合作渠道(比如:抖音、京东等),用户通过这些渠道获取到兑换码之后,再登录到我司研发的商城,使用兑换码兑换获得对应的优惠券。
整个需求大致分为两个部分:(1)批量生成兑换码;(2)使用兑换码兑换优惠券。接下来的几篇文章将针对批量生成兑换码功能实现过程中碰到的一系列问题进行分析描述,以便读者再碰到类似问题,可以快速解决。
文章系列如下:
在此之前,先简单介绍商城技术架构:商城后端服务均采用SpringCloud框架开发,数据库主备,商城所有服务共用一个数据库,数据库持久化框架为MybatisPlus,所有服务采用K8s技术进行部署和治理。
通过前面的三篇文章,读者们大概了解了兑换码生成的实现方案。但是在实际环境中测试时发现,基于事务控制的大量的数据插入以及大量数据导出,严重影响商城其他业务(共用一个数据库)。具体问题:
(1)基于事务控制的大量数据导入,严重影响数据库的使用,从而影响与兑换码生成业务交叉的业务。批量插入数据量大,插入速度慢,事务控制时间长,影响业务时间长;
(2)大量兑换码导出到Excel中,直接使用公司框架自带的注解导出方案,导出请求超时异常;
(3)由于兑换码接口采用同步逻辑,接口响应超时,前端超时异常,用户体验差;
本文将对上述问题提出针对性的解决方案,这些方案在网上都有专题说明,本文则是根据实际情况进行分析应用。
首先,我们要提高批量写入BD的效率。由于资源采用虚拟化技术分配,DB性能无法和个人本地的DB相提并论,何况目前大部分的个人电脑都是固态硬盘了。在资源情况不变的前提下,只能通过优化程序来提升插入效率。
原本批量插入使用MybatisPlus自带的saveBatch方法实现,我相信很多小伙伴也一样在用该方法,在数据量不大的情况,该方法的插入效率差异并不明显。一旦遇到大量数据批量写入的话,大家就会发现性能堪忧。网上已经有很多文章在分析并阐述saveBatch方法是伪批量,本文不赘述。
本文直接贴上兑换码批量写入的优化方案吧!
(1)开启批量更新sql重写
在数据库配置的url参数中,增加数据库连接参数:rewriteBatchedStatements=true,该参数能将多条插入记录拼接成一条插入语句,提交执行效率。至于具体原因,将在《关于rewriteBatchedStatements的源码分析》文章中详细阐述。
(2)在Mapper.xml自定义批量插入sql
<insert id="batchInsert" parameterType="java.util.List">
INSERT INTO dh_redeem_code (code, status, coupon_id, batch_id)
VALUES
<foreach collection="list" item="redeemCode" index="index" separator=",">
(
#{redeemCode.code, jdbcType=VARCHAR},
#{redeemCode.status, jdbcType=VARCHAR},
#{redeemCode.couponId, jdbcType=BIGINT},
#{redeemCode.batchId, jdbcType=BIGINT}
)
</foreach>
</insert>
经过上述优化之后,插入效率得到较大提升,具体提升率谁用谁知道(数据量越大,提升效率越明显),呵呵!
另外,saveBatch方法内部会自动分批次写入,默认一批1000条记录。如果自定义了batchInsert方法,就要自己实现分批逻辑,具体一批次多少记录合适,需要测试调整,兑换码插入采用每批次2000条记录。
关于导出Excel功能,框架默认基于注解+阿里巴巴的EasyExcel实现了一套通用方案,在实际使用中,1w以内数据的导出,问题不大,但是超过1w就会出现性能问题。笔者尝试了一次性导出5w条记录,请求经过60s之后,系统直接抛出异常。
本次优化将使用Hutool框架提供的BigExcelWriter实现大量数据导出到Excel,通过代码改造优化10w数据导出在10s以内可以完成输出。具体代码如下:
long startTime=System.currentTimeMillis();
String recordFileName="兑换码导出明细表";
List<DhCode> codeList = dhCodeService.list();
List<Map<String, Object>> rows = codeList.stream().map(item -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("code", item.getCode());
map.put("status", item.getStatus());
return map;
}).collect(Collectors.toList());
try {
ExcelWriter writer = ExcelUtil.getBigWriter(); //注意这里!!!
for(int i=0; i<2; i++) { //设置列宽
writer.setColumnWidth(i, 25);
}
//设置head的名称, 按照导出的顺序, key就是DhCode的属性名称, value就是别名
writer.addHeaderAlias("code", "兑换码");
writer.addHeaderAlias("status", "兑换状态");
writer.write(rows, true);
writer.setOnlyAlias(true);
response.reset();
response.setContentType("application/vnd.ms-excel;charset=utf-8");
response.setHeader("Content-Disposition", "attachment; filename="+ URLEncoder.encode(recordFileName, "UTF-8")+".xlsx");
writer.flush(response.getOutputStream());
writer.close();
long endTime=System.currentTimeMillis();
log.info("Hutool 写入 "+rows.size()+" 条记录耗时 "+(endTime-startTime)/1000 +"秒");
} catch (Exception e) {
//如果导出异常,则生成一个空的文件
log.warn("######导出兑换码excel异常:{}", e);
throw new BusinessException("导出兑换码Excel异常");
}
请注意上述代码的第14行,通过ExcelWriter writer = ExcelUtil.getBigWriter();获取了BigExcelWriter对象执行数据写文件的操作。
3、接口逻辑异步化,取消事务控制
兑换码批量插入效率即使实现了提升,但是对于10w以上的兑换码批量生成也无法在30s内完成,而且这个过程由于事务控制,这个期间会影响相关业务的稳定性和可用性。为此,本节将从两个方面进行优化:
(1)将批量生成过程异步化,具体代码如下:
import cn.hutool.core.thread.ThreadUtil;
public class DhCodeController {
@Resource
private DhCodeService dhCodeService;
@PostMapping("/create")
public R<Boolean> create(@RequestBody @Valid CodeCreateReqDTO codeCreateReqDTO) {
if(dhCodeService.check(codeCreateReqDTO)) {
// 兑换码批量生成异步化
ThreadUtil.execAsync(() -> {
dhCodeService.create(codeCreateReqDTO);
});
return R.ok("兑换码生成成功!");
}
return R.fail("参数校验错误,兑换码生成失败!");
}
}
经过异步化改造之后,经过不同校验过程之后,则快速给前端响应:校验失败或兑换码正在生成中!同时前端页面需要增加一个中间状态显示,通过刷新获取最新的执行情况。
(2)取消事务控制,增加补偿机制
由于create执行时间比较长,事务控制时间也比较长,影响兑换码其他相关业务,所以取消该方法的事务控制。那么,如果取消事务,兑换码批量生成有可能会少生成或者超发的情况。
????????针对这种情况,采取补偿机制:捕获异常,计算剩余未发数量,再执行一次兑换码生成任务。下面代码段的第27-31行实现兑换码补偿机制。
@Override
public void create(CodeCreateReqDTO codeCreateReqDTO) {
StopWatch stopWatch = new StopWatch("兑换码生成");
int actualGenerateNumber; //实际生成数量
int planGenerateNumber = codeCreateReqDTO.getNumber(); //计划生成数量
//生成兑换码并批量入库
stopWatch.start("生成随机code");
List<String> codeList = RedeemCodeUtils.generateRedeemCodes(codeCreateReqDTO.getNumber());
stopWatch.stop();
stopWatch.start("构建对象列表");
List<DhCode> dhCodeList = codeList.stream().map(s -> {
DhCode dhCode = new DhCode();
...
return dhCode;
}).collect(Collectors.toList());
stopWatch.stop();
try {
stopWatch.start("批量写入");
if(!dhCodeService.saveBatch(dhCodeList)) throw new BusinessException(CommonConstants.FAIL, "批量保存兑换码失败!");
stopWatch.stop();
} catch (Exception e) {
log.warn("兑换码生成失败!", e);
actualGenerateNumber = getActualGenerateNumber();
if(actualGenerateNumber < planGenerateNumber) {
codeCreateReqDTO.setNumber(planGenerateNumber - actualGenerateNumber); //设置补充生成数量
log.info("开始补偿生成兑换码");
compensationDhCodeGen(codeCreateReqDTO);
}
}
stopWatch.start("更新数量和状态");
//更新优惠券已生成兑换码数量和未兑换的兑换码数量, 更新兑换码生成记录状态
if(updateDhCodeNumberAndGenerateStatus(number, SUCCESS))) {
log.info("优惠券生成兑换码成功!");
} else {
throw new BusinessException(CommonConstants.FAIL, "更新优惠券兑换码数量或兑换码记录状态失败!");
}
stopWatch.stop();
log.info(stopWatch.prettyPrint(TimeUnit.SECONDS));
}
????????针对该情况,平台运营可以补发优惠券。
????????以上是在优惠券兑换码需求中一些零散的问题和解决方案,以便读者遇到类似问题进行参考实践,感谢大家持续关注!
????????附带兑换码生成工具类下载!