简介
一个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.
- 根据帖子id获取帖子对象
- 获取帖子最新状态(是否加精、点赞数、评论数)
- 计算帖子最新分数(加精分数:75)
- 更新帖子分数
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。
3. 从caffeine中获取帖子
缓存设计
Caffeine提供了四种缓存添加策略:手动加载
Cache,自动加载LoadingCache,手动异步加载AsyncCache和自动异步加载AsyncLoadingCache。
我们使用自动加载的缓存策略。
从public interface LoadingCache<K, V> extends Cache<K, V>可以看出,LoadingCache需要一个key,一个value。
-
帖子列表缓存
- key:
offset + ":" + limit,总体来看是一个字符串,包含第几页和每页多少条数据这两个信息 - value:帖子列表
- key:
-
帖子总数缓存
- key:用户的id,为0时表示首页
- value:帖子总数
// 帖子列表缓存
private LoadingCache<String, List<DiscussPost>> postListCache;
// 帖子总数缓存
private LoadingCache<Integer, Integer> postRowsCache;
初始化缓存
@PostConstruct注解的作用是在依赖注入完成后调用被注解的方法
初始化方式一:@PostConstruct注解 初始化方式二:实现InitializingBean接口
@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获取
offset和limit这两个参数,从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);
}