一、缓存更新策略
缓存可以减轻数据库压力,但也会存在数据库与缓存不一致的问题;
如果数据库数据更新,缓存没有更新,那查到的就是缓存中的旧数据;
- 内存淘汰:不用自己维护,利用redis内存淘汰机制,当内存不足时淘汰部分数据,下次查询时更新缓存;在一定程度上可以保证一致性;但是这种一致性不是我们能控制的,淘汰哪一部分数据,什么时候淘汰,不确定;好处是没有维护成本;
- 超时剔除:给缓存数据添加过期时间TTL,到期自动删除缓存,下次查询更新缓存;这个一致性的强度取决于时间长短;一致性一般,维护成本也低;
- 主动更新:自己编写业务逻辑;在修改数据库同时,改缓存;维护成本高;
根据业务场景选择对应的缓存更新策略:
二、主动更新策略
1、**Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存;**用的最多;
Cache Aside Pattern:
1、删除缓存还是更新缓存?
更新缓存,数据库更新n次,缓存就更新n次,如果这n次没有什么查询,那无效写操作较多;
删除缓存,更新数据库时让缓存失效,查询时再更新缓存;用的较多
2、如何保证缓存与数据库的原子性,同时成功或者失败?
单体系统,将缓存与数据库操作放在一个事务里,利用事务本身特性保证;
分布式系统:缓存操作和数据库操作很可能是不同的服务;利用TCC分布式事务方案;
3、先操作缓存还是数据库?
都可以。但综合分析,先操作数据库,再操作缓存比较妥当。
先删除缓存,再操作数据库;
如果有线程安全问题,线程1与线程2同时对缓存和数据库操作,由于缓存的读写速度远高于数据库的读写速度,最终造成缓存与数据库不一致的概率还是较高的;先操作数据库,再删缓存;在此操作上,可以在最后写缓存加上一个过期时间;
2、Read/Write Through Pattern : 缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题;
3、Write Behind Caching Pattern : 调用者会操作缓存,由其他线程将缓存数据持久化到数据库;但是这种方法不能保证最终一致;
三、缓存穿透
客户端请求的数据在缓存和数据库都不存在,这样缓存永远不会生效,这些请求都会落到数据库;一般外部攻击,不断用不存在的数据请求数据库,最终造成数据库压力过大崩溃;
解决方案:
1、缓存空对象
2、布隆过滤:
内存占用少,没有多余的key;实现复杂,存在误判可能
/**
* 获取店铺详情(通过设置null解决缓存穿透问题)
*
* @param id
* @return {@link Result}
*/
private Result getShopByIdWithCacheCross(Long id) {
// 1、先查询redis
String shopJson = redisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 在redis有,返回,将json串转为
if (StrUtil.isNotBlank(shopJson)) {
// 这里的isNotBlank 方法 为null,为"",为”\t“都返回false
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否是空字符串
if (shopJson != null) {
// 如果不是null空值,那一定是空字符串
return Result.fail("店铺不存在");
}
// 2、redis没有,再查数据库
Shop shop = getById(id);
// 3、数据库没有,返回错误
if (Objects.isNull(shop)) {
// 解决缓存穿透,缓存一个空字符串
redisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
// 将查到的数据存入redis,设置过期时间为30min
redisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
四、缓存雪崩
同一时段内,缓存key大面积失效或者redis服务宕机,导致请求落到数据库上,带来巨大压力;
解决方案:
- 给不同的key的过期时间,设置一个随机值;可以提前把一些导入到缓存中,在ttl后面可以跟上一个随机数,让过期时间分散在各个时间段,避免缓存同一时间大面积失效;
- **宕机情况:**利用redis集群,哨兵机制,提高服务的可用性;主从可以实现数据的同步;
- 给缓存业务添加降级限流策略;快速失败,降级服务;
- 给业务添加多级缓存;nginx缓存、redis缓存、层层加缓存;
五、缓存击穿
缓存击穿问题也叫热点key问题,缓存中没有但数据库中有的数据,就是被一个高并发访问,并且缓存重建业务复杂的key突然失效了;无数的请求在瞬间给数据库带来巨大冲击;
解决方案:
1、利用互斥锁;
缺点:锁未释放,互相等待;其他线程就会一直等待,阻塞,导致性能问题;
2、逻辑过期;
六、用互斥锁解决缓存击穿问题
利用setNx命令;这个命令只有key不存在才会设置成功;
/**
* 获取店铺详情(通过互斥锁解决缓存击穿问题)
*
* @param id
* @return {@link Result}
*/
private Shop getShopByIdWithCacheStave(Long id) {
// 1、先查询redis
String shopJson = redisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 在redis有,返回,将json串转为
if (StrUtil.isNotBlank(shopJson)) {
// 这里的isNotBlank 方法 为null,为"",为”\t“都返回false
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空字符串
if (shopJson != null) {
// 如果不是null空值,那一定是空字符串
return null;
}
// 2.1、redis没有,未命中,尝试获取锁 注意,lockKey与商铺key不一样
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
// 2.2判断是否获取了锁,如果未拿到,重试,循环,设置休眠时间
Shop shop;
try {
if (!tryLock(lockKey)) {
// 设置休眠时间
Thread.sleep(10);
return getShopByIdWithCacheStave(id);
}
// 2.3拿到了锁,再查数据库
shop = getById(id);
// todo 模拟重建的测试
Thread.sleep(200);
// 3、数据库没有,返回null
if (Objects.isNull(shop)) {
// 解决缓存穿透,缓存一个空字符串
redisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 将查到的数据存入redis,设置过期时间为30min
redisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 4、try-catch-finally 无论抛不抛异常,最后都要释放锁
realeseLock(lockKey);
}
return shop;
}
/**
* 获取锁
*
* @param key
* @return {@link boolean}
*/
private boolean tryLock(String key) {
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1", 2, TimeUnit.SECONDS);
return BooleanUtil.isTrue(result);
}
/**
* 释放锁
*
* @param key
* @return {@link boolean}
*/
private void realeseLock(String key) {
redisTemplate.delete(key);
}
/**
* 重建缓存数据,RedisData
*
* @param id
* @param expireTime
* @return {@link boolean}
*/
@Override
public boolean saveHotData(Long id, Long expireTime) throws InterruptedException {
String key = RedisConstants.CACHE_SHOP_KEY + id;
Shop shop = getById(id);
// 重建,模拟
Thread.sleep(20L);
if (Objects.nonNull(shop)) {
RedisData data = new RedisData();
// 设置比当前时间晚几个秒
data.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
data.setData(shop);
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data));
return Boolean.TRUE;
} else {
return Boolean.FALSE;
}
}
七、利用逻辑过期解决缓存击穿
/**
* 获取店铺详情(通过逻辑过期解决缓存击穿问题)
*
* @param id
* @return {@link Result}
*/
private Shop getShopByIdWithLogicalExpire(Long id) {
// 1、先查询redis
String shopJson = redisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// redis没有,未命中,返回null
if (StrUtil.isBlank(shopJson)) {
// 未命中,返回空
return null;
}
// 2.1、redis有,命中,判断过期时间
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
// 2.2 过期时间在当前时间之后,未过期,返回shop
RedisData data = JSONUtil.toBean(shopJson, RedisData.class);
Shop toShop = JSONUtil.toBean((JSONObject) data.getData(), Shop.class);
if (data.getExpireTime().isAfter(LocalDateTime.now())) {
// 过期时间在当前时间之后,未过期,返回shop
return toShop;
}
// 2.3 过期了,获取互斥锁,判断是否获取互斥锁
boolean isGetLock = tryLock(lockKey);
// 3.1 有互斥锁,重建,新开启一个线程,根据id查询数据库,将数据库数据写入redis
if (isGetLock) {
CACHE_EXPIRE_EXECUTOR.submit(() -> {
try {
this.saveHotData(id, 20L);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
realeseLock(lockKey);
}
});
}
// 3.2 没有互斥锁。返回旧的商品信息
return toShop;
}
八、缓存工具封装
private final StringRedisTemplate redisTemplate;
/**
* 构造函数注入redisTemplate
* @param redisTemplate
*/
public RedisCacheClient(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 将任意对象序列化json对象并存储在String类型的key中,并且可以设置过期时间
*/
public void setExpireTime(String key, Object value, Long expireTime, TimeUnit timeUnit){
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),expireTime,timeUnit);
}
/**
* 将任意对象序列化json对象并存储在String类型的key中,并且可以设置逻辑过期时间,处理缓存击穿问题
*/
public void setLogicalExpireTime(String key, Object value, Long expireTime, TimeUnit timeUnit){
RedisData data = new RedisData();
// 设置逻辑过期时间,比当前时间晚 timeUnit 分钟/秒/小时
// 传进来的expireTime不能确定是秒,所以把它转成秒
data.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
data.setData(value);
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data));
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用缓存控制的方式解决缓存穿透问题
* @param keyPrefix key的前缀
* @param id id为泛型,不确定它的类型,不一定是long,只有用到的时候才会确定
* @param objectType 具体要用到的对象类型
* @param dbCallback 要根据id查询具体返回数据库对象的函数式方法
* @param time 过期时间
* @param unit 时间单位
* @return {@link R}
*/
public <R,ID> R getObjectWithCacheCross(String keyPrefix, ID id, Class<R> objectType, Function<ID,R> dbCallback , Long time, TimeUnit unit) {
// KEY
String key = keyPrefix + id;
// 1、先查询redis
String shopJson = redisTemplate.opsForValue().get(key);
// 在redis有,返回,将json串转为
if (StrUtil.isNotBlank(shopJson)) {
// 这里的isNotBlank 方法 为null,为"",为”\t“都返回false
return JSONUtil.toBean(shopJson, objectType);
}
// 判断命中的是否是空字符串
if (shopJson != null) {
// 如果不是null空值,那一定是空字符串
return null;
}
// 2、redis没有,再查数据库
R r = dbCallback.apply(id);
// 3、数据库没有,返回错误
if (Objects.isNull(r)) {
// 解决缓存穿透,缓存一个空字符串
redisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 将查到的数据存入redis,设置过期时间为30min
this.setExpireTime(key, r, time, unit);
return r;
}
/**
* 过期时间线程池
*/
private static final ExecutorService CACHE_EXPIRE_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用逻辑过期的方式解决缓存击穿问题
* @param keyPrefix key的前缀
* @param id id为泛型,不确定它的类型,不一定是long,只有用到的时候才会确定
* @param objectType 具体要用到的对象类型
* @param dbCallback 要根据id查询具体返回数据库对象的函数式方法
* @param time 过期时间
* @param unit 时间单位
* @return {@link R}
*/
public <R,ID> R getObjectWithLogicalExpire(String keyPrefix, ID id, Class<R> objectType, Function<ID,R> dbCallback , Long time, TimeUnit unit) {
// key
String key = keyPrefix + id;
// 1、先查询redis
String shopJson = redisTemplate.opsForValue().get(key);
// redis没有,未命中,返回null
if (StrUtil.isBlank(shopJson)) {
// 未命中,返回空
return null;
}
// 2.1、redis有,命中,判断过期时间
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
// 2.2 过期时间在当前时间之后,未过期,返回shop
RedisData data = JSONUtil.toBean(shopJson, RedisData.class);
R r = JSONUtil.toBean((JSONObject) data.getData(), objectType);
if (data.getExpireTime().isAfter(LocalDateTime.now())) {
// 过期时间在当前时间之后,未过期,返回shop
return r;
}
// 2.3 过期了,获取互斥锁,判断是否获取互斥锁
boolean isGetLock = tryLock(lockKey);
// 3.1 有互斥锁,重建,新开启一个线程,根据id查询数据库,将数据库数据写入redis
if (isGetLock) {
CACHE_EXPIRE_EXECUTOR.submit(() -> {
try {
// 缓存重建
// 1.1 更新数据库,这里不知道具体用到的逻辑,用函数先apply,执行
R r1 = dbCallback.apply(id);
// 1.2再写入缓存
this.setLogicalExpireTime(lockKey,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
realeseLock(lockKey);
}
});
}
// 3.2 没有互斥锁。返回旧的商品信息
return r;
}
/**
* 获取锁
*
* @param key
* @return {@link boolean}
*/
private boolean tryLock(String key) {
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1", 2, TimeUnit.SECONDS);
return BooleanUtil.isTrue(result);
}
/**
* 释放锁
*
* @param key
* @return {@link boolean}
*/
private void realeseLock(String key) {
redisTemplate.delete(key);
}