引言:
最近在項目中參與了一個領取優惠劵的活動,當多個用戶領取同一張優惠劵的時候,使用了數據庫鎖控制並發,起初的設想是:如果多個人同時領一張劵,第一個到達的人領取成功,其它的人繼續查找是否還有剩余的劵,如果有,繼續領取,否則領取失敗。在實現中,我一開始使用了遞歸的方式去查找劵,實際的測試中發現出現了無窮遞歸,通過degug和查閱資料才發現這是由於mybatis的一級緩存引起的,以下將這次遇到的問題和大家分享討論。
1.知識儲備
簡單介紹:
Mybatis
一級緩存:默認開啟,sqlSession級別緩存,當前會話中有效,執行sqlSession commit()、close()、clearCache()操作後會清除緩存。
二級緩存:需要手工開啟,全局級別緩存,與mapper namespace相關。
詳情參見:http://www.mamicode.com/info-detail-890951.html
2.代碼示例
以下是一個領取優惠劵的輔助方法-隨機抽取一張優惠碼,調用這個輔助方法的public方法開啟了事務。實際測試的過程中發現,當數據庫中只有一張優惠劵時並且同時被多個用戶領取時,會出現無窮遞歸。代碼如下:
1 /**
2 * 隨機抽取一張優惠碼
3 *
4 * @param codePrefix
5 * 優惠碼前綴
6 * @return 優惠碼 9 */
10 private String randExtractOneTicketCode(String mobile, String codePrefix) {
11 List<String> notExchangeCodeList = yzTicketCodeDaoExt.getTicketCodeList(codePrefix,
12 MobileServiceConstants.TICKET_CODE_STATUS_NOT_EXCHANGE);
13 logger.info("領取優惠劵>>>優惠劵可用數量{}",CollectionUtils.size(notExchangeCodeList));
14 if (CollectionUtils.isEmpty(notExchangeCodeList)) {
15 logger.warn("領取優惠劵>>>優惠劵{}已領完", codePrefix);
16 throw new YzRuntimeException(MobileServiceConstants.TICKET_NOT_REMAINDER);
17 }
18
19 int randomIndex = random.nextInt(notExchangeCodeList.size()); // 隨機的索引
20 String ticketCode = notExchangeCodeList.get(randomIndex); // 隨機選擇的優惠碼
21 YzTicketCode ticketCodeObj = yzTicketCodeDaoExt.getByCode(ticketCode);
22 if (ticketCodeObj == null
23 || ticketCodeObj.getStatus() != MobileServiceConstants.TICKET_CODE_STATUS_NOT_EXCHANGE) {
24 // 如果優惠劵已被使用
25 logger.info("領取優惠劵>>>優惠劵碼{}不存在或已被使用",ticketCode);
26 return randExtractOneTicketCode(String mobile, String codePrefix); //遞歸查找
27 }
28 /*
29 * 更新優惠碼狀態
30 */
31 ticketCodeObj.setExchangeTime(Calendar.getInstance().getTime());
32 ticketCodeObj.setStatus(MobileServiceConstants.TICKET_CODE_STATUS_HAD_EXCHANGED);
33 ticketCodeObj.setMobile(mobile);
34 int updateCnt = yzTicketCodeDaoExt.update4Receive(ticketCodeObj);
35 if(updateCnt <= 0){
36 //樂觀鎖,沒有影響到行,表明更新失敗,可能是該劵不存在或已被使用
37 logger.info("領取優惠劵>>>優惠劵碼{}不存在或已被使用",ticketCode);
38 return randExtractOneTicketCode(String mobile, String codePrefix); //遞歸查找
39 };
40 return ticketCode;
41 }
通過debug發現,第11行執行的查詢結果被mybatis緩存了,所以每次都有一張劵可以被領取,但實際上這張劵已經被其它用戶領取了,導致了無窮遞歸。
3.解決方案
1)編程式事務,通過transactionManager來獲取sqlSession,然後通過sqlSession的clearCache()方法來清除一級緩存。
2)由於項目中使用了Spring申明式事務,並且並發量不高,考慮到減少復雜度,選擇了直接提示用戶系統繁忙。
/**
* 隨機抽取一張優惠碼
*
* @param codePrefix
* 優惠碼前綴
* @return 優惠碼
* @throws YzRuntimeException
* 如果沒有可用的優惠劵
*/
private String randExtractOneTicketCode(String mobile, String codePrefix) {
List<String> notExchangeCodeList = yzTicketCodeDaoExt.getTicketCodeList(codePrefix,
MobileServiceConstants.TICKET_CODE_STATUS_NOT_EXCHANGE);
logger.info("領取優惠劵>>>優惠劵可用數量{}",CollectionUtils.size(notExchangeCodeList));
if (CollectionUtils.isEmpty(notExchangeCodeList)) {
logger.warn("領取優惠劵>>>優惠劵{}已領完", codePrefix);
throw new YzRuntimeException(MobileServiceConstants.TICKET_NOT_REMAINDER);
}
int randomIndex = random.nextInt(notExchangeCodeList.size()); // 隨機的索引
String ticketCode = notExchangeCodeList.get(randomIndex); // 隨機選擇的優惠碼
YzTicketCode ticketCodeObj = yzTicketCodeDaoExt.getByCode(ticketCode);
if (ticketCodeObj == null
|| ticketCodeObj.getStatus() != MobileServiceConstants.TICKET_CODE_STATUS_NOT_EXCHANGE) {
// 如果優惠劵已被使用
logger.info("領取優惠劵>>>優惠劵碼{}不存在或已被使用",ticketCode);
throw new YzRuntimeException(MobileServiceConstants.TICKET_SYSTEM_BUSY);
}
/*
* 更新優惠碼狀態
*/
ticketCodeObj.setExchangeTime(Calendar.getInstance().getTime());
ticketCodeObj.setStatus(MobileServiceConstants.TICKET_CODE_STATUS_HAD_EXCHANGED);
ticketCodeObj.setMobile(mobile);
int updateCnt = yzTicketCodeDaoExt.update4Receive(ticketCodeObj);
if(updateCnt <= 0){
//樂觀鎖,沒有影響到行,表明更新失敗,可能是該劵不存在或已被使用
logger.info("領取優惠劵>>>優惠劵碼{}不存在或已被使用",ticketCode);
throw new YzRuntimeException(MobileServiceConstants.TICKET_SYSTEM_BUSY);
};
return ticketCode;
}
總結:
現在項目大多使用集群的方式,使用java提供的並發機制去控制並發已經不太適合,常用的是數據庫鎖和Redis操作,上面代碼中使用了數據庫的樂觀鎖,樂觀鎖相比於悲劇鎖而言,需要編寫外部算法,錯誤的外部算法和異常恢復容易導致出現未知的錯誤,需要謹慎的設計和嚴格的測試。