redis项目笔记(黑马自用)
验证码:校手机号 → 生成 6 位 →存(2 分钟)登录:比验证码 → 查/建用户 → 生成token→Redis Hash存(30 分钟)→ 返回 token每次请求拦1(order=0):读→ 查 Redis →UserHolder注入 →续期→ 放行拦2(order=1):需要登录的接口 → 没用户就 401 → 有就放行结束清理好,小羊~我用跟你刚才喜欢的那种“先讲概念 → 再走流程”的方
一.
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)。
-
-
本项目里有两个拦截器:
-
RefreshTokenInterceptor(order=0,先):-
干“查 token → 查 Redis → 放 UserHolder → 刷新 TTL”
-
不拦截任何请求(即使没登录也放行),因为它只负责“续期+注入用户”
-
-
LoginInterceptor(order=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)
-
校验手机号
if (RegexUtils.isPhoneInvalid(phone)) return Result.fail("手机号格式不正确!");
-
干嘛:挡掉无效手机号
-
概念:
RegexUtils(正则)
-
生成 6 位验证码
String code = RandomUtil.randomNumbers(6);
-
概念:
RandomUtil(hutool)
-
把验证码丢 Redis,2 分钟过期
stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 2, TimeUnit.MINUTES);
-
概念:
Redis String、TTL
小建议:成功登录后记得删掉验证码,避免多次复用。
B. 用验证码登录(接口:/user/login)
-
再次校验手机号(同上)
-
从 Redis 拿验证码 → 比对
String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + phone);
if (cacheCode == null || !cacheCode.equals(code)) return Result.fail("验证码错误");
-
概念:Redis 取值、和用户输入比对
-
查数据库是否已有这个手机号
User user = query().eq("phone", phone).one();
-
概念:数据库单表查询(MyBatis-Plus 封装)
-
没有就新建用户
if (user == null) {
user = new User();
user.setPhone(phone);
user.setNickName("user_" + RandomUtil.randomString(10));
save(user);
}
-
概念:插入数据库
-
生成 token(登录凭证)
String token = UUID.randomUUID().toString(true);
-
把“精简后的用户信息”存到 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);
-
概念:
DTO、Hash、TTL
-
把 token 返回给前端
-
概念:前端把它放到
localStorage或 Cookie,并在以后请求头里加Authorization: <token>
C. 以后每次访问任意接口(不管是不是需要登录)
先经过:RefreshTokenInterceptor(order=0,先跑)
目标:只要带了 token,就“注入用户 + 刷新TTL”
-
从请求头拿 token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) return true; // 没带就当游客,放行
-
概念:HTTP Header
Authorization
-
用 token 去 Redis 查用户
String key = "login:token:" + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()) return true; // token 失效/过期,当游客
-
概念:
Hash取全部字段
-
把 Hash 转成
UserDTO,放进UserHolder(ThreadLocal)
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO); // ThreadLocal:这个请求后面随便拿
-
概念:
DTO、ThreadLocal
-
刷新 TTL(滑动续期)
stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
-
概念:滑动续期(有访问就延长)
-
放行
return true;
-
请求结束后清理
UserHolder.removeUser();
-
概念:ThreadLocal 清理
注意:这个拦截器永远不拦截请求。没带 token 的人就当游客,有 token 的人就续期 + 注入用户。
D. 再经过:LoginInterceptor(order=1,后跑)
目标:只有“需要登录”的接口才真拦截
-
检查 ThreadLocal 里有没有用户
if (UserHolder.getUser() == null) {
response.setStatus(401);
response.getWriter().write("用户未登录!");
return false; // 拦住
}
return true; // 放行
-
概念:这一步才是“你没有登录就不让访问”的保安
-
结束后同样清理
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 String存login:code:{phone}(2 分钟) -
登录:比验证码 → 查/建用户 → 生成 token →
Redis Hash存login: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 逻辑步骤
-
先查 Redis
-
Key =
cache:shop:{id} -
有值:
-
如果是正常 JSON → 反序列化 → 返回
-
如果是空字符串("")→ 说明数据库里也没有 → 返回 null
-
-
-
Redis 没值 → 查数据库
-
查到 → 转 JSON → 存 Redis(TTL=30分钟)→ 返回
-
查不到 → 在 Redis 里存一个空字符串(TTL=2分钟)→ 返回 null
-
1.2 为什么要这样?
-
先查缓存:绝大多数请求直接命中缓存,速度快
-
存空值:解决缓存穿透(避免一直查不存在的 id)
2. 商户类型缓存(列表)
2.1 逻辑步骤
-
查 Redis List
-
Key =
cache:shoptype:list -
有值 → 遍历反序列化 → 返回
-
-
Redis 没值 → 查数据库
-
按 sort 排序
-
转 JSON,逐个存到 Redis List
-
设置 TTL(30分钟)
-
2.2 为什么用 List 存?
-
商户类型是一个有序列表,用 List 更直观
-
查询一次存整个列表,批量读取
3. 缓存更新策略(双写一致)
3.1 逻辑步骤
-
先更新数据库
-
再删除缓存(对应 Key)
3.2 为什么不是“先删缓存,再更新数据库”?
-
如果先删缓存,刚好有别的线程来查,就会查数据库(此时数据还没更新),又把旧数据放回缓存 → 脏数据
4. 缓存问题与解决方案
4.1 缓存穿透
-
问题:查一个不存在的 id,缓存也没有 → 每次都打数据库
-
解决:把 null 缓存起来,短 TTL(如 2 分钟)
4.2 缓存雪崩
-
问题:大量缓存同时过期,所有请求同时打数据库
-
解决:
-
给 TTL 加随机值(分散过期时间)
-
多级缓存(本地缓存 + Redis)
-
限流、降级
-
4.3 缓存击穿(热点 Key)
-
问题:一个超热数据过期,瞬间很多请求打数据库
-
解决:
-
互斥锁(SETNX)
-
第一个线程获取锁 → 查数据库 → 重建缓存
-
其他线程等待或重试
-
-
逻辑过期
-
数据加逻辑过期时间
-
过期后仍然返回旧数据,并异步重建缓存
-
-
5. 互斥锁实现缓存击穿
-
流程:
-
查 Redis → 没命中 → 尝试加锁
-
加锁成功 → 查数据库 → 更新缓存 → 释放锁
-
加锁失败 → 等一会重试
-
-
加锁代码:
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
-
释放锁:
redisTemplate.delete(key);
6. 逻辑过期实现缓存击穿
-
数据结构:
RedisData { Object data; LocalDateTime expireTime; } -
流程:
-
查 Redis → 命中
-
判断逻辑过期时间
-
没过期 → 直接返回
-
过期 → 异步线程重建缓存(不阻塞当前请求)
-
-
-
重建缓存:
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 逻辑过期
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐



所有评论(0)