目录
6.3.1需求:修改根据id查询商铺的业务,基于逻辑过期方式解决缓存击穿问题
一、什么是缓存
缓存是数据交换的缓冲区(称作cache),是存储数据的临时地方,一般读写性能较高。

缓存的作用:1.降低后端的负载
2.提高读写效率,降低响应时间
缓存的成本:1.数据一致性成本
2.代码维护成本
3.运维成本
二、添加缓存
2.1缓存模型图
没有添加缓存时

添加缓存时:


2.2代码实现
shopController
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
IShopService
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
}
ShopServicelmpl
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key =CACHE_SHOP_KEY+ id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
//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("cache:shop:" + id, JSONUtil.toJsonStr(shop));
//7.返回
return Result.ok( shop);
}
}
后台的操作日子里的商铺并没有sql语句 说明缓存已存入redis
2.3练习:给店铺类型查询业务添加缓存

-
先改 Controller(不动其他代码,只改调用逻辑)
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {@Resource private IShopTypeService typeService; @GetMapping("list") public Result queryTypeList() { // 直接调用Service层加了缓存的方法 List<ShopType> typeList = typeService.queryTypeList(); return Result.ok(typeList); }}
-
新增 Service 层的缓存实现
先补全接口:
public interface IShopTypeService extends IService<ShopType> {
List<ShopType> queryTypeList();
}
然后实现类:
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String CACHE_SHOP_TYPE_KEY = "cache:shop:type:list";
@Override
public List<ShopType> queryTypeList() {
// 1. 从Redis查缓存
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY);
if (StrUtil.isNotBlank(json)) {
// 2. 缓存命中,直接返回
return JSONUtil.toList(json, ShopType.class);
}
// 3. 缓存未命中,查数据库
List<ShopType> typeList = this.query()
.orderByAsc("sort")
.list();
// 4. 写入Redis
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_TYPE_KEY,
JSONUtil.toJsonStr(typeList)
);
// 5. 返回
return typeList;
}
}
3.如何验证缓存生效
-
清空控制台日志
-
访问接口:
http://localhost:8080/api/shop-type/list -
看控制台:
-
第一次请求:会打印
ShopTypeMapper.selectList的 SQL 日志(正常,第一次查库写缓存) -
第二次请求:没有任何 SQL 日志,说明缓存生效
-
-
关键说明
-
缓存的 key 是固定的字符串
cache:shop:type:list,因为这是全量列表,不需要按 id 区分 -
用
JSONUtil.toList把缓存的 JSON 字符串转回List<ShopType> -
Controller 不再直接调用
typeService.query()...list(),而是调用我们加了缓存的queryTypeList()方法
三、缓存更新策略

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

一般采用方案一 可控性更高
3.2操作缓存和数据库时有三个问题需要考虑:
1.删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存,无效写操作较多(No)
删除缓存:更新数据库时让缓存失效,查询时再更新缓存(yes)
2.如何保证缓存与数据库的操作的同时成功或失败?
单体系统:将缓存与数据库操作放在一个事务
分布式系统,利用TCC等分布式事务方案
3.先操作缓存还是先操作数据库?
先删除缓存,再操作数据库
先操作数据库,再删除缓存
3.1先删除缓存,再操作数据库(出现异常可能性高)
只线程1执行 线程1 2都执行 产生了不安全问题 缓存与库数据不同


3.2先操作数据库,再删除缓存(出现异常可能性低)
正常情况: 异常情况(恰好缓存失效):


3.3缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
1.缓存命中则直接返回
2.缓存未命中则查询数据库,写入缓存,设定超时时间
写操作:
1.先写数据库,然后再删除缓存
2.要确保数据库与缓存操作的原子性
4.给查询商铺的缓存添加超时剔除和主动更新策略
修改ShopController中的业务逻辑,满足下面的需求:
1.根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
2.根据id修改店铺时,先修改数据库,再删除缓存
@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);
//3.返回
return Result.ok();
}
数据库的餐厅信息发生变化后 缓存也会自动删除 等待下次读入后自动写进缓存
四、缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样的缓存永远不会生效,这些请求都会打到数据库。
4.1解决方案有两种:
1.缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗
可能造成短期的不一致(可以设置缓存TTL)

