前段时间收到一个优惠券兑换码的需求:管理后台针对一个优惠券发起批量生成兑换码,这些兑换码可以导出分发到各个合作渠道(比如:抖音、京东等),用户通过这些渠道获取到兑换码之后,再登录到我司研发的商城,使用兑换码兑换获得对应的优惠券。
整个需求大致分为两个部分:(1)批量生成兑换码;(2)使用兑换码兑换优惠券。接下来的几篇文章将针对批量生成兑换码功能实现过程中碰到的一系列问题进行分析描述,以便读者再碰到类似问题,可以快速解决。
文章系列如下:
在此之前,先简单介绍商城技术架构:商城后端服务均采用SpringCloud框架开发,数据库主备,商城所有服务共用一个数据库,数据库持久化框架为MybatisPlus,所有服务采用K8s技术进行部署和治理。
一、问题描述
言归正传,看到需求首先想到的是:一个兑换码兑换一张优惠券,要保证兑换码生成数量的准确性,不能生成少了,也不能多了。因为优惠券既可以被用户领取,也可以生成兑换码分发出去,就会出现一种并发情况:后台一边生成兑换码,用户一边通过平台各种活动领取对应的优惠券。优惠券的可用数量受到这两个情况的影响发生变化。基于以上情况考虑,兑换码生成过程需要进行事务控制。下面是兑换码生成的简略代码:
public class DhCodeController {
@Resource
private DhCodeService dhCodeService;
@PostMapping("/create")
public R<Boolean> create(@RequestBody @Valid CodeCreateReqDTO codeCreateReqDTO) {
dhCodeService.create(codeCreateReqDTO);
return R.ok(Boolean.TRUE);
}
}
public interface DhCodeService extends IService<DhCode> {
/**
* 生成兑换码
* @param codeCreateReqDTO
* @return
*/
void create(CodeCreateReqDTO codeCreateReqDTO);
/**
* 实际生成兑换码处理
* @param codeCreateReqDTO
* @return
*/
void doCreate(CodeCreateReqDTO codeCreateReqDTO);
}
@Service
@Slf4j
public class DhCodeServiceImpl extends ServiceImpl<DhCodeMapper, DhCode> implements DhCodeService {
@Override
public void create(CodeCreateReqDTO codeCreateReqDTO) {
//重复提交拦截
...
//校验剩余可生成数量
...
// 如果优惠券剩余数量 - 未使用兑换码数量 < 本次兑换码生成数量,则抛出异常
...
//批量创建兑换码
doCreate(redeemCodeCreateReqDTO);
}
@Transactional(rollbackFor = Exception.class)
@Override
public void doCreate(CodeCreateReqDTO codeCreateReqDTO) {
StopWatch stopWatch = new StopWatch("兑换码生成");
DhCodeBatch dhCodeBatch = new DhCodeBatch();
...
//写入本次兑换码生成记录
if(dhCodeBatchService.save(dhCodeBatch)) {
//生成兑换码并批量入库
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();
stopWatch.start("批量写入");
if(!dhCodeService.saveBatch(dhCodeList)) throw new BusinessException(CommonConstants.FAIL, "批量保存兑换码失败!");
stopWatch.stop();
stopWatch.start("更新数量和状态");
//更新优惠券已生成兑换码数量和未兑换的兑换码数量, 更新兑换码生成记录状态
if(updateDhCodeNumberAndGenerateStatus(...))) {
log.info("优惠券生成兑换码成功!");
} else {
throw new BusinessException(CommonConstants.FAIL, "更新优惠券兑换码数量或兑换码记录状态失败!");
}
stopWatch.stop();
} else {
throw new BusinessException(CommonConstants.FAIL, "保存兑换码生成记录失败!");
}
log.info(stopWatch.prettyPrint(TimeUnit.SECONDS));
}
}
上述代码中doCreate方法负责实际批量生成兑换码并写入数据,如果执行过程中出现异常,则进行回滚。但是在实际接口调用中,该方法未实现事务回滚,并且数据出现部分提交。由此可知,该方法标注的@Transactional(rollbackFor = Exception.class)未生效。
二、问题分析
一般什么情况会导致事务失效呢?借鉴同行总结的结果(具体信息请移步至本文第四节【参考资料】),如下图所示:
结合上图和代码,细心的读者可能很快就能发现,笔者的代码命中了第三点——方法内部调用。如果一个加了事务控制的方法被同类中的其他方法调用,则事务控制失效。
三、解决方案
针对上述事务控制失效的场景,笔者采用的方法是:调整代码结构,避免事务控制的方法在同类中方法调用。
在兑换码生成功能中,我将create方法实现的逻辑分为两个方法:check和create(删除现在的create方法,将doCreate改名为create方法),然后在Controller中直接调用check和create方法。调整后代码结构如下:
public class DhCodeController {
@Resource
private DhCodeService dhCodeService;
@PostMapping("/create")
public R<Boolean> create(@RequestBody @Valid CodeCreateReqDTO codeCreateReqDTO) {
if(dhCodeService.check(codeCreateReqDTO)) {
dhCodeService.create(codeCreateReqDTO);
return R.ok("兑换码生成成功!");
}
return R.fail("参数校验错误,兑换码生成失败!");
}
}
public interface DhCodeService extends IService<DhCode> {
/**
* 生成兑换码参数校验
* @param codeCreateReqDTO
* @return
*/
boolean check(CodeCreateReqDTO codeCreateReqDTO);
/**
* 生成兑换码处理
* @param codeCreateReqDTO
* @return
*/
void create(CodeCreateReqDTO codeCreateReqDTO);
}
@Service
@Slf4j
public class DhCodeServiceImpl extends ServiceImpl<DhCodeMapper, DhCode> implements DhCodeService {
@Override
public boolean check(CodeCreateReqDTO codeCreateReqDTO) {
//重复提交拦截
...
//校验剩余可生成数量
...
// 如果优惠券剩余数量 - 未使用兑换码数量 < 本次兑换码生成数量,则抛出异常
...
// 校验是否有生成中的兑换码任务,没有则写入一条,有则抛出异常
return Boolean.TRUE;
}
@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();
stopWatch.start("批量写入");
if(!dhCodeService.saveBatch(dhCodeList)) throw new BusinessException(CommonConstants.FAIL, "批量保存兑换码失败!");
stopWatch.stop();
stopWatch.start("更新数量和状态");
//更新优惠券已生成兑换码数量和未兑换的兑换码数量, 更新兑换码生成记录状态
if(updateDhCodeNumberAndGenerateStatus(...))) {
log.info("优惠券生成兑换码成功!");
} else {
throw new BusinessException(CommonConstants.FAIL, "更新优惠券兑换码数量或兑换码记录状态失败!");
}
stopWatch.stop();
log.info(stopWatch.prettyPrint(TimeUnit.SECONDS));
}
}
代码经过调整之后,Controller中的接口方法直接调用create方法,实现事务控制。
但是这里有一个问题:还记得在批量生成兑换码之前写入数据库的生成记录吗?默认状态是生成中,如果事务成功提交,则状态被更新为成功。如果执行异常,事务回滚,这个状态值会变吗?不会变!
如果执行异常,事务回滚,状态更新为失败,怎么办呢?请读者继续阅读《事务同步回调问题分析》。