【云岚到家】-day03-门户缓存实现实战
1.定时任务更新缓存
1.1 搭建XXL-JOB环境
1.1.1 分布式调度平台XXL-JOB介绍
对于开通区域列表的缓存数据需要由定时任务每天凌晨更新缓存,如何实现定时任务呢?
1.使用jdk提供的Timer定时器
示例代码如下:
每个Timer对应一个线程,可以同时启动多个Timer定时执行多个任务
java
public static void main(String[] args){
Timer timer = new Timer();
timer.schedule(new TimerTask(){
@Override
public void run() {
//TODO:something
}
}, 1000, 2000); //1秒后开始调度,每2秒执行一次
}
Time使用简单,可以实现每隔一定的时间去执行任务,但无法实现每天凌晨去执行任务,即在某个时间点去执行任务
2.使用第三方Quartz方式实现
Quartz 是一个功能强大的任务调度框架,它可以满足更多更复杂的调度需求,Quartz 设计的核心类包括 Scheduler, Job 以及 Trigger。其中,Job 负责定义需要执行的任务,Trigger 负责设置调度策略,Scheduler 将二者组装在一起,并触发任务开始执行。Quartz支持简单的按时间间隔调度、还支持按日历调度方式,通过设置CronTrigger表达式(包括:秒、分、时、日、月、周、年)进行任务调度
虽然Quartz可以实现按日历调度的方式,但无法支持分布式环境下任务调度。分布式环境下通常一个服务部署多个实例即多个jvm进程,假设运营基础服务部署两个实例每个实例定时执行更新缓存的任务,两个实例就会重复执行。如下图:
3.使用分布式调度平台XXL-JOB
在分布式环境下进行任务调度需要使用分布式任务调度平台,XXL-JOB是一个轻量级分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用
官网:https://www.xuxueli.com/xxl-job/
文档:https://www.xuxueli.com/xxl-job/#《分布式任务调度平台XXL-JOB》
XXL-JOB有调度中心,可以安排两个人做任务,但不是同时做,一个人挂了,就让另外一个人做任务
XXL-JOB主要有调度中心、执行器、任务:
调度中心:
负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码;
主要职责为执行器管理、任务管理、监控运维、日志管理等
任务执行器:负责接收调度请求并执行任务逻辑;
主要职责是执行任务、执行结果上报、日志服务等
使用XXL-JOB就可以解决使用多个jvm进程重复执行任务的问题,如下图:
XXL-JOB调度中心可以配置路由策略,比如:第一个、轮询策略、分片等,它们分别表示的意义如下:
第一个:即每次执行任务都由第一个执行器去执行。
轮询:即执行器轮番执行
分片:每次执行任务广播给每个执行器让他们同时执行任务(一起干活)
如果根据需求每次执行任务仅由一个执行器去执行任务可以设置路由策略:第一个、轮询如果根据需求每次执行任务由多个执行器同时执行可以设置路由策略为:分片
1.1.2 部署调度中心
1.查阅xxl-job的源码
首先下载XXL-JOB
GitHub:https://github.com/xuxueli/xxl-job
码云:https://gitee.com/xuxueli0323/xxl-job
项目使用2.3.1版本: https://github.com/xuxueli/xxl-job/releases/tag/2.3.1,使用IDEA打开解压后的目录
xxl-job-admin:调度中心
xxl-job-core:公共依赖
xxl-job-executor-samples:执行器Sample示例(选择合适的版本执行器,可直接使用)
:xxl-job-executor-sample-springboot:Springboot版本,通过Springboot管理执行器,推荐这种方式;
:xxl-job-executor-sample-frameless:无框架版本;
doc :文档资料,包含数据库脚本
2.启动xxl-job
执行docker start xxl-job-admin 启动xxl-job
访问:http://192.168.101.68:8088/xxl-job-admin/
账号和密码:admin/123456
1.1.3 执行器
1.添加执行器依赖
下边配置执行器,执行器负责与调度中心通信接收调度中心发起的任务调度请求,执行器负责执行微服务中定义的任务,执行器程序由xxl-job提供,在微服务中引入下边的依赖即加入了执行器的程序:
xml
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
</dependency>
本项目在framework中定义了jzo2o-xxl-job工程,它对执行器bean执行了定义:
所以在需要使用xxl-job的微服务中需要引入下边的依赖,在jzo2o-foundations服务中引入下边的依赖
xml
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-xxl-job</artifactId>
</dependency>
2.配置xxl-job
接下来进入nacos,配置shared-xxl-job.yaml:
address:调度中心的地址
appName:执行器名称,为spring.application.name表示微服务的名称(在bootstrap.yml中配置)
port:执行器端口号,通过xxl-job.port配置
在jzo2o-foundations.yaml中配置执行器的端口:
在jzo2o-foundations中加载shared-xxl-job.yaml:
3. 下边进入调度中心添加执行器
进入调度中心,进入执行器管理界面,如下图:
点击新增,填写执行器信息
AppName:执行名称,在shared-xxl-job.yaml中指定执行器名称就是微服务的应用名
名称:取一个中文名称
注册方式:自动注册,只要执行器和调度中心连通执行器会自动注册到调度中心
机器地址:自动注册时不用填写
找到应用名:jzo2o-foundations,如下图:
添加成功:
启动jzo2o-foundations,查看jzo2o-foundations的控制台:
>>>>>>>>>>> xxl-job remoting server start success, nettype = class com.xxl.job.core.server.EmbedServer, port = 11603 说明执行器启动成功
稍等片刻进入 xxl-job调度中心,进入执行器管理界面,执行器注册成功:
点击"查看(1)",查看执行器的地址,如下图:
小结
如何配置xxl-job?
1.安装调度中心
2.配置执行器
3.在微服务中添加执行器的依赖
4.在调度配置执行器
项目为什么要用xxl-job?xxl-job的执行器和调度中心有什么区别?
1.2 定义缓存更新任务
根据本节的目标,使用xxl-job定时更新开通区域列表的缓存
1.2.1 编写任务方法
定时执行任务就需要编写任务方法,此任务方法由执行器去调用
下边代码中demoJobHandler()就是一个任务方法,需要使用@XxlJob注解标识,所在类需要由spring去管理,所以加了@Component注解
java
@Component
public class SampleXxlJob {
private static Logger logger = LoggerFactory.getLogger(SampleXxlJob.class);
/**
* 1、简单任务示例(Bean模式)
*/
@XxlJob("demoJobHandler")//任务名字
public void demoJobHandler() throws Exception {
XxlJobHelper.log("XXL-JOB, Hello World.");
for (int i = 0; i < 5; i++) {
XxlJobHelper.log("beat at:" + i);
TimeUnit.SECONDS.sleep(2);
}
// default success
}
参考上边的代码我们编写更新开通区域列表缓存的任务方法:
先删除开通区域的缓存,再查询开通区域列表进行缓存
java
package com.jzo2o.foundations.handler;
/**
* springCache缓存同步任务
**/
@Slf4j
@Component
public class SpringCacheSyncHandler {
@Resource
private IRegionService regionService;
@Resource
private RedisTemplate redisTemplate;
/**
* 已启用区域缓存更新
* 每日凌晨1点执行
*/
@XxlJob(value = "activeRegionCacheSync")
public void activeRegionCacheSync() {
log.info(">>>>>>>>开始进行缓存同步,更新已启用区域");
//1.清理缓存
String key = RedisConstants.CacheName.JZ_CACHE + "::ACTIVE_REGIONS";
redisTemplate.delete(key);
//2.刷新缓存
regionService.queryActiveRegionListCache();
log.info(">>>>>>>>更新已启用区域完成");
}
}
1.2.2 配置任务
下边在调度中心配置任务
进入任务管理,新增任务:
填写任务信息:
说明:
调度类型:
固定速度指按固定的间隔定时调度。
Cron,通过Cron表达式实现更丰富的定时调度策略。Cron表达式是一个字符串,通过它可以定义调度策略,格式如下:
{秒数} {分钟} {小时} {日期} {月份} {星期} {年份(可为空)}
xxl-job提供图形界面去配置:
一些例子如下:
0 0 0 * * ? 每天0点触发
30 10 1 * * ? 每天1点10分30秒触发
0/30 * * * * ? 每30秒触发一次
* 0/10 * * * ? 每10分钟触发一次
为了方便测试这里第5秒执行一次,设置为:0/5 * * * * ?
运行模式有BEAN和GLUE,bean模式较常用就是在项目工程中编写执行器的任务代码,GLUE是将任务代码编写在调度中心
JobHandler即任务方法名,填写任务方法上边@XxlJob注解中的名称
路由策略:第一个:即每次执行任务都由第一个执行器去执行
轮询:即执行器轮番执行
分片:每次执行任务广播给每个执行器让他们同时执行任务
1.2.3 启动任务并测试
任务配置完成,下边启动任务
我们在任务方法上打断点跟踪,任务方法被执行,如下图:
尝试启用或禁用一个区域,观察redis中开通区域列表缓存是否更新
小结
项目中哪里用了xxl-job?怎么用的?
1.3 首页服务列表实现
我们先实现从数据库查询"首页服务列表",将整体功能调试通过,再实现查询缓存
下边我们先实现从数据库查询服务类型列表
1.3.1 首页服务列表实现
1.需求分析
1.界面原型
首页服务列表在门户的中心位置,下图红框中为首页服务列表区域:
默认展示前两个服务分类(按后台设置的排序字段进行升序排序),每个服务分类下取前4个服务项(按后台设置的排序字段进行升序排序)
一级服务分类显示的内容:服务分类的图标,服务分类的名称
服务分类下的服务项内容:服务项图标,服务项名称
2.接口定义
定义首页服务列表接口,查询2个一级服务分类,每个服务分类下查询4个服务项
接口名称:首页服务列表
接口路径:GET/foundations/customer/serve/firstPageServeList
响应示例:
xml
{
"msg": "OK",
"code": 200,
"data": [
{
"serveTypeId": 0,
"cityCode": "",
"serveTypeIcon": "",
"serveTypeSortNum": 0,
"serveResDTOList": [
{
"serveItemSortNum": 0,
"serveItemName": "",
"serveItemId": 0,
"serveItemIcon": "",
"id": 0
}
],
"serveTypeName": ""
}
]
}
定义FirstPageServeController 类提供门户界面查询类接口
java
package com.jzo2o.foundations.controller.consumer;
@RestController("consumerServeController")
@RequestMapping("/customer/serve")
@Api(tags = "用户端 - 首页服务查询接口")
public class FirstPageServeController {
@GetMapping("/firstPageServeList")
@ApiOperation("首页服务列表")
@ApiImplicitParams({
@ApiImplicitParam(name = "regionId", value = "区域id", required = true, dataTypeClass = Long.class)
})
public List<ServeCategoryResDTO> serveCategory(@RequestParam("regionId") Long regionId) {
return null;
}
...
3.mapper
数据来源于三张表:serve_type、serve_item、serve,区域id是非常重要的限制条件,因为要查询该区域显示在首页的服务列表
如何查询数据?可以先查询出服务类型,再根据服务类型id查询下边的服务项,伪代码如下:
java
package com.jzo2o.foundations.mapper;
public interface ServeMapper extends BaseMapper<Serve> {
/**
* 首页服务列表
*/
List<ServeCategoryResDTO> findServeIconCategoryByRegionId(@Param("regionId") Long regionId);
}
上边的代码会导致1+n次查询数据库,这种代码要避免
我们可以一次将符合条件的数据查询出来,再通过java程序对数据进行处理,再封装为接口要求的数据格式,最后返回给前端
下边编写mapper映射文件
xml
<select id="findServeIconCategoryByRegionId" resultMap="ServeCategoryMap">
SELECT
type.id as serve_type_id,
type.name as serve_type_name,
type.serve_type_icon,
serve.city_code,
serve.id as serve_id,
item.id as serve_item_id,
item.name as serve_item_name,
item.serve_item_icon,
item.sort_num as serve_item_sort_num
FROM
serve
inner JOIN serve_item AS item ON item.id = serve.serve_item_id
inner JOIN serve_type AS type ON type.id = item.serve_type_id
WHERE
serve.region_id = #{regionId}
AND serve.sale_status = 2
ORDER BY
type.sort_num,
item.sort_num
</select>
<!--手动的映射-->
<resultMap id="ServeCategoryMap" type="com.jzo2o.foundations.model.dto.response.ServeCategoryResDTO">
<!--id映射主键字段-->
<id column="serve_type_id" property="serveTypeId"></id>
<!--result映射普通字段-->
<result column="serve_type_name" property="serveTypeName"></result>
<result column="serve_type_icon" property="serveTypeIcon"></result>
<result column="city_code" property="cityCode"></result>
<!--column 数据库中的字段名-->
<!--property 实体类中对应的属性 该关键字可以省略... -->
<!--ofType 是javaType中的单个对象类型-->
<collection property="serveResDTOList" ofType="com.jzo2o.foundations.model.dto.response.ServeSimpleResDTO">
<id column="serve_id" property="id"></id>
<result column="serve_item_id" property="serveItemId"></result>
<result column="serve_item_name" property="serveItemName"></result>
<result column="serve_item_icon" property="serveItemIcon"></result>
<result column="serve_item_sort_num" property="serveItemSortNum"></result>
</collection>
</resultMap>
4.service
定义专门用于门户首页查询的service接口,用于实现查询缓存:
java
package com.jzo2o.foundations.service;
/**
* 首页查询相关功能
**/
public interface HomeService {
/**
* 根据区域id获取服务图标信息
* @param regionId 区域id
* @return 服务图标列表
*/
List<ServeCategoryResDTO> queryServeIconCategoryByRegionIdCache(Long regionId);
}
定义实现类
java
package com.jzo2o.foundations.service.impl;
/**
* 首页查询相关功能
**/
@Slf4j
@Service
public class HomeServiceImpl implements HomeService {
/**
* 根据区域id查询已开通的服务类型
* @param regionId 区域id
* @return 已开通的服务类型
*/
@Override
public List<ServeCategoryResDTO> queryServeIconCategoryByRegionIdCache(Long regionId) {
//1.校验当前城市是否为启用状态
Region region = regionService.getById(regionId);
if (ObjectUtil.isEmpty(region) || ObjectUtil.equal(FoundationStatusEnum.DISABLE.getStatus(), region.getActiveStatus())) {
return Collections.emptyList();
}
//2.根据城市编码查询所有的服务图标
List<ServeCategoryResDTO> list = serveMapper.findServeIconCategoryByRegionId(regionId);
if (ObjectUtil.isEmpty(list)) {
return Collections.emptyList();
}
//3.服务类型取前两个,每个类型下服务项取前4个
//list的截止下标
int endIndex = list.size() >= 2 ? 2 : list.size();
List<ServeCategoryResDTO> serveCategoryResDTOS = new ArrayList<>(list.subList(0, endIndex));
serveCategoryResDTOS.forEach(v -> {
List<ServeSimpleResDTO> serveResDTOList = v.getServeResDTOList();
//serveResDTOList的截止下标
int endIndex2 = serveResDTOList.size() >= 4 ? 4 : serveResDTOList.size();
List<ServeSimpleResDTO> serveSimpleResDTOS = new ArrayList<>(serveResDTOList.subList(0, endIndex2));
v.setServeResDTOList(serveSimpleResDTOS);
});
return serveCategoryResDTOS;
}
5.controller
在controller中调用service查询首页服务列表
java
package com.jzo2o.foundations.controller.consumer;
@Validated
@RestController("consumerServeController")
@RequestMapping("/customer/serve")
@Api(tags = "用户端 - 首页服务查询接口")
public class FirstPageServeController {
@Resource
private HomeService homeService;
@GetMapping("/firstPageServeList")
@ApiOperation("首页服务列表")
@ApiImplicitParams({
@ApiImplicitParam(name = "regionId", value = "区域id", required = true, dataTypeClass = Long.class)
})
public List<ServeCategoryResDTO> serveCategory(@RequestParam("regionId") Long regionId) {
List<ServeCategoryResDTO> serveCategoryResDTOS = homeService.queryServeIconCategoryByRegionIdCache(regionId);
return serveCategoryResDTOS;
}
}
6.测试
重启jzo2o-foundations服务、网关服务,打开小程序,观察小程序的访问记录
1.3.2 首页服务列表缓存
缓存方案分析
下边是门户的缓存设计:
下边分析首页服务列表的缓存方案:
查询缓存:查询首页服务列表,如果缓存没有则查询数据库并缓存,如果缓存有则直接返回
注意:缓存时需要考虑缓存穿透问题。
禁用区域:删除首页服务列表缓存
定时任务:每天凌晨缓存首页服务列表。
查询缓存
下边在首页服务列表查询方法上添加Spring Cache注解实现查询缓存
为了避免缓存穿透,如果服务列表为空则向redis缓存空值,缓存时间为30分钟;不为空则进行永久缓存
在Cacheable注解中有两个属性可以指定条件进行缓存:condition:指定一个 SpEL 表达式,用于决定是否要进行缓存。只有当条件表达式的结果为 true 时,方法的返回值才会被缓存。例如:
java@Cacheable(value = "myCache", condition = "#id != null")
unless:与 condition 相反,只有当 SpEL 表达式的结果为 false 时,方法的返回值才会被缓存
例如:
java@Cacheable(value = "myCache", unless = "#result.length() > 100")
#result 表示方法的返回值,如果返回值结果集的长度大于100不进行缓存
根据需求,我们需要根据方法的返回值去判断,如果结果集的长度大于0说明服务列表不空,此时缓存时间为永久缓存,否则缓存时间为30分钟condition不支持获取方法返回的值,不能识别#result。我们使用unless实现
unless 的特点是符合条件的不缓存。设置技巧:确定要缓存的条件,取反即不缓存的条件。
当方法返回的List的size为0时缓存30分钟,避免缓存穿透,设置为:#result.size() != 0
当方法返回的List的size大于0永不过期,设置为:#result.size() == 0
代码如下:
java
package com.jzo2o.foundations.service.impl;
/**
* 首页查询相关功能
**/
@Slf4j
@Service
public class HomeServiceImpl implements HomeService {
@Caching(
cacheable = {
//result为null时,属于缓存穿透情况,缓存时间30分钟
@Cacheable(value = RedisConstants.CacheName.SERVE_ICON, key = "#regionId", unless = "#result.size() != 0", cacheManager = RedisConstants.CacheManager.THIRTY_MINUTES),//查询为空处理缓存穿透
//result不为null时,永久缓存
@Cacheable(value = RedisConstants.CacheName.SERVE_ICON, key = "#regionId", unless = "#result.size() == 0", cacheManager = RedisConstants.CacheManager.FOREVER)
}
)
public List<ServeCategoryResDTO> queryServeIconCategoryByRegionIdCache(Long regionId) {
//1.校验当前城市是否为启用状态
Region region = regionService.getById(regionId);
if (ObjectUtil.isEmpty(region) || ObjectUtil.equal(FoundationStatusEnum.DISABLE.getStatus(), region.getActiveStatus())) {
return Collections.emptyList();
}
//2.根据城市编码查询所有的服务图标
List<ServeCategoryResDTO> list = serveMapper.findServeIconCategoryByRegionId(regionId);
if (ObjectUtil.isEmpty(list)) {
return Collections.emptyList();
}
//3.服务类型取前两个,每个类型下服务项取前4个
//list的截止下标
int endIndex = list.size() >= 2 ? 2 : list.size();
List<ServeCategoryResDTO> serveCategoryResDTOS = new ArrayList<>(list.subList(0, endIndex));
serveCategoryResDTOS.forEach(v -> {
List<ServeSimpleResDTO> serveResDTOList = v.getServeResDTOList();
//serveResDTOList的截止下标
int endIndex2 = serveResDTOList.size() >= 4 ? 4 : serveResDTOList.size();
List<ServeSimpleResDTO> serveSimpleResDTOS = new ArrayList<>(serveResDTOList.subList(0, endIndex2));
v.setServeResDTOList(serveSimpleResDTOS);
});
return serveCategoryResDTOS;
}
...
查询缓存测试
启动:jzo2o-foundations服务、网关服务,打开小程序,等待首页服务列表正常显示,进入redis查看首页服务列表是否缓存
预期结果:首页服务列表正常缓存
定时任务更新缓存
根据缓存方案的分析,对首页服务列表进行缓存
编写定时任务代码:
java
/**
* 已启用区域缓存更新
* 每日凌晨1点执行
*/
@XxlJob("activeRegionCacheSync")
public void activeRegionCacheSync() throws Exception {
log.info(">>>>>>>>开始进行缓存同步,更新已启用区域");
//删除缓存
Boolean delete = redisTemplate.delete(RedisConstants.CacheName.JZ_CACHE + "::ACTIVE_REGIONS");
//通过查询开通区域列表进行缓存
List<RegionSimpleResDTO> regionSimpleResDTOS = regionService.queryActiveRegionList();
//遍历区域对该区域下的服务类型进行缓存
regionSimpleResDTOS.forEach(item->{
//区域id
Long regionId = item.getId();
//删除该区域下的首页服务列表
String serve_type_key = RedisConstants.CacheName.SERVE_ICON + "::" + regionId;
redisTemplate.delete(serve_type_key);
homeService.queryServeIconCategoryByRegionIdCache(regionId);
//todo 删除该区域下的服务类型列表缓存
});
}
定时任务更新缓存测试
先将首页服务列表的缓存手动删除。
重启foundations服务,在上边代码中打断点,保证定时任务成功执行。
预期结果:对每个运营区域的首页服务列表进行缓存
示例:跟踪断点执行
编码规范
门户信息查询类接口统一在FirstPageServeController类中定义,service统一写在HomeService下。
首先实现业务接口的功能,测试通过后再去实现缓存。
禁用区域时删除缓存
找到禁用区域代码 ,添加删除首页服务列表缓存的代码,如下:
java
@Service
public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> implements IRegionService {
...
@Override
@Caching(evict = {
@CacheEvict(value = RedisConstants.CacheName.JZ_CACHE, key = "'ACTIVE_REGIONS'"),
@CacheEvict(value = RedisConstants.CacheName.SERVE_ICON, key = "#id")
})
public void deactivate(Long id) {
测试:禁用一个区域观察redis是否删除该区域的首页服务列表缓存
小结
项目中有做缓存吗?考虑缓存穿透问题了吗?怎么实现的?
定时任务更新缓存测试
先将首页服务列表的缓存手动删除。
重启foundations服务,在上边代码中打断点,保证定时任务成功执行。
预期结果:对每个运营区域的首页服务列表进行缓存
示例:跟踪断点执行
[外链图片转存中...(img-q8oigNJs-1737104374642)]
编码规范
门户信息查询类接口统一在FirstPageServeController类中定义,service统一写在HomeService下。
首先实现业务接口的功能,测试通过后再去实现缓存。
禁用区域时删除缓存
找到禁用区域代码 ,添加删除首页服务列表缓存的代码,如下:
java
@Service
public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> implements IRegionService {
...
@Override
@Caching(evict = {
@CacheEvict(value = RedisConstants.CacheName.JZ_CACHE, key = "'ACTIVE_REGIONS'"),
@CacheEvict(value = RedisConstants.CacheName.SERVE_ICON, key = "#id")
})
public void deactivate(Long id) {
测试:禁用一个区域观察redis是否删除该区域的首页服务列表缓存
小结
项目中有做缓存吗?考虑缓存穿透问题了吗?怎么实现的?