参考牛客网高级项目教程
狂神说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中的元素,随机弹
-
先根据帖子id查询出帖子
-
边界处理:防止帖子状态更新后,定时刷新分数前已经被管理员删除
-
计算帖子分数
-
更新数据库中的帖子分数
-
数据同步到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>
测试结果
-
默认按照发布时间先后排序
-
点击最热,切换到按照帖子分数排序