缓存

分析

主页需要展示一些推荐内容 比如推荐用户 推荐商品……

如果一股脑将mysql查询的结果拉取到前端页面

数据量一大 浏览器会崩溃

为了防止崩溃 可以加上一层分页查询 只在前端展示一页的结果 避免一次性返回大量数据

另外 毕竟涉及到查询 如果数据量一大 哪怕是用了分页 查询也是需要时间的 也就导致前端的响应比较慢

如果响应时间超过一秒 就很影响用户体验

解决方法是

换一个比数据库更快的地方做查询 同操作系统优化思路一样 从数据库查其实就是从硬盘里取 优化方法就是放入内存中或者缓存中

加上一层缓存 redis 常用的数据无需查询数据库 直接从缓存中取

用了缓存也会出现一个问题 如果没缓存的时候 查询就特别慢 有缓存查询特别快 给人一种随缘的感觉

如果只有用户访问时才填充缓存 第一次访问的数据加载会很慢

为了防止第一个人查询特别慢的情况出现

可以加上一个定时任务 在低峰期(用户量较少时)预加载数据到缓存

可以将常用的推荐内容或商品信息在空闲时间提前加载 保证用户在高峰期时能够获得快速响应

后续优化策略

缓存失效与更新策略

  • 为避免缓存内容过时 可以设置合适的缓存失效时间(TTL,Time-To-Live)

    另外 也可以在数据库更新时触发缓存的更新或删除 确保缓存中的数据始终是最新的

  • 可以考虑使用缓存穿透机制,例如使用Bloom Filter来判断数据是否存在于缓存中,避免查询到不存在的缓存项。

异步加载与懒加载

  • 如果页面展示的数据量较大,可以考虑使用异步加载技术,让推荐内容在用户滚动或交互时动态加载,而不是一开始就加载全部内容。

性能监控与分析

  • 使用监控工具(如Prometheus、Grafana等)定期查看数据库查询、缓存命中率、前端响应时间等性能指标,及时发现瓶颈,优化系统。

缓存分类

分布式缓存:

  • Redis(分布式缓存)
  • memcached(分布式)
  • Etcd(云原生架构的一个分布式存储, 存储配置 ,扩容能力)

单机缓存:

  • ehcache
  • Java 内存集合,如 HashMap
  • Caffeine(Java 内存缓存性能之王,高性能)
  • Google Guava|

redis

概念

https://v.douyin.com/iDG6X64k/

缓存

NoSQL 数据库

key - value 存储系统(区别于 MySQL,他存储的是键值对)

Redis 数据结构

基本:

  • String 字符串类型: name: "Zwww"
  • List 列表:names: ["Zwww", "zv04", "Zv"]
  • Set 集合:names: ["Zwww", "zv04"](值不能重复)
  • Hash 哈希:nameAge: { "Zwww": 1, "zv04": 2 }
  • Zset 集合:names: { Zwww - 9, zv04 - 12 }(适合做排行榜)

高级:

  • bloomfilter(布隆过滤器,主要从大量的数据中快速过滤值,比如邮件黑名单拦截)
  • geo(计算地理位置)
  • hyperloglog(pv / uv)
  • pub / sub(发布订阅,类似消息队列)
  • BitMap (1001010101010101010101010101)

引入

引入依赖

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.6.4</version>
        </dependency>

配置redis地址

spring:
  # redis 配置
  redis:
    port: 6379
    host: localhost
    database: 1

测试

package com.zwnsyw.backend.service;

import com.zwnsyw.backend.pojo.User;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;

@SpringBootTest
public class RedisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    void test(){
        ValueOperations valueOperations = redisTemplate.opsForValue();
        // 增
        valueOperations.set("ZwwwString", "cat");
        valueOperations.set("ZwwwInt", 1);
        valueOperations.set("ZwwwDouble", 2.0);
        User user = new User();
        user.setId(1L);
        user.setUserName("Zwww");
        valueOperations.set("ZwwwUser", user);

        // 查
        Object Zwww = valueOperations.get("ZwwwString");
        Assertions.assertTrue("cat".equals((String) Zwww));
        Zwww = valueOperations.get("ZwwwInt");
        Assertions.assertTrue(1 == (Integer) Zwww);
        Zwww = valueOperations.get("ZwwwDouble");
        Assertions.assertTrue(2.0 == (Double) Zwww);
        System.out.println(valueOperations.get("ZwwwUser"));
        valueOperations.set("ZwwwString", "cat");
        redisTemplate.delete("ZwwwString");
    }
}
image-8d0dc03e

