Redis 的 Java 客户端

客户端选择

  • Jedis: 以Redis 命令作为方法名称、学习成本低,简单实用,但是Jedus 实例是线程不安全的,多线程环境下需要基于连接池来实现
  • Lettuce 是基于Netty 实现的,支持同步,异步和响应式编程方式,并且是线程安全的。支持Redis 的哨兵模式,集群模式和管道模式

Jedis

  1. 引入依赖

    1
    2
    3
    4
    5
    6
    <!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
    <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.2.3</version>
    </dependency>
  2. 建立连接

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    package org.example;


    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import redis.clients.jedis.Jedis;

    public class AppTest {
    private Jedis jedis;

    @BeforeEach
    public void setUp() {
    // 建立连接
    jedis = new Jedis("192.168.22.4", 6379);
    // 设置密码
    jedis.auth("redis6.4");
    // 选择数据库
    jedis.select(0);
    }

    // 测试 String 数据类型
    @Test
    void testString(){
    // 插入数据 方法名称就是 redis 命令名称,非常简单
    String result = jedis.set("name","李四");
    System.out.println("result = " + result);
    // 获取数据
    String name = jedis.get("name");
    System.out.println("name = " + name);
    }

    // 测试 Hash
    @Test
    void testHash() {
    jedis.hset("srb:core:code", "17732906439", "97hf");
    jedis.hset("srb:core:code", "17732906429", "5fp2");
    Map<String, String> stringMap = jedis.hgetAll("srb:core:code");
    System.out.println("stringMap = " + stringMap);

    }

    @AfterEach
    void tearDown() {
    // 释放资源
    if (jedis != null) {
    jedis.close();
    }
    }
    }

  3. 基础使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /****************************************** 单元测试 **********************************************/
    @Test
    void testHset() {
    Map<String, String> map = new HashMap<String, String>();
    map.put("name","jack");
    map.put("age","31");
    map.put("sex","man");
    jedis.hmset("aa:bb",map);
    }
    /************************************************************************************************/
    查看

Jedis 连接池

  • Jedis 本身是线程不安全的,并且频繁的创建和销毁连接会有性能损耗,因此我们推荐大家使用Jedis 连接池代替Jedis 的直连方式

  • 创建工具类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    package org.example;

    import redis.clients.jedis.Jedis;
    import redis.clients.jedis.JedisPool;
    import redis.clients.jedis.JedisPoolConfig;

    public class JedisConnectionFactory {
    private static final JedisPool jedisPool;

    static {
    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
    // 最大连接
    jedisPoolConfig.setMaxTotal(8);
    // 最大空闲连接
    jedisPoolConfig.setMaxIdle(8);
    // 最小空闲连接
    jedisPoolConfig.setMinIdle(0);
    // 设置最长等待时间 ms
    jedisPoolConfig.setMaxWaitMillis(200);
    jedisPool = new JedisPool(jedisPoolConfig, "192.168.22.4", 6379, 1000, "redis6.4");
    }

    // 获取 Jedis 对象
    public static Jedis getJedis() {
    return jedisPool.getResource();
    }

    }

  • 使用

    1
    2
    jedis = JedisConnectionFactory.getJedis();
    // 后续不变

