最近公衆號作了一個抽獎活動,遇到了同一個獎品被抽走兩次的狀況。經排查是事務的問題形成的,在這裏作個記錄。spring
首先讓我先來簡單闡述下業務:數據庫
每一個新用戶都會贈送一些抽獎機會,能夠經過用戶自行分享操做能夠額外增長抽獎次數。而且中獎概率會隨着抽獎次數的增長而減小。具體的接口以下。併發
/** * 抽獎 * * @return */ @Transactional public JsonResult lottery(int userId) { //判斷抽獎次數 if (getLotteryNum(userId) == 0) { return JsonResult.error("沒有抽獎次數了"); } //防止用戶屢次提交形成計算錯誤 synchronized (this) { //判斷使用哪套抽獎規則 int lotteryCount = lotteryRecordDao.countByUserId(userId); boolean hit = false; //若是沒有抽過獎,則有兩次機會,機率50% if (lotteryCount < 2) { hit = LotteryMath.lottery50(); lotteryRecord(userId, "0"); logger.info("userId:" + userId + "當前抽獎次數爲:" + lotteryCount + ",使用系統贈送抽獎"); } else { String now = getNowZero(); //判斷是否使用的是系統贈送抽獎次數 if (lotteryRecordDao.countByUserIdAndCreateTimeAndType(userId, now, "0") == 0) { lotteryRecord(userId, "0"); logger.info("userId:" + userId + "共抽:" + lotteryCount + "次,當天抽獎次數爲0,使用系統天天贈送抽獎"); } else { //使用人氣積分抽獎 lotteryRecord(userId, "1"); logger.info("userId:" + userId + "共抽:" + lotteryCount + "次,使用人氣積分贈送抽獎"); } if (lotteryCount > 2 && lotteryCount < 5) { //第3到5次33%機率 hit = LotteryMath.lottery33(); } else { //大於5次20%機率 hit = LotteryMath.lottery20(); } } if (hit) { return hited(userId); } } return JsonResult.error("抱歉您沒中獎"); }
斷定爲中獎時,執行hited方法。dom
/** * 勞資中獎啦 * * @return */ private JsonResult hited(int userId) { //從獎池中隨機獲取一個獎品 Giftpool giftpool = randomGetGift(); if (giftpool != null) { //判斷是否中的是今日大獎 if (giftpool.getGiftId() == 6) { String now = getNowZero(); int count = lotteryRecordDao.countByCreateTime(now); if (count < 500) { logger.info("userId:" + userId + "是終極衰鬼,與今日大獎擦肩而過"); return JsonResult.error("抱歉您沒中獎"); } } Winner winner = WinRecord(userId, giftpool); logger.info("userId:" + userId + "中獎,中獎紀錄ID爲:" + winner.getId()); //標記爲獎品已抽走 giftpool.setIsHit("1").setUpdateTime(new Date()); giftpoolDao.save(giftpool); logger.info("獎池ID爲:" + giftpool.getId() + "標記爲已抽走"); return JsonResult.success(winner); } //填充獎池 fillGiftpool(); logger.info("衰鬼userId:" + userId + "原本中獎了,恰好碰上獎池填充,斷定爲不中了"); //獎池爲空直接認定爲沒中獎 return JsonResult.error("抱歉您沒中獎"); }
這個方法大概作的事情就是,從數據庫的獎池表中隨機抽取一個未被標記爲已抽中的獎品,記錄到中獎紀錄表中,並在獎池表將該產品標記爲已抽中。this
表結構以下線程
獎池表接口
中獎紀錄表隊列
好了,業務描述完畢。乍一看好像沒什麼問題。可是實際在線上運行,因爲活動自身熱度,併發量至關大。在查中獎紀錄計算成本時,驚奇的發現,獎池中同一個獎品竟然被抽中了兩次。事務
在編寫代碼時,我就曾考慮到併發量可能會很大,會出現獎品被屢次抽取,因此我在代碼中加了同步鎖,確保只有一個線程執行抽獎方法,這樣就不會出現同一個獎品被抽走兩次的狀況。可是事已願爲。get
接下來分析緣由:雖然代碼中確保了一次只會有一個線程可以執行抽獎,可是spring的事務提交時間倒是不可控的,它不是個隊列,咱們並不知道spring會在何時提交事務。具體的說就是,兩個線程同時進入了該方法,其中一個線程執行完後,此時事務並沒立刻提交,第二個線程執行完後,兩個線程都提交了事務。此時同一個獎品就產生了兩條中獎紀錄。
解決問題:加行鎖。
if (giftpoolDao.updateHit("1", giftpool.getId()) > 0) { Winner winner = WinRecord(userId, giftpool); logger.info("userId:" + userId + "中獎,中獎紀錄ID爲:" + winner.getId()); //標記爲獎品已抽走 giftpool.setIsHit("1").setUpdateTime(new Date()); giftpoolDao.save(giftpool); logger.info("獎池ID爲:" + giftpool.getId() + "標記爲已抽走"); return JsonResult.success(winner); }
@Modifying @Query("update Giftpool l set l.isHit=?1 where l.id=?2 and l.isHit=0") int updateHit(String hit, int id);
當執行update時,會爲該條記錄加上鎖,直到事務處理完畢後才釋放。執行完後會返回一個影響了多少條記錄的數值,因此,當大於0時則表示執行成功。問題解決。