redis排行榜

设计思路

因为看了一段时间的redis,准备动手做一个小demo,做一个排行榜,正好加在之前的未完成的新闻门户里面。关于排行榜他有一些跟排行榜本身相关的要求比如:

排行精确性

如果一个排行榜的结果关系到用户的权益问题,这个时候一个排行榜的精确性就需要非常高,比如一个运营同学进行了根据微博转发数量的营销活动,这个时候微博转发数量的排行榜就需要非常精确,否则会影响用户权益的分发。

排行榜实时性

游戏和社交互动的结合是目前的趋势,对于热门游戏的排行是用户的关注重点,在这部分用户中对于排行的实时性有很高的要求,如果一个用户升级了自己的装备和能力,而自己的排名一直没有更新,那这个用户一定要非常伤心抛弃这个游戏了。所以通过离线计算等平台来构建一个非实时的排行榜系统就不太适合这样的模型。

海量数据排行

海量数据是目前的一个趋势,比如对于淘宝全网商品的一个排行,这个榜单将会是一个亿级别的,所以我们设计的榜单也需要具备弹性伸缩能力,同时在对海量数据进行排行的时候拥有一定的实时性。

实现方法

目的是要实现一个热点新闻排行榜的话,毫无疑问,使用的是redis内置的zset这种数据结构,他可以根据score自动产生rank比较方便。我们将评论或者点赞数超过200的认为是热门文章

由于文章是从别的地方爬过来的,所以只有评论数没有点赞数,设置初始化分数为:

score = 发布时间毫秒数 + 432 * 评论数

而问题就在排行榜更新的频率,更新过快,缓存效果不好,会产生类似重建热key的问题(下一篇文章要讲一下),但是频率过慢又不能达到实时性,所以正如之前所说的,要根据排行榜自身的要求制定一个适合的更新策略:

针对自身的这个项目需求,我想实现的是一个热点新闻排行榜,他的时效性要求并不是很高,所以通过分析网易新闻的爬取量,对爬到的每个新闻建立一个news:id的hash进行初始化,类似关系型数据库中的一条字段,并设置一周后过期自动删除,排行榜肯定是用zset的,但是为了不刷新过快,再建立一个time:的zset缓存最近一个星期的文章,设置一周过期,每周一次定时维护time:,从time: 删除时间超过一个星期的文章,并重置score:,由于爬虫每隔6小时更新一次,且新闻量相对较小,所以对time:的频繁读写是可以容忍的,再维护一个score:的zset简历news和score的映射,所以总的来说就是

  1. 爬取新闻,建立新的hash(news:id),设置过期时间为一周,并加入zset(time:)
  2. 每周执行一次更新,删除zset:中过期的任务,对未过期的任务分数进行更新。
score: zset
news:id 分数
time: zset
news:id 时间
news:id hash
voted 投票数
title xxx
url xxx

这里介绍一个在网上看到的实时排行榜的设计策略,其思路类似于维护一个小顶堆:

  1. 第一次访问的时候,查数据库,查整个表查出topN(使用sql排序),丢给redis(使用sorted set数据类型)。

  2. 排序在redis,redis自动排序。以后的用户访问:均访问redis。

  3. 只要每次积分变化判断的时候拿topN的最后一个判别,大于最后一名,则整个user丢进redis排序。
    效率性能再优化:用户积分变动的时候,(守护线程)服务器预存一下变化的数量。。到一定量再通知。

  4. 再往下去设定一个小距离为阈值。比如现在第50名的积分是100,那80分一下的应该就没必要扔给redis了吧?

注意:这个排行榜的用户是会不断增加的,比如1亿用户,如果刚开始只有前50,后5千万人的积分大于第50名,那么就会往redis加入这个用户的信息。(虽然看起来要存很多,其实一亿用户怎么存也就1G左右的内存,简单暴力优雅方案了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Autowired
private RedisDao redisDao;
private final int ONE_WEEK_IN_SECONDS = 7 * 86400;
@Scheduled(cron = "0 0 0 1/7 * ?")
public void updataRank() {
redisDao.zRemRangeByRank("score:", 0, -1);
long cutOff = System.currentTimeMillis() / 1000 - ONE_WEEK_IN_SECONDS;
Set<TypedTuple<Object>> set = redisDao.zRangeWithScores("time:", 0, -1);
for (TypedTuple<Object> o: set) {
//如果过期直接删除,否则计算结果
BigDecimal db = new BigDecimal(o.getScore().toString());db.toPlainString();
if (Long.valueOf(db.toPlainString()) < cutOff) {
redisDao.zrem("time:", o.getValue());
} else {
redisDao.zadd("score:", o.getValue(),
Double.valueOf(o.getScore().toString()) +
432 * Double.valueOf(redisDao.hget(o.getValue().toString(), "voted").toString()));
}
}
}

成品效果如图(忽略我这个丑陋的前端):

新闻爬虫2.0

由于这次修改也涉及到了之前爬取数据的爬虫,索性就把爬虫也一并进行了修改,对整个爬虫进行了重构,使用多线程对爬虫进行优化,具体步骤如下:

将爬虫分为两个部分,使用生产者和消费者模式,将redis作为任务队列,生产者爬虫爬取新闻url,消费者爬虫根据新闻url爬取具体信息。使用2个redis集合存储已爬新闻和未爬新闻,作为简单去重。

完整代码请参考:

新闻门户代码:https://github.com/MoriatyC/OmegaNews

爬虫代码:https://github.com/MoriatyC/nethard