SpringDataRedis

  • Spring 中数据操作的模块,包含对各种数据库的集成,其中对Redis 的集成模块就叫做SpringDataRedis

  • 提供了对不同Redis 客户端的整合(LettuceJedis)

  • 提供了RedisTemplate 统一API 来操作Redis

  • 支持Redis 的发布订阅模型

  • 支持Redis 哨兵和Redis 集群

  • 支持基于Lettuce 的响应式编程

  • 支持基于JDK,JSON,字符串,Spring 对象的数据序列化以及反序列化

  • 支持基于Redis JDKCollection 实现

  • SpringDataRedis 中提供了RedisTemplate 工具类,其中封装了各种对Redis 的操作。并且将不同数据类型的操作API 封装到了不同的类型中

    API 返回值类型 说明
    redisTemplate.opsForValue() ValueOperations 操作String 类型数据
    redisTemplate.opsForHash() HashOperations 操作Hash 类型数据
    redisTemplate.opsForList() ListOperations 操作List 类型数据
    redisTemplate.opsForSet() SetOperations 操作Set 类型数据
    redisTemplate.opsForZSet() ZSetOperations 操作SortedSet 类型数据
    redisTemplate 通用的命令
  • 使用步骤

    1. 引入依赖

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>

      <!-- 连接池依赖 -->
      <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
      </dependency>
    2. 配置

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      spring:
      redis:
      host: 192.168.22.4
      port: 6379
      password: redis6.4
      lettuce:
      pool:
      max-active: 8 # 最大连接
      max-idle: 8 # 最大空闲连接
      min-idle: 0 # 最小空闲连接
      max-wait: 100 # 连接等待时间
    3. 注入redisTemplate

      1
      2
      3
      @Resource
      private RedisTemplate redisTemplate;

    4. 编写测试

      1
      2
      3
      4
      5
      6
      7
      8
      9

      @Test
      void contextLoads() {
      // 插入一条数据
      redisTemplate.opsForValue().set("springData", "saveValue");
      // 读取一条 String 类型数据
      Object name = redisTemplate.opsForValue().get("springData");
      System.out.println("name = " + name);
      }
    5. 序列化配置

      1
      2
      3
      4
      5
      <!-- 没有引入 springmvc 时添加 -->
      <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      </dependency>
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      // 简单配置
      @Configuration
      public class RedisConfig {

      @Bean
      public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
      // 创建 RedisTemplate 对象
      RedisTemplate<String, Object> template = new RedisTemplate<>();
      // 设置连接工厂
      template.setConnectionFactory(connectionFactory);
      // 创建 JSON 序列化工具
      GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
      // 设置 key 的序列化
      template.setKeySerializer(RedisSerializer.string());
      template.setHashKeySerializer(RedisSerializer.string());
      // 设置 value 的序列化
      template.setValueSerializer(jackson2JsonRedisSerializer);
      template.setHashValueSerializer(jackson2JsonRedisSerializer);
      return template;
      }
      }
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      // 详细配置
      @Configuration
      public class RedisConfig {
      @Bean
      public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory redisConnFactory) {
      RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
      // 设置连接池工厂
      redisTemplate.setConnectionFactory(redisConnFactory);
      // 1. 解决 key 的序列化方式
      StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
      redisTemplate.setKeySerializer(stringRedisSerializer);
      // 2. 解决 value 的序列化方式
      Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
      // 序列化时将类的数据类型存入 json,以便反序列化的时候转换成正确的类型
      ObjectMapper objectMapper = new ObjectMapper();
      // 将当前对象的数据类型也存入序列化的结果字符串中
      objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
      // 解决jackson2无法反序列化 LocalDateTime的问题
      objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
      objectMapper.registerModule(new JavaTimeModule());
      jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
      redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
      return redisTemplate;
      }
      }

      objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
      • 出现时可以进行自动反序列化,但是占用内存

      • 获取对比

        • 无字节码存储

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          @SpringBootTest
          class DemoRedisBootApplicationTests {
          @Autowired
          private RedisTemplate redisTemplate;

          @Test
          void contextLoads() {
          // redisTemplate.opsForValue().set("userinfo", new User("coder-itl2", 18, LocalDate.now()));
          String jsonString = (String) redisTemplate.opsForValue().get("userinfo");
          // {username=coder-itl2, age=18, birthday=2023-01-27}
          System.out.println(jsonString);
          }

          }

        • 有字节码存储

          1
          2
          3
          4
          5
          6
          7
          // 修改 RedisConfig 的配置
          @Test
          void contextLoads() {
          User userinfo = (User) redisTemplate.opsForValue().get("userinfo");
          // User(username=coder-itl2, age=18, birthday=2023-01-27)
          System.out.println(userinfo);
          }
          强转类型说明

          在获取结果时,出现强转,这个选择时是因为了解返回的结构,所以确定了强转的类型,但当存储时,字节码不存在时,反序列化时将会出现上述错误

  • 存储一个对象

    1
    2
    3
    4
    5
    6
    7
    8
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class User implements Serializable {
    private String username;
    private Integer age;
    }

    1
    2
    3
    4
    5
    6
    @Test
    void testSaveUser() {
    redisTemplate.opsForValue().set("core:user", new User("coder-itl", 19));
    Object user = redisTemplate.opsForValue().get("core:user");
    System.out.println("user = " + user);
    }
    对象的序列化

