Redis学习笔记(实战篇1)

一、短信登录

1. 思路分析
(1) 发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号。如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户。

(2) 短信验证码登录、注册:

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息。

(3) 校验登录状态:

用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行。

2. 实现发送短信验证码功能
(1) 发送验证码
java 复制代码
    @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);
        // 返回ok
        return Result.ok();
    }

说明:

session.setAttribute("code", code):核心是「保存校验凭证,实现用户绑定」

当用户输入手机号 + 验证码提交登录时,后端会执行 session.getAttribute("code"),取出这里存的验证码,和用户输入的对比 ------没有这行存储,后续登录就没有 "基准值" 可校验

log.debug("发送短信验证码成功,验证码:{}", code):核心是「调试排查,记录运行状态」

{}:SLF4J 的占位符 ,会自动把后面的 code 变量值替换到这个位置(比如最终打印:发送短信验证码成功,验证码:123456)。

(2) 登录
java 复制代码
    @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)){
             //3.不一致,报错
            return Result.fail("验证码错误");
        }
        //一致,根据手机号查询用户
        User user = query().eq("phone", phone).one();

        //5.判断用户是否存在
        if(user == null){
            //不存在,则创建
            user =  createUserWithPhone(phone);
        }
        //7.保存用户信息到session中
        session.setAttribute("user",user);

        return Result.ok();
    }

说明:

① 为什么校验的时候,要将cacheCode.toString(),不能直接比较?

  • session.getAttribute("code") 的返回值是 Object 类型(这是 HttpSession API 的设计),所以变量 cacheCode 被声明为 Object
  • loginForm.getCode() 返回的是 String 类型,变量 codeString

② User user = query().eq("phone", phone).one(); query()的作用是什么?

  • 这是 MyBatis-Plus 提供的一个方法,通常在继承了 ServiceImpl 的 Service 层类中使用。
  • 它的作用是:创建并返回一个 QueryWrapper 查询条件构造器
3. 实现登录拦截功能
(1) 拦截器代码
java 复制代码
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((User)user);
        //6.放行
        return true;
    }
}

说明:

LoginInterceptorpreHandle 方法中,当判断用户已登录后,会调用 UserHolder.saveUser(...),将用户信息存入当前处理请求的线程ThreadLocal 中。

(2) 让拦截器生效
java 复制代码
@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);
    }
}
4. Redis代替session的业务流程
(1) 思路分析

当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

(2) 代码实现
UserServiceImpl代码
java 复制代码
@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). UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);

  • 作用 :把数据库查出来的 User 实体(包含密码等敏感字段),复制为 UserDTO(只保留 id、nickName、icon 等非敏感字段)。
  • 核心意义:避免敏感信息(比如密码)泄露到前端 / Redis 中,保证数据安全。

2). Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);

  • 作用 :把 UserDTO 对象转换成 Map 集合(键是字段名,值是字段值)。
  • 核心意义:Redis 的 Hash 结构只能接收「键值对」形式的数据,所以必须把对象转成 Map 才能存入。

3). setFieldValueEditor(...):强制把所有字段值转成字符串

  • Redis 的 Hash 结构有个「死规矩」:键和值都必须是字符串类型,不支持 Long、Integer 等数字类型。
  • ✅ 加了这个配置:fieldValueEditor 是个「编辑器」,它会遍历所有字段值,把不管是 Long、Integer 还是其他类型的值,都强制转成字符串 ------ 比如 Long 的 10086 会变成字符串 "10086",存入 Redis 后完全符合 Redis 的字符串要求,后续取值转换也不会报错。

4), stringRedisTemplate.expire(...)

给 Redis 中指定的 Key 设置「自动删除倒计时」,时间一到,Redis 就自动把这个 Key 和它对应的所有数据删掉

5. 登录刷新问题
(1) 思路分析

之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

(2) 代码实现

① RefreshTokenInterceptor:做 "数据准备 + Token 续期"(永远放行)

java 复制代码
@Override
public boolean preHandle(...) {
    // 1. 从请求头拿Token(比如前端传的authorization: xxx)
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)) {
        return true; // 没Token也放行,交给LoginInterceptor判断
    }
    // 2. 去Redis查用户信息(根据TokenKey查Hash)
    String key = LOGIN_USER_KEY + token;
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
    // 3. 没查到用户(Token过期/无效),放行(交给LoginInterceptor拦截)
    if (userMap.isEmpty()) {
        return true;
    }
    // 4. 查到用户,转成UserDTO存入ThreadLocal(给LoginInterceptor用)
    UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    UserHolder.saveUser(userDTO);
    // 5. 关键:刷新Token有效期(用户发请求就续期,避免使用中过期)
    stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 6. 永远放行(它只做准备,不拦人)
    return true;
}

