热帖排行
不同的算分方式:
只存变化的帖子到redis中,每五分钟算一次分,定时任务
存redis
构建redis键
//统计帖子分数
//key:post:score -> value:postId
public static String getPostScoreKey() {return PREFIX_POST + SPLIT + "score";
}
添加帖子时
// 计算帖子分数String redisKey = RedisKeyUtil.getPostScoreKey();redisTemplate.opsForSet().add(redisKey, post.getId());
加精时
@RequestMapping(path = "/wonderful", method = RequestMethod.POST)@ResponseBodypublic String setWonderful(int id) {//加精是改statusdiscussPostService.updateStatus(id, 1);//触发发帖事件,将帖子存入es服务器Event event = new Event().setTopic(TOPIC_PUBLISH).setUserId(hostHolder.getUser().getId()).setEntityType(ENTITY_TYPE_POST).setEntityId(id);eventProducer.fireEvent(event);// 计算帖子分数String redisKey = RedisKeyUtil.getPostScoreKey();redisTemplate.opsForSet().add(redisKey, id);return CommunityUtil.getJsonString(0);}
评论时
//触发发帖时间,存到es服务器if(comment.getEntityType() == ENTITY_TYPE_POST) {event = new Event().setTopic(TOPIC_PUBLISH).setUserId(comment.getUserId()).setEntityType(ENTITY_TYPE_POST).setEntityId(discussPostId);eventProducer.fireEvent(event);// 计算帖子分数String redisKey = RedisKeyUtil.getPostScoreKey();redisTemplate.opsForSet().add(redisKey, discussPostId);}
点赞时
if(entityType == ENTITY_TYPE_POST){//计算帖子分数String redisKey = RedisKeyUtil.getPostScoreKey();redisTemplate.opsForSet().add(redisKey, postId);}
设置定时任务
定时任务类:
public class PostScoreRefreshJob implements Job, CommunityConstant {private static final Logger logger = LoggerFactory.getLogger(PostScoreRefreshJob.class);@Autowiredprivate RedisTemplate redisTemplate;@Autowiredprivate DiscussPostService discussPostService;@Autowiredprivate LikeService likeService;@Autowiredprivate ElasticsearchService elasticsearchService;// 牛客纪元private static final Date epoch;static {try {epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2021-08-01 00:00:00");} catch (ParseException e) {throw new RuntimeException("初始化日期失败!", e);}}@Overridepublic 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("[任务结束] 帖子分数刷新完毕!");}private void refresh(int postId) {// 查询帖子DiscussPost post = discussPostService.findDiscussPostById(postId);if (post == null) {logger.error("该帖子不存在: id = " + postId);return;}// 是否精华boolean wonderful = post.getStatus() == 1;// 评论数量int commentCount = post.getCommentCount();// 点赞数量long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);// 计算权重double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;// 分数 = 帖子权重 + 距离天数(天数越大,分数越低)//Math.max(w, 1) 防止分数为负数//秒->天double score = Math.log10(Math.max(w, 1))+ (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);// 更新帖子分数discussPostService.updateScore(postId, score);// 同步es的搜索数据post.setScore(score);elasticsearchService.saveDiscussPost(post);}
}
配置Quartz任务
//刷新帖子分数的任务@Beanpublic JobDetailFactoryBean postScoreRefreshJobDetail() {JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();factoryBean.setJobClass(PostScoreRefreshJob.class);factoryBean.setName("postScoreRefreshJob");factoryBean.setGroup("communityJobGroup");// 是否持久保存factoryBean.setDurability(true);factoryBean.setRequestsRecovery(true);return factoryBean;}//刷新帖子分数的触发器@Beanpublic SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();factoryBean.setJobDetail(postScoreRefreshJobDetail);factoryBean.setName("postScoreRefreshTrigger");factoryBean.setGroup("communityTriggerGroup");// 5分钟刷新一次factoryBean.setRepeatInterval(1000 * 60 * 5);factoryBean.setJobDataMap(new JobDataMap());return factoryBean;}
在首页按时间和分数展现
之前的mapper默认按时间排,现在修改成两种模式,0安时间排,1按得分排
@Mapper
public interface DiscussPostMapper {//userId为0时,表示查询所有用户的帖子,如果不为0,表示查询指定用户的帖子//offset表示起始行号,limit表示每页最多显示的行数//orderMode表示排序模式,0-默认排序,1-按热度排序List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit, int orderMode);//查询帖子的行数//userId为0时,表示查询所有用户的帖子int selectDiscussPostRows(@Param("userId") int userId);//@param注解用于给参数取别名,拼到sql语句中,如果只有一个参数,并且在<if>标签里,则必须加别名int insertDiscussPost(DiscussPost discussPost);DiscussPost selectDiscussPostById(int id);//根据id查询帖子int updateCommentCount(int id, int commentCount);//修改帖子类型int updateType(int id, int type);//修改帖子状态int updateStatus(int id, int status);//修改帖子分数int updateScore(int id, double score);}
修改xml:
<select id="selectDiscussPosts" resultType="DiscussPost">select<include refid="selectFields"></include>from discuss_postwhere 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>
重构service:
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);}
重构homeController(传入orderMode)
package com.newcoder.community.controller;import com.newcoder.community.entity.DiscussPost;
import com.newcoder.community.entity.Page;
import com.newcoder.community.service.DiscussPostService;
import com.newcoder.community.service.LikeService;
import com.newcoder.community.service.UserService;
import com.newcoder.community.util.CommunityConstant;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;import java.util.ArrayList;
import java.util.List;
import java.util.Map;@Controller
public class HomeController implements CommunityConstant {@Autowiredprivate UserService userService;@Autowiredprivate DiscussPostService discussPostService;@Autowiredprivate LikeService likeService;@RequestMapping(path = "/index", method = RequestMethod.GET)public String getIndexPage(Model model, Page page, @RequestParam(name="orderMode", defaultValue = "0") int orderMode) {//方法调用前,Spring会自动把page注入给model,所以html中可以直接访问page的数据。//先查前10个帖子page.setRows(discussPostService.findDiscussPostRows( 0));page.setPath("/index?orderMode=" + orderMode);List<DiscussPost> list = discussPostService.findDiscussPosts(0,page.getOffset(), page.getLimit(), orderMode);List<Map<String, Object>> discussPosts = new ArrayList<>();if(list != null) {for (DiscussPost post : list) {Map<String, Object> map = new java.util.HashMap<>();map.put("post", post);map.put("user", userService.findUserById(post.getUserId()));//查询帖子的点赞数量long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());map.put("likeCount", likeCount);discussPosts.add(map);}}model.addAttribute("discussPosts", discussPosts);model.addAttribute("orderMode", orderMode);return "/index";}@RequestMapping(path = "/error", method = RequestMethod.GET)public String getErrorPage() {return "/error/500";}// 没有权限时的页面@RequestMapping(path = "/denied", method = RequestMethod.GET)public String getDeniedPage() {return "/error/404";}}
- @RequestParam注解,参数通过request请求传过来,有默认值。
修改index.html
<!-- 筛选条件 --><ul class="nav nav-tabs mb-3"><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></ul>
生成长图(Deprecated)
wkhtmltopdf
将文件上传到云服务器(Deprecated)
上面上一步的长图,工具没调试好,因此也不用。
优化热门帖子列表
缓存:适用于不经常更新的数据(更新缓存不频繁)
(避免直接访问数据库,Redis可跨服务器)
多级缓存-redis
缓存详细的调用过程:
(本地缓存→ redis→ 数据库)
导入依赖
使用Caffeine进行本地缓存
<!-- https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine -->
<dependency><groupId>com.github.ben-manes.caffeine</groupId><artifactId>caffeine</artifactId>
</dependency>
设置参数
# CaffeineProperties
caffeine.posts.max-size=15
caffeine.posts.expire-seconds=180
在业务层注入:
@Value("${caffeine.posts.max-size}")
private int maxSize;@Value("${caffeine.posts.expire-seconds}")
private int expireSeconds;
修改DiscussPostService
配置缓存
//帖子列表缓存
private LoadingCache<String, List<DiscussPost>> postListCache;//帖子总数缓存
private LoadingCache<Integer, Integer> postRowsCache;
缓存写
在构造函数执行之前:
@PostConstructpublic void init() {//初始化帖子列表缓存postListCache = Caffeine.newBuilder().maximumSize(maxSize).expireAfterWrite(expireSeconds, TimeUnit.SECONDS).build(new CacheLoader<String, List<DiscussPost>>() {@Overridepublic @Nullable List<DiscussPost> load(String key) throws Exception {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.valueOf(params[0]);int limit = Integer.valueOf(params[1]);//TODO: 二级缓存:Redis -> mysqllogger.debug("load post list from DB.");return discussPostMapper.selectDiscussPosts(0, offset, limit, 1);}});//初始化帖子总数缓存postRowsCache = Caffeine.newBuilder().maximumSize(maxSize).expireAfterWrite(expireSeconds, TimeUnit.SECONDS).build(new CacheLoader<Integer, Integer>() {@Overridepublic @Nullable Integer load(Integer integer) throws Exception {logger.debug("load post rows from DB.");return discussPostMapper.selectDiscussPostRows(0);}});}
缓存读
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {//只有首页按照热门排序才会缓存//key是offset:limit//get方法是从缓存中取数据if (userId == 0 && orderMode == 1) {return postListCache.get(offset + ":" + limit);}//不缓存logger.debug("load post list from DB.");return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);}public int findDiscussPostRows(int userId) {if (userId == 0) {return postRowsCache.get(userId);}//不缓存logger.debug("load post rows from DB.");return discussPostMapper.selectDiscussPostRows(userId);}
首先在测试类中创建函数插入100000w条帖子:
//创建10w条数据进行压力测试@Testpublic void initDataForTest() {for(int i = 0; i < 100000; i++) {DiscussPost post = new DiscussPost();post.setUserId(111);post.setTitle("互联网寒冬");post.setContent("今年的互联网寒冬真是太冷了。");post.setCreateTime(new Date());post.setScore(Math.random() * 2000);discussPostService.addDiscussPost(post);}}
测试代码
@Testpublic void testCache() {//第一次查询,应该从数据库中查System.out.println(discussPostService.findDiscussPosts(0, 0, 10, 1));//第二次查询,应该从缓存中查System.out.println(discussPostService.findDiscussPosts(0, 0, 10, 1));//第三次查询,应该从缓存中查System.out.println(discussPostService.findDiscussPosts(0, 0, 10, 1));//第三次查询,应该从数据库中查System.out.println(discussPostService.findDiscussPosts(0, 0, 10, 0));}
(前三次查询,日志只打印了一次,说明只差了一次数据库)
压力测试
使用JMeter进行测试,下载地址:
https://jmeter.apache.org/download_jmeter.cgi
下载后到bin目录下,运行
sh jmeter.sh
出来GUI界面,依次配置测试计划、线程组、HTTP请求,在聚合报告里看:
不加缓存
注释掉ServiceLogAspect的注解,可以干净日志
100个线程,吞吐量约12.5
加缓存
直接191.7,增加了十几倍