告警:MyBatis-Plus中慎用@Transactional注解,坑的差点被开了...

点击上方“芋道源码”,选择“设为星标

管她前浪,还是后浪?

能浪的浪,才是好浪!

每天 10:33 更新文章,每天掉亿点点头发...

源码精品专栏

 

来源:yes的练级攻略

96f0188c569c59e66d9b0637c8c3546f.jpeg


昨天测试说有个 xx 功能用不了,扔给我一个截图,说有报错:

855cb4cfaad7d1dd19092836a032545f.png

报错信息就是:Transaction rolled back because it has been marked as rollback-only,很好理解:事务被回滚了,因为它已经被标记了只能回滚。

我一看巧了,这不就是我之前分析过的面试题吗!

695c3970780dabb9385473f234a1e0b6.png

之前的文章我解释过:这种错一般发生在嵌套事务中,即内层事务出错,但是由于是否提交事务的操作由外层事务触发,于是乎内层事务只能做个标记,来设置当前事务只能回滚。

紧接着它想抛出错误,但是由于被 try catch 了,于是乎正常执行后续的逻辑,等执行到最后,外层要提交事务了,发现当前事务已经被打了回滚的标记,所以提交失败,报了上面的错。

问题重现

具体原理可以看我之前的那篇文章,这里简单举例下会出错的示例代码:

大致就是下面这个代码调用逻辑,有一个 service 标记了 @Transcational,采用默认的事务传播机制:

4891df21b8bb29b4368508931c9a4afa.png

紧接着 UserService#insert 调用了 addressService#errorInvoker,这个方法也标记了 @Transcational:

a37ec1407aa442a8bff77b93bf5164b0.png

这样一来,只要 addressService#errorInvoker 的调用发生报错,那么必然能重现上面的报错信息。

原理很清晰,我不可能犯这个错。

我信誓旦旦的对测试说:这肯定是老陈写的 bug,与我无关!

bfa12893bef1cc300bf649b413e6b7f6.png

老陈瞄了我一眼:老子已经 2 个月没碰过那个项目了,你扯犊子呢?

随后这个老测试直接把更详细的报错扔了过来,咳咳,涉及公司的类名这里不展示的,反正确实是我的代码....

85f2a4fbf10f79c0685007433733186e.png

但是我还是觉得很不可思议,这部分逻辑是我新写的,我压根就没有使用嵌套事务啊!

大致的代码如下:

@Override
@Transactional(rollbackFor = Exception.class)
public Boolean xxx(xxx dto) {
    list1 = .....;
    try {
          数据库批量保存list1;
    } catch (Exception e) {
        if (e instanceof DuplicateKeyException) {
            //筛选过滤重复 key 的数据
            //打标发送
            数据库批量保存过滤之后的list1;
        }
        ....
    }
    sendToMQ(xxx);
    list2 = .....;
    try {
         数据库批量保存list2;
    } catch (Exception e) {
        if (e instanceof DuplicateKeyException) {
            //筛选过滤重复 key 的数据
            //打标发送
            数据库批量保存过滤之后的list2;
        }
        ...
    }
    sendToMQ(xxx);
    return Boolean.TRUE;
}

这个接口其实是一次性接口,用来补数据的,线上跑过一次后,后面应该不会再使用。

出于保险原则,兼容上游部分数据重复调用,所以我做了重复key的处理,剔除重复的部分,让不重复的部分正常保存。

正常情况下不会出现这个场景,刚好测试环境测试来回折腾有很多重复数据(其实我这样写也是为了兼容测试,随便他折腾)

这里的代码逻辑不复杂,明面上来看,我并没有调用别的 service !也并不存在嵌套事务的问题,所以我思来想去也看不明白。

1b73098a7901238a7d5f452b6b07cb86.png

于是......

我出门放了个水,顺带逛了一圈,接着买了杯咖啡,遇事不决,量子力...个屁,立马屁颠屁颠的跑回来继续看代码。

