JAVA-实战8 Redis实战项目—雷神点评(2)商户查询缓存

素敵だった未来に繋がった夢

雷神点评——商户查询缓存

由于商店数量较少,所以多次查询返回结果相同,且实际操作中往往查询操作占大多数,考虑使用缓存优化查询操作,避免多次访问数据库

缓存概念

缓存就是数据交换的缓冲区(称作Cache),是存贮数据的临时地方,一般读写性能较高。

缓存优缺点如下:

优点:

降低后端负载
提高读写效率,降低响应时间

缺点:

数据一致性成本
代码维护成本
运维成本

添加Redis缓存

Redis缓存模型作用示意如下:
image

具体流程示意图如下:
image

根据ID查询商店

控制层方法

@RestController
@RequestMapping("/shop")
public class ShopController {
    @Autowired
    private ShopService shopService;

    @GetMapping(value="/{id}")
    public ResultData GetShopByID(@PathVariable("id") Long ShopId) {
        ShopData Shop = shopService.SelectShopById(ShopId);
        if(Shop==null) return ResultData.error("Shop dose not exist!");
        else return ResultData.success(Shop);
    }
}

服务层方法

@Service
public class ShopServiceImpl implements ShopService {
    @Autowired
    private ShopMapper shopMapper;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public ShopData SelectShopById(Long ShopId) {
        // 查询Redis,查看缓存中是否存在,有则直接返回
        String ShopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+ShopId);
        
        if(!StringUtil.isNullOrEmpty(ShopJson)) {
            return JSONUtil.toBean(ShopJson,ShopData.class);
        }
        // 缓存中没有,则查询数据库,并更新缓存
        LambdaQueryWrapper<ShopData> lambdaWrapper = new LambdaQueryWrapper<>();
        lambdaWrapper.eq(ShopData::getId,ShopId);
        ShopData Shop = shopMapper.selectOne(lambdaWrapper);
        if(Shop!=null) {
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ShopId,JSONUtil.toJsonStr(Shop));
        }
        return Shop;
    }
}

常量封装工具类:

public class RedisConstants {
    public static final Long CACHE_NULL_TTL = 2L;
    public static final String CACHE_NULL_VALUE = "";

    public static final Long CACHE_SHOP_TTL = 30L;
    public static final String CACHE_SHOP_KEY = "cache:shop:";

    public static final String LOCK_SHOP_KEY = "lock:shop:";
    public static final Long LOCK_SHOP_TTL = 10L;
}

测试效果如下:

第一次查询,走数据库查询并且缓存Redis更新
image
image

第二次同样的查询,没有走数据库
image

缓存更新策略

Redis如果缓存过量的话就需要及时汰换,这就是缓存更新策略
image

业务场景:

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

读操作

缓存命中直接返回
缓存未命中则查询数据并写入缓存

写操作

先写数据库再删除缓存
确保数据库操作和缓存操作的原子性

根据ID插入或更新餐厅

// 控制层方法
@PutMapping
public ResultData UpdateShop(@RequestBody ShopData NewShop) {
    shopService.UpdateShop(NewShop);
    return ResultData.success();
}

// 服务层方法
@Override
@Transactional // 添加业务注解保证操作原子性
public void UpdateShop(ShopData NewShop) {
    shopMapper.updateById(NewShop);
    stringRedisTemplate.delete(CACHE_SHOP_KEY+NewShop.getId());
    return;
}

缓存穿透和解决方法

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。

常见的解决方案有两种:

缓存空对象

优点:实现简单易于维护
缺点:①额外的内存消耗 ②可能造成短期的数据不一致

布隆过滤器

优点:内存占用较少,没有多余Key
缺点:①实现复杂 ②存在误判可能

除此之外,还有以下方法应对缓存穿透:

增强id的复杂度,避免被猜测id规律
做好数据的基础格式校验
加强用户权限校验
做好热点参数的限流

这里采用缓存空对象方法来应对缓存穿透
image

代码如下:

@Override
public ShopData SelectShopById(Long ShopId) {

    String ShopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+ShopId);

    if(!StringUtil.isNullOrEmpty(ShopJson)) {
        return JSONUtil.toBean(ShopJson,ShopData.class);
    }

    if (ShopJson != null && ShopJson.equals(CACHE_NULL_VALUE)) {
        // 判断是否是缓存中的空对象
        return null;
    }

    LambdaQueryWrapper<ShopData> lambdaWrapper = new LambdaQueryWrapper<>();
    lambdaWrapper.eq(ShopData::getId,ShopId);
    ShopData Shop = shopMapper.selectOne(lambdaWrapper);
    // 如果为空则将空对象插入Redis
    if(Shop==null) {
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ShopId,CACHE_NULL_VALUE,CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }

    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ShopId,JSONUtil.toJsonStr(Shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Shop;
}

测试效果如下:
image
image

缓存雪崩

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

解决方案:

给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性
给缓存业务添加降级限流策略
给业务添加多级缓存

缓存击穿

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

常见的解决方案有两种:

互斥锁:当缓存失效时,只有一个线程能获取到分布式锁去查询数据库并重建缓存,其他未获取到锁的线程则等待一段时间后重试,或直接返回降级数据。