@Override
public void afterCompletion(...) {
    UserHolder.removeUser(); // 请求结束,清空ThreadLocal,防内存泄漏
}

② LoginInterceptor:只做 "权限拦截"(只判断,不干活)

java 复制代码
@Override
public boolean preHandle(...) {
    // 只看ThreadLocal里有没有用户(RefreshTokenInterceptor存的)
    if (UserHolder.getUser() == null) {
        response.setStatus(401); // 没用户,返回401
        return false; // 拦截
    }
    return true; // 有用户,放行
}

二、商户查询缓存

1. 添加商户缓存
(1) 思路分析

标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

(2) 代码实现
java 复制代码
@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);
}

说明:

@Resource 能成功注入 StringRedisTemplate,核心原因是:Spring 容器中已经存在一个类型为 StringRedisTemplate 的 Bean,而 @Resource 注解可以根据名称或类型来查找并注入这个 Bean 。Spring Boot 有一套「自动配置」逻辑,当你在项目中引入 spring-boot-starter-data-redis 依赖后,Spring 会自动加载 RedisAutoConfiguration 类(这个类是 Spring 框架自带的,不是你写的),里面会自动创建 StringRedisTemplateRedisTemplate 两个 Bean

② Shop shop = JSONUtil.toBean(shopJson, Shop.class); 为什么要转化类型?

  • Redis 的存储限制 :Redis 是一个键值对数据库,它只能存储字符串、数字等基础类型,不能直接存储 Java 对象。所以你在把 Shop 存进 Redis 时,必须先用 JSONUtil.toJsonStr(shop) 把它转成 JSON 字符串(文本)才能存。
  • Java 的业务需求 :从 Redis 取出来后,必须把 JSON 字符串还原成 Shop 对象,才能在 Java 代码里正常使用。
2. 缓存更新策略
(1) 三种策略

**① 内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)

**② 超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存

**③ 主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

(2) 先操作缓存还是先操作数据库?

答案:应该先操作数据库。

左边「先删缓存,再更数据库」的并发问题(旧值 10、新值 20)

① 初始状态

  • 数据库中值:10(旧值)
  • Redis 缓存中值:10(旧值)

② 并发时序

时间顺序 线程 1(更新操作:把值改成 20) 线程 2(查询操作)
1 删除 Redis 缓存(缓存变为空) -
2 - 发起查询,缓存未命中
3 - 去数据库读取,拿到旧值 10
4 - 把旧值 10 写入 Redis 缓存
5 执行数据库更新,把值改成 20 -

③ 最终结果

  • 数据库值:20(新值)
  • Redis 缓存值:10(旧值)
  • 后续所有查询都会直接读缓存的 10,直到缓存过期 / 被删除 ------ 这就是缓存与数据库的数据不一致,本质是「缓存脏读」。
(3) 实现商铺和缓存与数据库双写一致
java 复制代码
@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_KEY + id);
    return Result.ok();
}

说明:

@Transactional 事务注解

  • 作用:将整个方法包裹在一个数据库事务中,确保 "更新数据库" 和 "删除缓存" 这两个操作要么全部成功,要么全部失败(回滚)。
(4) 缓存穿透问题的解决思路

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

② 常见的解决方案有两种:

1). 缓存空对象

当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去。这样,下次用户过来访问这个不存在的数据,那么在 redis 中也能找到这个数据, 就不会进入到数据库了。

2). 布隆过滤

布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中。

缺点:不存在一定不存在,存在可能不存在

(5) 编码解决商品查询的缓存穿透问题

重点代码

java 复制代码
if (shopJson != null) {
    return Result.fail("店铺信息不存在");
}

说明:

① 先明确两个前置条件:

  • StrUtil.isNotBlank(shopJson):判断字符串非空且非空白 (即 shopJson 不是 null、不是 ""、不是全空格);
  • 前一行已经判定 StrUtil.isNotBlank(shopJson) == false,说明 shopJson 只有两种可能:null""(空字符串)。