StringRedisTemplate(推荐)

  • 问题

    额外存储键,占用存储

    为了在反序列化时知道对象的类型,JSON 序列化器会将类的class 类型写入json 结果中,存入Redis,会带来额外的内存开销

  • 解决方案

    为了节省内存空间,我们并不会使用JSON 序列化器来处理Value,而是统一使用String 序列化器,要求只能存储在String 类型的key value.当我们需要存储Java 对象时,手动完成对象的序列化和反序列化

    过程

    Spring 默认提供了一个StringRedisTemplate 类,他的key value 的序列化方式默认都是String。省去了我们自定义RedisTemplate 的过程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void testStringTemplate() {
    // 存储
    stringRedisTemplate.opsForValue().set("strKey","数据");
    // 读取
    String s = stringRedisTemplate.opsForValue().get("strKey");
    System.out.println("s = " + s);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 序列化工具的一种
    private static final ObjectMapper mapper = new ObjectMapper();

    @Test
    void testStringTemplate() throws JsonProcessingException {
    // 准备对象
    User user = new User("coder-itl", 18);
    // 手动序列化
    String json = mapper.writeValueAsString(user);
    // 存储
    stringRedisTemplate.opsForValue().set("user", json);
    // 读取
    String val = stringRedisTemplate.opsForValue().get("user");
    // 反序列化
    User valUser = mapper.readValue(val, User.class);
    System.out.println("valUser = " + valUser);
    }
    节省存储空间
  • Hash 测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Test
    void testHash() {
    // 存储值
    stringRedisTemplate.opsForHash().put("srb:core:code","phone","jh97g");
    stringRedisTemplate.opsForHash().put("srb:core:code","time","5");
    // 取值
    Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries("srb:core:code");
    System.out.println("entries = " + entries);
    }

短线验证码

  • 基于Session 的短信登录

  • 发送短信流程

    发送短信验证码流程
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // service 实现
    @Override
    public Result sendCode(String phone, HttpSession session) {
    // 1. 校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
    // 2. 如果不符合 返回错误信息
    return Result.fail("手机号格式错误!");
    }
    // 3. 符合 生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4. 保存验证码到 session
    session.setAttribute("code", code);
    // 5. 第三方平台发送短信
    log.debug("发送短信验证码成功,验证码: {}", code);
    // 返回结果
    return Result.ok();
    }
  • 注册、登录

    短信验证码 注册、登录
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    // 登录、注册(如果用户是新用户直接注册该用户)
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
    // 2. 如果不符合 返回错误信息
    return Result.fail("手机号格式错误");
    }
    // 3. 校验验证码
    Object cacheCode = session.getAttribute("code");
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.toString().equals(code)) {
    // 4. 不一致 报错
    return Result.fail("验证码错误");
    }

    // 5. 一致 根据手机号查询用户
    User user = query().eq("phone", phone).one();
    // 6. 判断用户是否存在
    if (user == null) {
    // 7. 不存在 创建新用户并保存
    user = createUserWithPhone(phone);
    }
    // 8. 保存用户信息到 session 中 user 可以定义存储粒度(隐私信息进行忽略)
    // session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

    session.setAttribute("user", user);
    return Result.ok();
    }

    private User createUserWithPhone(String phone) {
    // 创建用户
    User user = new User();
    user.setPhone(phone);
    // 生成随机名称
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 保存用户
    save(user);
    return user;
    }
  • 校验

    校验登录状态 拦截器使用
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    // 创建拦截器
    public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 1. 获取 session
    HttpSession session = request.getSession();
    // 2. 获取 session 中的用户
    Object user = session.getAttribute("user");
    // 3. 判断用户是否存在
    if (user == null) {
    // 4. 不存在 拦截 返回 401 状态码
    response.setStatus(401);
    return false;
    }
    // 5. 存在 保存用户信息到 ThreadLocal
    UserHolder.saveUser((UserDTO) user);
    // 6. 放行
    return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    // 移除用户 避免内存泄露
    UserHolder.removeUser();
    }
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 注册 使拦截器生效

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    // 添加登录拦截器
    registry.addInterceptor(new LoginInterceptor())
    // 排除不需要拦截的路径
    .excludePathPatterns(
    "/shop/**",
    "/voucher/**",
    "/shop-type/**",
    "/upload/**",
    "/blog/hot",
    "/user/code",
    "/user/login"
    );

    }
    }