但在quickredis中 key不存在 为乱码

image-1a6789c3

是因为redisTemplate的配置默认是以jdk序列化的 我们看上去就是乱码

需要写一个配置类 配置成json格式的序列化

自定义序列化

为了防止写入 Redis 的数据乱码、浪费空间等,可以自定义序列化器。

com.zwnsyw.backend.config.RedisTemplateConfig

package com.zwnsyw.backend.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.zwnsyw.backend.common.ErrorCode;
import com.zwnsyw.backend.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

/**
 * RedisTemplate 配置
 *
 * @author Zwww
 */
@Configuration
@EnableRedisHttpSession
@Slf4j
public class RedisTemplateConfig {

    @Value("${redis.host:localhost}")
    private String redisHost;

    @Value("${redis.port:6379}")
    private int redisPort;

    /**
     * 配置 RedisTemplate 使用 Jackson 序列化
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);

        GenericJackson2JsonRedisSerializer genericSerializer = genericJackson2JsonRedisSerializer();
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setHashValueSerializer(genericSerializer);
        redisTemplate.setDefaultSerializer(genericSerializer);

        redisTemplate.setEnableTransactionSupport(true); // 开启事务支持

        try {
            redisTemplate.afterPropertiesSet();
        } catch (RedisConnectionFailureException e) {
            log.error("Redis 连接失败:主机:{},端口:{}", redisHost, redisPort);
            throw new BusinessException(ErrorCode.DATABASE_ERROR, "连接 Redis 失败");
        } catch (Exception e) {
            log.error("Redis 配置失败:主机:{},端口:{}", redisHost, redisPort);
            throw new BusinessException(ErrorCode.DATABASE_ERROR, "Redis 配置出错");
        }

        log.info("Redis 配置成功,主机:{},端口:{}", redisHost, redisPort);
        return redisTemplate;
    }

    /**
     * 通用 JSON 序列化器
     */
    private GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
        // 创建 ObjectMapper 并配置类型安全
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder()
                        .allowIfSubType("com.zwnsyw") // 仅允许业务相关类
                        .allowIfSubType("com.zwnsyw.backend.pojo.*") // 添加业务对象所在的包
                        .allowIfSubType("com.baomidou.mybatisplus.extension.plugins.pagination.Page") // 添加 MyBatisPlus 分页类
                        .allowIfSubType("java.util.ArrayList") // 允许的集合类型:ArrayList
                        .allowIfSubType("java.util.HashMap") // 允许的集合类型:HashMap
                        .allowIfSubType("java.util.LinkedList") // 可根据需要添加更多集合或类型
                        .allowIfSubType("java.util.TreeMap")
                        .allowIfSubType("java.util.LinkedHashMap")
                        .allowIfSubType("java.lang.String") // 基本类型支持
                        .allowIfSubType("java.lang.Long")
                        .allowIfSubType("java.lang.Integer")
                        .allowIfSubType("java.lang.Double")
                        .build(),
                ObjectMapper.DefaultTyping.NON_FINAL
        );
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }
}

引入一个新库时,记得先写测试类

image-03faba22

session序列化为json

虽然配置了RedisTemplateConfig 但登入信息还是以乱码存在redis里面的

配置application.yml

spring:
  # session 配置
  session:
    store-type: redis
    redis:
      namespace: session # 设置 Redis 中存储 session 数据的命名空间
      flush-mode: on_save # 表示当 session 更新时,才会将数据保存到 Redis
      timeout: 86400 # session 过期时间:24小时
      # 在RedisTemplateConfig中 使用@EnableRedisHttpSession注解
      # 表示用自定义的 RedisTemplate 来存储 session 数据
      # 确保 session 数据以 JSON 格式存储在 Redis 中 而不是默认的 JDK 序列化 显示为乱码

另外注意

RedisHttpSessionConfiguration中Bean名称必须是springSessionDefaultRedisSerializer才能指定Jackson序列化器

修改RedisTemplateConfig:

package com.zwnsyw.backend.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.zwnsyw.backend.common.ErrorCode;
import com.zwnsyw.backend.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

/**
 * RedisTemplate 配置
 *
 * @author Zwww
 */
@Configuration
@EnableRedisHttpSession
@Slf4j
public class RedisTemplateConfig {

    @Value("${redis.host:localhost}")
    private String redisHost;

    @Value("${redis.port:6379}")
    private int redisPort;

