缓存预热
为什么要做预热:
当活动真正开始时,需要超高的并发访问活动相关信息 必须把必要的数据提前加载进redis
预热的策略:
在msg中写一个定时任务
每分钟扫描一遍card_game表
把(开始时间 > 当前时间)&& (开始时间 <= 当前时间+1分钟)的活动及相关信息放入redis
缓存预热流程:
代码上需要取出满足条件的活动列表,对每个活动查出相应的奖品放到令牌桶,查出相应的活动策略放到Redis
1、查询1分钟内的活动
2、循环遍历活动列表,挨个处理,假设当前取出的是A
3、查询A相关的奖品列表及数量
4、根据总数量生成奖品相关的令牌桶
5、查询A相关的活动策略:抽奖次数、中奖次数等,放入Redis
缓存体系
1)活动基本信息 k-v,以活动id为key,活动对象为value,永不超时
redisUtil.set(RedisKeys.INFO+game.getId(),game,-1);
2)活动策略信息
使用hset,以活动id为group,用户等级为key,策略值为value
redisUtil.hset(RedisKeys.MAXGOAL + game.getId(),r.getUserlevel()+"",r.getGoalTimes());
redisUtil.hset(RedisKeys.MAXENTER +
game.getId(),r.getUserlevel()+"",r.getEnterTimes());
3)抽奖令牌桶 双端队列,以活动id为key,在活动时间段内,随机生成时间戳做令牌,有多少个奖品就生成多少个令牌。令牌即奖品发放的时间点。从小到大排序后从右侧入队。
redisUtil.rightPushAll(RedisKeys.TOKENS + game.getId(),tokenList);
4)奖品映射信息
k-v , 以活动id_令牌为key,奖品信息为value,会员获取到令牌后,如果令牌有效,则用令牌token值,来这里获取 奖品详细信息
redisUtil.set(RedisKeys.TOKEN + game.getId() +"_"+token,cardProduct,expire);
5)令牌设计技巧
假设活动时间间隔太短,奖品数量太多。那么极有可能产生的时间戳发生重复。
解决技巧:额外再附加一个随机因子。将 (时间戳 * 1000 + 3位随机数)作为令牌。抽奖时,将抽中的令牌/1000 ,还原真实的时间戳。
//活动持续时间(ms)
long duration = end - start;
long rnd = start + new Random().nextInt((int)duration);
//为什么乘1000,再额外加一个随机数呢? - 防止时间段奖品多时重复
long token = rnd * 1000 + new Random().nextInt(999);
6)中奖计数 k-v,以活动id_用户id作为key,中奖数为value,利用redis原子性,中奖后incr增加计数。 抽奖次数计数也是同样的道理
redisUtil.incr(RedisKeys.USERHIT+gameid+"_"+user.getId(),1);
7)中奖逻辑判断 : 抽奖时,从令牌桶左侧出队和当前时间比较,如果令牌时间戳小于等于当前时间,令牌有效,表示中奖。大于当前 时间,则令牌无效,将令牌还回,从左侧压入队列。
代码开发
代码在msg项目下的GameTask里,已集成Spring调度
看了下Spring调度 ,@Scheduled内写循环时间,下面的代码会定时执行
commons模块下有个RedisKeys,已经定义了可用的Redis key前缀,可以直接使用
接下来补全GameTask中的函数
先用QueryWrapper取到下一分钟所有的任务
// 获取当前时间
Date now = new Date();
// 查询将来1分钟内要开始的活动
QueryWrapper<CardGame> gameQueryWrapper = new QueryWrapper<>();
// 开始时间大于当前时间
gameQueryWrapper.gt("starttime", now);
// 小于等于(当前时间+1分钟)
gameQueryWrapper.le("starttime", DateUtils.addMinutes(now, 1));
List<CardGame> list = gameService.list(gameQueryWrapper);
if (list.isEmpty()) {
// 没有查到要开始的活动
log.info("No upcoming games within the next minute.");
} else {
log.info("Found {} upcoming games.", list.size());
}
对于每个任务,获取到要存入Redis中的信息
list.forEach(game -> {
// 活动开始时间
long start = game.getStarttime().getTime();
// 活动结束时间
long end = game.getEndtime().getTime();
// 计算活动结束时间到现在还有多少秒,作为redis key过期时间
long expire = (end - now.getTime()) / 1000;
// 活动持续时间(ms)
long duration = end - start;
// 创建查询参数的Map
Map<String, Object> queryMap = new HashMap<>();
queryMap.put("gameid", game.getId());
先将基本信息存入Redis
// 活动基本信息
game.setStatus(1);
redisUtil.set(RedisKeys.INFO + game.getId(), game, -1);
log.info("Loaded game info: {}, {}, {}, {}", game.getId(), game.getTitle(), game.getStarttime(), game.getEndtime());
把奖品放入map
// 活动奖品信息
List<CardProductDto> products = gameLoadService.getByGameId(game.getId());
Map<Integer, CardProduct> productMap = new HashMap<>(products.size());
products.forEach(p -> productMap.put(p.getId(), p));
log.info("Loaded product types: {}", productMap.size());
//奖品数量等配置信息
List<CardGameProduct> gameProducts = gameProductService.listByMap(queryMap);
log.info("load bind product:{}",gameProducts.size());
令牌桶创建,然后token存入Redis
// 令牌桶
List<Long> tokenList = new ArrayList<>();
gameProducts.forEach(cgp -> {
// 生成amount个start到end之间的随机时间戳做令牌
for (int i = 0; i < cgp.getAmount(); i++) {
long rnd = start + new Random().nextInt((int) duration);
// 为什么乘1000,再额外加一个随机数呢? - 防止时间段奖品多时重复
// 记得取令牌判断时间时,除以1000,还原真正的时间戳
long token = rnd * 1000 + new Random().nextInt(999);
// 将令牌放入令牌桶
tokenList.add(token);
// 以令牌做key,对应的商品为value,创建redis缓存
log.info("Token -> Game: {} -> {}", token / 1000, productMap.get(cgp.getProductid()).getName());
// Token到实际奖品之间建立映射关系
redisUtil.set(RedisKeys.TOKEN + game.getId() + "_" + token, productMap.get(cgp.getProductid()), expire);
}
});
// 排序后放入redis队列
Collections.sort(tokenList);
log.info("Loaded tokens: {}", tokenList);
// 从右侧压入队列,从左到右,时间戳逐个增大
redisUtil.rightPushAll(RedisKeys.TOKENS + game.getId(), tokenList);
redisUtil.expire(RedisKeys.TOKENS + game.getId(), expire);
接着将策略存入Redis
// 奖品策略配置信息
List<CardGameRules> rules = gameRulesService.listByMap(queryMap);
// 遍历策略,存入redis hset
rules.forEach(r -> {
redisUtil.hset(RedisKeys.MAXGOAL + game.getId(), r.getUserlevel() + "", r.getGoalTimes());
redisUtil.hset(RedisKeys.MAXENTER + game.getId(), r.getUserlevel() + "", r.getEnterTimes());
redisUtil.hset(RedisKeys.RANDOMRATE + game.getId(), r.getUserlevel() + "", r.getRandomRate());
log.info("Loaded rules: level={}, enter={}, goal={}, rate={}",
r.getUserlevel(), r.getEnterTimes(), r.getGoalTimes(), r.getRandomRate());
});
redisUtil.expire(RedisKeys.MAXGOAL + game.getId(), expire);
redisUtil.expire(RedisKeys.MAXENTER + game.getId(), expire);
redisUtil.expire(RedisKeys.RANDOMRATE + game.getId(), expire);
写完运行发现每隔一分钟尝试将活动信息写入缓存
然后写了个缓存接口来测试,代码从Redis里取数据即可
@GetMapping("/info/{gameid}")
@ApiOperation(value = "缓存信息")
@ApiImplicitParams({
@ApiImplicitParam(name="gameid",value = "活动id",example = "1",required = true)
})
public ApiResult info(@PathVariable int gameid) {
Map<String, Object> resMap = new LinkedHashMap<>();
Map<String, Object> tokenMap = new LinkedHashMap();
Object gameInfo = redisUtil.get(RedisKeys.INFO + gameid);
Map<Object, Object> maxGoalMap = redisUtil.hmget(RedisKeys.MAXGOAL + gameid);
Map<Object, Object> maxEnterMap = redisUtil.hmget(RedisKeys.MAXENTER + gameid);
List<Object> tokenList = redisUtil.lrange(RedisKeys.TOKENS + gameid, 0, -1);
resMap.put(RedisKeys.INFO + gameid, gameInfo);
resMap.put(RedisKeys.MAXGOAL + gameid, maxGoalMap);
resMap.put(RedisKeys.MAXENTER + gameid, maxEnterMap);
for (Object item : tokenList) {
Object tokenData = redisUtil.get(RedisKeys.TOKEN + gameid + "_" + item.toString());
Long key = Long.valueOf(item.toString());
Date date = new Date(key / 1000);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
String formattedDate = dateFormat.format(date);
tokenMap.put(formattedDate, tokenData);
}
resMap.put(RedisKeys.TOKENS + gameid, tokenMap);
return new ApiResult(200, "缓存信息", resMap);
}
运行测试
在后台数据库启动一个近期活动:
查看Redis