Redis-代替 session

  • session 共享出现的问题

    多台Tomcat 并不共享session 存储空间,当请求切换到不同tomcat 服务时导致数据丢失的问题

  • Redis-代替 session 实现

    Redis-代替 session 实现
    1
    2
    // 修改 session 的存储为 redis 存储 LOGIN_CODE_KEY(代替魔法值)
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL,TimeUnit.MINUTES);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
    // 2.如果不符合,返回错误信息
    return Result.fail("手机号格式错误!");
    }
    // 3.redis获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
    // 不一致,报错
    return Result.fail("验证码错误");
    }

    // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();

    // 5.判断用户是否存在
    if (user == null) {
    // 6.不存在,创建新用户并保存
    user = createUserWithPhone(phone);
    }

    // 7.保存用户信息到 redis
    // 7.1.随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 7.2.User对象转为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 7.3.存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4.设置token有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 8.返回token
    return Result.ok(token);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    package com.hmdp.config;

    import cn.hutool.core.bean.BeanUtil;
    import cn.hutool.core.util.StrUtil;
    import com.hmdp.dto.UserDTO;
    import com.hmdp.utils.UserHolder;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.web.servlet.HandlerInterceptor;

    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.util.Map;
    import java.util.concurrent.TimeUnit;

    import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
    import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;

    public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 1.获取请求头中的token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)) {
    return true;
    }
    // 2.基于TOKEN获取redis中的用户
    String key = LOGIN_USER_KEY + token;
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
    // 3.判断用户是否存在
    if (userMap.isEmpty()) {
    return true;
    }
    // 5.将查询到的hash数据转为UserDTO
    UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    // 6.存在,保存用户信息到 ThreadLocal
    UserHolder.saveUser(userDTO);
    // 7.刷新token有效期
    stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8.放行
    return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    // 移除用户
    UserHolder.removeUser();
    }
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // 配置生效  拦截器顺序通过 .order(0);修改,越小越先执行

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    // 登录拦截器
    registry.addInterceptor(new LoginInterceptor())
    .excludePathPatterns(
    "/shop/**",
    "/voucher/**",
    "/shop-type/**",
    "/upload/**",
    "/blog/hot",
    "/user/code",
    "/user/login"
    ).order(1);
    // token 刷新的拦截器
    registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
    }

    1
    2
    3
    4
    5
    6
    @GetMapping("/me")
    public Result me(){
    // 获取当前登录的用户并返回
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
    }