    /**
     * 配置 RedisTemplate 使用 Jackson 序列化
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);

        GenericJackson2JsonRedisSerializer genericSerializer = genericJackson2JsonRedisSerializer();
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setHashValueSerializer(genericSerializer);
        redisTemplate.setDefaultSerializer(genericSerializer);

        redisTemplate.setEnableTransactionSupport(true); // 开启事务支持

        try {
            redisTemplate.afterPropertiesSet();
        } catch (RedisConnectionFailureException e) {
            log.error("Redis 连接失败:主机:{},端口:{}", redisHost, redisPort);
            throw new BusinessException(ErrorCode.DATABASE_ERROR, "连接 Redis 失败");
        } catch (Exception e) {
            log.error("Redis 配置失败:主机:{},端口:{}", redisHost, redisPort);
            throw new BusinessException(ErrorCode.DATABASE_ERROR, "Redis 配置出错");
        }

        log.info("Redis 配置成功,主机:{},端口:{}", redisHost, redisPort);
        return redisTemplate;
    }

    /**
     * 通用 JSON 序列化器
     */
    private GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
        // 创建 ObjectMapper 并配置类型安全
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder()
                        .allowIfSubType("com.zwnsyw") // 仅允许业务相关类
                        .allowIfSubType("com.zwnsyw.backend.pojo.*") // 添加业务对象所在的包
                        .allowIfSubType("com.baomidou.mybatisplus.extension.plugins.pagination.Page") // 添加 MyBatisPlus 分页类
                        .allowIfSubType("java.util.ArrayList") // 允许的集合类型:ArrayList
                        .allowIfSubType("java.util.HashMap") // 允许的集合类型:HashMap
                        .allowIfSubType("java.util.LinkedList") // 可根据需要添加更多集合或类型
                        .allowIfSubType("java.util.TreeMap")
                        .allowIfSubType("java.util.LinkedHashMap")
                        .allowIfSubType("java.lang.String") // 基本类型支持
                        .allowIfSubType("java.lang.Long")
                        .allowIfSubType("java.lang.Integer")
                        .allowIfSubType("java.lang.Double")
                        .build(),
                ObjectMapper.DefaultTyping.NON_FINAL
        );
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }

    /**
     * Spring Session 使用的 Redis 序列化器
     */
    @Bean
    @Qualifier("springSessionDefaultRedisSerializer")
    public RedisSerializer springSessionDefaultRedisSerializer() {
        return genericJackson2JsonRedisSerializer();
    }

}

成功将session序列化为json

image-76aab8c2

主页推荐用户功能实现

总体思路就是 先从缓存里找 如果没有 再从数据库中找 加入缓存

  • 优先缓存:缓存能够快速响应请求,因此首先从缓存中查找用户的推荐内容。
  • 缓存缺失:如果缓存中没有,才从数据库中查询,并将结果缓存起来,便于后续使用。

设计缓存key

不同用户得到的推荐肯定不同 不同的用户需要不同的缓存

怎么区别用户?

用userid

但需要考虑一个问题 redis一般是多个项目共享的 而userid很容易发生项目级的冲突

所以需要用一个类似于namespace命名空间的概念来做好区别

  • 用户区分:每个用户的推荐内容是唯一的,因此缓存Key必须与用户紧密绑定。
  • 避免冲突:由于缓存系统可能是多个项目共享的,因此要避免Key的冲突,通常使用带有命名空间的方式来避免这种情况。

一般设计为 systemId:modleId:func:

  • 系统ID (systemId):区分不同的系统或应用
  • 模块ID (modleId):区分不同的功能模块,避免不同模块冲突
  • 功能 (func):区分不同的功能或数据集,具体到“主页推荐”的功能
  • 可选项(<options>):唯一标识,以确保每种情况有自己的推荐数据

这里我就设计为: FriendMarry:user:recommend:userId

实现

controller

    /**
     * 获取推荐用户
     *
     * @param pageSize 每页大小
     * @param pageNum 当前页数
     * @param request 请求上下文
     * @return BaseResponse<Page<User>>
     */
    @GetMapping("/recommend")
    public BaseResponse<Page<User>> recommendUsers(long pageSize, long pageNum, HttpServletRequest request) {
        Page<User> userPage = userService.getRecommendedUsers(pageSize, pageNum, request);
        return ResultUtils.success(userPage);
    }

service

    /**
     * 获取推荐用户
     *
     * @param pageSize 每页大小
     * @param pageNum 当前页数
     * @param request 请求上下文
     * @return 推荐用户分页数据
     */
    Page<User> getRecommendedUsers(long pageSize, long pageNum, HttpServletRequest request);

