一.

1) 浏览器/接口/HTTP 是啥?

  • 浏览器:你在电脑上开的那个 Chrome/Edge,就是它在给服务器发“请求”。

  • 接口(API):服务器留的门,比如 /user/login。浏览器去敲门:“我来登录了”。

  • HTTP 请求:敲门的那张“纸条”,上面能带:

    • URL:比如 http://localhost:8081/user/login

    • 方法GET/POST(常用登录是 POST

    • 请求头(Headers):额外信息(下面会讲 Authorization

    • 请求体(Body):表单或 JSON(比如手机号、验证码)

  • HTTP 响应状态码:服务器回你一串数字。常见:

    • 200 成功

    • 401 未登录/没权限(本项目里,拦截器会这样回)


2) Header 的 Authorization 是啥?

  • 它是 HTTP 的标准请求头,专门放“你是谁”的证明。

  • 在我们项目里,我们自己约定把登录后的 token 放进去:

    Authorization: <你的token>
    
  • 以后你访问任意接口,都把这行带上,后端就能“凭 token 认人”。


3) token 是啥?

  • 一个随机字符串,像一张“门禁卡号”。你登录成功后,服务器发给你。

  • 以后你每次访问,都拿这张“卡”(放在 Authorization 头里)让保安(拦截器)看一下。

  • 我们这个项目不是 JWT,就普通随机串,优点是好续期、好踢人下线


4) Session / Cookie / Redis 这仨有啥关系?

  • Session:服务器记住你的“会话对象”(像临时档案)。传统做法放在服务器内存里。

  • Cookie:浏览器的小饼干,自动带着一个 JSESSIONID 给服务器,好让服务器找到你的 Session。

  • 问题:多台服务器时,内存不共享,Session 会丢。

  • Redis:高速的内存数据库(大家都能连),把“验证码、登录的用户信息”都放这里,就共享了,还能设置过期时间(TTL)。

  • 本项目做法不再用服务器内存 Session,统一把验证码登录用户信息Redis


5) Redis 里常用的两种存法

  • String:存一串值。
    用来存验证码login:code:{phone} -> 6位数字,TTL=2分钟

  • Hash:像小字典(很多字段)。
    用来存登录后的用户信息
    login:token:{token} -> {id: "1", nickName: "user_xxx", icon: "..."}

  • TTL(过期时间):到点自动删除(比如 30 分钟)。
    我们还会做“滑动续期”:有人访问就把 TTL 重新设回 30 分钟。


6) DTO / Entity 是啥?为什么不用把 User 直接塞出去?

  • Entity(实体):数据库表对应的完整对象(常含隐私字段、无用字段)。

  • DTO(数据传输对象):只保留必要、不敏感的字段给前端/Redis。

  • 为什么要 DTO:安全&干净。比如:只放 id、nickName、icon,不放手机号、密码。

  • 项目里常用 BeanUtil.copyProperties(user, UserDTO.class) 来“拷贝需要的字段”。


7) ThreadLocal / UserHolder 是啥?

  • ThreadLocal:Java 提供的“本线程专属小抽屉”。放进去的东西,同一请求(同一线程)里随时能取,其他线程看不到。

  • UserHolder:自己封装的一个小工具,底层就是 ThreadLocal,用来存当前登录用户

  • 为什么要用:后面的业务代码就不用层层传参或反复查 Redis,直接 UserHolder.getUser() 拿。

结束时一定要清空UserHolder.removeUser(),避免线程复用时“串用户”。


8) 拦截器(Interceptor)是啥?order(0)/order(1) 又是啥?

  • 拦截器:在“请求到 Controller 之前/之后”统一做事情的钩子。

  • 两个关键方法

    • preHandle():进门前检查(常用来验登录)

    • afterCompletion():请求结束后(常用来清理 ThreadLocal)

  • order:谁先执行。“数字越小越先执行”。

    • order(0) 先跑,再 order(1)

  • 本项目里有两个拦截器

    1. RefreshTokenInterceptororder=0,先):

      • 干“查 token → 查 Redis → 放 UserHolder → 刷新 TTL

      • 不拦截任何请求(即使没登录也放行),因为它只负责“续期+注入用户”

    2. LoginInterceptororder=1,后):

      • 干“需要登录的接口才检查 UserHolder 有没有用户 → 没有就 401

      • 它才是“拦住未登录”的人