缓存

  • 是什么

    缓存: 就是数据交换的缓冲区(称做Cache),是存贮数据的临时地方,一般读写性能比较高。

  • 缓存

    添加之前 添加缓存后
  • 根据id 查询商铺缓存的流程

    流程分析
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Override
    public Result queryById(Long id) {
    String key = "cache:shop:" + id;
    // 1. 从 redis 中查询商铺的缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    // 3. 存在 直接返回
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return Result.ok(shop);
    }
    // 4. 不存在 根据 id 查询数据库
    Shop shop = getById(id);
    // 5. 不存在 返回错误
    if (shop == null) {
    return Result.fail("商铺不存在");
    }
    // 6. 存在 写入 redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
    // 7. 返回
    return Result.ok(shop);
    }

缓存更新策略

  • 策略

    内存淘汰 超时剔除 主动更新
    说明 不用自己维护,利用Redis 的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存 给缓存数据添加TTL 时间,到期时自动删除缓存。下次查询时更新缓存 编写业务逻辑,在修改数据库的同时,更新缓存
    一致性 一般
    维护成本
  • 业务场景

    • 第一致性需求: 使用内存淘汰机制。例如店铺类型的查询缓存
    • 高一致性需求: 主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
  • 主动更新策略

    1. Cache Aside Pattern(胜出选择): 由缓存的调用者,在更新数据库的同时更新缓存
    2. Read/Write Throuhg Pattern 缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题
    3. Write Behind Caching Pattern: 调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保持最终一致。
  • 缓存更新策略的最佳实践方案

    • 低一致性需求: 使用Redis 自带的内存淘汰机制
    • 高一致性需求:主动更新,并以超时剔除作为兜底方案
      • 读操作
        • 缓存命中则直接返回
        • 缓存未命中则查询数据库,并写入缓存,设定超时时间
      • 写操作
        • 先写数据库,然后再删除缓存
        • 要确保数据库与缓存操作的原子性

给查询商铺的缓存添加超时剔除和主动更新的策略

  • 需求: 修改ShopController 中的业务逻辑,满足下面的需求

    • 根据id 查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      @Override
      public Result queryById(Long id) {
      String key = "cache:shop:" + id;
      // 1. 从 redis 中查询商铺的缓存
      String shopJson = stringRedisTemplate.opsForValue().get(key);
      // 2. 判断是否存在
      if (StrUtil.isNotBlank(shopJson)) {
      // 3. 存在 直接返回
      Shop shop = JSONUtil.toBean(shopJson, Shop.class);
      return Result.ok(shop);
      }
      // 4. 不存在 根据 id 查询数据库
      Shop shop = getById(id);
      // 5. 不存在 返回错误
      if (shop == null) {
      return Result.fail("商铺不存在");
      }
      // 6. 存在 写入 redis 设置超时时间
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES);
      // 7. 返回
      return Result.ok(shop);
      }
    • 根据id 修改店铺时,先修改数据库,再删除缓存

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      @Override
      @Transactional
      public Result update(Shop shop) {
      Long id = shop.getId();
      if (id == null) {
      return Result.fail("店铺id不能为空");
      }
      // 1. 更新数据库
      updateById(shop);
      // 2. 删除缓存
      stringRedisTemplate.delete("cache:shop:" + shop.getId());

      return Result.ok();
      }