回来突然就看 try-catch 不爽。

但是 try 里面就是一个  mybatis-plus 的 IService,批量保存数据的操作。

难道它有什么骚操作?点进去一看突然发现:

886f771f17453cd7b508ffce33022067.png

我丢!

唤起了我的记忆,mybatis-plus 为了保证批量保存的事务性,加了 @Transactional

合着我确实没想着使用嵌套事务,但是这被迫上了“贼船”啊!

这本是好意,但是在我这个场景有点麻,它完美的复现了上文提到的那个错误使用,在有重复 key 的场景确实报错了,但是被外层 try-catch 拦住了抛错,不过事务上已经打了失败的标了!

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/ruoyi-vue-pro

  • 视频教程:https://doc.iocoder.cn/video/

解决方案

解决办法其实很简单:

  1. 把 saveBatch 上的 @Transactional 注解删了,很明显我做不到,这是 mybatisplus 的源码。

  2. 把 saveBatch 上的 @Transactional 注解上设置事务传播机制为:REQUIRES_NEW 或 NESTED,很明显,我也做不到,这是 mybatis-plus 的源码。

然后我找了下,好像也没有什么参数可以指定 saveBatch 的事务传播机制。

所以咋办。。。测试还在催我,没办法,只能不用 mybatis-plus 的 saveBatch ,自己通过 mapper 写个批量插入了:

f754c3cc62619b0b4394dc110e18987d.png

一波操作提交代码重启服务,让测试再试试,且轻飘飘的甩一句:这不是我的bug,我被框架坑了。

咳咳,反正我不管,我的代码没有bug,这是程序员最后的倔强。

1d7f21aeba51d11406b056a33b6efeda.png

基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/yudao-cloud

  • 视频教程:https://doc.iocoder.cn/video/

最后

所以在使用三方代码的情况下还是需要多留个心眼点点看。

我记得以前还听说过一个段子,就是有个人用了一个网上的组件,正常情况下都没事,异常情况下,系统就挂了。

后面一找,那个组件在个角落嘎达写了 System.exit

88277b9108dd44da0a8c346ba3b258d5.png

欢迎加入我的知识星球,一起探讨架构,交流源码。加入方式,长按下方二维码噢

658d44135e56ba7d74aa10eda4b1f3db.png

已在知识星球更新源码解析如下:

48aff6d7676b2fbfbf87a4a9bf5f37dc.jpeg

75851ac5e557485829b6c2141fbeb524.jpeg

f0b8ca5ab0212a742f408c0db0c5d92a.jpeg

d42aa3168350b7e75d05d6307cf4af8c.jpeg

最近更新《芋道 SpringBoot 2.X 入门》系列,已经 101 余篇,覆盖了 MyBatis、Redis、MongoDB、ES、分库分表、读写分离、SpringMVC、Webflux、权限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能测试等等内容。

提供近 3W 行代码的 SpringBoot 示例,以及超 4W 行代码的电商微服务项目。

获取方式:点“在看”,关注公众号并回复 666 领取,更多内容陆续奉上。