9) StringRedisTemplate / @Configuration / @Service 这些注解是啥?

  • StringRedisTemplate:Spring 提供的 Redis 客户端,写起来方便:opsForValue()opsForHash()

  • @Service:把这个类标成“服务层”,Spring 帮你创建并托管(叫“Bean”)。

  • @Configuration:标记“配置类”,比如你在里面注册拦截器

  • @Resource / @Autowired:把 Bean 注入进来(拿来用)。


10) RandomUtil / UUID / RegexUtils

  • RandomUtil.randomNumbers(6)(hutool):生成 6 位数字验证码。

  • UUID.randomUUID().toString(true)(hutool):生成不带横杠的随机串(当 token)。

  • RegexUtils:你们项目里的手机号正则校验工具(自己写的 util)。


二.商户查询缓存


A. 发验证码(接口:/user/code?phone=xxx

  1. 校验手机号

if (RegexUtils.isPhoneInvalid(phone)) return Result.fail("手机号格式不正确!");
  • 干嘛:挡掉无效手机号

  • 概念:RegexUtils(正则)

  1. 生成 6 位验证码

String code = RandomUtil.randomNumbers(6);
  • 概念:RandomUtil(hutool)

  1. 把验证码丢 Redis,2 分钟过期

stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 2, TimeUnit.MINUTES);
  • 概念:Redis StringTTL

小建议:成功登录后记得删掉验证码,避免多次复用。


B. 用验证码登录(接口:/user/login

  1. 再次校验手机号(同上)

  2. 从 Redis 拿验证码 → 比对

String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + phone);
if (cacheCode == null || !cacheCode.equals(code)) return Result.fail("验证码错误");
  • 概念:Redis 取值、和用户输入比对

  1. 查数据库是否已有这个手机号

User user = query().eq("phone", phone).one();
  • 概念:数据库单表查询(MyBatis-Plus 封装)

  1. 没有就新建用户

if (user == null) {
  user = new User();
  user.setPhone(phone);
  user.setNickName("user_" + RandomUtil.randomString(10));
  save(user);
}
  • 概念:插入数据库

  1. 生成 token(登录凭证)

String token = UUID.randomUUID().toString(true);
  1. 把“精简后的用户信息”存到 Redis 的 Hash 里,设置 TTL(比如 30 分钟)

UserDTO dto = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, String> map = new HashMap<>();
map.put("id", dto.getId().toString());
map.put("nickName", dto.getNickName());
map.put("icon", dto.getIcon());
String key = "login:token:" + token;
stringRedisTemplate.opsForHash().putAll(key, map);
stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
  • 概念:DTOHashTTL

  1. 把 token 返回给前端

  • 概念:前端把它放到 localStorage 或 Cookie,并在以后请求头里加 Authorization: <token>


C. 以后每次访问任意接口(不管是不是需要登录)

先经过:RefreshTokenInterceptororder=0,先跑

目标:只要带了 token,就“注入用户 + 刷新TTL”

  1. 从请求头拿 token

String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) return true; // 没带就当游客,放行
  • 概念:HTTP Header Authorization

  1. 用 token 去 Redis 查用户

String key = "login:token:" + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()) return true; // token 失效/过期,当游客
  • 概念:Hash 取全部字段

  1. 把 Hash 转成 UserDTO,放进 UserHolder(ThreadLocal)

UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO); // ThreadLocal:这个请求后面随便拿
  • 概念:DTOThreadLocal

  1. 刷新 TTL(滑动续期)

stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
  • 概念:滑动续期(有访问就延长)

  1. 放行

return true;
  1. 请求结束后清理

UserHolder.removeUser();
  • 概念:ThreadLocal 清理

注意:这个拦截器永远不拦截请求。没带 token 的人就当游客,有 token 的人就续期 + 注入用户。


D. 再经过:LoginInterceptororder=1,后跑

目标:只有“需要登录”的接口才真拦截

  1. 检查 ThreadLocal 里有没有用户

