三、Redis企业实战 - 商户查询缓存
缓存查询策略
标准的操作方式是查询数据库之前先查询缓存,如果缓存存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
在实际的项目开发中,要采用主动更新(手动解决缓存与数据库不一致的问题,做到可控),并用超时剔除作为兜底方案。
数据库和缓存不一致解决方案
解决数据库与缓存不一致需要考虑三个问题
-
如何保证缓存与数据库的操作的同时成功或失败
-
单体系统,将缓存与数据库操作放在一个事务
-
分布式系统,利用
TCC
等分布式事务方案
-
更新数据库数据库,是删除缓存还是更新缓存
-
更新缓存:每次更新数据库都更新缓存,无效写操作较多(如果都是更新10次,查询一次,中间九次更新缓存就是无效操作)
-
删除缓存(选这个):更新数据库时让缓存失效,查询时再更新缓存
-
更新数据时,先操作数据库还是先操作缓存
-
先删除缓存,再操作数据库
-
先操作数据库(选这个),再删除缓存(操作数据库用的时间更长,在多并发的情况下,数据不一致的情况发生概率比上面的小)
实现商铺和缓存与数据库双写一致
-
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。
-
根据id修改店铺时,先修改数据库,再删除缓存。
缓存穿透问题的解决思路
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常用的解决方案:
-
缓存空对象,并设置过期时间
-
优点:实现简单
-
缺点:造成额外的内存消耗
-
-
布隆过滤
-
优点:内存占用较少,没有多余key
-
缺点:实现复杂,存在误判(哈希冲突)
-
布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中。
布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。
编码解决商品查询的缓存穿透问题
代码示例
/**
* 用返回空字符串解决缓存击穿
*
* @param id 商户id
* @return 商户信息
*/
public Shop queryShopByIdSolveBreakWithWriteNull(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 如果redis未命中查数据库
if (StrUtil.isNullOrUndefined(shopJson)) {
Shop shop = getById(id);
// 数据库也没有则写入空数据到redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_SHOP_TTL, TimeUnit.MINUTES);
queryShopByIdSolveBreakWithWriteNull(id);
}
// 数据库存在则将数据写进redis
shopJson = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, shopJson, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 重新查一遍,就可以从redis获得数据
queryShopByIdSolveBreakWithWriteNull(id);
}
// 如果命中了redis,但是为空,直接返回空对象
if (StrUtil.isBlank(shopJson)) {
return null;
}
// 如果命中了redis且不为空,重置时间
stringRedisTemplate.expire(CACHE_SHOP_KEY + id, CACHE_SHOP_TTL, TimeUnit.MINUTES);
return JSONUtil.toBean(shopJson, Shop.class);
}
缓存雪崩问题的解决思路
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
常用解决方案
-
给不同的key的TTL添加随机值
-
利用Redis集群提高服务可用性
-
给缓存业务添加降级限流策略
-
给业务添加多级缓存
缓存击穿问题的解决思路
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然
失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到
缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设
在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这
些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接
着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大。
常见的解决方案:
-
互斥锁
-
优点:实现简单、只需要加一把锁
-
缺点:可能会发生死锁,性能受影响
-
-
逻辑过期
-
优点:额外的锁重构数据,性能好
-
缺点:有一段时间的脏数据
-
利用互斥锁解决缓存击穿问
代码示例
/**
* 用互斥锁解决缓存击穿
*
* @param id 商户id
* @return 商户信息
*/
public Shop queryShopByIdSolvePenetrateWithWriteMutex(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 如果redis未命中则查询数据库
if (StrUtil.isNullOrUndefined(shopJson)) {
// 在查询数据库之前先获取锁
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
if (!isLock) {
// 如果获取锁失败了休眠一段时间后重试
Thread.sleep(50);
return queryShopByIdSolvePenetrateWithWriteMutex(id);
}
// 获取锁成功之后的操作
Shop shop = getById(id);
// 解决缓存穿透问题
// 如果数据库中也不存在则写入空数据到redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_SHOP_TTL, TimeUnit.MINUTES);
queryShopByIdSolveBreakWithWriteNull(id);
}
// 数据库中存在则将数据写入redis
shopJson = JSONUtil.toJsonStr(shopJson);
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, shopJson, CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
unLock(lockKey);
}
// 重新查一遍,就可以从redis中获取数据
return queryShopByIdSolveBreakWithWriteNull(id);
}
// 如果命中了redis,但是为空,直接返回空对象
if (StrUtil.isBlank(shopJson)) {
return null;
}
// 如果命中了redis且不为空,重置时间
stringRedisTemplate.expire(CACHE_SHOP_KEY + id, CACHE_SHOP_TTL, TimeUnit.MINUTES);
return JSONUtil.toBean(shopJson, Shop.class);
}
/**
* 解决缓存击穿加锁
*
* @param key 标识id
* @return true/false
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
return BooleanUtil.isTrue(flag);
}
/**
* 解决缓存击穿解锁
*
* @param key 标识id
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
利用逻辑过期解决缓存击穿问题
代码示例
/**
* 用逻辑过期时间解决缓存穿透
*
* @param id 商户id
* @return 商户信息
*/
public Shop queryShopByIdSolvePenetrateWithWriteLogicalExpireTime(Long id) {
String redisDataJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 如果redis未命中查数据库
if (StrUtil.isEmpty(redisDataJson)) {
// 未命中说明不是热点数据,转入处理缓存击穿的函数
return null;
}
// 如果命中了redis且不为空,将其反序列化成对象,拿到逻辑过期时间
RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 如果时间已经过期,直接返回旧数据并开启新线程修改数据
if (expireTime.isBefore(LocalDateTime.now())) {
// 在开启新线程之前获得锁,锁的名称要与锁的id关联,修改不同的id可以并行,一样的id才需要加锁
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
// 获取锁成功
if (isLock) {
// 开启新线程用线程池,性能比自己创建线程好
CACHE_REBUILD_EXECUTOR.execute(() -> {
// 重建缓存
saveShopWithExpireTimeToRedis(id, LOGICAL_EXPIRE_TIME);
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
unLock(lockKey);
}
}
return shop;
}
/**
* 将带有逻辑过期的Shop数据写入redis
*
* @param id id
* @param expireMinutes 逻辑过期时间增加值
*/
public void saveShopWithExpireTimeToRedis(long id, Long expireMinutes) {
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusMinutes(expireMinutes));
String redisDataJson = JSONUtil.toJsonStr(redisData);
// 存入数据库
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, redisDataJson);
}
总结