改状态,你会改吗?你真的会改吗?
企业应用中,涉及到修改数据记录的状态的场景太多了。比如,企业入网后,要审核资质。个人领取任务后,企业管理员要审核领取人。
应用管理系统中,通常是下图这样,在查询列表后有操作按钮来修改数据记录的状态。
点击“通过”/“拒绝”操作,要修改数据记录的status字段。服务端程序逻辑怎么实现呢?
系统采用前后端分离模式。其中,服务端是微服务架构,SpringMVC的WebController通过RPC调用Dubbo服务,来实现业务数据持久化的处理。dubbo服务的持久框架是MybatisPlus。
先定义服务端api接口:
注:下文程序逻辑中所调用的接口方法 taskApplyAPI.updateById,它的实现逻辑是将入参对象映射成entity,然后直接使用 MybatisPlus里的原子方法 BaseMapper#updateById。
程序逻辑v1
@Reference private TaskApplyAPI taskApplyAPI; @PostMapping("/enterprise/taskApply/audit") public Result audit(@RequestBody TaskApplyVO taskApplyVO, HttpServletRequest req){ //当前登录企业 EnterpriseVO enterpriseVo = EnterpriseContext.getEnterpriseVo(); String[] applyIds = taskApplyVO.getApplyIds().split(","); for (String applyId : applyIds) { TaskApplyDTO taskApplyDTO = new TaskApplyDTO(); taskApplyDTO.setApplyId(Long.parseLong(applyId)); taskApplyDTO.setStatus(taskApplyVO.getStatus()); taskApplyDTO.setAuditTime(DateUtils.now()); taskApplyAPI.updateById(taskApplyDTO); } return Result.success("操作成功"); }
其中,taskApplyAPI是远程RPC接口引用。
系统使用了一段时间后,bug出现了————已经审核完了的记录还能再审核。什么情况下会出现这种情况呢?我们且不说。不过看代码逻辑,我们可以发现,在修改一条记录的状态字段之前并没有判断状态字段的初始值(在 待审核 状态下 才能修改为 审核通过or审核拒绝),所以会出现这种情况。
程序逻辑v2
修复上面的bug,我们要加上前置状态判断。
幸好是这种常见的业务处理,fix掉即可。如果是在支付系统里,付款成功的交易还能被改成付款失败,那可就出现资金损失了,哭都没地儿哭去。
@PostMapping("/enterprise/taskApply/audit") public Result audit(@RequestBody TaskApplyVO taskApplyVO, HttpServletRequest req){ //当前登录企业 EnterpriseVO enterpriseVo = EnterpriseContext.getEnterpriseVo(); String[] applyIds = taskApplyVO.getApplyIds().split(","); for (String applyId : applyIds) { TaskApplyDTO taskApplyDTO = taskApplyAPI.getById(applyId); if(TaskApplyStatusEnum.TO_AUDIT != taskApplyDTO.getStatus()){ continue; } taskApplyDTO.setStatus(taskApplyVO.getStatus()); taskApplyDTO.setAuditTime(DateUtils.now()); taskApplyAPI.updateById(taskApplyDTO); } return Result.success("操作成功"); }
系统使用了一段时间,bug出现了————用户对数据记录的修改莫名其妙的丢失了。哈哈,结合上面的代码,我们分析一下,在并发较大的情况下,对这张数据表的同一条记录的多个不同操作请求,比如这里是审核操作,其他地方的信息修改操作,这两个并行的update操作,是不是会出现数据覆盖的情况?是的,因为在rpc调用getById与rpc调用updateById之间是有时间间隔的。两个线程都通过getById取到了相同的数据记录,接着分别变更了不同的字段值然后执行updateById,数据库事务本身是有先后的,后提交的事务会覆盖先提交事务修改的内容。
那怎么改呢?用分布式锁来控制?那可说来话长了。为了减少这种冲突发生的可能性,还是先重构一下我们这个方法的逻辑吧。
程序逻辑v3
修改上面逻辑,updateById 时只update所需字段。
@PostMapping("/enterprise/taskApply/audit") public Result audit(@RequestBody TaskApplyVO taskApplyVO, HttpServletRequest req){ //当前登录企业 EnterpriseVO enterpriseVo = EnterpriseContext.getEnterpriseVo();
String[] applyIds = taskApplyVO.getApplyIds().split(","); for (String applyId : applyIds) { TaskApplyDTO taskApplyDTO = taskApplyAPI.getById(applyId); if(TaskApplyStatusEnum.TO_AUDIT != taskApplyDTO.getStatus()){ continue; } TaskApplyDTO taskApplyDTONew = new TaskApplyDTO(); taskApplyDTONew.setApplyId(Long.parseLong(applyId)); taskApplyDTONew.setStatus(taskApplyVO.getStatus()); taskApplyDTONew.setAuditTime(DateUtils.now()); taskApplyAPI.updateById(taskApplyDTONew); } return Result.success("操作成功"); }
系统使用了一段时间,bug出现了。什么bug?————在企业端和运营端这两端同时审核一条记录,或者是同一个页面在不同窗口打开,一个点击“通过”、一个点击“拒绝”,结果呢,数据库里的状态有时候是审核通过,有时候是审核拒绝。 !!!奔溃了~~
程序逻辑v4
使用状态机幂等。
这属于乐观锁的范畴,将前置状态放在update语句的where条件里,可以保证数据的强一致性。类似于:UPDATE tableXxx SET state=state2 WHERE id=#id# and state=state1
接着说这段程序,我们做个调整,将审核的逻辑从MVC后置封装到RPC服务里。良好的程序设计往往是把逻辑封装起来,利于维护,也利于复用。
@PostMapping("/enterprise/taskApply/audit") public Result audit(@RequestBody TaskApplyVO taskApplyVO, HttpServletRequest req){ //当前登录企业 EnterpriseVO enterpriseVo = EnterpriseContext.getEnterpriseVo();
String[] applyIds = taskApplyVO.getApplyIds().split(","); return taskApplyAPI.audit(applyIds, TaskApplyStatusEnum.of(taskApplyVO.getStatus())); }
下面是远程服务接口TaskApplyAPI#audit的方法实现。可以看出来,每次循环还少了一次读库查询。程序执行的sql是 UPDATE task_apply SET status=?, audit_time=? WHERE apply_id = ? AND status = 'TO_AUDIT'
,即审核的前提是“待审核”,利用状态机实现幂等操作。
@Override @Transactional public Result<Void> audit(String[] applyIdArr, TaskApplyStatusEnum auditStatus) { Assert.notEmpty(applyIdArr);
for (String applyId : applyIdArr) { TaskApply entity = new TaskApply(); entity.setStatus(auditStatus); entity.setAuditTime(DateUtils.now()); taskApplyManager.update(entity, new LambdaQueryWrapper<TaskApply>() .eq(TaskApply::getApplyId, applyId) .eq(TaskApply::getStatus, TaskApplyStatusEnum.TO_AUDIT)); } return Result.success("操作成功"); }
【EOF】
感谢阅读,本文整理用时2:51:00(18:00~20:51)。不足之处,欢迎交流!
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/12554965.html