假设我们要模拟金铲铲中塔姆的爆金币需求,我们该如何实现该需求呢?
所以假设下面具体场景:
1.在每一回合的15s中,该棋子不断被攻击。
2.该棋子被攻击时有十分之三的概率的会爆出一个金币,
3.每被攻击10次必爆一个金币
4.每回合最多爆出两个金币。
要求编写后端接口将其实现。前端会传给后端一个回合id
经过分析我们可以得知接口流程逻辑如下:
1.实现检验回合时间是否超时,可以使用redis的key过期,回合id存入redis中,并设置过期时间。
将id存入key,回合结束时间存入value。每次调用时查询此时是否还在结束时间之内。
如果是,代表回合未结束,如果不是则该回合已结束。
2.判断是否超过2个金币,可以用redis的incr命令,这样是安全的
3.使用随机数或者队列进行概率生成金币,生成一个10以内的随机数,如果小于三则爆金币
因此可写出如下接口
@RestController
@Slf4j
public class GameController {
@Value("${second:15}")
private Long second;
@Value("${money:2}")
private Integer maxMoney;
@Resource
private RedisTemplate redisTemplate;
/**
* 默认线程池
*/
@Resource
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Resource
private StringRedisTemplate stringRedisTemplate;
@GetMapping("attack")
public Boolean attack(AttackParam attackParam) {
String id = attackParam.getRoundId();
log.info("攻击了一次,回合id:{}", id);
LocalDateTime now = LocalDateTime.now();
/**前置检查**/
if (!preCheck(id, now)) {
return false;
}
return money(id);
}
/**
* 检测是否获得金币,获得--true ,未获得--false
*
* @param id id
* @return {@link Boolean}
*/
private Boolean money(String id){
Random random = new Random();
int i = random.nextInt(9);
if (i <= 2) {
log.info("获得到了金币:{}", id);
stringRedisTemplate.boundValueOps(buildMoneyKey(id)).increment();
return true;
}
log.info("未获得到金币:{}", id);
return false;
}
private String buildMoneyKey(String id) {
return "attack:money:" + id;
}
/**
* 预检查
*
* @param id id
* @param now 现在
* @return {@link Boolean}
*/
private Boolean preCheck(String id, LocalDateTime now) {
if (!checkRound(id, now)) {
return false;
}
if (!checkMoney(id)) {
return false;
}
return true;
}
/**
* 校验回合是否结束
*
* @param id id
* @return {@link Boolean}
*/
private Boolean checkRound(String id, LocalDateTime now) {
if (Boolean.TRUE.equals(redisTemplate.hasKey(id))) {
LocalDateTime endTime = (LocalDateTime) redisTemplate.boundValueOps(id).get();
if (now.isAfter(endTime)) {
log.info("该回合已经结束:回合id:{}", id);
return false;
}
}
redisTemplate.boundValueOps(id).set(now.plusSeconds(second));
return true;
}
/**
* 校验金钱是够超限
*
* @param id id
* @return {@link Boolean}
*/
private Boolean checkMoney(String id) {
String moneyKey = buildMoneyKey(id);
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(moneyKey))) {
int money = Integer.parseInt(stringRedisTemplate.boundValueOps(moneyKey).get());
if (money > maxMoney) {
log.info("金钱超限。回合id:{}", id);
return false;
}
}
return true;
}
/**
* 使用线程池模拟并发测试
*
* @return {@link String}
*/
@GetMapping("/test")
public String test(){
AttackParam attackParam = new AttackParam();
attackParam.setRoundId(UUID.randomUUID().toString());
for (int i = 0; i <= 10000; i++) {
CompletableFuture.runAsync(() -> {
this.attack(attackParam);
}, threadPoolTaskExecutor);
}
return "aa";
}
/**
* 获取回合结束后的金钱数
*
* @param id id
* @return {@link String}
*/
@GetMapping("/get-money")
public String getMoney(String id){
return stringRedisTemplate.boundValueOps(buildMoneyKey(id)).get();
}
}
此时的代码还不完善,在多线程下很可能会出现错误。
在目前的基础上如何保证多线程下的安全的呢?
错误发生场景:
如果第一个线程运行到了该获取金币的位置但是还没获取到,此时第二个线程进来进行判断金币是否超额,
并且由于第一个线程还没完成添加,使得第二个线程满足了金币未超额的条件,
这样这两个都会进行获取金币,就有可能使实际金币数超出最大金币数,造成错误。
我们有两种方法可以解决:
一是加锁,但是加锁就会有资源的竞争,会损耗性能,因此能不加锁就不加锁。
二是我们可以将读和写放在一起写,
使用redis自带的方法,将读和写放在一起运行,这个操作是原子性的,多线程下可以保证安全。
具体做法为在判断本次被攻击是否应该获取金币前也进行金币是否超额的判断,
也就是把检测是否获得金币的方法中加上判断金币是否超额,改变money()方法
使用redis自带的具有原子性的方法,将读和写放在一起执行,这样就可以得到正确的返回值,避免了错误。
改后代码如下:
这样就保证了多线程下的安全性。
/**
* 检测是否获得金币,获得--true ,未获得--false
*
* @param id id
* @return {@link Boolean}
*/
private Boolean money(String id) {
Random random = new Random();
int i = random.nextInt(9);
if (i <= 2) {
log.info("获得到了金币:{}", id);
Long increment = stringRedisTemplate.boundValueOps(buildMoneyKey(id)).increment();
if(increment > maxMoney){
log.info("金钱超限。回合id:{}", id);
return false;
}
return true;
}
log.info("未获得到金币:{}", id);
return false;
}