文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)
@Override @Transactional public void createTemporarySpotCheckOrder(CreateTemporarySpotCheckLo createTemporarySpotCheckLo) { MaintTypeEnum maintType = MaintTypeEnum.DRC; MaintForm maintForm; List<MaintFormEqp> formEqps = new ArrayList<>(); List<MaintFormAction> equipmentActions = new ArrayList<>(); List<MaintFormActionInput> cells = new ArrayList<>(); String eqpCode = createTemporarySpotCheckLo.getEqpCode(); String planEffectTime = createTemporarySpotCheckLo.getPlanEffectTime(); LocalDate planDate = null; LocalDateTime planEffectDate = null; if (StringUtils.isNotEmpty(planEffectTime)) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); planDate = LocalDate.parse(planEffectTime, formatter); planEffectDate = LocalDateTime.parse(planEffectTime, formatter); } String isImedEffect = createTemporarySpotCheckLo.getIsImedEffect(); maintForm = newForm(null, planDate, eqpCode, new MaintPlan(), MaintTypeEnum.TRC, new MaintPlanItem(), null, isImedEffect, planEffectDate); LambdaQueryWrapper<EquipmentCapability> qm = new LambdaQueryWrapper<>(); qm.eq(EquipmentCapability::getEqpCode, eqpCode); qm.eq(EquipmentCapability::getActive, "Y"); EquipmentCapability equipmentCapability = equipmentCapabilityMapper.selectOne(qm); LambdaQueryWrapper<EquipmentDef> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(EquipmentDef::getEqpCode, eqpCode); EquipmentDef equipmentDef = equipmentDefMapper.selectOne(queryWrapper); MaintManual manual = getManual(equipmentDef.getBrandCode(), maintType, equipmentCapability, createTemporarySpotCheckLo.getDescEncode()); if (manual == null) { throw new BusinessException("9999", "该设备不存在已激活的维保手册!"); } //临时点检名称使用手册全名 maintForm.setMaintPlanFullname(manual.getMaintManualFullname()); //临时点检标题使用手册描述 maintForm.setFormTitle(manual.getDescription()); //表单编号和版本 maintForm.setCode(manual.getCode()); maintForm.setVersion(manual.getVersion()); //临时点检备注提示取手册中的 maintForm.setFillinComment(manual.getNotes()); //设置表单类型 maintForm.setFormType(createTemporarySpotCheckLo.getDescEncode()); MaintFormEqp formEqp = new MaintFormEqp(); formEqp.setFormCode(maintForm.getFormCode()); formEqp.setEqpCode(equipmentDef.getEqpCode()); formEqp.setMaintManualFullname(manual.getMaintManualFullname()); formEqp.setMaintType(maintType.name()); formEqp.setSortingOrder(String.valueOf(0)); formEqp.setPeriodConditionCode("UNSCHEDULED"); formEqps.add(formEqp); String maintManualFullname = manual.getMaintManualFullname(); if (StringUtils.isNotBlank(manual.getRefMaintManualName())) { MaintManual refMaintManual = getMaintManualByVersionOrActive(manual.getRefMaintManualName(), manual.getRefMaintManualVersion()); if (null == refMaintManual) { throw new BusinessException("9999", "refMaintManual不存在!"); } maintManualFullname = refMaintManual.getMaintManualFullname(); } // equipment action record List<MaintManualItem> manualItems = getMaintManualItems(maintManualFullname, "UNSCHEDULED"); if (CollectionUtils.isEmpty(manualItems)) { throw new BusinessException("9999", "该设备的激活维保手册没有维护不定期点检项目!"); } List<BasicActionWrapper> basicActions = new ArrayList<>(); for (MaintManualItem manualItem : manualItems) { if (manualItem.getGroupAction()) { List<BasicActionWrapper> actions = getBasicActionsOfGroup(manualItem.getActionName(), manualItem.getActionVersion()); basicActions.addAll(actions); } else { BasicAction action = getBasicActionByNameVersion(manualItem.getActionName(), manualItem.getActionVersion()); BasicAction basicAction = new BasicAction(); try { BeanUtils.copyProperties(action, basicAction); } catch (Exception e) { throw new BusinessException("9999", "事项" + manualItem.getActionName() + "不存在" + (manualItem.getActionVersion().equals(ModelVersion.AA) ? "激活的版本!" : manualItem.getActionVersion() + "版本!")); } // 取manual中的PhotoRequired basicAction.setPhotoRequired(manualItem.getPhotoRequired()); BasicActionWrapper wrapper = new BasicActionWrapper(basicAction, null); basicActions.add(wrapper); } } calculateSortOrderForActions(basicActions); // 计算每个form action的序号 for (int j = 0; j < basicActions.size(); j++) { BasicActionWrapper actionWrapper = basicActions.get(j); MaintFormAction formAction = new MaintFormAction(); formAction.setFormCode(maintForm.getFormCode()); formAction.setEqpCode(formEqp.getEqpCode()); formAction.setBasicActionFullname(actionWrapper.basicAction.getBasicActionFullname()); formAction.setPhotoRequired(actionWrapper.basicAction.getPhotoRequired()); formAction.setGroupActionFullname(actionWrapper.groupActionFullname); formAction.setSortingOrder(actionWrapper.sortingOrder); equipmentActions.add(formAction); // action items(cells) for (int row = 0; row < actionWrapper.basicAction.getTotalRow(); row++) { for (int col = 0; col < actionWrapper.basicAction.getTotalCol(); col++) { MaintFormActionInput cell = new MaintFormActionInput(); cell.setFormCode(maintForm.getFormCode()); cell.setEqpCode(equipmentDef.getEqpCode()); cell.setBasicActionFullname(actionWrapper.basicAction.getBasicActionFullname()); cell.setRow(row); cell.setCol(col); cell.setSortingOrder(formAction.getSortingOrder()); cells.add(cell); } } } // save records to db maintFormMapper.insert(maintForm); LOGGER.info("formEqps:" + formEqps); for (MaintFormEqp maintFormEqp : formEqps) { maintFormEqpMapper.insert(maintFormEqp); } LOGGER.info("equipmentActions:" + equipmentActions); for (MaintFormAction maintFormAction : equipmentActions) { maintFormActionMapper.insert(maintFormAction); } LOGGER.info("cells:" + cells); for (MaintFormActionInput maintFormActionInput : cells) { maintFormActionInputMapper.insert(maintFormActionInput); } //推送消息 LambdaQueryWrapper<SpotCheckPushSetting> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SpotCheckPushSetting::getEqpCode, eqpCode) .eq(SpotCheckPushSetting::getNoticeType, SpotCheckPushSettingEnum.CREATED.getCode()) .eq(SpotCheckPushSetting::getDeleteStatus, "Y"); SpotCheckPushSetting setting = spotCheckPushSettingMapper.selectOne(wrapper); String userNumber = null; //判断是否有维护点检通知 if (setting != null) { //获取周期频率的中文描述使用 //查询定期的维保周期 List<Period> periods = new LambdaQueryChainWrapper<>(periodMapper).list(); //基于维保周期Code将集合转化为map Map<String, Period> periodMap = periods.stream() .collect(Collectors.toMap(Period::getPeriodCode, Function.identity())); Period period = periodMap.get(maintForm.getPeriodCode()); //维保周期没有维护,但是计划里面有相应的定期动作,则提示异常并结束 if (null == period) { LOGGER.error(String.format("The period [%s] is not existed", maintForm.getPeriodCode())); return; } userNumber = setting.getNoticePersonNumber(); String descEncode = createTemporarySpotCheckLo.getDescEncode(); if (descEncode.length() >= "SBGCDJ".length() && descEncode.substring(0, "SBGCDJ".length()).equals("SBGCDJ")) { // 进入这个代码块如果前几位(至少和"SBGCDJ"一样长)是"SBGCDJ" String emailContent = "有一份设备过程点检工单创建成功!" + "<br>表单:"+ maintForm.getFormTitle() + "<br>点检工单编号:" + maintForm.getFormCode() + "<br>设备:" + maintForm.getMaintObject() +"("+ equipmentDef.getDescription()+ ")" + "<br>生成时间:" + maintForm.getCreatedTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + "<br>如有疑请查看:<a href='" + jumpIp + "/platform/#/TM20/login'>PMS设备管理系统</a>" + jumpIp + "/platform/#/TM20/login"; String wechatContent = "有一份设备过程点检工单创建成功!" + "\n表单:"+ maintForm.getFormTitle() + "\n点检工单编号:" + maintForm.getFormCode() + "\n设备:" + maintForm.getMaintObject() + "(" + equipmentDef.getDescription()+ ")" + "\n周期频率:" + maintForm.getPeriodCode() + "(" + period.getDescription() + ")" + "\n生成时间:" + maintForm.getCreatedTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + "\n如有疑请查看:<a href='" + jumpIp + "/platform/#/TM20/login'>PMS设备管理系统</a>"; LogInfo logInfo = new LogInfo(); logInfo.setFormCode(maintForm.getFormCode()); logInfo.setEqpCode(maintForm.getMaintObject()); logInfo.setNoticeType(SpotCheckPushSettingEnum.CREATED.getCode()); logInfo.setUpgradeTime(setting.getUpgradeTime()); emailWeComSendBo.sendMessage(userNumber, "设备过程点检工单创建通知", emailContent, wechatContent, true, true, logInfo); }else { String emailContent = "有一份工艺过程点检工单创建成功!" + "<br>表单:"+ maintForm.getFormTitle() + "<br>点检工单编号:" + maintForm.getFormCode() + "<br>设备:" + maintForm.getMaintObject() +"("+ equipmentDef.getDescription()+ ")" + "<br>生成时间:" + maintForm.getCreatedTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + "<br>如有疑请查看:<a href='" + jumpIp + "/platform/#/TM20/login'>PMS设备管理系统</a>" + jumpIp + "/platform/#/TM20/login"; String wechatContent = "有一份工艺过程点检工单创建成功!" + "\n表单:"+ maintForm.getFormTitle() + "\n点检工单编号:" + maintForm.getFormCode() + "\n设备:" + maintForm.getMaintObject() + "(" + equipmentDef.getDescription()+ ")" + "\n周期频率:" + maintForm.getPeriodCode() + "(" + period.getDescription() + ")" + "\n生成时间:" + maintForm.getCreatedTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + "\n如有疑请查看:<a href='" + jumpIp + "/platform/#/TM20/login'>PMS设备管理系统</a>"; LogInfo logInfo = new LogInfo(); logInfo.setFormCode(maintForm.getFormCode()); logInfo.setEqpCode(maintForm.getMaintObject()); logInfo.setNoticeType(SpotCheckPushSettingEnum.CREATED.getCode()); logInfo.setUpgradeTime(setting.getUpgradeTime()); emailWeComSendBo.sendMessage(userNumber, "工艺过程点检工单创建通知", emailContent, wechatContent, true, true, logInfo); } } }在这个方法中maintFormMapper.insert(maintForm);可以插入成功,请给出我在 /** * 批量保存数据到数据库 */ @Override @Transactional public void batchSaveData(List<MaintForm> forms, List<MaintFormEqp> formEqps, List<MaintFormAction> equipmentActions, List<MaintFormActionInput> cells) { if (!forms.isEmpty()) { if (forms.stream().anyMatch(Objects::isNull)) { throw new IllegalArgumentException("列表中存在null元素"); } forms.forEach(form -> LOGGER.info("Form to insert: {}", form)); for (MaintForm maintForm : forms) { maintFormMapper.insert(maintForm); } // maintFormMapper.maintFormBatchInsert(forms); LOGGER.info("Saved {} forms", forms.size()); } if (!formEqps.isEmpty()) { maintFormEqpMapper.formEqpsBatchInsert(formEqps); LOGGER.info("Saved {} formEqps", formEqps.size()); } if (!equipmentActions.isEmpty()) { maintFormActionMapper.equipmentActionsBatchInsert(equipmentActions); LOGGER.info("Saved {} equipmentActions", equipmentActions.size()); } if (!cells.isEmpty()) { maintFormActionInputMapper.cellsBatchInsert(cells); LOGGER.info("Saved {} cells", cells.size()); } }错误的原因
07-16
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值