文章目录
- 完成任务
-
- [1. 什么是缓存](#1. 什么是缓存)
- [2. 添加商户缓存](#2. 添加商户缓存)
- [3. 缓存更新策略](#3. 缓存更新策略)
-
- [3.1 主动更新](#3.1 主动更新)
- [4. 缓存穿透](#4. 缓存穿透)
- [5. 缓存雪崩](#5. 缓存雪崩)
- [6. 缓存击穿](#6. 缓存击穿)
-
- [6.1 使用互斥锁查询商铺信息](#6.1 使用互斥锁查询商铺信息)
- [6.2 使用逻辑过期查询商铺信息](#6.2 使用逻辑过期查询商铺信息)
- [7. 封装 Redis 工具类](#7. 封装 Redis 工具类)
完成任务
1. 什么是缓存
缓存:数据交换的缓冲区(Cache),是临时存储数据的地方,一般读写性能较高。
比如说,CPU读取数据是内存从磁盘中读取,再到CPU,磁盘中读取数据速度非常慢,于是在 CPU 中设置一个缓冲区 ,将 CPU 常用的数据存储在该缓冲区中,需要使用这些数据时,直接从缓冲区中读取要比从磁盘中读取快得多!
- 缓存的作用: 降低后端负载;提高读写效率,降低响应时间
- 缓存的成本:数据一致性成本;代码维护成本;运维成本
2. 添加商户缓存
使用Redis,用户访问商铺信息的过程:
关于 Redis 的操作有:
- 先从 Redis 中查询数据,如果 Redis 中存在响应数据,则返回给客户端。
- 如果 Redis 中不存在响应数据,将从数据库中查询到的数据存储到 Redis,以便于下次访问时,直接从 Redis 中获取,效率会提高很多。
实现代码:
java
/**
* 根据id查询店铺信息
*/
@Override
public Result queryById(Long id) {
// 1. 查询 Redis 缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3. 如果存在,从 Redis 缓存中获取返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根据 id 查询数据库
Shop shop = getById(id);
// 5. 如果 shop 不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 6. 存在,将 shop 存入 Redis 缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
// 7. 返回
return Result.ok(shop);
}
对比一下,从 Redis 中获取数据和从数据库中获取数所需时间:
第一次访问,Redis 中没有存储对应数据,需要从数据库中获取,花费较多的时间。第二次访问,直接从 Redis 中获取数据,可以发现,需要的时间比较少。
查询商铺类型使用 Redis,因为商铺类型基本都是静态的,不会很大地变动,所以建议使用 Redis:
java
/**
* 查询所有商铺类型
* @return
*/
@Override
public Result queryTypeList() {
// 1. 从 Redis 中获取缓存
String shopTypesJson = stringRedisTemplate.opsForValue().get(CACHE_SHOPTYPE_KEY);
// 2. 判断缓存是否存在
if (shopTypesJson != null) {
// 3. 缓存存在,直接返回
List<ShopType> shopTypes = JSONUtil.toList(shopTypesJson, ShopType.class);
return Result.ok(shopTypes);
}
// 4. 缓存不存在,查询数据库
List<ShopType> shopTypes = this.query().orderByAsc("sort").list();
// 5. 将查询结果缓存到 Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOPTYPE_KEY, JSONUtil.toJsonStr(shopTypes));
return Result.ok(shopTypes);
}
3. 缓存更新策略
(1)内存淘汰 :当 Redis 内存不足时,自动淘汰部分数据,下次查询时更新缓存。一致性差,无维护成本。
(2)超时剔除 :给缓存数据添加 TTL 时间,到期后自动删除缓存。下次查询时更新缓存。一致性一般,维护成本低。
(3)主动更新:编写业务逻辑,再修改数据库的同时,更新缓存。一致性好,维护成本高。
- 低一致性需求(不经常修改数据):使用内存淘汰机制。
- 高一致性需求(需要常常对数据进行修改):使用主动更新,并以超时剔除作为兜底。比如说:优惠券。
3.1 主动更新
在更新数据库的同时更新缓存。
- 删除缓存 还是更新缓存?
不采取更新缓存:每次更新数据库都要更新缓存,增加无效写 操作。比如,当更新了100次数据库,那就要更新100次缓存,而这期间并未对缓存的内容进行访问,此时就是有100次无效写的操作。
采取删除缓存:更新数据库时让缓存失效,查询时再更新缓存。 - 如何保证缓存和数据库操作的同时成功或失败?
单体系统:将缓存与数据库操作放在一个事务中
分布式系统:利用 TCC 等分布式事务方案 - 先操作数据库,再删除缓存。这样发生线程安全的可能性更低。
更新店铺信息,同时更新数据库和缓存中的数据信息:
java
/**
* 更新店铺信息
*/
@Override
public Result update(Shop shop) {
Long id = shop.getId();
if (null == id) {
return Result.fail("店铺id不能为空!");
}
// 1. 更新数据库
updateById(shop);
// 2. 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
4. 缓存穿透
缓存穿透:客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
如果客户端请求一个根本不存在的 id,数据库只能返回 null 给客户端,客户端收到 null,再次请求... ...
当一个人恶意地使用多个线程并发请求数据库中根本不存在的 id,这些请求都会到达数据库,很可能使数据库崩溃。
那应该怎么解决缓存穿透问题呢?
(1)缓存空对象 :实现简单,维护方便;但会有额外的内存消耗。
(2)布隆过滤 :内存占用较少,没有多余的 key;但实现复杂,存在误判 的可能。
那布隆过滤器怎么知道数据库是否存在当前访问的数据?
- 可理解布隆过滤器中有一个byte 数组,里面存储二进制位 ,当要判断数据库中是否存在当前访问对象时,把数据库中的是数据基于某种哈希算法计算出哈希值,再将哈希值转换为二进制位保存在布隆过滤器中,以0和1的形式进行保存,判断数据是否存在时,就是判断对应的位置是0还是1,以此判断数据是否存在。
- 判断存在是有误判的可能 的。也就是说,布隆过滤器判断不存在,那就一定是不存在;但如果判断是存在的,也有可能数据库中并不存在。
5. 缓存雪崩
缓存雪崩 :指同一时段大量的缓存 key 同时失效 或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
(1)给不同的 key 的 TTL 添加随机值 ------> 大量缓存同时失效
(2)利用 Redis 集群提高服务的可用性 ------> Redis 服务宕机
(3)给缓存业务添加降级限流策略
(4)给业务添加多级缓存
6. 缓存击穿
缓存击穿 :也叫热点 key 问题 ,就是一个高并发访问 并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方案:
(1)互斥锁
(2)逻辑过期
优点 | 缺点 | |
---|---|---|
互斥锁 | 没有额外的内存消耗;保证一致性;实现简单 | 线程需要等待,性能受影响;可能有死锁的风险 |
逻辑过期 | 线程无需等待,性能较好 | 不保证一致性;有额外的内存消耗;实现复杂 |
6.1 使用互斥锁查询商铺信息
java
public Shop queryWithMutex(Long id) {
// 1. 查询 Redis 缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3. 如果存在,从 Redis 缓存中获取返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 判断是否为空字符串
if (shopJson.equals("")) {
return null;
}
// 4. 实现缓存重建
// 4.1 尝试获取锁
Shop shop = null;
try {
boolean lock = tryLock(LOCK_SHOP_KEY + id);
// 4.2 判断是否获取到锁
if (!lock) {
// 4.3 获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.4 获取锁成功,根据 id 查询数据库
shop = getById(id);
// 5. 如果 shop 不存在,返回错误
if (shop == null) {
// 将空字符串写入 Redis 缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6. 存在,将 shop 存入 Redis 缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. 释放锁
unLock(LOCK_SHOP_KEY + id);
}
// 8. 返回
return shop;
}
/**
* 尝试获取锁
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
6.2 使用逻辑过期查询商铺信息
java
// 逻辑过期解决缓存击穿
public Shop queryWithLogicalExpire(Long id) {
// 1. 查询 Redis 缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2. 判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3. 如果不存在,从 Redis 缓存中获取返回
return null;
}
// 4. 存在,Json 反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 5. 判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 5.1 未过期,直接返回
return shop;
}
// 5.2 过期,缓存重建
// 6. 缓存重建
// 6.1 获取互斥锁
boolean flag = tryLock(LOCK_SHOP_KEY + id);
// 6.2 判断是否获取到锁
if (flag) {
// 6.3 如果获取到锁,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 缓存重建
saveShop2Redis(id, 30L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(LOCK_SHOP_KEY + id);
}
});
}
// 6.4 未获取到锁,返回过期的缓存数据
return shop;
}
/**
* 保存店铺信息到 Redis
*/
public void saveShop2Redis(Long id, Long expireSeconds) {
// 1. 查询数据库
Shop shop = getById(id);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 存入 Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
7. 封装 Redis 工具类
Redis 的缓存穿透和缓存击穿的解决方法还是比较复杂的,如果每次都重写这些方法,会浪费较多的时间,所以需要将对 Redis 的缓存穿透和缓存击穿的解决方法封装到一个工具类中。
这段代码有比较高的复用性,我粘贴在这里,以便于以后使用:
java
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将任意 Java 对象序列化为 json 并存储在 String 类型的 key 中,并可以设置TTL过期时间
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 将任意 Java 对象序列化为 json 并存储在 String 类型的 key 中,并可以设置逻辑过期时间,用于处理缓存击穿问题
*/
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)));
// 写入 Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
*/
public <R, ID> R queryWithPassThrough(
String predixKey, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = predixKey + id;
// 1. 查询 Redis 缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3. 如果存在,从 Redis 缓存中获取返回
return JSONUtil.toBean(json, type);
}
// 判断是否为空值
if (json != null) {
// 返回错误信息
return null;
}
// 4. 不存在,根据 id 查询数据库
R r = dbFallback.apply(id);
// 5. 如果 shop 不存在,返回错误
if (r == null) {
// 将空字符串写入 Redis 缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6. 存在,将 shop 存入 Redis 缓存
set(key, r, time, unit);
// 7. 返回
return r;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 根据指定的 key 查询缓存,并反序列化为指定类型,利用逻辑过期的方式解决缓存击穿问题
*/
public <R, ID> R queryWithLogicalExpire(
String prefixKey, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = prefixKey + id;
// 1. 查询 Redis 缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isBlank(json)) {
// 3. 如果不存在,直接返回
return null;
}
// 4. 存在,Json 反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
// 5. 判断是否过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 5.1 未过期,直接返回
return r;
}
// 5.2 过期,缓存重建
// 6. 缓存重建
// 6.1 获取互斥锁
String lockKey = LOGIN_CODE_KEY + id;
boolean flag = tryLock(lockKey);
// 6.2 判断是否获取到锁
if (flag) {
// 6.3 如果获取到锁,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R r1 = dbFallback.apply(id);
// 存储到 Redis
setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 6.4 未获取到锁,返回过期的缓存数据
return r;
}
/**
* 尝试获取锁
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}