在这里用户活跃度排行榜,主要是基于redis的zset数据结构来实现的,下面来看一下实例。
方案设计
来看一下业务场景先
1.场景说明
在技术派中,提供了一个用户的活跃排行榜,当然作为一个博客社区,更应该实现的是作者排行榜;但为了更好的活跃用户,让用户有参与感,所以用用户活跃度来设计一个排行榜,区分日/月两个排行榜单
用户活跃度计算方式:
1.用户每访问一个新的页面+1分
2.对于每一篇文章,点赞、收藏+2分;取消点赞、取消收藏,将之前的活跃分收回
3.文章评论+3分
4.发布一篇审核通过的文章+10分
榜单:
展示活跃度最高的前三十名用户
实际的榜单效果如下

2.设计方案
排行榜的业务属性比较去清晰简单,对应的数据结构也可以很容易设计出来,核心的信息如下
存储单元
表示排行榜中每一位上应该持有的所有的信息如下。

数据结构
排行榜,一般而言都是连续的,借此我们可以联想到一个合适的数据结构LinkedList,好处在于排名变动时,不需要数组的拷贝。

上图演示,当一个用户活跃度改变时,需要向前遍历找到合适的位置,插入并获取新的排名,在更新和插入时,相比较与ArrayList要好的多,但依然有以下几个缺陷
- 问题1:用户如何获取自己的排名?
 - 使用LinkedList在更新插入和删除的带来优势之外,在随机获取元素的支持上会差一点,最差的情况就是从头到尾进行扫描。
 - 问题2:并发支持的问题?
 - 当有多个用户同时更新score时,并发的更新排名问题就比较突出了,当然可以使用jdk中类似写时拷贝数组的方案
 
上面是我们自己来实现这个数据结构时,会遇到的一些问题,当然我们的主题是借助redis来实现排行榜,下面则来看,利用redis可以怎么简单的支持我们的需求场景。
3.redis使用方案
这里主要使用的是redis的ZSET数据结构,带权重的集合,下面分析一下可能性。
- set:集合确保元素的唯一性
 - 权重:这个可以看做我们的score,这样每个元素都有一个score;
 - zset:根据score进行排序的集合
 
从zset的特性来看,我们每个用户的积分,丢到zset中,就是一个带权重的元素,而且是已经排好序的了,只需要获取元素对应的index,就是我们预期的排名
排行榜实现
接下来我们看一下技术派中的活跃排行榜是如何实现的
核心包路径:com/github/paicoding/forum/service/rank
核心代码实现:src/main/java/com/github/paicoding/forum/service/rank/service/impl/UserActivityRankServiceImpl.java
1.更新用户活跃积分
我们先实现一个更新用户活跃的方法,首先定义一个涵盖上面业务场景的参数传递实体ActivityScoreBo
接下来我们先思考一下,这个具体的应该怎么实现,先梳理实现的业务流程
1.根据业务实体,计算需要增加/减少的活跃度
2.对于增加活跃度时:
2.1做一个幂等,防止重复添加,因此需要判断下之前有没有重复添加过相关的活跃度
2.2 若幂等了,则直接返回;否则,执行更新,并做好幂等保存。
3.对于减少活跃度时:
3.1 判断之前有没有加过活跃度,防止减扣为负数
3.2 之前没有减扣过,则直接返回;否则;执行箭扣,并移除幂等判断
上面的业务逻辑清晰之后,再看一下我们实现的关键因素
- 1.怎样做幂等?
 - 2.如何更新榜单的评分?
 