serviceimpl

  /**
     * 获取推荐用户
     *
     * @param pageSize 每页大小
     * @param pageNum 当前页数
     * @param request 请求上下文
     * @return 推荐用户分页数据
     */
    @Override
    public Page<User> getRecommendedUsers(long pageSize, long pageNum, HttpServletRequest request) {
        User loginUser = getLoginUser(request);
        String redisKey = String.format("FriendMarry:user:recommend:%s", loginUser.getId());
        ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();

        // 如果有缓存,直接读缓存
        Page<User> userPage = null;
        try {
            // 从 Redis 获取缓存数据
            String cachedData = (String) valueOperations.get(redisKey);  // 获取缓存的 JSON 字符串
            if (cachedData != null) {
                // 使用 Jackson 反序列化 JSON 字符串为 Page<User> 对象
                ObjectMapper objectMapper = new ObjectMapper();
                userPage = objectMapper.readValue(cachedData, new TypeReference<Page<User>>() {});
                log.info("Redis缓存命中,key: {}", redisKey);
                return ResultUtils.success(userPage).getData();
            }
        } catch (Exception e) {
            // 捕获缓存读取异常
            log.error("Redis缓存读取失败,key: {}", redisKey, e);
        }

        // 无缓存,查数据库
        // todo 替換為真實的查詢
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        userPage = page(new Page<>(pageNum, pageSize), queryWrapper);

        // 将查询结果写入缓存
        try {
            if (userPage != null) {
                // 使用 Jackson 序列化 Page<User> 对象为 JSON 字符串
                ObjectMapper objectMapper = new ObjectMapper();
                String userPageJson = objectMapper.writeValueAsString(userPage);
                valueOperations.set(redisKey, userPageJson, 30, TimeUnit.MINUTES);  // 设置缓存过期时间为30分钟
                log.info("Redis缓存写入成功,key: {}", redisKey);
            } else {
                log.warn("查询到的用户列表为空,未写入缓存,key: {}", redisKey);
            }
        } catch (Exception e) {
            // 捕获缓存写入异常
            log.error("Redis缓存写入失败,key: {}", redisKey, e);
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "缓存写入失败");
        }

        return ResultUtils.success(userPage).getData();
    }
image-dfb49a65 image-816d9c71 image-02a43994

分析

缓存不能无限增加 一定要设置过期时间 否则会只进不出造成内存泄漏

问题:

    第一个用户还是很慢 需要加入一个缓存预热

缓存预热

概念

缓存预热是一种通过提前加载缓存的方式来提高应用程序性能的策略,特别是在高并发、高访问量的场景下,缓存预热可以有效减少用户首次访问时的延迟。

优点:

  1. 提高用户访问速度
    • 预热后的数据能够立即被访问,从而避免了首次访问时从数据库读取的延迟。
  2. 减轻数据库负载
    • 数据库查询压力被缓存承担,减少了直接访问数据库的频率,从而降低数据库负载。
  3. 保护数据库
    • 特别适用于数据库查询量大且查询结果变化较少的场景,可以有效减少对数据库的频繁访问,提升稳定性。

缺点:

  1. 增加开发成本
    • 实现缓存预热需要额外的开发和设计工作,需要额外考虑缓存的存储、清理机制等问题。
  2. 缓存数据可能过时
    • 如果缓存预热的时机和频率设置不当,可能会导致缓存数据不准确或过时,影响业务。
  3. 占用额外存储空间
    • 存储预热数据会消耗缓存的空间,可能会占用更多的内存或者存储资源,可能影响其他缓存数据的存储。

缓存预热的实现方式:

缓存预热可以通过定时任务来实现,确保缓存数据在固定时间点被刷新,或者通过手动触发来确保数据的有效性。

  1. 定时任务
    • 使用定时任务每天定时刷新所有用户的推荐列表等缓存数据。
  2. 手动触发
    • 通过特定的触发机制手动更新缓存,适合缓存更新频率较低的场景。

用定时任务,每天刷新所有用户的推荐列表。

注意点:

  1. 缓存的空间限制:缓存数据的体积要适度,确保预热缓存不会占用过多内存或存储空间,影响其他缓存的使用。
  2. 缓存更新周期:选择合适的缓存更新周期,本例采用了每天执行一次。
  3. 缓存的用户范围:建议针对热门用户或重要数据进行优先缓存预热,以提高数据访问的效率。

定时任务实现

