项目1在线交流平台-7.构建安全高效的企业服务-7. Quartz定时任务应用-热帖排行-kafka、redis、ES


参考牛客网高级项目教程
狂神说Redis教程笔记

功能需求

在这里插入图片描述

  • 1.定期对帖子的分数进行计算更新,因此需要用到Quartz定时任务的线程池处理计算任务
  • 2.对帖子的分数计算,应该与帖子状态、评论数和点赞数正相关,与发布时间成反比
    • 且,用到log函数,目的是使得分数变化平缓,特别是随着时间的增加,变化更加平缓
  • 3.更新完帖子分数后,
    • 需要在页面显示热帖排行
    • ES搜索时排序也更新,因此,要将更新的帖子数据同步到ES服务器中

一、处理要计算分数的帖子

1. 处理策略

  • 将要计算的帖子先放进redis缓存中,定时时间到,再统一从redis中取出计算处理

    • 之所以要存入redis中,因为redis缓存读取和储存性能比较好
    • 而不是直接放入kafka中,kafka消费是被动的,随机的,无法保证定时处理缓存中的数据
  • 触发帖子加入redis中的时机有

    • 帖子加精
    • 新增帖子-需要给新的帖子一个初始分
    • 对帖子评论、点赞处理时,均会影响帖子的分数
    • 注意:对帖子置顶无需算分,因为,置顶默认排序靠前

2. redis定义key及储存处理

2.1 key的定义
  • value中存帖子id,故,key无需传参
  • 可以储存所有帖子,故定位到帖子分数即可,即知道要处理value中id的帖子分数
/**
 * 定义要更新帖子分数的key
 *      value中存帖子id,故,key无需传参
 */
public static String getPostScoreKey() {
    return PREFIX_POST + SPLIT + "score";
}
2.2 redis中储存帖子id的时机
Set数据结构
  • 储存的结构选择Set无序集合
    • 因为,一个帖子在定时处理前状态改变可能不止一次,但储存一次即可,不能用list等,要去重
    • 定时任务要更新所有状态改变的帖子的分数**,对顺序没有要求**
加精处理时
/** 储存要更新分数的帖子的key*/
private String postScoreKey = RedisKeyUtil.getPostScoreKey();
/**
 * 加精处理-异步请求
 *     注意:更改帖子后,要将帖子信息同步到ES服务器中,-重新覆盖就是修改
 *     故,要增加发帖事件-进kafka消息队列中
 */
@RequestMapping(value = "/wonderful", method = RequestMethod.POST)
@ResponseBody
public String setWonderful(int id) {
    // 先修改帖子类型
    discussPostService.updatePostStatus(id, POST_STATUS_WONDERFUL);
    // 定义发帖事件-发布到topic中
    Event event = new Event()
            .setTopic(TOPIC_PUBLISH)
            .setEntityId(id);
    eventProducer.sendEvent(event);

    // 将帖子储存到redis缓存中-定时更新帖子分数
    redisTemplate.opsForSet().add(postScoreKey, id);
    
    return CommunityUtil.getJSONString(0);
}
新增帖子时
/**
 * 处理ajax异步发布帖子请求
 * @param title     帖子主题
 * @param content   帖子内容
 * @return          JSON字符串
 */
@RequestMapping(value = "/add", method = RequestMethod.POST)
@ResponseBody
public String addPost(String title, String content) {
    // 1.先获取当前用户,进行权限判断
    User user = hostHolder.getUser();
    if(user == null) {
        return CommunityUtil.getJSONString(403, "您还没登录,无法发布帖子!");
    }
    // 创建帖子,并调用service层处理
    DiscussPost discussPost = new DiscussPost();
    discussPost.setTitle(title);
    discussPost.setContent(content);
    discussPost.setUserId(user.getId());
    discussPost.setCreateTime(new Date());
    discussPostService.insertPost(discussPost);

    // 发布帖子后,触发发帖事件-向kafka服务器发布消息
    Event event = new Event()
            .setTopic(TOPIC_PUBLISH)
            .setFromUserId(user.getId())
            .setEntityType(ENTITY_TYPE_POST)
            .setEntityId(discussPost.getId());
    eventProducer.sendEvent(event);
    
    // 将帖子储存到redis中-定时计算更新分数
    redisTemplate.opsForSet().add(postScoreKey, discussPost.getId());
    
    // 返回JSON字符串,先处理成功的,失败的今后统一处理
    return CommunityUtil.getJSONString(0, "发布成功!");
}
对帖子评论时
/**
 * 针对特定帖子发布评论
 * @param discussPostId     // 目标帖子id
 * @param comment           // 评论内容
 * @return
 */