①查询缓存,若命中则直接返回。
②若未命中,尝试获取分布式锁(如使用 Redis 的 SETNX 命令,需设置过期时间防止死锁)。
③双重检查‌:获取锁成功后,再次检查缓存是否已有数据(防止其他线程在等待期间已重建完成)。若有则直接返回。
④若仍无数据,则查询数据库,将结果写入缓存,并释放锁。
⑤未获取到锁的线程可短暂休眠后重试查询缓存,或快速失败返回提示。

逻辑过期:缓存中不设置物理过期时间(TTL),而是在数据内部包含一个逻辑过期时间字段。

①查询缓存时,如果缓存中不存在数据(极少见,如首次加载),则直接查库并写入缓存(此时可加锁保证唯一性)。
②如果缓存存在,查看逻辑过期时间是否到期
③若未到期,直接返回数据。
④若已到期,不立即阻塞当前请求,而是返回旧数据(或空数据),同时启动一个异步线程去查询数据库并更新缓存。
⑤后续请求会逐渐获取到新数据。

流程示意图如下:
image

优缺点对比如下:
image

互斥锁应对缓存穿透

这里基于互斥锁方法来应对缓存穿透,流程示意图如下:
image

使用Redis中的setnx操作来模拟互斥锁

// 加锁操作
private boolean TryLock(String Key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(Key,"1",LOCK_SHOP_TTL,TimeUnit.MINUTES);
    return BooleanUtil.isTrue(flag);
}
// 去锁操作
private void Unlock(String Key) {
    stringRedisTemplate.delete(Key);
}

private ShopData SelectShopByIdWithMutex(Long ShopId) throws InterruptedException {

    String ShopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+ShopId);

    if(!StringUtil.isNullOrEmpty(ShopJson)) {
        return JSONUtil.toBean(ShopJson,ShopData.class);
    }

    if (ShopJson != null && ShopJson.equals(CACHE_NULL_VALUE)) {
        return null;
    }
    // 设置加锁的键
    String LockKey = LOCK_SHOP_KEY+ShopId;
    ShopData Shop = null;
    try {
        boolean IsLock = TryLock(LockKey);
        if(!IsLock) {
            // 模拟循环等待
            Thread.sleep(50);
            return SelectShopByIdWithMutex(ShopId);
        }

        LambdaQueryWrapper<ShopData> lambdaWrapper = new LambdaQueryWrapper<>();
        lambdaWrapper.eq(ShopData::getId,ShopId);
        Shop = shopMapper.selectOne(lambdaWrapper);
        Thread.sleep(200); // 模拟重建延迟
        
        if(Shop==null) {
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ShopId,CACHE_NULL_VALUE,CACHE_NULL_TTL, TimeUnit.MINUTES);
        }

        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ShopId,JSONUtil.toJsonStr(Shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        // 保证无论如何都能去锁
        Unlock(LockKey);
    }

    return Shop;
}

逻辑过期应对缓存穿透

由于查询缓存时缓存中不存在数据情况极为少见,所以这里设定缓存中已存在数据(讨论情况为内容是否为空以及时间是否过期)

实现流程图如下:
image

实现代码如下:

// 用于封装过期时间的RedisData
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
// 服务层方法
@Service
public class ShopServiceImpl implements ShopService {
    @Autowired
    private ShopMapper shopMapper;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 设置线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    // 从数据库中读取最新信息并更新过期时间
    private void SaveShopToRedis(Long ShopId,Long ExpireSeconds) {
        LambdaQueryWrapper<ShopData> lambdaWrapper = new LambdaQueryWrapper<>();
        lambdaWrapper.eq(ShopData::getId,ShopId);
        ShopData Shop = shopMapper.selectOne(lambdaWrapper);

        RedisData redisData = new RedisData();
        redisData.setData(Shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(ExpireSeconds));

        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+ShopId,JSONUtil.toJsonStr(redisData));
    }

    private ShopData SelectShopByIdWithLogicExpire(Long ShopId) {
        String ShopKey = CACHE_SHOP_KEY+ShopId;

        String ShopJson = stringRedisTemplate.opsForValue().get(ShopKey);
        // 查看是否为空,如果为空表示不存在直接返回
        if(StrUtil.isBlank(ShopJson)) {
            return null;
        }
        //从Redis缓存中获取Shop数据,由于RedisData中封装数据类型为Object,需要进行格式转化
        RedisData redisData = JSONUtil.toBean(ShopJson,RedisData.class);
        ShopData Shop = JSONUtil.toBean(JSONUtil.toJsonStr(redisData.getData()), ShopData.class);
        
        LocalDateTime ExpireTime = redisData.getExpireTime();
        // 查看是否过期,如果没过期直接返回
        if(ExpireTime.isAfter(LocalDateTime.now())) {
            return Shop;
        }
        // 如果过期的话那么查看是否已加锁,获取锁成功则开启异步线程开始更新缓存数据
        String LockKey = LOCK_SHOP_KEY + ShopId;
        boolean IsLock = TryLock(LockKey);
        if(IsLock) {
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try{
                    this.SaveShopToRedis(ShopId,20L);

                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 无论是否成功都要去锁
                    Unlock(LockKey);
                }
            });
        }

        return Shop;
    }
}
posted @ 2026-05-03 23:27  tcswuzb  阅读(17)  评论(0)    收藏  举报