使用定时任务每天刷新缓存

缓存预热的关键是在合适的时机刷新缓存数据。

定时任务是一种常见的实现方式,以下是基于 Spring Boot 的实现方案:

  1. Spring Scheduler:Spring Boot 默认支持,适用于简单的定时任务。
  2. Quartz:用于更复杂的定时任务管理和调度。
  3. 分布式调度平台:如 XXL-Job,适用于分布式环境中的定时任务管理。

Spring Scheduler 实现方式

  1. 主类开启 @EnableScheduling 注解
    • 在 Spring Boot 的主类中添加 @EnableScheduling 注解,启用定时任务功能。

      package com.zwnsyw.backend;
      
      import org.springframework.boot.SpringApplication;
      import org.springframework.boot.autoconfigure.SpringBootApplication;
      import org.springframework.scheduling.annotation.EnableScheduling;
      
      @SpringBootApplication
      @EnableScheduling
      public class BankendApplication {
          public static void main(String[] args) {
              SpringApplication.run(BankendApplication.class, args);
          }
      }
      
  2. 定时任务方法添加 @Scheduled 注解
    • 在需要定时执行的方法上添加 @Scheduled 注解,并配置 CRON 表达式或执行频率。
    • 不需要背 cron 表达式 用现成的工具即可:
    • @Component
      @Slf4j
      public class PreCacheJob {
      
          @Scheduled(cron = "0 31 0 * * *")
          public void doCacheRecommendUser() {
                 // 缓存更新逻辑
          }
      }
      

@Scheduled(cron = "0 31 0 * * *"):该 CRON 表达式表示每天的 0 点 31 分执行任务。

控制定时任务的执行:分布式锁

在分布式环境中,我们需要确保定时任务在同一时刻只有一个服务器执行,防止多个服务器同时执行任务导致数据不一致或重复插入

控制任务同一时间仅一台服务器执行的几种实现方案:

缓存

  • 方案一:分离定时任务程序与主程序:只有一台服务器运行定时任务程序,其他服务器不执行。缺点是成本较高,管理复杂。
  • 方案二:IP 配置控制:每台服务器都运行定时任务,但仅允许特定 IP 执行业务逻辑。这种方式依赖于 IP 配置,难以应对动态 IP 或大规模集群。
  • 方案三:分布式锁:通过 Redis 等中间件实现分布式锁,确保同一时刻只有一台服务器能够执行任务。

[[17-锁|锁]]

Redisson 实现分布式锁

Redisson 是一个 java 操作 Redis 的客户端, 提供了大量的分布式数据集来简化对 Redis 的操作和使用,可以让开发者像使用本地集合一样使用 Redis,完全感知不到 Redis 的存在。

关键词:Java Redis 客户端,分布式数据网格,实现了很多 Java 里支持的集合。

引入
  1. spring boot starter 引入(不推荐,版本迭代太快,容易冲突):https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter
  2. 直接引入:https://github.com/redisson/redisson#quick-start
<!-- https://github.com/redisson/redisson#quick-start -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.17.5</version>
        </dependency>
使用 Redisson

代码示例:

void testWatchDog() {
    RLock lock = redissonClient.getLock("FriendMarry:precachejob:docache:lock");
    try {
        // 只有一个线程能获取到锁
        if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
            // 执行实际的缓存更新操作
            doSomeThings();
            System.out.println("getLock: " + Thread.currentThread().getId());
        }
    } catch (InterruptedException e) {
        System.out.println(e.getMessage());
    } finally {
        // 释放锁
        if (lock.isHeldByCurrentThread()) {
            System.out.println("unLock: " + Thread.currentThread().getId());
            lock.unlock();
        }
    }
}

注意:

  1. waitTime 设置为 0,只抢一次,抢不到就放弃
  2. 注意释放锁要写在 finally 中

Redisson 看门狗机制:

Redisson 提供了一个自动续期的机制,即看门狗,能够自动延长锁的过期时间,从而避免在任务执行过程中锁过期的情况。

  • 默认过期时间:30秒,若任务执行时间超过30秒,则每10秒续期一次。

锁的注意事项:

  • 锁的释放:锁的释放必须放在 finally 语句块中,确保无论任务是否成功执行,锁都能被释放。
  • 锁的过期时间:确保锁有合理的过期时间,以避免死锁。
  • 续期机制:如果任务执行时间较长,需要续期机制来防止锁提前过期。

建议阅读:https://blog.csdn.net/qq_26222859/article/details/79645203

