Redis 缓存问题详解
缓存穿透
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,因此缓存永远不会生效,这些请求都会直接打到数据库。
如果恶意用户利用无数线程并发访问不存在的数据,这些请求都会到达数据库,很可能会导致数据库崩溃。
解决方案
缓存空对象
思路:当用户请求一个 id 时,Redis 和数据库都不存在该数据,我们直接将 id 对应的空值缓存到 Redis,这样下次用户再重复请求这个 id 时,Redis 就能命中(命中空值),就不会去查数据库了。
优点:实现简单,易于维护
缺点:
额外的内存消耗(可通过添加 TTL 解决)
- 可能引起短时不一致(控制 TTL 时间可在一定程度上缓解):缓存了空值后,我们刚好在数据库中设置了该值,用户查询到的是空值,但数据库中实际存在该数据,这就造成了不一致(可通过插入数据时自动覆盖之前的空值数据来解决)
布隆过滤器
在客户端和 Redis 之间增加一层布隆过滤器。用户访问时,先由布隆过滤器判断数据是否存在,不存在则直接拒绝;存在则走正常流程。
布隆过滤器如何判断数据是否存在?
布隆过滤器可以简单理解为一个字节数组,存储二进制位。判断数据库中数据是否存在时,不是直接将数据存入布隆过滤器,而是通过哈希算法计算哈希值,再将这些哈希值转换为二进制位存入布隆过滤器。判断数据是否存在时,只需判断对应位置是 0 还是 1(这种判断是概率性的,不是 100% 准确,所以判断不存在时不一定真的不存在,仍有穿透风险)
优点:内存占用少,无冗余 Key(二进制存储)
缺点:
实现复杂
存在误判可能(不一定准确)
缓存空对象 Java 实现
/**
* Cache penetration
*
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Does not exist, query the database according to the id
Shop shop = getById(id);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. return
return shop;
}
缓存雪崩
缓存雪崩是指大量缓存 Key 同时失效或 Redis 服务同时宕机,导致大量请求到达数据库,带来巨大压力。
解决方案
为不同 Key 的 TTL 添加随机值(解决同时失效问题):例如缓存预热时,需要将数据库中的数据分批提前导入缓存。由于数据是同时导入的,这些数据的 TTL 值相同,可能导致某一时刻数据同时过期,从而引发雪崩。为解决此问题,可以在导入时为 TTL 加上随机数(例如 TTL 为 30 ± 1~5),使这些 Key 的过期时间分散在一段时间内,而不是同时失效,从而避免雪崩发生
使用 Redis 集群提高服务可用性(解决 Redis 宕机):借助 Redis 哨兵机制,当一台机器宕机时,哨兵可以自动选择一台机器替代,主从同步数据以保证 Redis 高可用
为缓存业务添加降级和限流策略:如快速失败、拒绝服务,防止请求涌入数据库
为业务添加多级缓存:浏览器可添加缓存(通常是静态资源),反向代理服务器 Nginx 可添加缓存,Nginx 缓存未命中再请求 Redis,Redis 缓存未命中到达 JVM,JVM 内部也可建立本地缓存,最后才到达数据库
缓存击穿
缓存击穿问题,也称热 Key 问题,是指一个被高并发访问的 Key 且缓存重建业务较复杂时突然失效。无数请求的访问将在瞬间对数据库产生巨大冲击。
缓存重建:Redis 中的缓存过期后失效,需要重新从数据库查询并写入 Redis。从数据库查询和构建数据的过程可能较复杂,需要多表关联查询等,最后将结果缓存。此业务可能耗时较长(数十甚至数百毫秒)。在此期间 Redis 中无缓存,传入的请求将直接访问数据库。
解决方案
互斥锁
当线程请求发现缓存未命中时,在查询数据库前执行加锁操作,写入缓存后释放锁。这样其他线程在未命中时查询数据库也会获取互斥锁。获取失败后睡眠一段时间再重试。
显然,其他线程只有在写入缓存后才能获取数据。虽然能保证一致性,但性能较差,且可能导致死锁。
Java 实现
/**
* Get the lock
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* release lock
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
* Mutex
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Implement cache rebuild
// 4.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 Determine whether the acquisition is successful
if (!isLock) {
// 4.3 Fail, sleep and try again
Thread.sleep(50);
// recurse
return queryWithMutex(id);
}
// 4.4 Success, query the database according to id
shop = getById(id);
// simulate rebuild delay
Thread.sleep(200);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. Release the mutex
unlock(lockKey);
}
// 8. return
return shop;
}
用 jmeter 测试一下,发送 1000 个请求,可以看到所有请求都通过了,数据库只查询了一次
逻辑过期
顾名思义,不是真正过期,可以看作永不过期。在 Redis 中缓存数据时不设置 TTL,在存储数据时增加一个过期时间字段(不是 TTL,基于当前时间 + 过期时间,逻辑维护的时间),这样任何线程查询都能命中,只需在逻辑上判断是否已过期。
如下图所示,如果线程 1 查询缓存时发现逻辑时间已过期,需要重建缓存,然后获取互斥锁,开启独立线程进行缓存重建(而不是自行执行缓存重建操作)。缓存重建完成后释放锁,线程 1 直接返回过期数据。当其他线程也未命中时,获取互斥锁失败也会直接返回过期数据。虽然保证了性能,但无法保证一致性。
Java 实现
/**
* Cache warm-up
*
* @param id
* @param expireSeconds logical expiration time
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. Query store data
Shop shop = getById(id);
Thread.sleep(200);
// 2. Encapsulate logic expiration time
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. Write to redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* Logical expiration
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isBlank(shopJson)) {
// 3. Missed, return directly
return null;
}
// 4. Hit, you need to deserialize json to object first
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. Determine whether it has expired
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 If it has not expired, return the store information directly
return shop;
}
// 5.2 has expired and needs to be rebuilt
// 6. Cache rebuild
// 6.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 Determine whether the lock is acquired successfully
if (isLock) {
// 6.3 Success, open an independent thread to achieve cache reconstruction
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// rebuild cache
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// release the lock
unlock(lockKey);
}
});
}
// 6.4 Return expired store information
return shop;
}
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,因此缓存永远不会生效,这些请求都会直接打到数据库。
如果恶意用户利用无数线程并发访问不存在的数据,这些请求都会到达数据库,很可能会导致数据库崩溃。
解决方案
缓存空对象
思路:当用户请求一个 id 时,Redis 和数据库都不存在该数据,我们直接将 id 对应的空值缓存到 Redis,这样下次用户再重复请求这个 id 时,Redis 就能命中(命中空值),就不会去查数据库了。
优点:实现简单,易于维护
缺点:
额外的内存消耗(可通过添加 TTL 解决)
- 可能引起短时不一致(控制 TTL 时间可在一定程度上缓解):缓存了空值后,我们刚好在数据库中设置了该值,用户查询到的是空值,但数据库中实际存在该数据,这就造成了不一致(可通过插入数据时自动覆盖之前的空值数据来解决)
布隆过滤器
在客户端和 Redis 之间增加一层布隆过滤器。用户访问时,先由布隆过滤器判断数据是否存在,不存在则直接拒绝;存在则走正常流程。
布隆过滤器如何判断数据是否存在?
布隆过滤器可以简单理解为一个字节数组,存储二进制位。判断数据库中数据是否存在时,不是直接将数据存入布隆过滤器,而是通过哈希算法计算哈希值,再将这些哈希值转换为二进制位存入布隆过滤器。判断数据是否存在时,只需判断对应位置是 0 还是 1(这种判断是概率性的,不是 100% 准确,所以判断不存在时不一定真的不存在,仍有穿透风险)
优点:内存占用少,无冗余 Key(二进制存储)
缺点:
实现复杂
存在误判可能(不一定准确)
缓存空对象 Java 实现
/**
* Cache penetration
*
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Does not exist, query the database according to the id
Shop shop = getById(id);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. return
return shop;
}
缓存雪崩
缓存雪崩是指大量缓存 Key 同时失效或 Redis 服务同时宕机,导致大量请求到达数据库,带来巨大压力。
解决方案
为不同 Key 的 TTL 添加随机值(解决同时失效问题):例如缓存预热时,需要将数据库中的数据分批提前导入缓存。由于数据是同时导入的,这些数据的 TTL 值相同,可能导致某一时刻数据同时过期,从而引发雪崩。为解决此问题,可以在导入时为 TTL 加上随机数(例如 TTL 为 30 ± 1~5),使这些 Key 的过期时间分散在一段时间内,而不是同时失效,从而避免雪崩发生
使用 Redis 集群提高服务可用性(解决 Redis 宕机):借助 Redis 哨兵机制,当一台机器宕机时,哨兵可以自动选择一台机器替代,主从同步数据以保证 Redis 高可用
为缓存业务添加降级和限流策略:如快速失败、拒绝服务,防止请求涌入数据库
为业务添加多级缓存:浏览器可添加缓存(通常是静态资源),反向代理服务器 Nginx 可添加缓存,Nginx 缓存未命中再请求 Redis,Redis 缓存未命中到达 JVM,JVM 内部也可建立本地缓存,最后才到达数据库
缓存击穿
缓存击穿问题,也称热 Key 问题,是指一个被高并发访问的 Key 且缓存重建业务较复杂时突然失效。无数请求的访问将在瞬间对数据库产生巨大冲击。
缓存重建:Redis 中的缓存过期后失效,需要重新从数据库查询并写入 Redis。从数据库查询和构建数据的过程可能较复杂,需要多表关联查询等,最后将结果缓存。此业务可能耗时较长(数十甚至数百毫秒)。在此期间 Redis 中无缓存,传入的请求将直接访问数据库。
解决方案
互斥锁
当线程请求发现缓存未命中时,在查询数据库前执行加锁操作,写入缓存后释放锁。这样其他线程在未命中时查询数据库也会获取互斥锁。获取失败后睡眠一段时间再重试。
显然,其他线程只有在写入缓存后才能获取数据。虽然能保证一致性,但性能较差,且可能导致死锁。
Java 实现
/**
* Get the lock
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* release lock
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
* Mutex
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isNotBlank(shopJson)) {
// 3. Exist, return directly
return JSONUtil.toBean(shopJson, Shop.class);
}
// Determine if the hit is a null value
if (shopJson != null) {
// return an error message
return null;
}
// 4. Implement cache rebuild
// 4.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 Determine whether the acquisition is successful
if (!isLock) {
// 4.3 Fail, sleep and try again
Thread.sleep(50);
// recurse
return queryWithMutex(id);
}
// 4.4 Success, query the database according to id
shop = getById(id);
// simulate rebuild delay
Thread.sleep(200);
// 5. does not exist, return error
if (shop == null) {
// write null value to redis (cache penetration)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Exist, write to redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. Release the mutex
unlock(lockKey);
}
// 8. return
return shop;
}
用 jmeter 测试一下,发送 1000 个请求,可以看到所有请求都通过了,数据库只查询了一次
逻辑过期
顾名思义,不是真正过期,可以看作永不过期。在 Redis 中缓存数据时不设置 TTL,在存储数据时增加一个过期时间字段(不是 TTL,基于当前时间 + 过期时间,逻辑维护的时间),这样任何线程查询都能命中,只需在逻辑上判断是否已过期。
如下图所示,如果线程 1 查询缓存时发现逻辑时间已过期,需要重建缓存,然后获取互斥锁,开启独立线程进行缓存重建(而不是自行执行缓存重建操作)。缓存重建完成后释放锁,线程 1 直接返回过期数据。当其他线程也未命中时,获取互斥锁失败也会直接返回过期数据。虽然保证了性能,但无法保证一致性。
Java 实现
/**
* Cache warm-up
*
* @param id
* @param expireSeconds logical expiration time
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. Query store data
Shop shop = getById(id);
Thread.sleep(200);
// 2. Encapsulate logic expiration time
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. Write to redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* Logical expiration
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Query the store cache from redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Determine if it exists
if (StrUtil.isBlank(shopJson)) {
// 3. Missed, return directly
return null;
}
// 4. Hit, you need to deserialize json to object first
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. Determine whether it has expired
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 If it has not expired, return the store information directly
return shop;
}
// 5.2 has expired and needs to be rebuilt
// 6. Cache rebuild
// 6.1 Acquire the mutex
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 Determine whether the lock is acquired successfully
if (isLock) {
// 6.3 Success, open an independent thread to achieve cache reconstruction
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// rebuild cache
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// release the lock
unlock(lockKey);
}
});
}
// 6.4 Return expired store information
return shop;
}
