一、业务描述
现有一排行榜业务,数据库中拥有百万级用户,中秋佳节将要来临,用户可以写一篇关于中秋的故事,故事可邀请好友点赞,也可以打赏该故事,现在要挑选出关于中秋话题相关的故事,根据用户故事的点赞数、获得的打赏数以及故事查看次数为依据,根据一定规则算出后在客户端展示前100的故事排行榜。
二、业务分析
分析业务得知,用户故事排行榜可不必实时刷新,比如5分钟或者是10分钟刷新一次,用户是没有多少感知的,如果涉及数据量太过庞大或者是业务计算复杂需要更多时间排行,完全可以明确的告知用户5分钟或者10分钟刷新一次排行榜,甚至是更久。面对这样的业务,不可能在用户请求的时候去计算排行,这样做服务器不仅响应慢,服务器还会消耗大量的资源,比如大量的逻辑计算导致服务器CPU爆满,内存消耗过大等问题。要想用户的请求能够很快的响应,在查询时这个排行榜就已经排好了。下面我介绍一种我在工作中所使用的一种方法,也许并不是最好的,但满足一般的业务还是没有任何问题的,对于超大的数据集,需要另觅方法。
三、方法介绍
合理使用Redis的有序集合。Redis是一种将数据放到内存中的数据库,面对排行榜这样的业务需求,找它是完全没有问题的。更值得拍手叫好的是,Redis的有序集合可以帮助我们实现排行榜,将多个维度的数据源取得之后,通过一定的规则计算出分值,把这些数据放入到Redis的有序集合中,那么排行结果自然而然的就出来了,取出排行数据后,在填充一些客户端展示需要的数据即可。要想实现每隔一分钟或是5分钟刷新一次,只需开启一个定时器,定时去调用排行计算逻辑。更多关于Redis的有序集合的命令,请参考链接 http://doc.redisfans.com/ 或是Redis官网 https://redis.io/ 。
四、具体实现
实例以上诉故事业务为题,列出部分代码,仅供参考。
(1) 获取故事并计算排行分值。从多个数据库中取得关于中秋相关话题的故事,得到故事后在根据故事的id取得故事的一些统计信息,比如故事点赞数、获得的打赏数、查看数等。(注意:在将分值放到Redis时,一定要将故事Id与分值一一对应,否则在取数据时并不知道分值对应的故事是谁)
/*** 计算排行榜分值*/
public void calStoryRankList() {// 取得故事List<Story> storyList = storyService.getFestivalStoryList();if (CollectionUtils.isEmpty(storyList)) {return;}// 得到故事的idList<Long> storyIds = levyArticleStoryList.stream().map(story -> story.getId()).collect(Collectors.toList());// 根据故事id批量取得故事的统计信息 Map<Long, StoryStat> storyStats = statService.getStoryStats(storyIds);// 保存根据排行规则得到的分值Map<Long, Double> storiesRankMap = new HashMap<>(levyArticleStoryList.size());// 计算排行分值storyList.stream().forEach(story -> {StoryStat storyStat = stats.get(story.getId());if (null != storyStat) {Long priseCount = storyStat.getPriseCount();Long beansCount = bbStoryStat.getTotalEarning();Double score = priseCount + beansCount / 100.0;// 保留一位小数score = Math.round(score * 10) / 10.0;storiesRankMap.put(story.getId(), score);}});// 将分值放到Redis中rankListDao4Redis.putStoryRankList(storiesRankMap);
}
(2) 将计算好的排行分值放到Redis中。这里需要考虑几个问题,如果排行榜在第一次刷新时A故事是存在的,但在下次刷新时A故事被作者或者是运营人员删除了,那么Redis中应该去除这条废弃的数据,因此,这里我们需要使用Redis的删除键命令将这个键下的所有数据删除,在存入新的数据,如果新的数据存入失败,那么之前的删除操作应该回滚,此时可以使用Redis的事务控制。如果不这样做,会导致排行榜出现空的情况。详情见代码。
/*** 设置征文排行数据** @param stories* @return*/public Boolean putStoryRankList(Map<Long, Double> stories) {// 根据规则生成Redis的key键String rankKey = RedisKeys.keyOfStoryRankList();// 自行在Service层注入jedisPipeline pipelined = jedis.pipelined();// 开启事务pipelined.multi();// 删除以前的数据pipelined.del(rankKey);// 存入新的值pipelined.zadd(rankKey , stories);pipelined.exec();pipelined.sync();return true;
}
(3) 取得排行榜。有了前面的铺垫,取得排行榜就变得非常简单了。此处没有给全分页的实现,有兴趣的自己尝试做一下。实现如下:
public Map<Long,Double> getStoryRankList(int size, int offset) {// 根据规则生成Redis的key键String rankKey = RedisKeys.keyOfStoryRankList();Pipeline pipelined = jedis.pipelined();// 取得排行榜,可以加入分页,需要注意Redis的取数据时根据数组下表来取的,因此,如果按照MySQL数据库的规则,那么size需要减去1Response<Set<Tuple>> scores = pipelined.zrevrangeWithScores(levyRankKey, offset, size - 1);// 保存排行结果的MapMap<Long,Double> result = new HashMap<>();for (Tuple score : scores.get()) {result.put(Long.parseLong(score.getElement().toString()), score.getScore())); }return result;
}
(4) 填充数据。因为从Redis得到的排行榜数据仅仅包含故事的Id,很明显,这样的数据用户是无法识别的,因此,在此步仅仅是根据故事的ID去填充排行展示所需要的数据,具体业务,就得具体分析,此处不在给出代码。
五、总结
从这个例子看出,Redis给我们带了很大遍历,减少了实现业务的难度和工作量。倘若你所在的公司没有使用Redis(这样的情况应该不存在),那么要想实现一个高效且稳定的排行榜业务,还是得有两把刷子,比如自己使用缓存实现。Redis给我提供的便利不仅仅是这些,还有很多有用的东西等待我们去挖掘,本篇博文仅仅是给没有蓝图的同僚一个小小的启发,由于小编能力有限,文中难免有纰漏之处,还望指出,谢谢!