用定时任务和缓存实现热帖 | 社区项目

1,514 阅读7分钟

简介

一个SpringBoot的社区项目。

使用quartz、redis和caffeine作完成热帖的开发。

实现

帖子的分数公式:score = log10(精华分 + 评论数 \ 10 + 点赞数 \ 2) + (发布时间 - 上线时间)

影响分数的因素:是否加精、评论数、点赞数、发布时间。

在其他条件(是否加精、评论数、点赞数)相同的情况下,新发布的帖子的分数一定大于以前发布的帖子,因为新帖的发布时间更大,而上线时间表示该应用的上线时间,是一个常数。

定时对帖子的分数进行更新,热帖根据帖子的分数从大到小排序。

1. 要更新的帖子id存reids

不需要每次都对所有帖子的分数进行更新,只需要对加精有新的点赞有新的评论新发布的帖子的分数进行更新,发布时间 - 上线时间在帖子发布后是一个常数。


使用redis中的set结构存储需要更新分数的帖子id。

为什么不用消息队列?因为使用消息队列会有重复更新的问题。

帖子a被点赞
消息队列:a

帖子b被点赞
消息队列:a b

帖子a被点赞
消息队列:a b a

...

出于对性能的考虑,我们的热帖并不是要求实时更新的,而是间隔一段时间(5分钟)更新一次,
所以消费消息队列中的消息时,帖子a的分数一定会被更新至少两次,但是除了第一次更新以外都是无用并且浪费性能的更新。

将需要更新的帖子id存入redis。

String key = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(key,post.getId());

redis中key为psot:score是一个字符串常量,value是需要更新分数的帖子id。

public static String getPostScoreKey() {
    return PREFIX_POST + SPLIT + "score";
}

2. quartz更新帖子分数

用quartz设置定时任务,每过5分钟更新一次redis中存储的帖子的分数。

quartz各版本MySQL数据库存储建表SQL语句_zhu19774279的博客-CSDN博客_quartz sql

spring:
  quartz:
    job-store-type: jdbc #持久化到数据库
    scheduler-name: communityScheduler

任务类

写类PostScoreRefreshJob实现Job接口,重写execute()方法。

  • 若redis中没有需要更新分数的帖子operations.size() == 0,就不需要更新分数了。
  • 若redis中有需要更新分数的帖子operations.size() > 0,就需要不断从redis中拿出帖子的idoperations.pop(),根据帖子id更新分数refresh(),直到redis中没有需要更新分数的帖子了。
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
    String redisKey = RedisKeyUtil.getPostScoreKey();
    BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);

    if (operations.size() == 0) {
        logger.info("[任务取消] 没有需要刷新的帖子!");
        return;
    }

    logger.info("[任务开始] 正在刷新帖子分数: " + operations.size());
    while (operations.size() > 0) {
        this.refresh((Integer) operations.pop());
    }
    logger.info("[任务结束] 帖子分数刷新完毕!");
}

Set operations bound to a certain key.

BoundSetOperations (Spring Data Redis 2.7.1 API)


  1. 根据帖子id获取帖子对象
  2. 获取帖子最新状态(是否加精、点赞数、评论数)
  3. 计算帖子最新分数(加精分数:75)
  4. 更新帖子分数
private void refresh(int postId) {
    DiscussPost post = discussPostService.getDiscussPost(postId);

    if (post == null) {
        logger.error("该帖子不存在: id = " + postId);
        return;
    }

    // 是否精华
    boolean wonderful = post.getStatus() == 1;
    // 评论数量
    int commentCount = post.getCommentCount();
    // 点赞数量
    long likeCount = likeService.countLike(ENTITY_TYPE_POST, postId);

    // 计算权重
    double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;
    // 分数 = 帖子权重 + 距离天数
    double score = Math.log10(Math.max(w, 1))
            + (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);
    // 更新帖子分数
    discussPostService.updateScore(postId, score);
    // 同步搜索数据
    post.setScore(score);
    elasticsearchService.saveDiscussPost(post);
}

配置类

@Configuration
public class QuartzConfig {

    // 刷新帖子分数任务
    @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;
    }

    @Bean
    public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {
        SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
        factoryBean.setJobDetail(postScoreRefreshJobDetail);
        factoryBean.setName("postScoreRefreshTrigger");
        factoryBean.setGroup("communityTriggerGroup");
        factoryBean.setRepeatInterval(1000 * 60 * 5);//5分钟执行一遍
        factoryBean.setJobDataMap(new JobDataMap());
        return factoryBean;
    }
}

quartz相关

为什么设计成JobDetail + Job,不直接使用Job? 这是因为任务是有可能并发执行,如果Scheduler直接使用Job,就会存在对同一个Job实例并发访问的问题。而JobDetail & Job 方式,sheduler每次执行,都会根据JobDetail创建一个新的Job实例,这样就可以规避并发访问的问题。 Quartz定时任务框架(一)_一源星河的博客-CSDN博客_quartz定时任务

先了解Quartz的核心类:

Quartz有四个核心概念:

  • Job:是一个接口,只定义一个方法 execute(JobExecutionContext context),在实现接口的 execute 方法中编写所需要定时执行的 Job(任务),JobExecutionContext 类提供了调度应用的一些信息;Job 运行时的信息保存在 JobDataMap 实例中,通过JobDataMap 我们可以为任务传参数。
  • JobDetail:Quartz 每次调度 Job 时,都重新创建一个 Job 实例,因此它不接受一个 Job 的实例,相反它接收一个 Job 实现类(JobDetail,描述 Job 的实现类及其他相关的静态信息,如 Job 名字、描述、关联监听器等信息),以便运行时通过 newInstance() 的反射机制实例化 Job。
  • trigger:是一个类,描述触发 Job 执行的时间触发规则,主要有 SimpleTrigger 和 CronTrigger 这两个子类。当且仅当需调度一次或者以固定时间间隔周期执行调度,SimpleTrigger 是最适合的选择;而 CronTrigger 则可以通过 Cron 表达式定义出各种复杂时间规则的调度方案:如工作日周一到周五的 15:00 ~ 16:00 执行调度等。
  • Scheduler:调度器就相当于一个容器,装载着任务和触发器,该类是一个接口,代表一个 Quartz 的独立运行容器,Trigger 和 JobDetail 可以注册到 Scheduler 中,两者在 Scheduler 中拥有各自的组及名称,组及名称是 Scheduler 查找定位容器中某一对象的依据,Trigger 的组及名称必须唯一,JobDetail 的组和名称也必须唯一(但可以和 Trigger 的组和名称相同,因为它们是不同类型的)。Scheduler 定义了多个接口方法,允许外部通过组及名称访问和控制容器中 Trigger 和 JobDetail。

Job 为作业的接口,为任务调度的对象;JobDetail 用来描述 Job 的实现类及其他相关的静态信息;Trigger 作为作业的定时管理工具,一个 Trigger 只能对应一个作业实例,而一个作业实例可对应多个触发器;Scheduler 作为定时任务容器,是 Quartz 最上层的东西,它提携了所有触发器和作业,使它们协调工作,每个 Scheduler 都存有 JobDetail 和 Trigger 的注册,一个 Scheduler 中可以注册多个 JobDetail 和多个 Trigger。

springboot中定时任务执行Quartz的使用_asoklove的博客-CSDN博客

3. 从caffeine中获取帖子

缓存设计

Caffeine提供了四种缓存添加策略:手动加载Cache,自动加载LoadingCache,手动异步加载AsyncCache和自动异步加载AsyncLoadingCache

Population zh CN · ben-manes/caffeine Wiki (github.com)

我们使用自动加载的缓存策略。


public interface LoadingCache<K, V> extends Cache<K, V>可以看出,LoadingCache需要一个key,一个value。

  • 帖子列表缓存

    • key:offset + ":" + limit,总体来看是一个字符串,包含第几页和每页多少条数据这两个信息
    • value:帖子列表
  • 帖子总数缓存

    • key:用户的id,为0时表示首页
    • value:帖子总数
// 帖子列表缓存
private LoadingCache<String, List<DiscussPost>> postListCache;

// 帖子总数缓存
private LoadingCache<Integer, Integer> postRowsCache;

初始化缓存

@PostConstruct注解的作用是在依赖注入完成后调用被注解的方法

@PostConstruct 注解说明 - 简书 (jianshu.com)

初始化方式一:@PostConstruct注解 初始化方式二:实现InitializingBean接口

@PostConstruct注解详解_大局观的小老虎的博客-CSDN博客_@postconstruct注解

@PostConstruct
public void init() {
    // 初始化帖子列表缓存
    postListCache = Caffeine.newBuilder()
            .maximumSize(maxSize)
            .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
            .build(key -> {
                if (key == null || key.length() == 0) {
                    throw new IllegalArgumentException("参数错误!");
                }

                String[] params = key.split(":");
                if (params == null || params.length != 2) {
                    throw new IllegalArgumentException("参数错误!");
                }

                int offset = Integer.parseInt(params[0]);
                int limit = Integer.valueOf(params[1]);

                logger.info("load post list from DB.");
                return discussPostDao.listDiscussPost(0, offset, limit, 1);
            });
    // 初始化帖子总数缓存
    postRowsCache = Caffeine.newBuilder()
            .maximumSize(maxSize)
            .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
            .build(key -> {
                logger.info("load post rows from DB.");
                return discussPostDao.countDiscussPost(key);
            });
}
  • postListCache:通过解析key获取offsetlimit这两个参数,从DB中获取帖子列表

  • postRowsCache:通过key直接获取帖子总数,key为用户id,为0时表示首页

我们设计的是先从caffeine中查,如果caffeine中查不到数据就直接从DB中查。还可以在caffeine和DB中再加一层redis,防止缓存雪崩,提高可用性。

获取缓存

获取首页根据热度排行的帖子时才走缓存查询。缓存应该存储变化不是太大的数据,所以获取首页的根据时间排行的帖子时直接从DB中获取,获取某个用户发布的所有帖子时直接从DB中获取。

使用cache.get(key)根据key查找缓存。

public List<DiscussPost> listDiscussPost(int userId, int offset, int limit, int orderMode) {
    if (userId == 0 && orderMode == 1) {
        return postListCache.get(offset + ":" + limit);
    }

    logger.info("load post list from DB.");
    return discussPostDao.listDiscussPost(userId, offset, limit, orderMode);
}

public int countDiscussPost(int userId) {
    if (userId == 0) {
        return postRowsCache.get(userId);
    }

    logger.info("load post rows from DB.");
    return discussPostDao.countDiscussPost(userId);
}