目录
- 一、什么是缓存
- 二、给业务添加缓存(减少数据库访问次数)
- 三、给店铺类型查询业务添加缓存
- (1) 使用 String 类型
- (2) 使用 List 类型
- 四、缓存的更新策略
- (1) 主动更新
- (2) 最佳实现方案
- (3) 给查询商铺的缓存添加超时剔除和主动更新的策略
- ① 存缓存,设置超时时间
- ② 更新,先修改数据库后删除缓存
- 五、缓存穿透
- (1) 是啥
- (2) 解决方案
- (3) 添加缓存穿透代码
- 六、缓存雪崩
- 七、缓存击穿(热点 key 问题)
- (1) 互斥锁
- (2) 逻辑过期
- (3) 基于【互斥锁】防止缓存击穿问题
- (4) 基于【逻辑过期】防止缓存击穿问题
- ① 把数据写入 Redis 并添加逻辑过期时间(缓存重建)
- ② 代码实现
- 八、缓存工具类 ★
一、什么是缓存
📗 缓存是数据交换的缓冲区(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);
}
}