2021-11-08 杭州 【工作学习遇到的(经典+重点+难点)分享 CASE 1】重复点击提交、产生多笔数据、保持数据只操作一次—->接口幂等性校验
一、工作真实场景 + 常出现场景
- 真实场景:在一次工作中进行成品出库创建成品出库单时,手抖了一下,重复点击了两次确定(提交表单)。结果很神奇的发现居然产生了两笔一模一样的数据(流水号都一样),当时就很懵逼,稍作思考,想想应该是在同一时刻创建了两个出库单。感觉很有意思(因为之前没有遇到过,写代码的时候也没有考虑到这个问题的发生),后面换了一种方式复现场景:提交表单时多次点击Enter按钮,还是会产生多笔重复数据。
- 常见场景:
订单接口, 不能多次创建订单
支付接口, 重复支付同一笔订单只能扣一次钱
支付宝回调接口, 可能会多次回调, 必须处理重复回调
普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次
等等
二、工作解决方案 + 百度解决方案
- 工作:
(1) 在对应业务接口上加上@Transactional,后面发现不能要(只是为了在遇到错误时之前对数据库操作做一个回滚),因为在做相同数据校验的时候去数据库是查不到同比数据的,此时所有代码没走完,事务未提交。所以只能拿掉事务。
(2)在对应的业务代码块加上synchronized (this) {}对业务代码进行代码块的同步锁,锁里面做唯一性校验(查询数据库是否有相同的流水号[流水号是按照英文前缀加上时间戳实现],有的话直接抛异常)。后面发现如果在极端条件下这个判断还是有一定缺陷(时间戳只是精确到秒)。
@Override
public JsonData saveOrUpdate(OutboundProductReq outboundProductReq) {
// 出库单不能为空
if (StringUtils.isBlank(outboundProductReq.getOutboundName())) {
return JsonData.buildResult(BizCodeEnum.STORE_PRODUCT_OUTBOUND_NAME_EMPTY);
}
OutboundProductDO outboundProductDO;
if (outboundProductReq.getId() != null && outboundProductReq.getId() > 0) {
outboundProductDO = outboundProductMapper.selectById(outboundProductReq.getId());
Integer outboundCount = outboundProductDO.getOutboundCount();
// 校验成品库存
JsonData data = checkProductStock(outboundProductReq.getProductId(), outboundProductReq.getOutboundCount(), outboundCount);
if (data.getCode() == 0) {
// 修改
if (outboundProductDO.getOutboundState() == 0) {
BeanUtils.copyProperties(outboundProductReq, outboundProductDO);
outboundProductMapper.updateById(outboundProductDO);
// 修改库存详情表
updateTODetailProduct(outboundProductDO);
// 修改成品冻结数
StockProductDO stockProductDO = stockProductMapper.selectOne(new QueryWrapper<StockProductDO>().eq("product_id", outboundProductDO.getProductId()));
stockProductDO.setFreezeCount(stockProductDO.getFreezeCount() - outboundCount + outboundProductReq.getOutboundCount());
stockProductMapper.updateById(stockProductDO);
} else {
return JsonData.buildResult(BizCodeEnum.STORE_PRODUCT_NOT_MODIFY);
}
} else {
return data;
}
} else {
// 校验成品库存
JsonData data = checkProductStock(outboundProductReq.getProductId(), outboundProductReq.getOutboundCount());
if (data.getCode() == 0) {
// 新增
synchronized (this) {
// 问题:连续点击速度很快时,会出现两条重复记录 解决方案:将以下操作抽取出来做方法,在方法上加事务和锁。
if (outboundProductMapper.selectOne(new QueryWrapper<OutboundProductDO>()
.eq("serial_code", CommonUtil.getCurrentSerialNumber("SN_OUT"))) != null) {
return JsonData.buildResult(BizCodeEnum.STORE_PRODUCT_OUTBOUND_SAME);
}
outboundProductDO = new OutboundProductDO();
BeanUtils.copyProperties(outboundProductReq, outboundProductDO);
outboundProductDO.setOutboundId(UUID.randomUUID().toString());
outboundProductDO.setOutboundState(0);
outboundProductDO.setOutboundType(0);
outboundProductDO.setSerialCode(CommonUtil.getCurrentSerialNumber("SN_OUT"));
outboundProductMapper.insert(outboundProductDO);
// 冻结成品库存
StockProductDO stockProductDO = stockProductMapper.selectOne(new QueryWrapper<StockProductDO>()
.eq("product_id", outboundProductDO.getProductId()));
stockProductDO.setFreezeCount(outboundProductReq.getOutboundCount() + stockProductDO.getFreezeCount());
stockProductMapper.updateById(stockProductDO);
// 保存到库存详情表
saveTODetailProduct(outboundProductDO, OpTypeEnum.OP_LOCK);
}
} else {
return data;
}
}
return JsonData.buildSuccess();
}
- 唯一索引 – 防止新增脏数据
token机制 – 防止页面重复提交
悲观锁 – 获取数据的时候加锁(锁表或锁行)
乐观锁 – 基于版本号version实现, 在更新数据那一刻校验数据
分布式锁 – redis(jedis、redisson)或zookeeper实现
状态机 – 状态变更, 更新数据时判断状态
分享优秀博客
幂等校验