1.1幂等策略
为了防止重复添加活跃度,怎么做幂等呢?一个简单的方案就是将用户的每个加分项,都直接记录下来,在执行具体加分时,基于此来做幂等判定
基于上面这个思路,很容易想到的一个方案就是,每个用户维护一个活跃更新操作历史记录表,我们设计得尽量轻量级点
直接将用户的历史日志,保存在redis的hash数据结构中,每天一个记录
key: activity_rank_{user_id}_{年月日}
field:活跃度更新key
value:添加的活跃度
1.2 榜单评分更新
这个就相对而言比较容易,直接基于zset的incr即可
我们同样是扩展一下RedisClient的工具类,增加上了zset的相关操作。
    /**
     * 分数更新
     *
     * @param key
     * @param value
     * @param score
     * @return
     */
    public static Double zIncrBy(String key, String value, Integer score) {
        return template.execute(new RedisCallback<Double>() {
            @Override
            public Double doInRedis(RedisConnection connection) throws DataAccessException {
                return connection.zIncrBy(keyBytes(key), score, valBytes(value));
            }
        });
    }
 
 
1.3 具体实现
接下来我们看一下具体的实现代码
    /**
     * 添加活跃分
     *
     * @param userId
     * @param activityScore
     */
    @Override
    public void addActivityScore(Long userId, ActivityScoreBo activityScore) {
        if (userId == null) {
            return;
        }
        // 1. 计算活跃度(正为加活跃,负为减活跃)
        String field;
        int score = 0;
        if (activityScore.getPath() != null) {
            field = "path_" + activityScore.getPath();
            score = 1;
        } else if (activityScore.getArticleId() != null) {
            field = activityScore.getArticleId() + "_";
            if (activityScore.getPraise() != null) {
                field += "praise";
                score = BooleanUtils.isTrue(activityScore.getPraise()) ? 2 : -2;
            } else if (activityScore.getCollect() != null) {
                field += "collect";
                score = BooleanUtils.isTrue(activityScore.getCollect()) ? 2 : -2;
            } else if (activityScore.getRate() != null) {
                // 评论回复
                field += "rate";
                score = BooleanUtils.isTrue(activityScore.getRate()) ? 3 : -3;
            } else if (BooleanUtils.isTrue(activityScore.getPublishArticle())) {
                // 发布文章
                field += "publish";
                score += 10;
            }
        } else if (activityScore.getFollowedUserId() != null) {
            field = activityScore.getFollowedUserId() + "_follow";
            score = BooleanUtils.isTrue(activityScore.getFollow()) ? 2 : -2;
        } else {
            return;
        }
        final String todayRankKey = todayRankKey();
        final String monthRankKey = monthRankKey();
        // 2. 幂等:判断之前是否有更新过相关的活跃度信息
        final String userActionKey = ACTIVITY_SCORE_KEY + userId + DateUtil.format(DateTimeFormatter.ofPattern("yyyyMMdd"), System.currentTimeMillis());
        Integer ans = RedisClient.hGet(userActionKey, field, Integer.class);
        if (ans == null) {
            // 2.1 之前没有加分记录,执行具体的加分
            if (score > 0) {
                // 记录加分记录
                RedisClient.hSet(userActionKey, field, score);
                // 个人用户的操作记录,保存一个月的有效期,方便用户查询自己最近31天的活跃情况
                RedisClient.expire(userActionKey, 31 * DateUtil.ONE_DAY_SECONDS);
                // 更新当天和当月的活跃度排行榜
                Double newAns = RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score);
                RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score);
                if (log.isDebugEnabled()) {
                    log.info("活跃度更新加分! key#field = {}#{}, add = {}, newScore = {}", todayRankKey, userId, score, newAns);
                }
                if (newAns <= score) {
                    // 日活跃榜单,保存31天;月活跃榜单,保存1年
                    RedisClient.expire(todayRankKey, 31 * DateUtil.ONE_DAY_SECONDS);
                    RedisClient.expire(monthRankKey, 12 * DateUtil.ONE_MONTH_SECONDS);
                }
            }
        } else if (ans > 0) {
            // 2.2 之前已经加过分,因此这次减分可以执行
            if (score < 0) {
                Boolean oldHave = RedisClient.hDel(userActionKey, field);
                if (BooleanUtils.isTrue(oldHave)) {
                    Double newAns = RedisClient.zIncrBy(todayRankKey, String.valueOf(userId), score);
                    RedisClient.zIncrBy(monthRankKey, String.valueOf(userId), score);
                    if (log.isDebugEnabled()) {
                        log.info("活跃度更新减分! key#field = {}#{}, add = {}, newScore = {}", todayRankKey, userId, score, newAns);
                    }
                }
            }
        }
    } 
