一、短信登录
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类型(这是HttpSessionAPI 的设计),所以变量cacheCode被声明为Object。loginForm.getCode()返回的是String类型,变量code是String。
② 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;
}
}
说明:
在 LoginInterceptor 的 preHandle 方法中,当判断用户已登录后,会调用 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 框架自带的,不是你写的),里面会自动创建 StringRedisTemplate 和 RedisTemplate 两个 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),查数据库获取最新的店铺数据 ------ 泛型 + 函数式接口让这个方法适配所有业务的数据库查询。