② 这行 if (shopJson != null):说明 shopJson""(空字符串)------ 这个 "" 是你之前主动存入 Redis 的「空对象标记」(对应代码里 stringRedisTemplate.opsForValue().set(key, "", ...))。

③ 逻辑意义:

  • 如果 Redis 里存的是 "",说明之前已经查过这个 ID 的店铺,数据库里根本没有
  • 此时直接返回 "店铺不存在",不用再查数据库,避免恶意请求(比如传不存在的 ID)反复穿透到数据库,这就是「缓存空对象防穿透」的核心逻辑。
(5) 缓存雪崩问题及解决思路

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

② 解决方案:

  • 给不同的Key的TTL添加随机值

  • 利用Redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

  • 给业务添加多级缓存

(6) 缓存击穿问题及解决思路

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

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大。

② 解决方案:

1). 互斥锁

假设现在线程1过来访问,它查询缓存没有命中,但是此时它获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

2). 逻辑过期

我们把过期时间设置在redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个线程去进行以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

(7) 利用互斥锁解决缓存击穿问题

① 思路分析

核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true,如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。

② 代码实现

java 复制代码
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);
}

说明:

setIfAbsent(key, "1", 10, TimeUnit.SECONDS):Redis 的SETNX命令(不存在则设置),实现分布式锁:

  • 如果 key 不存在,设置 key=1,过期时间 10 秒,返回true(加锁成功);
  • 如果 key 已存在,返回false(加锁失败);
  • 注意:返回值是Boolean(包装类),可能为 null(比如 Redis 连接超时 / 异常);

② 为什么不用直接 return flag?

直接 return 可能出现NullPointerException(比如 flag=null 时),这个工具类帮你做了空值兜底,保证返回的是 boolean(基本类型),更安全。

(8) 利用逻辑过期解决缓存击穿问题

① 思路分析

当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

② 代码实现

1). 新建一个实体类

java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

2). ShopServiceImpl

java 复制代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
    String key = CACHE_SHOP_KEY + 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);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未过期,直接返回店铺信息
        return shop;
    }
    // 5.2.已过期,需要缓存重建
    // 6.缓存重建
    // 6.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 6.2.判断是否获取锁成功
    if (isLock){
        CACHE_REBUILD_EXECUTOR.submit( ()->{

            try{
                //重建缓存
                this.saveShop2Redis(id,20L);
            }catch (Exception e){
                throw new RuntimeException(e);
            }finally {
                unlock(lockKey);
            }
        });
    }
    // 6.4.返回过期的商铺信息
    return shop;
}

说明:

① RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);

核心前提RedisData 是一个自定义的封装类(你项目里肯定有这个类),作用是把 "业务数据(Shop)" 和 "逻辑过期时间" 打包存到 Redis 里。

② JSONObject data = (JSONObject) redisData.getData();

redisData.getData() 拿到的是 RedisData 里的 data 字段(类型是 Object),但这个字段实际存的是 Shop 对象的 JSON 格式数据,所以需要强转成 JSONObject(Hutool 工具类的 JSON 对象,也可以理解成 Map)。

(9) 封装Redis工具类
① 通用的缓存穿透解决方案

遵循 "先查缓存 → 缓存无则查数据库 → 数据库无则缓存空值 → 数据库有则缓存数据" 的逻辑,且通过泛型实现了 "通用化"

java 复制代码
    public <R, ID> R queryWithPassThrough(String keyPrefix , ID id, Class<R> type, Function<ID, R> dbFallback,
                                          Long time, TimeUnit unit) {  //不太理解
        String key = keyPrefix + id;

        String json = stringRedisTemplate.opsForValue().get(key);

        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }

        if (json != null) {  //不太理解
            return null;
        }

        R r = dbFallback.apply(id);
        if (r == null) {
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }

        this.set(key, r, time, unit);

        return r;
    }
    Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES); //不太理解

说明:

1). 先拆解方法签名

java 复制代码
// 泛型定义:<R, ID> (和上一版一致)
// R = 返回值类型(如Shop、User、Goods),ID = 主键类型(如Long、Integer)
public <R, ID> R queryWithPassThrough(
    String keyPrefix,       // 缓存key前缀(如"cache:shop:"、"cache:goods:")
    ID id,                  // 要查询的主键(如店铺id=1、商品id=2)
    Class<R> type,          // 返回值的类类型(如Shop.class、Goods.class)
    Function<ID, R> dbFallback,  // 数据库查询回调(传入id,返回业务数据)
    Long time,              // 新增:缓存有效数据的过期时间(数值)
    TimeUnit unit           // 新增:过期时间的单位(如分钟、小时,和time配合)
)
  • R:代表返回值类型(可以是 Shop、User、Goods 等任意业务类);
  • ID:代表主键类型(可以是 Long、Integer、String 等);