基本上,前面的业务逻辑清楚之后,再看上面的实现,应该没有什么太大的难度,还有点问题?
1.事务问题:多次的redis操作,存在事务问题
2.并发问题:没有做并发,幂等无法100%生效,依然可能存在重复添加/减扣活跃度的情况
上面抛出了两个问题,是在做真实的排行榜时,需要重点考虑的,这里先不进行扩散,提几个关键知识点(并发通过加锁,事务通过最终一致性来保障)
1.4触发活跃度更新
前面只是提供了一个增加活跃度的方法,但啥时候调用它?这里我们借助值之前实现的Event/Listenter方式来处理活跃度更新
文章/用户的相关操作事件监听,并更新对应的活跃度
 /**
     * 用户操作行为,增加对应的积分
     *
     * @param msgEvent
     */
    @EventListener(classes = NotifyMsgEvent.class)
    @Async
    public void notifyMsgListener(NotifyMsgEvent msgEvent) {
        switch (msgEvent.getNotifyType()) {
            case COMMENT:
            case REPLY:
                CommentDO comment = (CommentDO) msgEvent.getContent();
                userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setRate(true).setArticleId(comment.getArticleId()));
                break;
            case COLLECT:
                UserFootDO foot = (UserFootDO) msgEvent.getContent();
                userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setCollect(true).setArticleId(foot.getDocumentId()));
                break;
            case CANCEL_COLLECT:
                foot = (UserFootDO) msgEvent.getContent();
                userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setCollect(false).setArticleId(foot.getDocumentId()));
                break;
            case PRAISE:
                foot = (UserFootDO) msgEvent.getContent();
                userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPraise(true).setArticleId(foot.getDocumentId()));
                break;
            case CANCEL_PRAISE:
                foot = (UserFootDO) msgEvent.getContent();
                userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPraise(false).setArticleId(foot.getDocumentId()));
                break;
            case FOLLOW:
                UserRelationDO relation = (UserRelationDO) msgEvent.getContent();
                userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setFollow(true).setArticleId(relation.getUserId()));
                break;
            case CANCEL_FOLLOW:
                relation = (UserRelationDO) msgEvent.getContent();
                userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setFollow(false).setArticleId(relation.getUserId()));
                break;
            default:
        }
    } 
发布文章事件
 /**
     * 发布文章,更新对应的积分
     *
     * @param event
     */
    @Async
    @EventListener(ArticleMsgEvent.class)
    public void publishArticleListener(ArticleMsgEvent<ArticleDO> event) {
        ArticleEventEnum type = event.getType();
        if (type == ArticleEventEnum.ONLINE) {
            userActivityRankService.addActivityScore(ReqInfoContext.getReqInfo().getUserId(), new ActivityScoreBo().setPublishArticle(true).setArticleId(event.getContent().getId()));
        }
    } 
然后就是基于用户浏览行为的活跃度更新,这个就可以在Filter/inteceptor层来实现了

2.排行榜查询
前面的实现,我们的数据层,一个完整的排行榜就已经存储下来了,接下来就是将这个榜单展示给用户看
基本流程如下:
- 1.从redis中获取topN的用用户+评分
 - 2.查询用户信息
 - 3.根据用户评分进行排序,并更新每个用户的排名
 

核心的redis实现如下,直接基于zRangeWithScores获取指定排名的用户+对应分数,其中topN的写法如下

3.小结:
基于此,后端的排行榜单的功能就全部实现了;至于前后端交互细节不展开了,这里提供了一个基础、简单可用的排行榜设计及实现的全流程。至于复杂的,要考虑的问题如数据量大,存储的用户操作记录导致存储压力的问题等等不在展开。



