2.布隆过滤
优点:内存占用较少,没用多余的key
缺点:实现复杂
存在误判可能

4.2缓存穿透流程图

4.3代码实现
bash
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从 Redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在(有效数据)
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值(防止缓存穿透)
if (shopJson != null) {
// 说明是我们之前存的空字符串,直接返回 null
return null;
}
// 4. 缓存未命中,根据 id 查询数据库
Shop shop = getById(id);
// 5. 数据库也不存在,存空值到 Redis,防止穿透
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);
// 7. 返回商铺信息
return shop;
}
4.4缓存穿透产生的原因是什么?
用户请求的数据在缓存中和数据库中都不存在不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有:
1.缓存null值
- 布隆过滤
3.增强id的复杂度,避免被猜测id规律
4.做好数据的基础格式校验
5.加强用户权限校验
6.做好热点参数的限流
五、缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

5.1解决方案:
1.给不同的key的TTL添加随机值
2.利用Redis集群提高哦服务的可用性
3.给缓存业务添加降级限流策略
4.给业务添加多级缓存
六、缓存击穿
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击

6.1常见的解决方案有两种:
1.互斥锁

用 "热门奶茶店排队做奶茶" 来打比方:
-
缓存就像前台的奶茶,没了就代表缓存过期了
-
数据库就是后厨,做奶茶很费时间,一次只能进一个人
-
互斥锁就像后厨门口的 "一次性准入券",只有拿到券的人才能进后厨做奶茶
互斥锁的流程,一步一步说:
-
线程 1(第一个来的顾客)
-
先看前台(查缓存),发现奶茶没了(缓存未命中)
-
抢后厨的 "准入券"(获取互斥锁),成功了
-
进后厨做奶茶(查数据库)
-
做好奶茶,放回前台(写入缓存)
-
把 "准入券" 还给后厨(释放锁),让别人也能抢
-
线程 2(后面来的顾客)
-
也看前台(查缓存),发现奶茶没了(缓存未命中)
-
抢 "准入券"(获取互斥锁),但被线程 1 先抢走了(获取失败)
-
只能先在店门口等一会儿(休眠重试)
-
过一会儿再去前台看(重试查缓存),这时候线程 1 已经把奶茶放回去了(缓存已重建)
-
直接拿走做好的奶茶(缓存命中),不用再进后厨了
一句话总结互斥锁的核心:
缓存没命中时,只有第一个拿到锁的线程能去查库重建缓存,其他所有线程都等待重试,不会直接打数据库。
2.逻辑过期

用 "外卖店取餐" 来打比方:
你是一个外卖店,缓存就像前台的餐品展示柜,数据库就是后厨。
-
餐品展示柜里的餐品(缓存)有个 "逻辑过期时间" 标签
-
后厨做新餐(更新缓存)很费时间,不能让顾客都堵在后厨门口
逻辑过期的流程,一步一步说:
- 线程 1(第一个来的顾客)
-
看展示柜(查缓存),发现餐品标签写着 "逻辑过期"
-
他先抢了个 "后厨锁"(互斥锁),成功了
-
他立刻开了个 "后厨帮工"(新线程)去做新餐
-
自己拿着展示柜里的旧餐品(过期数据)先给顾客吃,不耽误顾客走
- 线程 2(帮工)
-
他拿到锁后,去后厨做新餐(查数据库)
-
做好新餐,放回展示柜(更新缓存),贴上新的 "逻辑过期时间" 标签
-
然后把 "后厨锁" 释放,让别人也能抢
- 线程 3、4(后面来的顾客)
-
看展示柜,也发现餐品过期了,想抢锁,但是被线程 1 先抢了
-
抢不到锁,就直接拿展示柜里的旧餐品(过期数据)走了,不用等后厨
一句话总结逻辑过期的核心:
缓存里的数据永远不删,只是贴个 "逻辑过期" 的标签。过期后,只有第一个拿到锁的线程,会开个新线程去后台更新缓存,其他所有线程都直接返回旧数据,不会去查数据库。
对比一下:

| 方案 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 核心逻辑 | 缓存没了,锁着重建,其他线程等 | 缓存永远不删,只是标记过期,后台异步更新 |
| 用户体验 | 部分请求需要等待重试,会有短暂延迟 | 所有请求都直接返回数据,无等待 |
| 数据一致性 | 重建后是最新数据,一致性好 | 会短暂返回过期数据,一致性稍差 |
| 适用场景 | 数据实时性要求高,并发不是极端夸张 | 数据实时性要求不高,并发极高的热点场景 |
6.2.1基于互斥锁方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题

6.2.2代码实现:
首先先创建锁和释放锁
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);
}
然后再根据流程图实现业务
bash
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
//互斥锁实现缓存击穿
Shop shop = queryWithMutex(id);
if(shop==null){
return Result.fail("店铺不存在");
}
//7.返回
return Result.ok(shop);
}
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中的是否为空值
if (shopJson != null) {
//返回错误信息
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
Shop shop = null;
String lockKey = null;
try {
lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
//4.2判断是否获取成功
if (!isLock) {
//4.3失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.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 {
//7.释放锁
unLock(lockKey);
}
//7.返回
return shop;
}
6.3基于逻辑过期方式解决缓存击穿问题
6.3.1需求:修改根据id查询商铺的业务,基于逻辑过期方式解决缓存击穿问题

6.3.2代码实现
1.RedisData
bash
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2.shopserivelmpl
bash
private void savaShop2Redis(Long id,Long expireSeconds){
//1.查询店铺数据
Shop shop = getById(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(CACHE_SHOP_TTL));
//3.写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
bash
private static final ExecutorService CACHE_THREAD_POOL = Executors.newFixedThreadPool(10);
public Shop querWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//3.存在,直接返回
return null;
}
//4.命中,需要先把json反序列化为对象
RedisData redisData=JSONUtil.toBean(shopJson, 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) {
//6.3成功,开启独立线程,实现缓存重建
CACHE_THREAD_POOL.submit(() -> {
try{
//重建缓存
this.savaShop2Redis(id,20L);
}catch(Exception e){
e.printStackTrace();
}finally {
//释放锁
unLock(lockKey);
}
});
}
//6.4返回过期的商铺信息
return shop;
}
bash
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
//互斥锁实现缓存击穿
//Shop shop = queryWithMutex(id);
//逻辑过期解决缓存击穿
Shop shop = querWithLogicalExpire(id);
if(shop==null){
return Result.fail("店铺不存在");
}
//7.返回
return Result.ok(shop);
}
七、缓存工具封装
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
代码:
首先缓存工具封装
bash
package com.hmdp.utils;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.Data;
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.TimeUnit;
import java.util.function.Function;
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final Long CACHE_NULL_TTL = 2L;
// ============== 1. 普通存缓存 ==============
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
// ============== 2. 逻辑过期存缓存 ==============
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)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// ============== 3. 解决缓存穿透(你要的核心方法)==============
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)) {
return JSONUtil.toBean(json, type);
}
// 3. 命中空值
if (json != null) {
return null;
}
// 4. 查数据库
R r = dbFallback.apply(id);
// 5. 数据库不存在,缓存空值
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. 写入Redis
this.set(key, r, time, unit);
return r;
}
// ============== 内部类:逻辑过期封装 ==============
@Data
public static class RedisData {
private Object data;
private LocalDateTime expireTime;
}
}
bash
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//互斥锁实现缓存击穿
//Shop shop = queryWithMutex(id);
//逻辑过期解决缓存击穿
//Shop shop = querWithLogicalExpire(id);
if(shop==null){
return Result.fail("店铺不存在");
}
//7.返回
return Result.ok(shop);
}
八、总结





