商户查询缓存

目录

一、什么是缓存

二、添加缓存

2.1缓存模型图

2.2代码实现

2.3练习:给店铺类型查询业务添加缓存

三、缓存更新策略

3.1主动更新策略

3.2操作缓存和数据库时有三个问题需要考虑:

1.删除缓存还是更新缓存?

2.如何保证缓存与数据库的操作的同时成功或失败?

3.先操作缓存还是先操作数据库?

3.3缓存更新策略的最佳实践方案:

四、缓存穿透

4.1解决方案有两种:

1.缓存空对象

2.布隆过滤

4.2缓存穿透流程图

4.3代码实现

4.4缓存穿透产生的原因是什么?

五、缓存雪崩

5.1解决方案:

六、缓存击穿

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

1.互斥锁

2.逻辑过期

6.2.1基于互斥锁方式解决缓存击穿问题

6.2.2代码实现:

6.3基于逻辑过期方式解决缓存击穿问题

6.3.1需求:修改根据id查询商铺的业务,基于逻辑过期方式解决缓存击穿问题

6.3.2代码实现

七、缓存工具封装

八、总结


一、什么是缓存

缓存是数据交换的缓冲区(称作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练习:给店铺类型查询业务添加缓存

  1. 先改 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);
     }

    }

  2. 新增 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.如何验证缓存生效

  1. 清空控制台日志

  2. 访问接口:http://localhost:8080/api/shop-type/list

  3. 看控制台:

    • 第一次请求:会打印 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值

  1. 布隆过滤

3.增强id的复杂度,避免被猜测id规律

4.做好数据的基础格式校验

5.加强用户权限校验

6.做好热点参数的限流

五、缓存雪崩

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

5.1解决方案:

1.给不同的key的TTL添加随机值

2.利用Redis集群提高哦服务的可用性

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

4.给业务添加多级缓存

六、缓存击穿

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

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

1.互斥锁

用 "热门奶茶店排队做奶茶" 来打比方:

  • 缓存就像前台的奶茶,没了就代表缓存过期了

  • 数据库就是后厨,做奶茶很费时间,一次只能进一个人

  • 互斥锁就像后厨门口的 "一次性准入券",只有拿到券的人才能进后厨做奶茶


互斥锁的流程,一步一步说:

  1. 线程 1(第一个来的顾客)

  2. 先看前台(查缓存),发现奶茶没了(缓存未命中)

  3. 抢后厨的 "准入券"(获取互斥锁),成功了

  4. 进后厨做奶茶(查数据库)

  5. 做好奶茶,放回前台(写入缓存)

  6. 把 "准入券" 还给后厨(释放锁),让别人也能抢

  7. 线程 2(后面来的顾客)

  8. 也看前台(查缓存),发现奶茶没了(缓存未命中)

  9. 抢 "准入券"(获取互斥锁),但被线程 1 先抢走了(获取失败)

  10. 只能先在店门口等一会儿(休眠重试)

  11. 过一会儿再去前台看(重试查缓存),这时候线程 1 已经把奶茶放回去了(缓存已重建)

  12. 直接拿走做好的奶茶(缓存命中),不用再进后厨了


一句话总结互斥锁的核心:

缓存没命中时,只有第一个拿到锁的线程能去查库重建缓存,其他所有线程都等待重试,不会直接打数据库。

2.逻辑过期

用 "外卖店取餐" 来打比方:

你是一个外卖店,缓存就像前台的餐品展示柜,数据库就是后厨。

  • 餐品展示柜里的餐品(缓存)有个 "逻辑过期时间" 标签

  • 后厨做新餐(更新缓存)很费时间,不能让顾客都堵在后厨门口


逻辑过期的流程,一步一步说:

  1. 线程 1(第一个来的顾客)
  • 看展示柜(查缓存),发现餐品标签写着 "逻辑过期"

  • 他先抢了个 "后厨锁"(互斥锁),成功了

  • 他立刻开了个 "后厨帮工"(新线程)去做新餐

  • 自己拿着展示柜里的旧餐品(过期数据)先给顾客吃,不耽误顾客走

  1. 线程 2(帮工)
  • 他拿到锁后,去后厨做新餐(查数据库)

  • 做好新餐,放回展示柜(更新缓存),贴上新的 "逻辑过期时间" 标签

  • 然后把 "后厨锁" 释放,让别人也能抢

  1. 线程 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);
    }

八、总结

相关推荐
Yupureki1 小时前
《Redis数据库》1.初识Redis
数据库·redis·缓存
Lyyaoo.1 小时前
Redis实现分布式锁
数据库·redis·分布式
ch.ju1 小时前
Java程序设计(第3版)第二章——函数的返回值
java
架构源启2 小时前
OpenClaw 只能命令行触发?自研企业微信实现发消息即执行
java·chrome·自动化·企业微信
逻辑驱动的ken2 小时前
Java高频面试考点场景题22
java·开发语言·jvm·面试·职场和发展·求职招聘·春招
小则又沐风a2 小时前
list模拟实现
java·服务器·list
卷卷说风控2 小时前
【卷卷观察】Redis 之父用 AI 写新数据类型:4个月,我干了以前一年才敢干的事
人工智能·redis·bootstrap
上弦月-编程2 小时前
C语言链表详解,新手也能看懂! ——从入门到精通的完整教程
java·c语言·c++
网络工程小王2 小时前
[RAG 与文本向量化详解]RAG篇
数据库·人工智能·redis·机器学习