如何优雅地进行缓存预热?
一、问题和解决方案
场景:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。
为了保证非数据不占用太多内存空间,我们设置了逻辑过期时间。
但是如果热点数据出现过期就会造成缓存穿透、雪崩这些问题。为了解决这些问题,我们需要对已经过期或者将要过期的数据进行缓存重建。
重新导入数据到Redis中,并且重新设置逻辑过期时间。缓存重建需要对一些热点数据进行预热。之前我是这么预热的
▼text复制代码@Test void testSaveShop() { //测试id=1,时间10s redisUtils.saveShop2Redis(1L, 120L); }
这样一个一个的写入id号,效率着实有点太慢了,而且万一不记得了,程序就会出现报错了。
针对缓存重建问题,我这里介绍使用缓存预热的两种方法来实现
二、缓存预热两种方案
1、定时任务
使用@EnableScheduling开启定时任务
1)获取ID列表
我们在mapper上创建方法,获取数据id号
▼text复制代码@Select("SELECT id FROM tb_shop") List<Integer> selectAllIds();
2)缓存重建逻辑
这段缓存重建的逻辑: 先根据传入的id号从数据库中获取值, 封装逻辑过期时间和数据,最后将数据进行写入
▼text复制代码//缓存重建 public void saveShop2Redis(Long id, Long expireSecond) { String key = CACHE_SHOP_KEY + id; //1、查询店铺数据 Shop shop = shopMapper.selectById(id); //2、封装逻辑过期时间 RedisData redisData = new RedisData(); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecond)); redisData.setData(shop); //3、写入redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); }
3)定时任务缓存类
因为不需要特别复杂的逻辑,所以我这里就使用最为简单的spring自带的定时任务。 创建缓存重建定时任务类
-
首先开启定时任务注解
-
调用shopMapper方法,查询所有id号,遍历
-
将遍历的id号传入saveShop2Redis方法中,并设置逻辑过期时间60秒
-
日志打印输出
▼text复制代码@Component @Slf4j public class CachePreheatTask { @Resource private RedisUtils redisUtils; @Resource private ShopMapper shopMapper; // 执行缓存预热任务的方法 @Scheduled(cron = "0 19 9 * * ?") public void preheatCache() { // 执行缓存预热逻辑 List<Integer> selectAllIds = shopMapper.selectAllIds(); for (Integer allId : selectAllIds) { //测试id=1,时间10s redisUtils.saveShop2Redis(Long.valueOf(allId), 60L); log.debug("缓存数据预热成功,id为:{}" ,allId); } } }
这里的表达式:cron = "0 19 9 * * ?"
表示的是每天上午9点19执行定时任务
调用 selectAllIds方法,把数据库表中的id查询出来,然后进行遍历
再利用for循环,把每次查询出的id传给saveShop2Redis方法进行缓存重建
为了方便测试,我设置逻辑过期时间为60秒。
@Scheduled注解是Spring框架中用于创建定时任务的注解,它有三个不同类型的参数:cron、fixedDelay、fixedRate,分别用于不同的定时任务需求。
cron参数:用于指定一个cron表达式,可以精确控制任务的执行时间。cron表达式是一个字符串,包含六个或七个空格分隔的时间字段,用于指定秒、分、时、日、月、周几等时间点。例如,"0 * * * * ?"表示每分钟执行一次。
fixedDelay参数:用于指定任务执行结束后到下一次任务开始的间隔时间,单位为毫秒。即任务的执行周期是任务结束后延迟指定的时间后再执行。例如,
@Scheduled(fixedDelay = 1000)表示任务执行结束后延迟1秒后再执行。
fixedRate参数:用于指定任务开始执行后到下一次任务开始的间隔时间,单位为毫秒。即任务的执行周期是任务开始后固定的时间间隔再执行。例如,@Scheduled(fixedRate = 1000)表示任务开始后每隔1秒执行一次。
这些参数可以根据实际需求来选择,cron表达式适用于需要精确控制执行时间的场景,fixedDelay适用于任务执行时间不固定的场景,fixedRate适用于固定频率执行任务的场景。
| 表达式 | 意义 | |
|---|---|---|
| 每隔5秒钟执行一次 | */5 * * * * ? | |
| 每隔1分钟执行一次 | 0 * /1 * * * ? | |
| 每天1点执行一次 | 0 0 1 * * ? | |
| 每天23点55分执行一次 | 0 55 23 * * ? |
这样就能达到我想要的效果,可以随便设置定时任务的执行时间,这样就可以提前进行预热了。
2、消息队列
下面再介绍一种可以进行数据预热的方式——消息队列。
思考一下:我们的诉求是什么?
我们需要将数据进行预热,那我们是不是要拿到数据的id号。
拿到了id号呢,我们怎么让程序自动地去执行这段重建逻辑呢?
对的,使用消息队列,把id号传给消息队列,然后在项目启动的时候,让生产者去发送这个消息。消费者拿到消息之后,就会去执行重建的逻辑了。
▼mermaid复制代码graph TD 消息shopID号 --> 消息队列 --> 发送消息 --> 消费消息 --> 缓存重建
这里一些配置什么的我就不写了,都是固定的,
1)生产者代码
▼text复制代码@Component public class MyMessageProducer { @Resource private RabbitTemplate rabbitTemplate; // 向指定交换机发送消息 public void sendMessage(String exchange, String routingKey, String message) { //将消息发送到指定的交换机和路由键 rabbitTemplate.convertAndSend(exchange,routingKey,message); } }
2)消费者代码
▼text复制代码@Component @Slf4j public class MyMessageConsumer { @Resource private RedisUtils redisUtils; /** * 接收消息的方法 * * @param message * @param channel * @param deliveryTag */ //使用@SneakyThrows注解简化异常处理 @SneakyThrows //使用该注解指定程序要监听的队列,,并设置消息的确认机制为手动 @RabbitListener(queues = {"hmdp_queue"}, ackMode = "MANUAL") //@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag 用于从消息头中获取投递标签deliveryTag //在mq中,每条消息都会被分配一个唯一投递标签,用于标识该消息在通道中的投递状态和顺序,使用该注解可以从消息头中获取该投递标签,并将其赋值给deliveryTag参数, public void receiveMessage(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) { long shopId = Long.parseLong(message); redisUtils.saveShop2Redis(shopId, 60L); log.info("收到消息,传入的shopId为:{}", message+",缓存数据预热成功!"); //手动确认消息,消息确认标志设置为false,消息才能被确认 channel.basicAck(deliveryTag, false); } }
3)创建队列交换机
在程序执行前创建好交换机和对列
▼text复制代码public class biInitMain { public static void main(String[] args) { try { ConnectionFactory factory = new ConnectionFactory(); factory.setHost("192.168.88.130"); factory.setPort(5672); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); String EXCHANGE_NAME = "hmdp_exchange"; channel.exchangeDeclare(EXCHANGE_NAME, "direct"); // 声明一个队列,并且设置持久化消息 String queueName = "hmdp_queue"; String ROUTING_KEY="hmdp_routingKey"; channel.queueDeclare(queueName, true, false, false, null); //队列绑定交换机,routing_key用于指定消息应该发送到哪个队列。 channel.queueBind(queueName, EXCHANGE_NAME, ROUTING_KEY); } catch (Exception e) { e.printStackTrace(); } } }
4)发送消息
用于获取热点id并将其发送到消息队列。这个程序应该只执行一次,以确保不会重复发送相同的id。
@PostConstruct注解会让项目启动时初始化这段代码,被执行一次。这样消息也就被发送给消费者了,缓存重建的逻辑也就执行成功了
▼text复制代码@PostConstruct public void init() { myMessageProducer.sendMessage("hmdp_exchange","hmdp_routingKey",String.valueOf(1L)); // 启动项目时初始化bloomFilter //bloomFilter = bloomFilterManager.create("bloomShopID", expectedInsertions, falseProbability); //List<Integer> list = shopMapper.selectAllIds(); //for (Integer shopId : list) { // bloomFilter.add(BLOOM_FILTER_SHOP + shopId); //} }
看看控制台
其实代码到这里还是有点小问题的,细心的兄弟应该看出这里的问题了。
对的,之前使用定时任务,获取的是所有数据的id,获取的是所有的数据。 而这次消息队列改造,传入的是一个固定的id值。其实这里应该需要去获取一些热点数据id,再将这些id号传给方法。其中涉及到日志记录、监控数据判断是否是热点数据。
到这里我的缓存预热就结束了,其实就类似于项目的一个小优化的一样