2). this::getById

语法含义 :等价于一个 Lambda 表达式 (Long id) -> this.getById(id),本质是 "把查询数据库的方法作为参数传给通用方法";

② 通用化、适配所有业务的缓存击穿解决方案(逻辑过期策略)
java 复制代码
// 1. 拼接通用缓存key(比如keyPrefix=cache:shop:,id=1 → key=cache:shop:1)
String key = keyPrefix + id;

// 2. 查Redis缓存:逻辑过期的前提是缓存里有数据(只是过期),如果缓存为空,直接返回null
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) { // 缓存为空(没存过/被删了),不是热点key,直接返回
    return null;
}

// 3. 反序列化:把Redis里的JSON转成封装了"数据+过期时间"的RedisData对象(核心!)
// Redis里存的不是单纯的Shop/User,而是RedisData的JSON(包含业务数据+逻辑过期时间)
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 从RedisData中拆解出业务数据(比如Shop的JSON),转成JSONObject
JSONObject data = (JSONObject) redisData.getData();
// 把JSONObject转成泛型R(比如Shop、User)
R r = JSONUtil.toBean(data, type);
// 从RedisData中拆解出逻辑过期时间(不是Redis的TTL)
LocalDateTime expireTime = redisData.getExpireTime();

// 4. 判断是否逻辑过期:没过期→直接返回缓存数据,不走数据库
if (expireTime.isAfter(LocalDateTime.now())) {
    return r;
}

// 5. 已过期→尝试获取分布式锁(保证只有1个线程更新缓存)
String lockKey = LOCK_SHOP_KEY + id; // 锁key(比如lock:shop:1)
boolean isLock = tryLock(lockKey);   // 加锁(setIfAbsent)
if (isLock) { // 只有1个线程能拿到锁
    // 6. 异步重建缓存:用线程池执行,不阻塞当前请求
    CACHE_REBUILD_EXECUTOR.submit(() -> {
        try {
            // 6.1 查数据库(调用传入的dbFallback,比如this::getById)
            R r1 = dbFallback.apply(id);
            // 6.2 把新数据封装成RedisData,存入Redis(设置新的逻辑过期时间)
            this.setWithLogicalExpire(key, r1, time, unit);
        } catch (Exception e) {
            log.error("重建缓存失败", e); // 异常只打日志,不影响主线程
        } finally {
            unlock(lockKey); // 最终释放锁,避免死锁
        }
    });
}

// 7. 不管有没有拿到锁,都返回旧数据(核心!解决缓存击穿的关键)
return r;

说明:

1). dbFallback.apply(id) 是什么?

dbFallback是你调用方法时传入的 "数据库查询逻辑",比如:

java 复制代码
// 调用时传入this::getById(根据id查店铺)
cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.MINUTES);

dbFallback.apply(id) 就是执行this.getById(id),查数据库获取最新的店铺数据 ------ 泛型 + 函数式接口让这个方法适配所有业务的数据库查询。

相关推荐
清空mega2 小时前
动手学深度学习(李沐)笔记:Softmax 回归简洁实现(PyTorch 版)
笔记·深度学习·回归
yc_xym2 小时前
Redis哨兵(Sentinel)机制
数据库·redis·sentinel
deng-c-f2 小时前
Linux C/C++ 学习日记(73):Kafka(一):基本介绍
分布式·学习·kafka
彭于晏Yan2 小时前
Spring Boot监听Redis Key过期事件
java·spring boot·redis
低调小一2 小时前
OpenClaw 模型配置与火山 Coding Plan 支持清单(实践笔记)
java·前端·笔记·openclaw
陈辛chenxin2 小时前
【零基础学Web-Day1】HTML 基础标签 + CSS 样式规范,附实战案例
css·经验分享·笔记·html·课程设计
王夏奇2 小时前
python-PyQt6库学习
开发语言·python·学习
Naisu Xu2 小时前
数学笔记:最小二乘法(直线拟合)
笔记·算法·最小二乘法
圆弧YH2 小时前
排版基本操作
学习