# lg-guavacache **Repository Path**: sunli1103_admin/lg-guavacache ## Basic Information - **Project Name**: lg-guavacache - **Description**: 【java训练营作业18-Guava Cache本地缓存】第五阶段 大型分布式系统缓存架构进阶 模块二 Guava Cache、EVCache、Tair、Aerospike本模块对市场上其他缓存服务进行讲解,例如:Guava Cache、EVCache、Tair、Aerospike等,可以提高在缓存方面的架构选型能力。 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2020-08-03 - **Last Updated**: 2022-03-04 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## 作业说明 #### 课程介绍 > **第五阶段 大型分布式系统缓存架构进阶** > > 模块一 Guava Cache、EVCache、Tair、Aerospike > > 本模块对市场上其他缓存服务进行讲解,例如:Guava Cache、EVCache、Tair、Aerospike等,可以提高在缓存方面的架构选型能力。 #### 作业内容 > **模拟拉勾网首页热门职位的缓存设计和实现** > > ![输入图片说明](https://images.gitee.com/uploads/images/2020/0811/032858_647b084a_1712191.png "屏幕截图.png") > > - BS结构:springboot或ssm都行 > - 设计合适的数据结构用于缓存数据 > - 先读本地缓存,本地缓存没有则读分布式缓存,分布式缓存没有则读数据库 > - 采用CacheAsidePattern方式读写缓存 > - 分布式缓存可以采用RedisCluster或Tair搭建 #### 软件版本 ``` CentOS 7.7 Redis 5.0.5 JDK 11 Spring Boot 2.3.2.RELEASE Mybatis-Spring-Boot-Starter 2.1.3 Guava 29.0-jre ``` #### 实现思路 1. 访问首页【作业要求以外内容,因时间紧张,编码后未测试】 * 每当客户端访问拉勾首页的热门岗位时,从本地缓存中取出排行榜部分数据(比如首页只展示9条,那本地缓存就从redis中获取该数量的记录)。因为这样比较节省本地缓存的空间。当用户进入二级页面查看更过热门岗位时再查询redis缓存,并通过分页过滤数据。 * redis中有个zset实现的排行榜,记录当前某段时间内热门的岗位信息(根据用户浏览或投递评分倒排)。 * 可以是24小时内每隔一段时间(比如10分钟,为了避免有的岗位信息更新而不一致的情况时间间隔尽量很小)就通过定时任务刷新一下缓存。可以先从数据库中查询最热门的500个岗位倒排后保存到redis缓存。然后各个服务节点再通过定时任务从缓存中取得首页展示数量的数据(具体数量根据不同客户端展示的记录条数而定)。 * 还没考虑好如何避免redis排行榜更新时瞬时缓存穿透问题。不知道是否可以采用CopyOnWrite策略,生成新排行榜数据后直接覆盖旧数据,而不是先删除再插入。`待直播提问` 2. 查看某一条岗位信息【作业内容,已实现】 * 提供通过岗位id(步骤1中获取一览数据后取得岗位id)查看该条岗位信息的api。 * 先从本地缓存中获取,如果没有就查询redis,然后存入本地缓存。 * 如果redis缓存没有,就查询数据库,最后存入redis和本地缓存。 3. 更新岗位信息【作业内容,已实现】 * 提供通过岗位id修改该条岗位信息的api。 * 修改岗位信息后会同时清除redis和本地缓存中对应的该条数据。 4. 清除本地缓存【作业要求以外内容,因时间紧张,未实现】 * 如果是分布式环境下多个服务节点的场合,服务节点A更新岗位信息后清除redis和本机的本地缓存,但是因为跨机器了,无法通知服务节点B、C、D等其他节点同时清除本地缓存(各服务节点之间无感知无通信)。 * 所以考虑是否需要通过redis订阅机制或者MQ异步请求其他节点清除各机本地缓存。`待直播提问` 5. 缓存过期 * 暂时没有考虑过期策略。 6. 前端页面展现【作业要求以外内容,因时间紧张,未实现】 * 使用前后端分离开发模式。 * 前端使用jquery调用后端api,然后设置相应css样式,达到拉勾首页效果。 #### 实现内容 **Java** PostServiceImpl.java ```java package com.lagou.guavacache.service.impl; import com.google.common.base.Optional; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.lagou.guavacache.dto.ModifyPostDTO; import com.lagou.guavacache.entity.PostInfo; import com.lagou.guavacache.enums.CompanyTypeEnum; import com.lagou.guavacache.enums.EducationTypeEnum; import com.lagou.guavacache.enums.SeniorityTypeEnum; import com.lagou.guavacache.mapper.PostMapper; import com.lagou.guavacache.service.PostService; import com.lagou.guavacache.vo.PostInfoVO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; @Service @Slf4j public class PostServiceImpl implements PostService { @Autowired private PostMapper postMapper; @Autowired private RedisTemplate redisTemplate; public static Cache> postCache; static { // 初始化本地缓存 postCache = CacheBuilder.newBuilder().build(); } /** * 根据岗位id查询岗位信息 * 优先从本地缓存取得;如果没有,则查询redis;如果还没有,则查询数据库,并写入redis和本地缓存 */ @Override public PostInfoVO getPostById(String postId) { log.info("PostServiceImpl.getPostById()"); final boolean[] isLoadCache = {true}; PostInfoVO postInfoVO = new PostInfoVO(); Optional cachePostInfo = null; // 1.从本地缓存取得岗位信息 try { cachePostInfo = (Optional) postCache.get(postId, new Callable>() { // 2.如果没有,则查询redis @Override public Optional call() throws Exception { isLoadCache[0] = false; log.info("从本地缓存中未获取到数据:" + postId); Object redisPostInfo = redisTemplate.opsForHash().get("post", postId); if (redisPostInfo != null) { log.info("从redis中获取数据:" + postId); log.info("已存入本地缓存:" + postId); return Optional.of((PostInfoVO) redisPostInfo); } else { log.info("从redis中未获取到数据:" + postId); return Optional.fromNullable(null); } } }); } catch (ExecutionException e) { e.printStackTrace(); } // 如果本地缓存中存在该岗位信息,则取出返回客户端 if (cachePostInfo.isPresent()) { postInfoVO = cachePostInfo.get(); if (isLoadCache[0]) { log.info("从本地缓存中获取数据:" + postId); } } else { // 3.如果还没有,则查询数据库 PostInfo postInfo = postMapper.selectPostById(Long.valueOf(postId)); log.info("从数据库中获取数据:" + postId); if (postInfo != null) { postInfoVO = editVO(postInfo); // 写入redis和本地缓存 redisTemplate.opsForHash().put("post", postInfoVO.getPostId(), postInfoVO); log.info("已存入redis缓存:" + postId); postCache.put(postInfoVO.getPostId(), Optional.of(postInfoVO)); log.info("已存入本地缓存:" + postId); } } return postInfoVO; } /** * 根据岗位id修改岗位信息 * 修改后删除缓存中旧的数据 */ @Override public void modifyPostById(String postId, ModifyPostDTO modifyPostDTO) { log.info("PostServiceImpl.modifyPostById()"); postMapper.updatePostById(Long.valueOf(postId), modifyPostDTO); log.info("数据库已修改:" + postId); redisTemplate.opsForHash().delete("post", postId); log.info("redis缓存已清除:" + postId); postCache.invalidate(postId); log.info("本地缓存已清除:" + postId); } /** * 根据页面展现格式的要求编辑数据库中的信息 */ private PostInfoVO editVO(PostInfo postInfo) { PostInfoVO postInfoVO = new PostInfoVO(); postInfoVO.setPostId(String.valueOf(postInfo.getPostId())); postInfoVO.setPostName(postInfo.getPostName()); postInfoVO.setSalaryMin(String.valueOf(postInfo.getSalaryMin())); postInfoVO.setSalaryMax(String.valueOf(postInfo.getSalaryMax())); postInfoVO.setPublishDate(String.valueOf(postInfo.getPublishDate())); postInfoVO.setSeniority(SeniorityTypeEnum.getNameByCode(postInfo.getSeniority())); postInfoVO.setEducation(EducationTypeEnum.getNameByCode(postInfo.getEducation())); postInfoVO.setAddress(postInfo.getAddress()); postInfoVO.setPostLabels(postInfo.getPostLabels()); postInfoVO.setCompanyName(postInfo.getCompanyName()); postInfoVO.setCompanyType(CompanyTypeEnum.getNameByCode(postInfo.getCompanyType())); postInfoVO.setCompanyIndustry(postInfo.getCompanyIndustry()); postInfoVO.setCompanyLogo(postInfo.getCompanyLogo()); return postInfoVO; } } ``` application.yml ```yml spring: datasource: url: jdbc:mysql://localhost:3306/lagou_guavacache?useUnicode=true&characterEncoding=utf-8&useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: database: 0 cluster: nodes: - 192.168.0.111:7001 - 192.168.0.111:7002 - 192.168.0.111:7003 - 192.168.0.111:7004 - 192.168.0.111:7005 - 192.168.0.111:7006 - 192.168.0.111:7007 - 192.168.0.111:7008 timeout: 5000 # jedis: # pool: # max-idle: 16 # max-active: 32 # min-idle: 8 mybatis: type-aliases-package: com.lagou.guavacache.entity mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true ``` **Mybatis** ```xml UPDATE post post_id=#{postId}, post_name=#{modifyPostDTO.postName}, address=#{modifyPostDTO.salaryMin}, salary_max=#{modifyPostDTO.salaryMax}, seniority=#{modifyPostDTO.seniority}, education=#{modifyPostDTO.education}, address=#{modifyPostDTO.address}, post_labels=#{modifyPostDTO.postLabels} WHERE post_id = #{postId}; ``` **MySQL** post.sql ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for post -- ---------------------------- DROP TABLE IF EXISTS `post`; CREATE TABLE `post` ( `post_id` bigint(20) NOT NULL, `post_name` varchar(16) DEFAULT NULL, `salary_min` int(11) DEFAULT NULL, `salary_max` int(11) DEFAULT NULL, `publish_date` timestamp NULL DEFAULT NULL, `seniority` tinyint(4) DEFAULT NULL, `education` tinyint(4) DEFAULT NULL, `address` varchar(64) DEFAULT NULL, `post_labels` varchar(32) DEFAULT NULL, `company_id` bigint(20) DEFAULT NULL, PRIMARY KEY (`post_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of post -- ---------------------------- BEGIN; INSERT INTO `post` VALUES (1, '资深测试工程师', 30000, 50000, '2020-08-03 15:15:36', 3, 1, '深圳', NULL, 1); INSERT INTO `post` VALUES (2, '高级技术美术TA', 25000, 40000, '2020-08-03 15:16:46', 6, 1, '上海', NULL, 2); INSERT INTO `post` VALUES (3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO `post` VALUES (4, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO `post` VALUES (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO `post` VALUES (6, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); COMMIT; SET FOREIGN_KEY_CHECKS = 1; ``` company.sql ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for company -- ---------------------------- DROP TABLE IF EXISTS `company`; CREATE TABLE `company` ( `company_id` bigint(20) NOT NULL, `company_name` varchar(32) DEFAULT NULL, `company_type` tinyint(4) DEFAULT NULL, `company_industry` varchar(16) DEFAULT NULL, `company_logo` varchar(255) DEFAULT NULL, PRIMARY KEY (`company_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of company -- ---------------------------- BEGIN; INSERT INTO `company` VALUES (1, 'Shopee', 8, '电商', 'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1596449498785&di=cfdb936e69c981af3abec50bb2b6f310&imgtype=0&src=http%3A%2F%2Fcn.toluna.com%2Fdpolls_images%2F2018%2F11%2F13%2Fcf51fe1f-5a80-4ef5-8d93-5518629b8d60_x365.jpg'); INSERT INTO `company` VALUES (2, '莉莉丝游戏', 1, '移动互联网,游戏', 'https://dss0.baidu.com/6ONWsjip0QIZ8tyhnq/it/u=983426394,984206563&fm=179&app=42&f=JPEG?w=121&h=140&s=49A638724AD54BEB1AD02FEF02007023'); COMMIT; SET FOREIGN_KEY_CHECKS = 1; ``` #### 注意事项 1. Null的处理 覆写Guava Cache的get方法中Callable参数的call方法时,如果考虑了查询数据为null的场合,返回值需要使用Optional类包装下才可以。定义缓存时,也需要把泛型的限定类型使用Optional包装成`Cache>`,例如: ``` new Callable>() { @Override public Optional call() throws Exception { Object redisPostInfo = redisTemplate.opsForHash().get("post", postId); if (redisPostInfo != null) { return Optional.of((PostInfoVO) redisPostInfo); } else { return Optional.fromNullable(null); } } } ``` 2. 缓存刷新问题 当数据库数据的数据更新以后,如果按照CacheAsidePattern策略,则需要删除分布式缓存(Redis)和本地缓存(Guava Cache)中的数据。待下次客户端查询该数据时再从数据库中查询,然后保存到缓存中。但是因为发起修改操作的服务节点和其他分布式服务节点是跨机器环境,所以无法直接删除其他机器的本地缓存。作业中可以只使用单机环境实现,但觉得实际生产的分布式环境可以采用Redis订阅模式监听,或者MQ异步处理(会有短时数据不一致情况)。如果是频繁更新的场合,还需要根据具体业务场景考虑是否真的需要本地缓存。 3. redis集群配置 ``` spring: redis: database: 0 cluster: nodes: - 192.168.0.111:7001 - 192.168.0.111:7002 - 192.168.0.111:7003 - 192.168.0.111:7004 - 192.168.0.111:7005 - 192.168.0.111:7006 - 192.168.0.111:7007 - 192.168.0.111:7008 timeout: 5000 ``` #### 测试步骤 1. 首次使用postman调用根据岗位id查询岗位信息api,观察是否从数据库返回数据,并保存到redis和本地缓存。 2. 重启服务,第二次调用该api查询同一id岗位信息,观察是否从redis返回数据,并保存到本地缓存。 3. 第三次调用api,观察是否从本地缓存返回数据。 4. 使用postman调用修改岗位信息api,观察是否更新数据库后,清除redis和本地缓存。 5. 再次调用查询岗位信息api,重复步骤1。 #### 视频讲解 ![视频讲解](reference/md-videos/guavacache.mp4)