之前寫過一 關於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緩存進行申請預熱等。