# 使用Redis的常见问题及解决方案
# 缓存一致性
缓存一致性是指在使用缓存系统时,保证缓存数据与后端数据的一致性。由于缓存系统的读取速度快于后端数据存储系统,如果不进行一致性的处理,就会导致缓存数据与后端数据不一致的情况。
# 缓存失效策略
当后端数据发生变化时,及时使缓存失效。在数据更新或写入操作完成后,通过删除或更新缓存的方式,使缓存数据失效。下次访问时,缓存将重新从后端获取最新的数据,并更新缓存。
public Result updateShop(Shop shop) {
// 更新数据中 确保数据一致性
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺数据有误");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
return Result.ok(shop);
}
# 读写时加锁
在进行写操作时,先对相关数据进行加锁,防止其他并发操作修改后端数据。当写操作完成后,再释放锁,并更新缓存数据。在读操作时,如果发现缓存数据失效,可以先获取一个读锁,然后从后端获取最新数据并更新缓存,最后释放读锁。这种方式可以保证在写操作期间,其他读写操作无法同时进行,确保了数据的一致性。
# 延迟双删策略
在进行写操作时,先更新后端数据,然后立即使缓存失效。但在读操作时,如果发现缓存失效,不立即从后端获取最新数据,而是等待一段时间(如几秒钟),再从后端获取最新数据,并更新缓存。这样可以避免多个并发写操作导致缓存与后端数据频繁不一致的问题。
# 使用缓存更新消息队列
当后端数据发生变化时,将变更消息发送到消息队列。缓存系统订阅消息队列,接收到消息后,即使使缓存失效,并从后端获取最新数据进行更新。通过消息队列的方式,可以异步处理缓存的更新,提高系统的性能和并发能力。
# 读写分离策略
将读操作和写操作分离,将写操作直接操作后端数据存储系统,而读操作则优先从缓存中获取数据。这种方式可以降低对缓存一致性的要求,因为写操作直接操作后端数据,保证了数据的一致性,而读操作则通过缓存提供较高的性能。
# 缓存穿透
缓存穿透是指在使用缓存系统时,针对一个不存在于缓存和后端存储系统中的数据进行大量请求查询,导致请求直接访问后端存储系统,而无法从缓存中获取到数据的情况。
# 输入合法性校验
在接收到请求之前,对请求参数进行合法性校验。例如,对请求的参数进行非空、长度或者格式等校验,过滤掉非法请求,避免非法请求直接访问后端存储系统。
# 布隆过滤器(Bloom Filter)
使用布隆过滤器对请求进行预先过滤。布隆过滤器可以快速判断一个元素是否可能存在于集合中,如果布隆过滤器判断请求的数据不存在,可以直接返回缓存不存在的结果,避免对后端存储系统的不必要查询。
# 空值缓存
对于查询结果为空的数据,也将其缓存起来。当下次请求查询同样的数据时,可以直接从缓存中获取到空值,并设置适当的过期时间,以避免大量请求直接访问后端存储系统。
public Shop queryWithPassThrough(Long id) {
// 缓存穿透解决方案
// 1. 从redis中查询缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 命中 返回数据
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断是否为空字符串
if (shopJson != null) {
return null;
}
// 没有命中
// 1.从数据库中查询数据
Shop shop = getById(id);
// 2.判断数据是否存在
if (BeanUtil.isEmpty(shop)) {
// 不存在 返回错误,并将空值写入redis,防止缓存穿透
stringRedisTemplate.opsForValue().set(key, "", RandomTTL.getTTL(CACHE_NULL_TTL), TimeUnit.MINUTES);
return null;
}
// 存在
// 3.将数据写入redis中
shopJson = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key, shopJson, RandomTTL.getTTL(CACHE_SHOP_TTL), TimeUnit.MINUTES);
// 4. 返回数据
return shop;
}
# 热点数据预加载
预先将热点数据加载到缓存中,即使在缓存穿透时,也能从缓存中获取到数据。这可以通过定时任务或者异步加载的方式实现。
# 异步查询和缓存更新
在查询数据时,如果发现缓存不存在,可以异步地去后端存储系统查询数据,并将查询到的数据更新到缓存中。在这个过程中,可以使用互斥锁或者分布式锁来确保只有一个请求去查询后端存储系统,其他请求等待结果。
# 缓存穿透监控和限流
监控缓存穿透的情况,并采取限流措施,例如对请求进行限制或者采用缓存请求排队等方式,以保护后端存储系统的稳定性。
# 缓存击穿
缓存击穿是指在使用缓存系统时,当某个热点数据失效或者缓存中不存在时,大量请求同时涌入后端存储系统,导致存储系统压力剧增,性能下降甚至崩溃的情况。
# 热点数据预加载
在应用启动时或者缓存失效前,提前将热点数据加载到缓存中,同时设置逻辑过期时间。这样可以避免在热点数据失效时,大量请求直接访问后端存储系统,而是从缓存中获取数据。可以通过定时任务或者异步加载的方式实现预加载。
public Shop queryWithLogicExpire(Long id) {
// 缓存击穿 使用逻辑过期时间
// 1. 从redis中查询缓存
String key = CACHE_SHOP_KEY + id;
String dataJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否命中
if (StrUtil.isBlank(dataJson)) {
// 未命中 返回null
return null;
}
// 命中
// 3. 判断是否逻辑过期
// 获取数据
RedisData redisData = JSONUtil.toBean(dataJson, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 没有逻辑过期 返回数据
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
// 逻辑过期,修改缓存 返回过期数据
// 获取互斥锁,开启独立线程
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 是否获取成功
if (isLock) {
// 获取成功,创建线程,重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShopToRedis(id, 20L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
// 获取失败,返回旧数据
return shop;
}
public void saveShopToRedis(Long id, Long expireSeconds) throws InterruptedException {
// 设置逻辑过期时间 缓存击穿解决方案
// 数据预热
Shop shop = getById(id);
RedisData redisData = new RedisData();
Thread.sleep(200);
redisData.setData(shop);
// 设置逻辑过期时间
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
# 设置短期内不过期
针对热点数据,可以将其缓存设置为短期内不过期。当缓存失效时,先从缓存中获取数据,如果获取不到再去后端存储系统查询,并将查询到的数据更新到缓存中。这样可以避免缓存失效时,大量请求直接访问后端存储系统。
# 互斥锁(Mutex Lock)
在缓存失效时,通过加锁的方式,只允许一个请求去查询后端存储系统,其他请求等待结果。当第一个请求获取到数据后,更新缓存并释放锁,其他请求再从缓存中获取数据。这样可以避免大量请求同时访问后端存储系统。
public Shop queryWithMutex(Long id) {
// 缓存击穿 互斥锁
// 1. 从redis中查询缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 命中 返回数据
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断是否为空字符串
if (shopJson != null) {
return null;
}
// 没有命中 缓存重建
String lockKey = LOCK_SHOP_KEY + id;
Shop shop;
try {
// 1.获取互斥锁
boolean isLock = tryLock(lockKey);
if (!isLock) {
// 获取失败 休眠,重新调用
Thread.sleep(100);
return queryWithMutex(id);
}
// 2. 获取成功,再次判断缓存是否重建成功
// 从redis中查询缓存
shopJson = stringRedisTemplate.opsForValue().get(key);
// 判断是否命中
if (StrUtil.isNotBlank(shopJson)) {
// 命中 返回数据
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断是否为空字符串
if (shopJson != null) {
return null;
}
// 缓存没有重建成功,从数据库中查询数据
shop = getById(id);
// 模拟重建延时
Thread.sleep(200);
// 判断数据是否存在
if (BeanUtil.isEmpty(shop)) {
// 不存在 返回错误,并将空值写入redis,防止缓存穿透
stringRedisTemplate.opsForValue().set(key, "", RandomTTL.getTTL(CACHE_NULL_TTL), TimeUnit.MINUTES);
return null;
}
// 存在
// 3.将数据写入redis中
shopJson = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key, shopJson, RandomTTL.getTTL(CACHE_SHOP_TTL), TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 4.释放互斥锁
unlock(lockKey);
}
// 5. 返回数据
return shop;
}
# 布隆过滤器(Bloom Filter)
在查询缓存之前,先使用布隆过滤器进行判断。布隆过滤器是一种快速的、内存占用较小的数据结构,用于判断一个元素是否可能存在于集合中。如果布隆过滤器判断元素不存在,可以直接返回缓存不存在,避免不必要的查询后端存储系统。
# 失效时间错开
针对大量缓存同时失效的场景,可以在设置缓存失效时间时,加上一个随机的时间偏移。这样每个缓存的失效时间不完全相同,可以分散请求对后端存储系统的压力。
# 缓存穿透监控和限流
对于频繁发生缓存击穿的情况,可以监控缓存失效的频率,并进行限流措施,例如对请求进行限制或者采用缓存请求排队等方式,以保护后端存储系统的稳定性。
# 结束语
鱼与熊掌不可兼得
想要Redis或者其他缓存机制的快,就必须要解决缓存所带来的一系列问题,“欲戴皇冠,必承其重”。