最近公众号做了一个抽奖活动,遇到了同一个奖品被抽走两次的情况。经排查是事务的问题造成的,在这里做个记录。
首先让我先来简单阐述下业务:
每个新用户都会赠送一些抽奖机会,可以通过用户自行分享操作可以额外增加抽奖次数。并且中奖几率会随着抽奖次数的增加而减少。具体的接口如下。
/** * 抽奖 * * @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方法。
/** * 劳资中奖啦 * * @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("抱歉您没中奖"); }
这个方法大概做的事情就是,从数据库的奖池表中随机抽取一个未被标记为已抽中的奖品,记录到中奖纪录表中,并在奖池表将该产品标记为已抽中。
表结构如下
奖池表
中奖纪录表
好了,业务描述完毕。乍一看好像没什么问题。但是实际在线上运行,由于活动自身热度,并发量相当大。在查中奖纪录计算成本时,惊奇的发现,奖池中同一个奖品居然被抽中了两次。
在编写代码时,我就曾考虑到并发量可能会很大,会出现奖品被多次抽取,所以我在代码中加了同步锁,确保只有一个线程执行抽奖方法,这样就不会出现同一个奖品被抽走两次的情况。但是事已愿为。
接下来分析原因:虽然代码中确保了一次只会有一个线程能够执行抽奖,但是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时则表示执行成功。问题解决。