其他分布式锁实现方式

Zookeeper 实现:通过 Zookeeper 实现分布式锁,但通常不推荐使用,因其性能不如 Redis,且操作复杂。

RedissonConfig
package com.zwnsyw.backend.config;

import lombok.Data;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Redisson 配置
 *
 * @author Zwww
 */
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissonConfig {

    private String host;

    private String port;

    @Bean
    public RedissonClient redissonClient() {
        // 1. 创建配置
        Config config = new Config();
        String redisAddress = String.format("redis://%s:%s", host, port);
        config.useSingleServer().setAddress(redisAddress).setDatabase(2);
        // 2. 创建实例
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}
PreCacheJob
package com.zwnsyw.backend.job;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;

import com.fasterxml.jackson.databind.ObjectMapper;

import com.zwnsyw.backend.pojo.domain.User;

import com.zwnsyw.backend.service.UserService;

import lombok.extern.slf4j.Slf4j;

import org.redisson.api.RLock;

import org.redisson.api.RedissonClient;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.data.redis.core.ValueOperations;

import org.springframework.scheduling.annotation.Scheduled;

import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

import javax.annotation.Resource;

import java.util.Arrays;

import java.util.List;

import java.util.concurrent.TimeUnit;

/**

 * 缓存预热任务

 *

 * @author Zwww

 */

@Component

@Slf4j

public class PreCacheJob {

    @Resource

    private UserService userService;

    @Resource

    private RedisTemplate<String, Object> redisTemplate;

    @Resource

    private RedissonClient redissonClient;

    // 重点用户

    private List<Long> mainUserList = Arrays.asList(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L);

    // 每天执行,预热推荐用户

    @PostConstruct //debug 启动时立即执行的缓存预热任务

//    @Scheduled(cron = "0 0 0 * * ?")

    public void doCacheRecommendUser() {

        RLock lock = redissonClient.getLock("FriendMarry:precachejob:docache:lock");

        try {

            // 只有一个线程能获取到锁

            if (lock.tryLock(0, 5, TimeUnit.MINUTES)) {  // 设置最大等待时间和锁超时

                log.info("获取锁成功,线程ID: {}", Thread.currentThread().getId());

                for (Long userId : mainUserList) {

                    try {

                        // 这里假设分页查询的范围是每个用户的推荐列表前20条

                        // todo 替换为真实的查询 大数据分析缓存

                        QueryWrapper<User> queryWrapper = new QueryWrapper<>();

                        Page<User> userPage = userService.page(new Page<>(1, 20), queryWrapper);

                        if (userPage != null && !userPage.getRecords().isEmpty()) {

                            String redisKey = String.format("FriendMarry:user:recommend:%s", userId);

                            ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();

                            // todo 设置缓存过期时间,可以封装成从配置文件中读取

                            long cacheTimeout = 30 * 60; // 30 分钟

                            valueOperations.set(redisKey, userPage, cacheTimeout, TimeUnit.SECONDS);

                            log.info("缓存写入成功,key: {}", redisKey);

                        } else {

                            log.warn("没有查询到推荐用户,userId: {}", userId);

                        }

                    } catch (Exception e) {

                        log.error("用户 {} 推荐列表预热失败", userId, e);

                    }

                }

            } else {

                log.warn("无法获取缓存预热锁,当前线程ID: {}", Thread.currentThread().getId());

            }

        } catch (InterruptedException e) {

            log.error("缓存预热任务中断", e);

        } finally {

            if (lock.isHeldByCurrentThread()) {

                lock.unlock();

                log.info("释放锁,线程ID: {}", Thread.currentThread().getId());

            }

        }

    }

}
image-d6f9b217

原理就是

// 重点用户

private List mainUserList = Arrays.asList(1L,2L,3L,4L);

设置要缓存的用户有哪些 把FriendMarry;user;recommond:userid先加载进入redis

等他们取的时候 缓存里面已经有对应的FriendMarry;user;recommond:userid 就可以命中

其实用起来并不是很复杂 难点其实在于这个缓存数据的设计 给哪些人预加载哪些数据 可能要用到大数据的分析等等

总结

通过定时任务配合分布式锁,能够有效地进行缓存预热,确保在合适的时机刷新缓存数据,并且避免多台服务器同时执行相同的任务造成资源浪费或数据不一致。Redisson 提供了较为简便的分布式锁解决方案,适用于高并发、高可靠性的分布式系统。


项目分区导航:⬅️ 15-定时任务 | 16-缓存 | ➡️ 17-锁