SpringMVC 实现抽奖活动(2.Version1 —基于for update悲观锁)

SpringMVC 实现抽奖活动(2.Version1 —基于for update悲观锁)

流程图:

代码流程图

第一个版本实现的抽奖是保证所有奖品一定能发完的情况(在该场景下只有一个),故每次抽奖的概率不同,会根据剩余用户数与奖品数动态调整(现在整理的时候再看代码的原意真的是惨不忍睹,比如一个map对象命名的后缀是List??)

核心代码:

/**
* doLottery
*/
@Transactional
public int doLottery(String username) {
int res = 0;
//获取总的奖品池值
Map<String, Object> configList = configDao.getConfigList().get(0);
logger.info("configList: " + configList);
/** ----------- 解释1见下文 ----------- **/
int totalAward = Integer.parseInt(configList.get("totalAward").toString());
int acquiredAward = Integer.parseInt(configList.get("acquiredAward").toString());
if (totalAward - acquiredAward > 0) { //奖品有剩余
//获取中奖范围
int awardUpperLimit = Integer.parseInt(configList.get("awardUpperLimit").toString());
int totalUpperLimit = Integer.parseInt(configList.get("totalUpperLimit").toString());
//获取随机数
Random rand = new Random();
int random = rand.nextInt(totalUpperLimit); //用户抽奖,在[0,totalUpperLimit)生成int随机数
if (random <= awardUpperLimit && random >= 0) {//若该数在中奖范围内则说明中奖
res = 1; //中奖
lotteryService.updateAward(acquiredAward + 1, awardUpperLimit - 1);
} else {
res = -1; //未中奖
}
/** ----------- 解释2 ----------- **/
configDao.updateProbability("totalUpperLimit", totalUpperLimit - 1);
} else { //奖品无剩余
res = -1;
}
qualifyService.updateStatus(username, res);
return res;
}

解释1.
在获取配置信息时会有for update加锁(悲观锁,根据主键查询会在主键字段加行锁),在某一时刻只有一个线程可以成功拿到到config信息,保证了之后步骤并发的正确性,即经过此步骤之后所有的并发请求会成串行执行
在这里插入图片描述

拿出所有的config配置信息,其meta类如下:
config

解释2.
用户中奖则在数据库中将已获奖品数+ 1,同时将中奖范围-1;若用户未中奖,则将抽奖的上限-1,进而达到动态调整概率的目的,保证最后一个抽奖的用户抽中概率是1(初始的抽奖上限 = 所提供的可抽奖人数)

不足之处:

需求部分
  • 此处config中只有一列数据,for update在大部分情况下是行锁,但这里因为数据只有一条就相当于表锁,性能较低,关于for update的相关内容,见https://blog.csdn.net/claram/article/details/54023216
  • 确认需求后,应改为抽奖概率固定,即使最后可能没人中奖(很小的概率)
代码规范部分
  • 格式化日期不使用SimpleDateFormat(线程不安全),用相关的工具类完成(joda等)
  • 日期转化成Long型时间戳进行存储,比较也要采用时间戳的方式,直接存的话会有日期不唯一的风险,同时存储日期字符串效率低
  • 注意sql语句不能自己拼,有SQL注入的安全风险,要用封装好的方法
  • 代码中别直接用数字/写死的String,不能出现magic number,(要用的时候通过public static final去定义相关的变量)
  • 名称大写的规范:基本数据类型 + public
  • 注意每一个controller的方法(GET/POST/DELETE),区别:get方式一般用于多次访问结果一致的场景,post则用于每次访问返回结果都不尽相同的场景
  • Controller的访问url用名词,Controller层不要有逻辑层面的判断(除去基本的检测登陆),一般来说controller中只调用一个service层的方法,达到解耦
  • Dao层只负责返回数据,不去做逻辑处理,对数据的一些筛选/处理可以在Dao完成
  • 除基本数据类型以外的都要判空,注意保护条件(String与meta类)
  • 使用 SLF4J 代替 Log4J 来做 Java 日志
  • while循环体里边不嵌套dao操作,太耗时
  • 随机数相关用工具类,RandomStringUtils
  • 一大串的判断条件提出一个boolean值,更清晰
  • 用户完成某一操作后,记录数据时最好加上当前的时间戳
  • sql语句多次相同的话写成一句(多次查询同一张表的不同字段优化成一次全查出来,尽可能减少与数据库的交互)
  • 涉及字符串拼接,String和StringBuffer主要有2个区别:
    (1)String类对象为不可变对象,一旦你修改了String对象的值,隐性重新创建了一个新的对象,释放原String对象,StringBuffer类对象为可修改对象,可以通过append()方法来修改值
    (2)String类对象的性能远不如StringBuffer类
    (3)StringBuffer线程安全,StringBulider不安全,但速度比StringBuffer快。
    • @Transaction事物回滚必须在调用的地方捕获RuntimeException
    • 在字符串拼接时要小心中间字符串为null的话,直接就报空指针
    • object调用toString方法注意若object为null会报NPE
    • Service层要做好相关的参数合法性验证,一般Dao不再重复做

2019.3.16更新

昨天阿里一面在说悲观锁版本的时候,对Spring的事务机制有所遗忘,当时想for update 这个语句执行完锁不是释放了吗,怎么保证并发?然后想到所以要把它包在一个事务里边,即通过@Transaction注解。

当时不确定在一个标签内是否就会等跑完这一段事务才释放锁,现贴一个前人做的实验数据库中for update的使用,证实@Transaction作用和数据库中的开始事务类似,而在代码结束之后会自动提交,因此如果在代码中加了for update的锁,这个锁会持续整个事务的生命周期。

这也是为什么不推荐使用for update的原因,1.若在里面对多个资源加了锁,容易导致死锁;2.性能低,其实质是对索引加的锁,因此其他的地方如果想要通过这个索引获得别的行的数据也是会被阻塞的

Author: Apiao
Link: https://Apiao-1.github.io/2018/10/28/2018-10-28/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.