if (UserHolder.getUser() == null) {
  response.setStatus(401);
  response.getWriter().write("用户未登录!");
  return false; // 拦住
}
return true; // 放行
  • 概念:这一步才是“你没有登录就不让访问”的保安

  1. 结束后同样清理

UserHolder.removeUser();

配置顺序(在 MvcConfig.addInterceptors 里):

registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0); // 先
registry.addInterceptor(new LoginInterceptor())
        .excludePathPatterns("/user/code", "/user/login", "/shop/**", ...) // 放行公共接口
        .order(1); // 后
  • 概念:order 数字越小越先执行;排除不用登录的地址


最后给你一段“口袋版记忆”

  • 验证码:校手机号 → 生成 6 位 → Redis Stringlogin:code:{phone}(2 分钟)

  • 登录:比验证码 → 查/建用户 → 生成 tokenRedis Hashlogin:token:{token}(30 分钟)→ 返回 token

  • 每次请求

    • 拦1(order=0):读 Authorization → 查 Redis → UserHolder 注入 → 续期 → 放行

    • 拦2(order=1):需要登录的接口 → 没用户就 401 → 有就放行

    • 结束UserHolder.removeUser() 清理


好,小羊~我用跟你刚才喜欢的那种“先讲概念 → 再走流程”的方式,帮你把商户查询缓存这一整套从 0 到 1 全梳理出来,保证你能学到完整体系,逻辑没断点。


(一)、涉及到的所有概念(先把词都认识了)

1) 缓存(Cache)

  • 是什么:把数据暂存在一个更快的地方(比如内存、Redis),下次用的时候直接拿,不用每次都查数据库。

  • 好处

    • 速度快(Redis 在内存中,毫秒级)

    • 降低数据库压力

  • 缺点

    • 数据可能不是最新的 → 数据一致性问题

  • 本项目缓存位置:Redis


2) 数据一致性

  • 问题:数据库改了,但缓存没更新,就会出现查出来的还是旧数据

  • 原因:数据库更新和缓存更新不是同时完成,有时间差。

  • 常见策略

    • 先更新数据库,再删除缓存(推荐)

    • 更新缓存(不太推荐,容易出错)


3) Redis Key / TTL / 数据类型

  • Key:存数据的名字,比如 cache:shop:1

  • TTL(Time to Live):过期时间,到期后自动删除

  • 常用类型

    • String:简单值(商户 JSON 字符串)

    • List:有序集合(商户类型列表)

    • Hash:字段型存储(存多个属性)

  • 序列化/反序列化

    • 对象 → JSON(写 Redis 前要转成字符串)

    • JSON → 对象(读 Redis 后要转回 Java 对象)


4) 缓存问题类型

  • 缓存穿透

    • 查询一个数据库中没有的数据,缓存也没有 → 每次都打数据库

    • 解决:空值缓存(把 null 也存起来,短 TTL)

  • 缓存雪崩

    • 大量缓存同一时间过期,大量请求打到数据库

    • 解决:TTL 加随机值、分散过期时间

  • 缓存击穿(热点 Key)

    • 一个访问量巨大的 Key 突然失效,所有请求瞬间打到数据库

    • 解决:互斥锁、逻辑过期


5) 互斥锁(Mutex)

  • 是什么:一次只允许一个线程(请求)去干某事(比如重建缓存),其他线程等着。

  • Redis 实现SETNX key value(key 不存在才创建)

  • 好处:防止并发打数据库

  • 坏处:要等待,有性能损失


6) 逻辑过期

  • 是什么:数据里自带一个过期时间字段(expireTime),即使物理上还在 Redis 里,也根据逻辑时间判断是否过期。

  • 好处:过期时其他线程还能读旧数据,不会全都卡住

  • 坏处:可能返回过期数据


7) 序列化工具

  • JSONUtil(Hutool 提供):

    • toJsonStr(obj):对象 → JSON 字符串

    • toBean(json, Class):JSON 字符串 → 对象


8) Function<ID, R>(函数式接口)

  • 是什么:Java 8 引入的函数传参方式,可以把一个方法当作参数传进去。

  • 好处:通用性强(封装通用缓存查询方法时用到)


(二)、完整流程讲解(按开发顺序走)


1. 商户缓存查询(单个商户)

