前段时间收到一个优惠券兑换码的需求:管理后台针对一个优惠券发起批量生成兑换码,这些兑换码可以导出分发到各个合作渠道(比如:抖音、京东等),用户通过这些渠道获取到兑换码之后,再登录到我司研发的商城,使用兑换码兑换获得对应的优惠券。
整个需求大致分为两个部分:(1)批量生成兑换码;(2)使用兑换码兑换优惠券。接下来的几篇文章将针对批量生成兑换码功能实现过程中碰到的一系列问题进行分析描述,以便读者再碰到类似问题,可以快速解决。
文章系列如下:
在此之前,先简单介绍商城技术架构:商城后端服务均采用SpringCloud框架开发,数据库主备,商城所有服务共用一个数据库,数据库持久化框架为MybatisPlus,所有服务采用K8s技术进行部署和治理。
在《事务失效问题》一文末尾,笔者抛出了一个问题:兑换码生成记录初始状态是【生成中】,事务顺利提交,则该记录状态更新为【成功】。若执行异常导致事务回滚,则该记录状态需要更新为【失败】,该怎么处理?
有读者可能会问,为什么执行异常一定要更新状态为失败呢?不处理不行吗?这里先说说业务逻辑:整个平台需要保证同时只能有一个兑换码生成任务执行,因为兑换码的生成和批量插入比较占用资源。想想如果一次性要生成100w个兑换码并入库。为了实现这个要求,每个任务在check环节会校验是否有状态为【生成中】的任务记录,如果没有,则会插入一条状态为【生成中】的任务记录(5秒内禁止重复提交)。如果兑换码生成异常,事务回滚,没有将【生成中】状态更新为【失败】,则平台无法再次发起兑换码生成任务。
二、问题分析与解决
事务回滚之后进行回调处理涉及的技术方案是:事务同步回调处理逻辑。关键类:TransactionSynchronizationManager,事务同步管理器,监听Spring的事务操作。事务同步管理器通常使用方式如下:
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void beforeCommit(boolean readOnly) {
doSometing1(); //事务提交前处理逻辑
}
@Override
public void beforeCompletion() {
doSometing2(); //事务完成前处理逻辑
}
@Override
public void afterCommit() {
doSometing3(); //事务提交后处理逻辑
}
@Override
public void afterCompletion(int status) {
doSometing4(); //事务完成后处理逻辑
}
});
如果开发人员想在事务提交前、完成前、提交后和完成后,做一些额外的处理工作,则只需要覆盖重写上述4个方法。提交和完成的区别在于:无论commit还是rollback都是完成,TransactionSynchronization类中有三种完成状态定义:0表示事务提交,1表示事务回滚,2表示未知。
/** Completion status in case of proper commit. */
int STATUS_COMMITTED = 0;
/** Completion status in case of proper rollback. */
int STATUS_ROLLED_BACK = 1;
/** Completion status in case of heuristic mixed completion or system errors. */
int STATUS_UNKNOWN = 2;
现在我们回到兑换码生成功能中来,代码需要增加逻辑:如果事务回滚,则将兑换码生成记录状态更为失败。代码如下:
@Transactional(rollbackFor = Exception.class)
@Override
public void create(CodeCreateReqDTO codeCreateReqDTO) {
StopWatch stopWatch = new StopWatch("兑换码生成");
//生成兑换码并批量入库
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();
stopWatch.start("更新数量和状态");
//更新优惠券已生成兑换码数量和未兑换的兑换码数量, 更新兑换码生成记录状态
if(updateDhCodeNumberAndGenerateStatus(number, SUCCESS))) {
log.info("优惠券生成兑换码成功!");
} else {
throw new BusinessException(CommonConstants.FAIL, "更新优惠券兑换码数量或兑换码记录状态失败!");
}
stopWatch.stop();
} catch (Exception e) {
log.warn("兑换码生成失败!", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 这里非常重要,否则注册回调无法生效
}
//注册事务同步回调
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if(TransactionSynchronization.STATUS_ROLLED_BACK == status){
log.warn("========afterCompletion=========事务回滚============");
updateGenerateStatus(FAIL); //更新兑换码生成记录状态为失败
}
}
});
log.info(stopWatch.prettyPrint(TimeUnit.SECONDS));
}
上述代码中,有2个地方需要注意:
(1)第32-35行,这里必须catch所有异常,且不能再往外抛,且增加TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); 只有这样子,注册事务同步回调机制才能生效;
(2)第38-46行,则是具体的事务回滚处理逻辑;
至此,文章开头抛出的问题可以顺利解决。那么细心的读者在看业务逻辑图时,可能带发现一个问题:插入兑换生成记录时如何保证并发安全?从下面的流程图来看,这里是存在并发问题,有可能会同时写入两条记录,那么就不能保证平台同时只有一个兑换码任务在执行了。请读者继续阅读《批量生成任务全局限制唯一》。