缓存穿透

  • 缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

    缓存穿透
  • 解决方案

    • 缓存空对象

      • 优点: 实现简单,维护方便
      • 缺点
        • 额外的内存消耗
        • 可能造成短期的不一致
    • 布隆过滤

      布隆过滤流程
    • 优点: 内存占用较少,没有多余key

    • 缺点

      • 实现复杂
      • 存在误判可能
  • 实现

    修改解决缓存穿透:1. 空值写入 2. 判断是否是空值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    // 右侧流程图实现
    @Override
    public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1. 从 redis 中查询商铺的缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    // 3. 存在 直接返回
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return Result.ok(shop);
    }
    // 判断命中的是否是空值
    if (shopJson != null) {
    // 返回一个错误信息
    return Result.fail("店铺信息不存在!");
    }
    // 4. 不存在 根据 id 查询数据库
    Shop shop = getById(id);
    // 5. 不存在 返回错误
    if (shop == null) {
    // 将空值写入 redis
    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    // 返回错误信息
    return Result.fail("商铺不存在");
    }
    // 6. 存在 写入 redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7. 返回
    return Result.ok(shop);
    }
  • 总结

    • 缓存穿透产生的原因是什么?

      用户请求的数据在缓存中和数据库都不存在,不断发起这样的请求,给数据库带来巨大压力

    • 缓存穿透的解决方案有那些?

      1. 缓存null
      2. 布隆过滤
      3. 增强id 的复杂度,避免被猜测id 规律
      4. 做好数据的基础格式校验
      5. 加强用户权限校验
      6. 做好热点参数的限流

缓存雪崩

  • 缓存雪崩是指在同一时段大量的缓存key 同时失效或者Redis 服务宕机,导致大量请求到达数据库,带来巨大压力

    缓存雪崩: 导致大量请求到达数据库,带来巨大压力
  • 解决方案

    • 给不同的key TTL 添加随机值
    • 利用Redis 集群提高服务的可用性
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存

缓存击穿

  • 缓存击穿也叫热点key 问题,就是一个被高并发访问并且缓存重建业务比较复杂key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击

  • 解决方案

    • 互斥锁

      互斥锁
    • 逻辑过期

      时序图
  • 优缺点

    • 互斥锁

      • 优点
        • 没有额外的内存消耗
        • 保证一致性
        • 实现简单
      • 缺点
        • 线程需要等待,性能受影响
        • 可能有思索风险
    • 逻辑过期

      • 优点
        • 线程无需等待,性能比较好
      • 缺点
        • 不保证一致性
        • 有额外内存消耗
        • 实现复杂
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      @Override
      public Result queryById(Long id) {
      String key = CACHE_SHOP_KEY + id;
      // 1. 从 redis 中查询商铺的缓存
      String shopJson = stringRedisTemplate.opsForValue().get(key);
      // 2. 判断是否存在
      if (StrUtil.isNotBlank(shopJson)) {
      // 3. 存在 直接返回
      Shop shop = JSONUtil.toBean(shopJson, Shop.class);
      return Result.ok(shop);
      }
      // 判断命中的是否是空值
      if (shopJson != null) {
      // 返回一个错误信息
      return Result.fail("店铺信息不存在!");
      }
      // 4. 不存在 根据 id 查询数据库
      Shop shop = getById(id);
      // 5. 不存在 返回错误
      if (shop == null) {
      // 将空值写入 redis
      stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
      // 返回错误信息
      return Result.fail("商铺不存在");
      }
      // 6. 存在 写入 redis
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
      // 7. 返回
      return Result.ok(shop);
      }

基于互斥锁方式解决缓存击穿问题

  • 需求: 修改根据id 查询商铺的业务,基于互斥锁方式来解决缓存击穿问题

    流程分析
  • setnx

    说明
  • 互斥锁实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /**
    * 获取锁
    *
    * @param key
    * @return
    */
    private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
    }

    /**
    * 释放锁
    *
    * @param key
    */
    private void unlock(String key) {
    stringRedisTemplate.delete(key);
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    /**
    * 互斥锁
    */
    public Shop queryWithMutex(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1. 从 redis 中查询商铺的缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    // 3. 存在 直接返回
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return null;
    }
    // 判断命中的是否是空值
    if (shopJson != null) {
    // 返回一个错误信息
    return null;
    }

    // 5. 实现缓存重建
    String lockKey = "lock:shop:" + id;
    Shop shop = null;
    try {
    // 5.1 获取互斥锁
    boolean isLock = tryLock(lockKey);
    // 5.2 判断是否获取成功
    if (!isLock) {
    // 5.3 失败 休眠重试
    Thread.sleep(50);
    return queryWithMutex(id);
    }
    // 4. 不存在 根据 id 查询数据库
    shop = getById(id);
    // 5. 不存在 返回错误
    if (shop == null) {
    // 将空值写入 redis
    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    // 返回错误信息
    return null;
    }

    // 6. 存在 写入 redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    } finally {
    // 释放互斥锁
    unlock(lockKey);
    }

    return shop;

    }
    1
    2
    3
    4
    5
    6
    7
    8
    // 调用 
    @Override
    public Result queryById(Long id) {
    // 互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);
    // 7. 返回
    return Result.ok(shop);
    }