1.1 逻辑步骤
  1. 先查 Redis

    • Key = cache:shop:{id}

    • 有值:

      • 如果是正常 JSON → 反序列化 → 返回

      • 如果是空字符串("")→ 说明数据库里也没有 → 返回 null

  2. Redis 没值 → 查数据库

    • 查到 → 转 JSON → 存 Redis(TTL=30分钟)→ 返回

    • 查不到 → 在 Redis 里存一个空字符串(TTL=2分钟)→ 返回 null

1.2 为什么要这样?
  • 先查缓存:绝大多数请求直接命中缓存,速度快

  • 存空值:解决缓存穿透(避免一直查不存在的 id)


2. 商户类型缓存(列表)

2.1 逻辑步骤
  1. 查 Redis List

    • Key = cache:shoptype:list

    • 有值 → 遍历反序列化 → 返回

  2. Redis 没值 → 查数据库

    • 按 sort 排序

    • 转 JSON,逐个存到 Redis List

    • 设置 TTL(30分钟)

2.2 为什么用 List 存?
  • 商户类型是一个有序列表,用 List 更直观

  • 查询一次存整个列表,批量读取


3. 缓存更新策略(双写一致)

3.1 逻辑步骤
  1. 先更新数据库

  2. 再删除缓存(对应 Key)

3.2 为什么不是“先删缓存,再更新数据库”?
  • 如果先删缓存,刚好有别的线程来查,就会查数据库(此时数据还没更新),又把旧数据放回缓存 → 脏数据


4. 缓存问题与解决方案

4.1 缓存穿透
  • 问题:查一个不存在的 id,缓存也没有 → 每次都打数据库

  • 解决:把 null 缓存起来,短 TTL(如 2 分钟)

4.2 缓存雪崩
  • 问题:大量缓存同时过期,所有请求同时打数据库

  • 解决

    • 给 TTL 加随机值(分散过期时间)

    • 多级缓存(本地缓存 + Redis)

    • 限流、降级

4.3 缓存击穿(热点 Key)
  • 问题:一个超热数据过期,瞬间很多请求打数据库

  • 解决

    1. 互斥锁(SETNX)

      • 第一个线程获取锁 → 查数据库 → 重建缓存

      • 其他线程等待或重试

    2. 逻辑过期

      • 数据加逻辑过期时间

      • 过期后仍然返回旧数据,并异步重建缓存


5. 互斥锁实现缓存击穿

  1. 流程

    • 查 Redis → 没命中 → 尝试加锁

    • 加锁成功 → 查数据库 → 更新缓存 → 释放锁

    • 加锁失败 → 等一会重试

  2. 加锁代码

Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
  1. 释放锁

redisTemplate.delete(key);

6. 逻辑过期实现缓存击穿

  1. 数据结构RedisData { Object data; LocalDateTime expireTime; }

  2. 流程

    • 查 Redis → 命中

    • 判断逻辑过期时间

      • 没过期 → 直接返回

      • 过期 → 异步线程重建缓存(不阻塞当前请求)

  3. 重建缓存

public void saveShop2Redis(Long id, Long expireSeconds){
    Shop shop = getById(id);
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSONUtil.toJsonStr(redisData));
}

7. 封装通用缓存工具类(CacheClient)

  • 目的:避免每个业务方法都重复写“查缓存 → 查数据库 → 回写缓存”逻辑

  • 实现

    • queryWithPassThrough():解决缓存穿透

    • queryWithLogical():逻辑过期解决缓存击穿

  • 使用

Shop shop = cacheClient.queryWithPassThrough(
    CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES
);
  • 好处

    • 业务代码更简洁

    • 缓存逻辑集中管理,方便修改


(三)、整体流程总结图

【查询商户流程】
用户请求
   ↓
查 Redis
   ├─ 命中非空 → 反序列化返回
   ├─ 命中空值 → 返回 null(缓存穿透保护)
   └─ 未命中
        ↓
      查数据库
        ├─ 有 → 回写 Redis(正常 TTL)
        └─ 无 → 缓存空值(短 TTL)

【更新商户流程】
更新数据库
   ↓
删除 Redis 缓存 Key(下次查会重建)

【缓存击穿保护】
- 热点 Key → 互斥锁 OR 逻辑过期

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