之前写过一 关于mysql的隔离级别与事务,之后转发了一篇关于mysql中的锁,
这里补充一篇我应用乐悲观锁解决的一个并发项目案例
https://blog.csdn.net/qq_34299694/article/details/105196187 关于mysql中的锁
https://blog.csdn.net/qq_34299694/article/details/105119413 关于关于mysql的隔离级别与事务
先说下我这个项目中关于并发的要求 ,这是一个预约领取补贴的系统,大概预约条件有下:
1 每个用户待审核的预约最大限度只能为3次
2 每个用户提交的待审核预约车架号不能相同
3 每天分5个个时间段,每个时间段有50个名额,周六日,节假日不能预约,预约选择时间必须是当前时间后2天也就是大后台
重点是第3点,1,2可以忽略。
涉及并发的表有2张:
一张是预约时间段表(包括预约日期,时间段,剩余预约名额等)
一张是申请表(这个表包括初审审核,复审审核,即2个审核其中一个处于预约中都算预约中)
这里2个点,1是首先要检测这个人已经提交了多少次待审核的请求,2是扣除该时间段剩余名额。
为何1这里要加上排他锁呢?因为如果这里不加排他锁,假如当同个用户发2个线程进来后(用某些工具,重复提交也有这个可能,这个需要利用redis作下处理,我比较懒让前端处理下,不过这和我加锁是没关系的)都会查到用已经申请的待审核数只有2,那程序往下走就会到了处理2的情况,假如现在2这里存在足够的名额那么是不是就会同时写往数据库写2条申请是否就打破了约定,那么这里加了排他锁,顺便给两个审核状态加上普通索引(保证锁行,提高并发效率,提高数据库吞吐量),就可以保证同一用户每次操作都是一个独立的过程,避免类似幻读产生。
2这里会涉及一个类似脏的读的情况,这里说过一个时间段有名额是有限的那么不同的用户在经过步骤1后来这里就会去读取这个时间段剩余的名额,如果有几个线程同时间来到这里那么很可能就是得到同样的剩余数,那么自减1之后写入数据库就会出现互相覆盖的情况,那么这里要如果处理呢?2种办法 乐悲观锁都可以,具体怎么选可以看下面代码了的注释,(这里要注意一点,我这里查询时间段是根据当天时间和具体时间段去判断一条数据,理论是只要查询的字段都有锁就可以实现行锁,但是由于这里我的当天时间字段是date类型,经过手动命令行实验,查询一个加了索引的时间(=匹配)依然会锁表,这里我采取了先差出该数据(普通查询,innodb默认不加任何锁)在根据查出的id进行查询就可以避免排它锁,锁表了,(如果你是乐观锁(版本号),可以忽略这个)
@Override
@Transactional
public Result saveApply(String token, ApplySaveDTO dto) {
Integer memberId =JWTUtils.getUserId(token);
boolean authCode = checkAuthCode(dto.getMobile().trim(), dto.getCode().trim(), OperateEnum.COMMIT_APPLY.getOperate());
if(!authCode){
return ResultUtil.error(ResultEnum.INVALID_CODE.getCode(), ResultEnum.INVALID_CODE.getMsg());
}
//判断日期是否合法
Result result=judgeDateIsOk(dto);
if(result!=null){
return result;
}
//每个用户待审核的预约最大限度只能为3次(前提),使用死锁防止同一用户使用某些不正规渠道进行数据大量提交,导致大量名额给其占据和打破(前提)限制,其实就防止类似幻读,此部分在现实操作中会存在锁的效率问题,
// 因为其会锁住该学员所有数据,那后面来的都会得不到锁,导致数据库可用链接被频繁占用最终退款服务器,当然我们这里主要保证数据库在并发下数据的完整性,当然设当的锁也可以提高并发效率
List<Apply> applies=applyComplexMapper.selectAppointmentApplyByMemberIdForUpdate(memberId);
if(applies.size()>=3){
return ResultUtil.error(ResultEnum.HANDLE_FAILURE.getCode(),"抱歉每个用户待审核的预约最大限度只能为3次");
}
//每个用户提交的待审核预约车架号不能相同
List<Apply> vinApplies=applyComplexMapper.selectTheSameVinApplyByVin(dto.getVin().toUpperCase().trim());
if(!vinApplies.isEmpty()){
return ResultUtil.error(ResultEnum.HANDLE_FAILURE.getCode(),"抱歉系统中已存在相同的车架号");
}
//获取预约时间段
ApplyQuotaExample applyQuotaExample=new ApplyQuotaExample();
applyQuotaExample.createCriteria().andDelEqualTo(Constant.UN_DEL).andAppointmentDateEqualTo(dto.getAppointmentTime()).andTimeQuantumEqualTo(dto.getApplyTime());
List<ApplyQuota> applyQuotas=applyQuotaMapper.selectByExample(applyQuotaExample);
ApplyQuota applyQuota=null;
if(!applyQuotas.isEmpty()){
applyQuota=applyQuotas.get(0);
}else {
return ResultUtil.error(ResultEnum.HANDLE_FAILURE.getCode(),"抱歉查无该预约时间段");
}
/*关键部分---乐观锁和死锁处理皆可,要看情况而订,当前情况应该使用死锁,
因为当前考虑并发会有但不会太大,且名额不多,我们对当前时间段的修改时十分频繁的(写多读少),如果使用乐观锁去频繁尝试写数据那么对数据库的压力是比较大的(当前情况可以忽略事务回滚的影响),
而死锁能确保拿倒锁的线程成功修改数据,当名额用户后就会直接给用户返回名额已经用完,如果是名额多,且用户并发大那么就应该用乐观,避免大量数据链接等待锁,活活把数据库拖垮
*/
//0-死锁模式 1-乐观锁模式
try {
concurrentProcessing(0,applyQuota.getId());
}catch (CustomException c){
if(c.getCode().equals(5011)) {
return ResultUtil.error(ResultEnum.REQUEST_FAILURE_NO_QUOTA);
}else {
throw c;
}
}catch (Exception e){
throw e;
}
/*关键部分-结束*/
//插入申请记录
String newEnergyNum= UUIDUtil.getNewEnergyNum();
Apply newApply=new Apply(newEnergyNum,memberId,dto.getMobile(),dto.getName(),dto.getIdCard(),dto.getVin().toUpperCase(),dto.getLicensingTime(),new BigDecimal(dto.getCarMoney().doubleValue()),dto.getBank(),
dto.getBankNum(),dto.getAppointmentTime(),dto.getApplyTime());
applyMapper.insert(newApply);
return ResultUtil.success();
}
//这个是采取2种锁的具体实现
private void concurrentProcessing(Integer type,Integer applyQuotaId){
//死锁,防止多线程并发下出现脏读
if(type.equals(0)){
ApplyQuota lockApplyQuota=null;
try {
lockApplyQuota=applyQuotaMapper.selectByIdForUpdate(applyQuotaId);
}catch (Exception e){
throw new CustomException(ResultEnum.REQUEST_FAILURE_DEAD);
}
lockApplyQuota.setQuota(lockApplyQuota.getQuota()-1);
lockApplyQuota.setGmtModify(new Date());
if(lockApplyQuota.getQuota()<0){
throw new CustomException(ResultEnum.REQUEST_FAILURE_NO_QUOTA);
}
applyQuotaMapper.updateByPrimaryKeySelective(lockApplyQuota);
}else if(type.equals(1)){//乐观锁,防止多线程并发下出现脏读而修改数据异常
ApplyQuota lockApplyQuota=applyQuotaMapper.selectByPrimaryKey(applyQuotaId);
ApplyQuotaExample updateApplyQuotaExample=new ApplyQuotaExample();
updateApplyQuotaExample.createCriteria().andDelEqualTo(Constant.UN_DEL).andIdEqualTo(lockApplyQuota.getId()).andQuotaEqualTo(lockApplyQuota.getQuota());
lockApplyQuota.setQuota(lockApplyQuota.getQuota()-1);
lockApplyQuota.setGmtModify(new Date());
if(lockApplyQuota.getQuota()<0){
throw new CustomException(ResultEnum.REQUEST_FAILURE_NO_QUOTA);
}
Integer result=applyQuotaMapper.updateByExample(lockApplyQuota,updateApplyQuotaExample);
if(result.equals(0)){
throw new CustomException(ResultEnum.REQUEST_FAILURE_OPTIMISM);
}
}
}
最后在说下这里其实最重点是保证了数据库在高并发下的数据一致性,当然都并的效率也是有一定提高,但如果真正大量并发下,想提高性能那就需要在上面的逻辑处理之前,进行访问控制,例如队列削峰,将事务和行级悲观锁改成乐观锁,使用mq就行线程调度有序抽奖,数据库的读写分离,redis缓存进行申请预热等。