基于逻辑过期方式解决缓存击穿问题

  • 需求: 修改根据id 查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题

    流程分析
    1
    2
    3
    4
    5
    @Data
    public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    * 逻辑过期函数封装
    *
    * @param id
    * @param expirSeconds
    */
    private void saveShop2Redis(Long id, Long expirSeconds) {
    // 1. 查询店铺数据
    Shop shop = getById(id);
    // 2. 封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds));
    // 3. 写入 Redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    // 创建线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    // 逻辑过期实现
    public Shop queryWithLogicExpire(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1. 从 redis 中查询商铺的缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2. 判断是否存在
    if (StrUtil.isBlank(shopJson)) {
    return null;
    }
    // 命中,需要先把 json 反序列化为对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 判断是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
    // 未过期 直接返回店铺信息
    return shop;
    }
    // 已过期 需要缓存重建
    // 缓存重建
    // 获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    // 判断是否获取锁成功
    boolean isLock = tryLock(lockKey);
    if (isLock) {
    // 成功 开启独立线程 实现缓存重建
    CACHE_REBUILD_EXECUTOR.submit(() -> {
    try {
    // 重建缓存
    this.saveShop2Redis(id, 20L);
    } catch (Exception e) {
    throw new RuntimeException(e);
    } finally {
    // 释放锁
    unlock(lockKey);
    }
    });

    }
    // 返回过期的商铺信息
    return shop;

    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 锁 与 释放锁
    private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key) {
    stringRedisTemplate.delete(key);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Override
    public Result queryById(Long id) {
    // 逻辑过期实现
    Shop shop = queryWithLogicExpire(id);
    if (shop == null) {
    return Result.fail("店铺不存在");
    }
    // 7. 返回
    return Result.ok(shop);
    }

缓存工具封装

  • 基于StringRedisTemplate 封装一个缓存工具类,满足下列需求

    • 方法一: 将任意Java 对象序列化为json 并存储在string 类型的key 中,并且可以设置TTL 过期时间

    • 方法二: 将任意Java 对象序列化为json 并存储在string 类型的key 中,并且可以设置逻辑过期时间,用于处理缓存击穿的问题

    • 方法三: 根据指定的key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透的问题

    • 方法四: 根据指定的key 查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      package com.hmdp.utils;

      import cn.hutool.core.util.BooleanUtil;
      import cn.hutool.core.util.StrUtil;
      import cn.hutool.json.JSONObject;
      import cn.hutool.json.JSONUtil;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.data.redis.core.StringRedisTemplate;
      import org.springframework.stereotype.Component;

      import java.time.LocalDateTime;
      import java.util.concurrent.ExecutorService;
      import java.util.concurrent.Executors;
      import java.util.concurrent.TimeUnit;
      import java.util.function.Function;

      import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
      import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;

      @Slf4j
      @Component
      public class CacheClient {

      private final StringRedisTemplate stringRedisTemplate;

      private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

      public CacheClient(StringRedisTemplate stringRedisTemplate) {
      this.stringRedisTemplate = stringRedisTemplate;
      }

      public void set(String key, Object value, Long time, TimeUnit unit) {
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
      }

      public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
      // 设置逻辑过期
      RedisData redisData = new RedisData();
      redisData.setData(value);
      redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
      // 写入Redis
      stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
      }

      public <R,ID> R queryWithPassThrough(
      String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
      String key = keyPrefix + id;
      // 1.redis查询商铺缓存
      String json = stringRedisTemplate.opsForValue().get(key);
      // 2.判断是否存在
      if (StrUtil.isNotBlank(json)) {
      // 3.存在,直接返回
      return JSONUtil.toBean(json, type);
      }
      // 判断命中的是否是空值
      if (json != null) {
      // 返回一个错误信息
      return null;
      }

      // 4.不存在,根据id查询数据库
      R r = dbFallback.apply(id);
      // 5.不存在,返回错误
      if (r == null) {
      // 将空值写入redis
      stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
      // 返回错误信息
      return null;
      }
      // 6.存在,写入redis
      this.set(key, r, time, unit);
      return r;
      }

      public <R, ID> R queryWithLogicalExpire(
      String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
      String key = keyPrefix + id;
      // 1.redis查询商铺缓存
      String json = stringRedisTemplate.opsForValue().get(key);
      // 2.判断是否存在
      if (StrUtil.isBlank(json)) {
      // 3.存在,直接返回
      return null;
      }
      // 4.命中,需要先把json反序列化为对象
      RedisData redisData = JSONUtil.toBean(json, RedisData.class);
      R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
      LocalDateTime expireTime = redisData.getExpireTime();
      // 5.判断是否过期
      if(expireTime.isAfter(LocalDateTime.now())) {
      // 5.1.未过期,直接返回店铺信息
      return r;
      }
      // 5.2.已过期,需要缓存重建
      // 6.缓存重建
      // 6.1.获取互斥锁
      String lockKey = LOCK_SHOP_KEY + id;
      boolean isLock = tryLock(lockKey);
      // 6.2.判断是否获取锁成功
      if (isLock){
      // 6.3.成功,开启独立线程,实现缓存重建
      CACHE_REBUILD_EXECUTOR.submit(() -> {
      try {
      // 查询数据库
      R newR = dbFallback.apply(id);
      // 重建缓存
      this.setWithLogicalExpire(key, newR, time, unit);
      } catch (Exception e) {
      throw new RuntimeException(e);
      }finally {
      // 释放锁
      unlock(lockKey);
      }
      });
      }
      // 6.4.返回过期的商铺信息
      return r;
      }

      public <R, ID> R queryWithMutex(
      String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
      String key = keyPrefix + id;
      // 1.redis查询商铺缓存
      String shopJson = stringRedisTemplate.opsForValue().get(key);
      // 2.判断是否存在
      if (StrUtil.isNotBlank(shopJson)) {
      // 3.存在,直接返回
      return JSONUtil.toBean(shopJson, type);
      }
      // 判断命中的是否是空值
      if (shopJson != null) {
      // 返回一个错误信息
      return null;
      }

      // 4.实现缓存重建
      // 4.1.获取互斥锁
      String lockKey = LOCK_SHOP_KEY + id;
      R r = null;
      try {
      boolean isLock = tryLock(lockKey);
      // 4.2.判断是否获取成功
      if (!isLock) {
      // 4.3.获取锁失败,休眠并重试
      Thread.sleep(50);
      return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
      }
      // 4.4.获取锁成功,根据id查询数据库
      r = dbFallback.apply(id);
      // 5.不存在,返回错误
      if (r == null) {
      // 将空值写入redis
      stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
      // 返回错误信息
      return null;
      }
      // 6.存在,写入redis
      this.set(key, r, time, unit);
      } catch (InterruptedException e) {
      throw new RuntimeException(e);
      }finally {
      // 7.释放锁
      unlock(lockKey);
      }
      // 8.返回
      return r;
      }

      private boolean tryLock(String key) {
      Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
      return BooleanUtil.isTrue(flag);
      }

      private void unlock(String key) {
      stringRedisTemplate.delete(key);
      }
      }