@RequestMapping(value = "/add/{discussPostId}", method = RequestMethod.POST)
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
    // 除请求中需要写的评论内容外,需提供其他素材
    comment.setCreateTime(new Date());
    comment.setUserId(hostHolder.getUser().getId());
    comment.setStatus(0);
    // 将数据交给service处理
    commentService.addComment(comment);

    // 添加完评论后,系统向目标用户发送通知-触发评论事件
    // 封装评论事件信息-消费到DB中
    Event event = new Event()
            .setTopic(TOPIC_COMMENT)
            .setFromUserId(hostHolder.getUser().getId())
            .setEntityType(comment.getEntityType()) // 评论的可以是帖子,回帖,回复
            .setEntityId(comment.getEntityId())
            .setData("postId", discussPostId);  // 当前评论所属的帖子id
    // 事件对象的作者-分情况判定-帖子作者、评论作者
    if (comment.getEntityType() == ENTITY_TYPE_POST) {
        DiscussPost target = discussPostService.selectPostById(comment.getEntityId());
        event.setEntityUserId(target.getUserId());
    } else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) {
        Comment target = commentService.selectCommentById(comment.getEntityId());
        event.setEntityUserId(target.getUserId());
    }
    // 将信息发送到消息队列中
    eventProducer.sendEvent(event);

    if (comment.getEntityType() == ENTITY_TYPE_POST) {
        // 触发发帖事件-消费到ES服务器
        event = new Event()
                .setTopic(TOPIC_PUBLISH)
                .setFromUserId(comment.getUserId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(discussPostId);
        eventProducer.sendEvent(event);
        
        // 储存到redis中-定时更新分数
        String postScoreKey = RedisKeyUtil.getPostScoreKey();
        redisTemplate.opsForSet().add(postScoreKey, discussPostId);
    }
    return "redirect:/discuss/detail/" + discussPostId;
}
对帖子点赞时
/**
 * 处理点赞的异步请求
 * @param entityType
 * @param entityId
 * @return  json字符串,不传递msg,如果有问题,直接在网页alert提示
 */
@RequestMapping(value = "/like", method = RequestMethod.POST)
@ResponseBody
public String like(int entityType, int entityId, int entityUserId, int postId) {
    // 用来封装信息的map
    Map<String, Object> map = new HashMap<>();
    // 权限-统一管理,先获取当前用户
    User user = hostHolder.getUser();
    // 点赞事件处理
    likeService.like(user.getId(), entityType, entityId, entityUserId);
    // 点赞数量获取
    long likeCount = likeService.findEntityLikeCount(entityType, entityId);
    // 点赞状态获取
    int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);
    // 统一封装到map传给前端页面
    map.put("likeCount", likeCount);
    map.put("likeStatus", likeStatus);

    // 触发点赞事件后-系统向目标用户发送通知
    if(likeStatus == 1) {
        Event event = new Event()
                .setTopic(TOPIC_LIKE)
                .setFromUserId(hostHolder.getUser().getId())
                .setEntityType(entityType)
                .setEntityId(entityId)
                .setEntityUserId(entityUserId)
                .setData("postId", postId);
        eventProducer.sendEvent(event);
        
        // 帖子储存到redis中-定时更新帖子分数
        String postScoreKey = RedisKeyUtil.getPostScoreKey();
        redisTemplate.opsForSet().add(postScoreKey, postId);
    }
    return CommunityUtil.getJSONString(0, null, map);
}

二、使用Quartz定期更新帖子分数

1 定义更新帖子分数的任务Job

1.1 绑定redis的key,取value
boundSetOps(postScoreKey)-绑定key的set集合
  • 因要处理大量同一个key的value,故,可以先将key绑定,简化取值
1.2. 处理任务:刷新帖子的分数,前后记录日志方便调试
operations.pop()-弹出每一个set中的元素,随机弹
  1. 先根据帖子id查询出帖子

  2. 边界处理:防止帖子状态更新后,定时刷新分数前已经被管理员删除

  3. 计算帖子分数

  4. 更新数据库中的帖子分数

  5. 数据同步到ES服务器中,先放入kafka中,异步消费同步到ES中

// 社区纪元
private static final Date epoch;

static {
    try {
        epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2014-08-01 00:00:00");
    } catch (ParseException e) {
        throw new RuntimeException("初始化社区纪元失败!", e);
    }
}


@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
    // 1. 绑定redis的key,取value
    String postScoreKey = RedisKeyUtil.getPostScoreKey();
    BoundSetOperations operations = redisTemplate.boundSetOps(postScoreKey);

    // 2. 边界处理,先判定set集合中有无要处理的帖子
    if(operations.size() == 0) {
        logger.info("任务取消,没有需要刷新的帖子");
        return;
    }

    // 3. 处理任务:刷新帖子的分数,前后记录日志方便调试
    logger.info("任务开始,正在刷新帖子分数.."+operations.size());
    while(operations.size() > 0) {
        refresh((Integer) operations.pop());
    }
    logger.info("任务结束,分数刷新完毕!");
}

/**
 * 更新计算帖子分数逻辑
 * @param postId    要处理的帖子id
 */
