一、什么是缓存
📗 缓存是数据交换的缓冲区(Cache [ kæʃ ]
),是临时存贮数据的地方,一般读写性能较高
缓存作用:
📗 降低后端负载
📗 提高读写效率
📗 降低响应时间
缓存的成本:
📗数据一致性成本
📗代码维护成本
📗运维成本
二、给业务添加缓存(减少数据库访问次数)
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource StringRedisTemplate stringRedisTemplate; @Override public Result getShopById(Long id) { String key = "cache:shop:" + id; String shopJSON = stringRedisTemplate.opsForValue().get(key); // 缓存中有数据 if (StrUtil.isNotBlank(shopJSON)) { return Result.ok(JSONUtil.toBean(shopJSON, Shop.class)); } // 缓存中无数据, 查询数据库 Shop shopById = getById(id); if (null == shopById) { return Result.fail("没有查询到商铺信息"); } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopById)); return Result.ok(shopById); } }
三、给店铺类型查询业务添加缓存
(1) 使用 String 类型
@Service public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService { @Resource private StringRedisTemplate stringRedisTemplate; @Override public List<ShopType> listShopType() { String key = "cache:shopType"; String shopTypeListString = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopTypeListString)) { // 把数组类型的字符串转换为 List return JSONUtil.toList(shopTypeListString, ShopType.class); } List<ShopType> shopListBySort = query().orderByAsc("sort").list(); if (shopListBySort == null || shopListBySort.size() < 1) { return null; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopListBySort)); return shopListBySort; } }
(2) 使用 List 类型
😂 改天补充
四、缓存的更新策略
🎁低一致性需求:使用内存淘汰机制。如:店铺类型的查询
🎁高一致性需求:主动更新,并以超时剔除作为兜底方案。如:店铺详情
(1) 主动更新
🎁① 更新数据库,同时更新缓存
🎁② 缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题
🎁③ 调用者只操作缓存,由其它线程异步地将缓存数据持久化到数据库,保证最终一致
操作缓存和数据库时有三个问题需要考虑:
❓删除缓存还是更新缓存?
😓更新缓存:每次更新数据库都更新缓存,无效写操作较多【NO】
😀删除缓存:更新数据库时让缓存失效,查询时再更新缓存 【YES】
❓如何保证缓存与数据库的操作的同时成功或失败?
🍀单体系统,将缓存与数据库操作放在一个事务
🍀分布式系统,利用 TCC 等分布式事务方案
❓先操作缓存还是先操作数据库?
🍀先操作数据库,再删除缓存【YES】
🍀先删除缓存,再操作数据库
(2) 最佳实现方案
(3) 给查询商铺的缓存添加超时剔除和主动更新的策略
修改 ShopController 中的业务逻辑,满足下面的需求:
① 根据 id 查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
② 根据 id 修改店铺时,先修改数据库,再删除缓存
① 存缓存,设置超时时间
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource StringRedisTemplate stringRedisTemplate; @Override public Result getShopById(Long id) { // cache:shop: + id String key = RedisConstants.CACHE_SHOP_KEY + id; String shopJSON = stringRedisTemplate.opsForValue().get(key); // 缓存中有数据 if (StrUtil.isNotBlank(shopJSON)) { return Result.ok(JSONUtil.toBean(shopJSON, Shop.class)); } // 缓存中无数据, 查询数据库 Shop shopById = getById(id); if (null == shopById) { return Result.fail("没有查询到商铺信息"); } // 要设置过期时间 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopById), 33L, TimeUnit.MINUTES); return Result.ok(shopById); } }
② 更新,先修改数据库后删除缓存
@Override public Result updateShop(Shop shop) { if (shop == null) { return Result.fail("商铺信息不能为空(null)"); } Long id = shop.getId(); if (id == null || id < 1) { return Result.fail("商铺 id 不能为空"); } boolean updateByIdRet = updateById(shop); if (updateByIdRet) { // 更新数据库成功 // 删除缓存 // cache:shop: + id stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id); return Result.ok("修改店铺信息成功"); } return Result.fail("服务器忙(更新数据库失败)"); }
五、缓存穿透
(1) 是啥
❤️ 缓存穿透:客户端请求的数据在缓存中和数据库中都没有
❤️ 缓存永远不生效,这些请求都会到达数据库
(2) 解决方案
🍀 ① 缓存空对象
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗;可能造成数据短期不一致
🍀 布隆过滤
- 优点:内存占用较少,Redis 中没有多余的 key
- 缺点:实现复杂;存在误判可能
(3) 添加缓存穿透代码
@Override public Result getShopById(Long id) { // cache:shop: + id String key = RedisConstants.CACHE_SHOP_KEY + id; String shopJSON = stringRedisTemplate.opsForValue().get(key); // 缓存中有数据 if (StrUtil.isNotBlank(shopJSON)) { return Result.ok(JSONUtil.toBean(shopJSON, Shop.class)); } if (shopJSON != null) { return Result.fail("店铺不存在"); } // 缓存中无数据, 查询数据库 Shop shopById = getById(id); if (null == shopById) { // 防止缓存穿透(存储空数据) stringRedisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES); return Result.fail("没有查询到商铺信息"); } // 要设置过期时间 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopById), 33L, TimeUnit.MINUTES); return Result.ok(shopById); }
六、缓存雪崩
📖 缓存雪崩:同一时段大量的缓存 key 同时失效或者 Redis 服务宕机,导致大量请求到达数据库
- 解决方案:
📖 给不同的 Key 设置不同的过期时间(随机值)
📖 利用 Redis 集群提高服务的高可用性
📖 给缓存业务添加降级限流策略
📖 给业务添加多级缓存
七、缓存击穿(热点 key 问题)
📖 缓存击穿问题也叫热点 Key 问题
📖 一个被高并发访问并且缓存重建业务较复杂的 key 突然失效,无数的请求瞬间到达数据库
📖 常见的解决方案有两种:
(1) 互斥锁
(2) 逻辑过期
(3) 基于【互斥锁】防止缓存击穿问题
修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
✌ 使用 Redis 的
setnx
方式实现互斥锁的效果【① 获取锁:设置 key 的值;② 释放锁:删除 key】
/** * 尝试获取锁 */ private boolean tryLock(String key) { Boolean ret = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS); return BooleanUtil.isTrue(ret); } /** * 释放锁 */ public void unLock(String key) { Boolean ret = stringRedisTemplate.delete(key); }
private Result queryByMutex(Long id) { String key = RedisConstants.CACHE_SHOP_KEY + id; Result shopFromRedis = getShopFromRedis(key); if (shopFromRedis.getSuccess()) { return shopFromRedis; } /* 缓存中无数据 */ // 尝试获取锁 String lockKey = "lock:shop:" + id; boolean isGetLock = tryLock(lockKey); try { if (isGetLock) { // 获取到锁 // DoubleCheck Result shopFromRedis2 = getShopFromRedis(key); if (shopFromRedis2.getSuccess()) { return shopFromRedis2; } // 缓存中没有数据,查询数据库 Shop shopById = getById(id); if (null == shopById) { // 防止缓存穿透(存储空数据) stringRedisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES); return Result.fail("没有查询到商铺信息"); } // (把查询到的商铺数据保存到 Redis)要设置过期时间 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopById), 33L, TimeUnit.MINUTES); return Result.ok(shopById); } else { // 没有获取到锁 Thread.sleep(66); return queryByMutex(id); // 重试 } } catch (Exception e) { e.printStackTrace(); return Result.fail("缓存重建抛异常"); } finally { unLock(lockKey); } }
j
(4) 基于【逻辑过期】防止缓存击穿问题
🍀 修改根据 id 查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
① 把数据写入 Redis 并添加逻辑过期时间(缓存重建)
🎄 不把逻辑过期字段 expireTime 直接加入 Shop 类中,这对原有代码进行了修改,不是好的编程方式
🎄 可创建一个新的类(
RedisData
),类中包含 expireTime 字段,然后让 Shop 继承 RedisData【对原有代码还是有修改】🎄 最好的方式是:在 RedisData 中再包含
Object data
属性,该 data 属性可以是任何类型(包括:Shop)
/** * 逻辑过期 */ @Data public class RedisData { private LocalDateTime expireTime; private Object data; }
/** * 给店铺信息添加逻辑过期时间, 并保存到 Redis * * @param shopId 店铺 id * @param expireSeconds 过期秒数 */ public void cacheShop2Redis(Long shopId, Long expireSeconds) { RedisData redisData = new RedisData(); Shop shopById = getById(shopId); if (shopById != null) { redisData.setData(shopById); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); } // 保存到 Redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + shopId, JSONUtil.toJsonStr(redisData)); }
② 代码实现
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource StringRedisTemplate stringRedisTemplate; @Override public Result getShopById(Long id) { // 逻辑过期避免缓存击穿 Shop shop = queryByLogicExpire(id); if (shop == null) { return Result.fail("没有查询到商铺信息"); } return Result.ok(shop); } // 线程池(10个线程) private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); private Shop queryByLogicExpire(Long id) { String key = CACHE_SHOP_KEY + id; // 从 Redis 中查询商铺信息 String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(shopJson)) { return null; // 缓存中没有查询到商铺信息 } /* 缓存中有数据(命中) */ // 1.把 Redis 中的数据反序列化为 Java 实体 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); JSONObject jsonObject = (JSONObject) redisData.getData(); Shop shop = JSONUtil.toBean(jsonObject, Shop.class); // 2.判断是否过期 LocalDateTime expireTime = redisData.getExpireTime(); LocalDateTime nowTime = LocalDateTime.now(); if (expireTime.isAfter(nowTime)) { // 没有过期 return shop; } // 3.已过期 // 3.1 尝试获取锁 String lockKey = LOCK_SHOP_KEY + id; boolean lockGet = tryLock(lockKey); if (lockGet) { // 获取到锁, 开启子线程(缓存重建) // 新线程执行缓存重建操作 CACHE_REBUILD_EXECUTOR.submit(() -> { try { // 缓存重建 cacheShop2Redis(id, 15L); } catch (Exception e) { throw new RuntimeException(e); } finally { // 释放锁 unLock(lockKey); } }); } // 获取锁是否成功都返回数据 return shop; } /** * 尝试获取锁 */ private boolean tryLock(String key) { Boolean ret = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 30L, TimeUnit.SECONDS); return BooleanUtil.isTrue(ret); } /** * 释放锁 */ public void unLock(String key) { stringRedisTemplate.delete(key); } /** * 给店铺信息添加逻辑过期时间, 并保存到 Redis * * @param shopId 店铺 id * @param expireSeconds 过期秒数 */ public void cacheShop2Redis(Long shopId, Long expireSeconds) throws InterruptedException { RedisData redisData = new RedisData(); Thread.sleep(200); Shop shopById = getById(shopId); if (shopById != null) { redisData.setData(shopById); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); } // 保存到 Redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + shopId, JSONUtil.toJsonStr(redisData)); } }
八、缓存工具类 ★
基于 StringRedisTemplate 封装一个缓存工具类,满足下列需求:
🍀 将任意 Java 对象序列化为 json 并存储在 string 类型的 key 中,并且可以设置 TTL 过期时间
🍀 将任意 Java 对象序列化为 json 并存储在 string 类型的 key 中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
🍀 根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
🍀 根据指定的 key 查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
@Component public class MyRedisUtil { public static final String NULL_STRING = ""; public static final String CACHE_SHOP_KEY = "cache:shop:"; public static final Long CACHE_SHOP_TTL = 20L; public static final Long NULL_KEY_TTL = 2L; @Resource private StringRedisTemplate stringRedisTemplate; /** * 往 Redis 中写入数据(可设置存活时间) * * @param key key * @param data 写入的数据 * @param time ttl:存活时间 * @param timeUnit 时间单位 */ public void set(String key, Object data, Long time, TimeUnit timeUnit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data), time, timeUnit); } /** * 往 Redis 中写入数据(可设置逻辑过期) * * @param key key * @param data 写入的数据 * @param time ttl:逻辑过期时间 * @param timeUnit 时间单位 */ public void setWithLogicExpire(String key, Object data, Long time, TimeUnit timeUnit) { RedisData redisData = new RedisData(); redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time))); redisData.setData(data); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); } /** * 可防止缓存穿透的获取 */ public <R, ID> R getWithPassThrough(String keyPrefix, ID id, Class<R> type, Long ttl, TimeUnit unit, Function<ID, R> dbFallback) { String key = keyPrefix + id; String shopJSON = stringRedisTemplate.opsForValue().get(key); // 缓存中有数据 if (StrUtil.isNotBlank(shopJSON)) { return JSONUtil.toBean(shopJSON, type); } if (shopJSON != null) { return null; } // 缓存中无数据, 查询数据库 R dbData = dbFallback.apply(id); if (null == dbData) { // 数据库中无数据 // 防止缓存穿透(存储空数据) this.set(key, NULL_STRING, NULL_KEY_TTL, TimeUnit.MINUTES); return null; } // 数据库中有数据, 把数据缓存到 Redis, 需设置存活时间 this.set(key, dbData, ttl, unit); return dbData; } // 线程池(10个线程) private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); public <ID, T> T getByLogicExpire(String prefix, ID id, Class<T> type, Long time, TimeUnit unit, Function<ID, T> queryData) { String key = prefix + id; // 从 Redis 中查询数据 String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null; // 缓存中没有查询到数据 } /* 缓存中有数据(命中) */ // 1.把 Redis 中的数据反序列化为 Java 实体 RedisData redisData = JSONUtil.toBean(json, RedisData.class); JSONObject jsonObject = (JSONObject) redisData.getData(); T data = JSONUtil.toBean(jsonObject, type); // 2.判断是否过期 LocalDateTime expireTime = redisData.getExpireTime(); LocalDateTime nowTime = LocalDateTime.now(); if (expireTime.isAfter(nowTime)) { // 没有过期 return data; } // 3.已过期 // 3.1 尝试获取锁 String lockKey = LOCK_SHOP_KEY + id; boolean lockGet = tryLock(lockKey); if (lockGet) { // 获取到锁, 开启子线程(缓存重建) // 新线程执行缓存重建操作 CACHE_REBUILD_EXECUTOR.submit(() -> { try { // 缓存重建 T dbData = queryData.apply(id); this.setWithLogicExpire(key, dbData, time, unit); } catch (Exception e) { throw new RuntimeException(e); } finally { // 释放锁 unLock(lockKey); } }); } // 获取锁是否成功都返回数据 return data; } /** * 尝试获取锁 */ private boolean tryLock(String key) { Boolean ret = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 30L, TimeUnit.SECONDS); return BooleanUtil.isTrue(ret); } /** * 释放锁 */ public void unLock(String key) { stringRedisTemplate.delete(key); } }