概述
-
目标
:将 Redis 作为数据交换的缓冲区(临时储存数据的位置) -
功能
- 降低负载:缓存可以大大降低用户访问并发量对后端带来的数据库读写压力
- 提高效率:提高读写效率,降低响应时间
-
成本
- 数据一致性成本
- 代码维护成本
- 运维成本
-
缓存分级
- 浏览器缓存:浏览器端的本地缓存
- 应用层换层:tomcat 本地缓存,redis 缓存
- 数据库缓存:数据库的 buffer pool 空间,增删改查数据优先加载到这个位置
- CPU缓存:L1,L2,L3级缓存
- 磁盘缓存
缓存的查询 & 更新
-
工作流程
- 客户端发起查询请求
- 查询缓存数据(避免直接查询数据库)
- 缓存中存在目标数据 → 直接返回目标数据
- 缓存中不存在目标数据 → 查询数据库,将目标数据写入缓存并设置失效时间
- 返回结果给客户端
一、缓存更新策略
-
策略
- ⭐主动更新:手动调用方法删除缓存,通常用于解决缓存与数据库的不一致问题
- 内存淘汰:内存超限时,淘汰掉一些不重要的数据
- 超时剔除:将超时的数据进行删除
-
工作流程
- 用户提交修改请求
- 更新数据库
- 删除缓存 (直接删除而不更新,等待用户下次查询时自动更新缓存)
-
删除缓存而非更新缓存的原因
:避免写多读少降低效率,更新后用户第一次查询缓存未命中会查数据库并自动写入缓存
二、一致性问题
-
问题说明
:数据库中数据发生变化,Cache 没有及时同步而导致用户使用 Cache 过时数据而无法到达数据库取用最新数据 -
解决方案
:先更新数据库再删 Cache (因为更新数据库的速度很慢,这段时间如果用户查询则又会导致不一致问题)
三、代码实现(查询 & 更新)
-
ShopController
-
注入 Service 依赖 (用于调用实现的方法)
@Resource public IShopService shopService;
-
创建 queryById 方法
@GetMapping("/{id}") public Result queryShopById(@PathVariable("id") Long id){ return shopService.queryId(id); }
-
-
ShopServiceImpl
-
注入 StringRedisTemplate 依赖 (用于操作 Redis)
@Resource private StringRedisTemplate stringRedisTemplate;
-
重写 queryById 方法
@Override public Result queryById(Long id){ // 1. 查询redis中缓存的商铺信息 String shopKey = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(shopKey); // 2. 如果目标商户在缓存中,则直接返回目标数据 if( StrUtil.isNotBlank(shopJson) ){ Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 3. 如果目标商户不在缓存中,则查询数据库 Shop shop = getById(id); // 4. 如果数据库中也不存在商铺数据, 则返回错误信息 if( shop == null ) return Result.fail("店铺不存在!"); // 5. 如果数据库中存在商铺数据,写入缓存并返回商铺信息 stringRedisTemplate.opsForValue.set(shopKey, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES); return Result.ok(shop); }
-
重写 update 方法
@Override @Transactional public Result update(Shop shop){ Long id = shop.getId(); if(id == null) return Result.fail("店铺id不能为空"); updateById(shop); stringRedisTemplate.delete(CACHE_SHOP_KEY + id); return Result.ok(); }
-
缓存穿透
-
问题说明
:客户端请求的数据在缓存中和数据库中都不存在,这些请求都会穿透缓存,直接查询数据库 -
解决方案
- 缓存空对象
- 布隆过滤
一、缓存空对象
定义
:即使数据库中不存在也将被查询的数据写入 redis,下次查询此数据时在 redis 中也能找到这个数据,避免直击数据库优点
:实现简单,维护方便缺点
:消耗额外内存,可能造成短期不一致
二、⭐布隆过滤
-
优点
:内存占用极少,没有多余 key -
缺点
:实现复杂,存在误判可能 -
工作流程
三、其他方法
- 增强id的复杂度,避免被猜测 id 规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
缓存雪崩
-
问题说明
:在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求直达数据库造成的查询压力 -
解决方案
- 给不同的 Key 的 TTL 添加随机值
- 利用 Redis 集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
-
定义
- 缓存击穿:热点Key问题,一个被高并发访问并且缓存重建业务较复杂的 key 突然失效,大量访问该数据的直达数据
- 缓存重构:通过一些方法重构缓存且避免大量请求直达数据库
-
解决方案
- 互斥锁
- 逻辑过期
一、互斥锁
-
工作流程
- 线程1查询缓存,命中则直接返回结果,否则继续执行
- 线程1获得锁资源,执行业务逻辑
- 线程2查询此数据,等待锁资源
- 线程1释放资源锁,其他线程继续执行逻辑并能够顺利从缓存中拿到数据
-
优点
:实现简单,无内存消耗 -
缺点
:可能死锁,影响性能 (只能串行执行) -
实现
-
逻辑锁:用redis的setnx方法,插入成功表示获取锁,插入失败表示等待锁资源
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); }
-
缓存重构
... ... String lockKey = "lock:shop:" + id; // 获取逻辑锁的key值 Shop shop = null; try{ boolean isLock = tryLock(lockKey); // 如果资源锁被占用则等待一段时间后重新调用 if(!isLock){ // 如果锁已经占用则等待后重新尝试 Thread.sleep(50); return queryWithMutex(id); } shop = getById(id); // 获取锁后查询数据库 ... ...
-
二、逻辑过期
-
工作流程
- 缓存预热
- 查询缓存(调用方向缓存系统索要数据)
- 未命中 → 返回 null(调用方收到 null 时,自行决定是否查询数据库或执行降级逻辑)
- 命中且未过期 → 返回结果(调用方拿到正确结果)
- 命中且过期 → 获取锁资源(调用方拿到过期结果)
- 获取锁资源失败 → 返回过期数据 (高并发情况下避免多个线程同时修改导致缓存击穿)
- 获取锁资源成功 → 开启一个新线程,返回过期数据
- 新线程异步构建缓存:新线程查询数据库,重建缓存数据和逻辑过期时间,释放锁资源
-
优点
:异步的构建缓存,不需要等待,性能好 -
缺点
:在于在构建完缓存之前,返回的都是脏数据,并且实现很麻烦 -
实现
-
创建实体类 (开闭原则,避免代码侵入)
@Data public class RedisData { private LocalDateTime expireTime; private Object data; }
-
新增方法(ShopServiceImpl 中)
public void saveShop2Redis(Long id, Long expireSeconds) { Shop shop = getById(id); // 查询店铺信息 RedisData redisData = new RedisData(); // 给店铺信息封装失效时间 redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); redisData.setData(shop); // 写入Redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); }
-
缓存预热 (测试类中调用 saveShop2Redis 方法)
-
正式代码 (ShopServiceImpl 中)
public Shop queryWithLogicalExpire( Long id ){ String key = CACHE_SHOP_KEY + id; String json = stringRedisTemplate.opsForValue().get(key); if(StrUtil.isBlank(json)) return null; RedisData redisShop = JSONUtil.toBean(json, RedisData.class); Shop shop = redisShop.getShop(); LocalDateTime expireTime = redisShop.getExpire(); if(expireTime.isAfter(LocalDateTime.now()) return shop; // 重建缓存过程 String lockKey = LOCK_SHOP_KEY + id; // 获取互斥锁 boolean isLock = tryLock(lockKey); if(isLock){ // 如果获取成功,则更新缓存信息 CACHE_REBUILD_EXECUTOR.submit( ()->{ try{ this.saveShop2Redis(id, 20L); }catch (Exception e){ throw new RuntimeException(e); }finally{ unlock(lockKey); } }); } return shop; }
-
封装 Redis 工具类
-
定义
:基于 StringRedisTemplate 的操作,封装一个缓存工具类 -
前置知识
- 泛型
- 函数式编程
- 线程池
-
代码逻辑
@Slf4j @Component public class CacheClient { private final StringRedisTemplate stringRedisTemplate; // 调用者要将自己的stringRedisTemplate注入工具类中,以便工具类对调用者的缓存进行操作 public CacheClient(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } // 一. 将key-value写入Redis并设置过期时间 public void set(String key, Object value, Long time, TimeUnit unit){ stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit); } // 二. 将key-value写入Redis并设置逻辑过期时间 public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){ // 创建RedisData类,写入数据并设置逻辑过期时间 RedisData redisData = new RedisData(); redisData.setData(value); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); // 将RedisData序列化后作为value写入Redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); } // 三. 查询缓存,用缓存空值解决缓存穿透问题 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 valueJson = stringRedisTemplate.opsForValue().get(key); // 缓存中存在则直接返回结果 (空字符串属于Blank,不会进入if) if(StrUtil.isNotBlank(valueJson)) { return JSONUtil.toBean(valueJson, type); } // 拦截空字符串,防止缓存穿透(因为此时不为null就一定是 "", 否则已经被返回了) if(valueJson != null){ return null; } // 让调用者决定查找逻辑 (函数式编程) R r = dbFallback.apply(id); // 查找目标不存在 -> 写入空值并返回空值 if ( r == null) { stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } // 查找目标存在 -> 写入缓存并返回查找结果 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r), time, unit); return r; } // 创建一个线程池,用于在方法中开启新线程 private final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); // 查询缓存,用逻辑过期解决热点Key问题 public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> typeR, String lockPrefix, Function<ID, R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; // 查询缓存中是否存在目标结果 String valueJson = stringRedisTemplate.opsForValue().get(key); // 查找到空值 -> 返回null (热点key预热过,如果查到空值则说明没有数据) if(StrUtil.isBlank(valueJson)) { return null; } // Json -> RedisData -> Bean RedisData redisData = JSONUtil.toBean(valueJson, RedisData.class); R r = JSONUtil.toBean((JSONObject) redisData.getData(), typeR); LocalDateTime expireTime = redisData.getExpireTime(); // 结果未过期 -> 返回结果 if(expireTime.isAfter(LocalDateTime.now())){ return r; } // 结果已过期 -> 新线程重建缓存,返回过期数据 String lockKey = lockPrefix + id; boolean isLock = tryLock(lockKey); // 成功获取锁, 开启新线程重建缓存 if(isLock){ CACHE_REBUILD_EXECUTOR.submit(() -> { // 重建缓存 R rFallback = dbFallback.apply(id); this.setWithLogicalExpire(key, rFallback, time, unit); // 释放锁 unlock(lockKey); }); } // 返回过期商品信息 return r; } // 热点key上锁(如果没有锁,则上锁10秒) private boolean tryLock(String key){ Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } // 热点key释放锁 private void unlock(String key){ stringRedisTemplate.delete(key); } }
单节点 Redis 问题
- 数据丢失问题:通过 Redis 数据持久化解决
- 并发能力问题:通过搭建主从集群、实现读写分离实现
- 故障恢复问题:通过 Redis 哨兵,实现健康检测和自动回复
- 存储能力问题:搭建分片集群,利用插槽机制实现动态扩容