private void refresh(int postId) {
    // 1. 先根据帖子id查询出帖子
    DiscussPost post = discussPostService.selectPostById(postId);

    // 2. 边界处理:防止帖子状态更新后,定时刷新分数前已经被管理员删除
    if(post == null) {
        logger.error("该帖子不存在:id="+postId);
        return;
    }

    // 3. 计算帖子分数
    // 是否加精
    boolean wonderful = post.getStatus() == POST_STATUS_WONDERFUL;
    // 评论数量
    int commentCount = post.getCommentCount();
    // 点赞数量
    long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
    // 计算权重
    double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;
    // 计算分数=log(w) + 距离起始值天数
    // 新增帖子没有权重,默认为1
    // 时间以天为单位
    double score = Math.log10(Math.max(w, 1))
            + (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);

    // 4. 更新数据库中的帖子分数
    discussPostService.updateScore(postId, score);

    // 5. 数据同步到ES服务器中,先放入kafka中,异步消费同步到ES中
    Event event = new Event()
            .setTopic(TOPIC_PUBLISH)
            .setEntityId(postId);
    eventProducer.sendEvent(event);
}

2. 配置JobDetail和Trigger

// 配置JobDetail
    @Bean
    public JobDetailFactoryBean postScoreRefreshJobDetail() {
        JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
        factoryBean.setJobClass(PostScoreRefreshJob.class);
        factoryBean.setName("postScoreRefreshJob");        // 任务名称唯一
        factoryBean.setGroup("communityJobGroup");  // 任务组名唯一
        factoryBean.setDurability(true);        // 声明这个任务是不是持久化的
        factoryBean.setRequestsRecovery(true);  // 声明这个任务是不是可恢复的
        return factoryBean;
    }

    // 配置Trigger(SimpleTriggerFactoryBean, CronTriggerFactoryBean)
	@Bean
    public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {
        SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
        factoryBean.setJobDetail(postScoreRefreshJobDetail);   // 优先绑定到传入名称相同的jobDetail
        factoryBean.setName("postScoreRefreshTrigger");        // 名称唯一
        factoryBean.setGroup("communityJobGroup");  // 组名唯一
        factoryBean.setRepeatInterval(1000 * 60 * 5);        // 重复执行时间间隔5分钟
        factoryBean.setJobDataMap(new JobDataMap());    // 使用默认map装数据
        return factoryBean;
    }

测试

  • 创建三个帖子

    • 其中,AAA帖子评论,点赞最多,且加精
    • BBB帖子,有点赞和评论
    • CCC最新发布,点赞和评论最少

    在这里插入图片描述

  • 三个帖子5分钟后,统计的分数如下:

    在这里插入图片描述

    在这里插入图片描述

  • ES搜索也能搜索到更新后的数据

    在这里插入图片描述

三、展现帖子排行

1. dao层更新查询帖子列表的操作

  • 查询帖子列表时,增加一个动态选择参数,定义不同的排序规则
    • 是普通的排序或是根据帖子分数排序
sql语句定义
  • 根据orderMode参数动态拼接sql
<!--    查询指定页面信息的帖子列表-->
    <select id="getPosts" resultType="discussPost">
        <include refid="selectFields"></include>
        where status != 2
        <if test="userId != 0">
            and user_id = #{userId}
        </if>
        <if test="orderMode==0">
            order by type desc, create_time desc
        </if>
        <if test="orderMode==1">
            order by type desc, score desc, create_time desc
        </if>
        limit #{offset}, #{limit}
    </select>
接口方法修改
// 查询指定页面信息的帖子列表
List<DiscussPost> getPosts(int userId, int offset, int limit, int orderMode);

2. server层更新

// 查询指定页面信息的帖子列表
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
    return discussPostMapper.getPosts(userId, offset, limit, orderMode);
}

3. controller层更新

参数中传入排序规则
  • 首页有两个选项,选择哪个按钮,传入相应的排序规则
  • 注意,打开首页后,要有一个默认的排序规则
@RequestParam(name = "orderMode", defaultValue = "0") int orderMode
查询帖子时,参数传入
List<DiscussPost> posts = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit(), orderMode);
将排序规则传给前端模板,动态显示按钮样式
model.addAttribute("orderMode", orderMode);
分页时,要根据排序规则分页
page.setPath("/index?orderMode=" + orderMode);

4. 前端模板更新处理

  • 样式根据排序规则动态切换
<li class="nav-item">
   <a th:class="|nav-link ${orderMode==0?'active':''}|" th:href="@{/index(orderMode=0)}">最新</a>
</li>
<li class="nav-item">
   <a th:class="|nav-link ${orderMode==1?'active':''}|" th:href="@{/index(orderMode=1)}">最热</a>
</li>

测试结果

  • 默认按照发布时间先后排序

    在这里插入图片描述

  • 点击最热,切换到按照帖子分数排序

    在这里插入图片描述

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值