缓存穿透、击穿、雪崩的成因及解决方案

2024-05-10 18:27:23 浏览数 (1)

缓存穿透的成因 缓存穿透是指查询的数据在数据库中根本不存在,因此也不会存在于缓存中。正常情况下,第一次请求查不到数据不会有问题,但在高并发场景下,如果大量的这类请求持续不断地发起,每次都会直接穿透缓存去查询数据库,这不仅浪费了数据库资源,而且可能导致数据库因为承载不了如此大的请求量而崩溃。 解决方案 一种常用的解决方案是在查询数据库为空的情况下,也将空值存入缓存,设置一个较短的有效期(比如几分钟),这样可以抵挡住恶意的连续请求,保护数据库。 Java代码示例 以下是一个基于Spring Boot整合Redis的简单示例,展示了如何处理缓存穿透的情况: import org.springframework.cache.annotation.Cacheable; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @Service public class UserService { private final RedisTemplate<String, Object> redisTemplate; public UserService(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } // 使用自定义注解处理缓存穿透 @Cacheable(value = "users", unless = "#result == null") public User getUserById(String id) { // 先尝试从缓存中获取用户 User user = (User) redisTemplate.opsForValue().get(id); // 如果缓存中没有找到用户 if (user == null) { // 去数据库查询 user = queryFromDatabase(id); // 数据库查询结果为空,为了避免缓存穿透,将空值暂时存入缓存 if (user == null) { redisTemplate.opsForValue().set(id, "", 5, TimeUnit.MINUTES); // 设置一个短时效的空值 } else { // 若数据库中有数据,则正常存入缓存 redisTemplate.opsForValue().set(id, user); } } return user; } private User queryFromDatabase(String id) { // 这里模拟数据库查询,实际开发中调用数据库查询的方法 // ... return dbQueryResult; // 假设这是从数据库查询得到的结果 } } 在上述代码中,我们使用了Spring Cache的`@Cacheable`注解,并添加了一个条件`unless = "#result == null"`,这意味着只有当查询结果非空时才会缓存结果。如果数据库查询结果为空,则会将一个临时空值存入缓存,以防止后续的同样请求再次穿透到数据库。注意要根据业务需求合理设定空值缓存的过期时间。

缓存击穿的成因 缓存击穿是指在高并发场景下,某个热点数据的缓存突然失效(如缓存过期),而这时恰好有大量的并发请求来访问这个刚刚失效的key,所有请求都无法从缓存中获取到数据,进而都涌向数据库,导致数据库瞬时压力过大,这就是所谓的“击穿”。尤其是在数据更新并不频繁的情况下,这种集中性的数据库查询压力可能导致数据库响应变慢,甚至宕机。 解决方案 - Java代码示例(使用Redis分布式锁) 下面是一个基于Redis实现分布式锁,用于解决缓存击穿问题的基本Java代码框架: import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import java.util.Collections; @Service public class CacheService { private final StringRedisTemplate redisTemplate; private final RedisScript<Long> luaLockScript; public CacheService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; luaLockScript = new DefaultRedisScript<>(// 定义Lua脚本,用于获取分布式锁 "if redis.call('exists', KEYS[1]) == 0 then " "redis.call('hset', KEYS[1], ARGV[1], 1);" "redis.call('pexpire', KEYS[1], ARGV[2]); " "return 1; " "end;" "return 0;", Long.class); } public Object getDataFromDBWithLock(String cacheKey) { Boolean locked = acquireLock(cacheKey, "uniqueId"); // 尝试获取锁 if (locked) { try { // 如果获取到锁,则尝试从缓存中获取数据 Object data = getDataFromCache(cacheKey); if (data != null) { return data; } // 缓存未命中,从数据库加载数据 data = loadFromDatabase(cacheKey); // 将数据写入缓存 writeToCache(cacheKey, data); return data; } finally { releaseLock(cacheKey, "uniqueId"); // 无论何时,都要确保最后释放锁 } } else { // 没有获取到锁,等待其他线程完成数据库操作后从缓存中读取 return getDataFromCacheAfterWait(cacheKey); } } private Boolean acquireLock(String key, String uniqueId) { // 调用Lua脚本获取分布式锁,这里假设expireTime是你设置的锁超时时间 Long result = redisTemplate.execute(luaLockScript, Collections.singletonList(key), uniqueId, String.valueOf(expireTime)); return result == 1L; } private void releaseLock(String key, String uniqueId) { // 在实际应用中,释放锁可能涉及到更复杂的逻辑,比如判断持有锁的线程ID一致才能释放 // 这里简化处理,仅作示意 redisTemplate.delete(key); } // 其他辅助方法... private Object getDataFromCache(String cacheKey) { ... } private Object loadFromDatabase(String cacheKey) { ... } private void writeToCache(String cacheKey, Object data) { ... } } 上述代码是一个简化的示例,`loadFromDatabase` 和 `writeToCache` 方法需要根据实际的缓存和数据库操作进行实现。通过这种方式,当缓存失效时,只有一个线程能够获得锁并执行数据库查询,其他线程则等待锁释放后从缓存中读取数据,从而避免了数据库的并发压力。

缓存雪崩的成因 缓存雪崩通常是指缓存层出现了大规模的缓存失效,可能是由于以下原因导致的: 1. 大量缓存集中在同一时刻失效,比如设置了一致的过期时间,到期后大量缓存同时失效。 2. 缓存服务整体宕机,导致所有请求无法通过缓存,直接达到后端数据库。 3. 缓存数据的大规模删除,如误操作清空了大量缓存。 解决方案 1. 随机设置过期时间:让缓存的过期时间分散分布,而不是同时失效,可以减少同时失效带来的压力。 2. 缓存预热:在缓存重建前,提前将热门数据加载到缓存中。 3. 设置二级缓存:即使一级缓存失效,还有二级缓存作为备用,减轻数据库压力。 4. 使用互斥锁:当缓存失效时,只允许一个请求去数据库加载数据,其它请求等待锁释放后从缓存获取。 5. 服务熔断与降级:在缓存雪崩发生时,采取熔断措施,避免请求继续涌入数据库,同时提供降级服务。 6. 缓存高可用架构:搭建缓存集群,具备故障转移能力,防止单点故障引起的服务中断。 Java代码示例(伪代码,仅展示关键思路) #### 随机过期时间设置 import org.springframework.cache.annotation.Cacheable; import java.time.Duration; import java.util.concurrent.TimeUnit; @Service public class ProductService { @Cacheable(value = "products", key = "#id", condition = "#result != null", unless = "#result == null", sync = true, cacheManager = "cacheManager") // sync=true 表示同步方式获取缓存,避免并发时多次查询数据库 public Product getProductById(Long id) { Duration randomExpiration = Duration.ofSeconds(randomBetween(minExpireSecs, maxExpireSecs)); // 查询数据库并获取产品数据 Product product = queryProductFromDatabase(id); // 设置缓存时加上随机过期时间 cacheManager.getCache("products").put(id, product, randomExpiration.toMillis(), TimeUnit.MILLISECONDS); return product; } // ... // 内部实现randomBetween函数生成随机的过期时间区间 } 服务熔断与降级(借助Hystrix或其他熔断工具) import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand; @Service public class ProductService { @HystrixCommand(fallbackMethod = "getFallbackProductById") @Cacheable("products") public Product getProductById(Long id) { // 正常查询逻辑 return queryProductFromDatabase(id); } // 当数据库压力过大时,提供降级服务 private Product getFallbackProductById(Long id) { log.error("Cache miss and fallback for productId: {}", id); // 提供默认或缓存的备份数据,或直接返回错误信息 return defaultProduct(); } // ... // 内部实现queryProductFromDatabase和defaultProduct方法 } 实际上,Java代码的具体实现会依赖于使用的具体缓存组件(如Redis、Memcached)以及熔断框架(如Hystrix、Resilience4j)。以上代码旨在表达解决缓存雪崩问题的核心思路,而非完整的生产环境下的代码实现。在实际项目中,还需要考虑更多细节,比如配置管理、并发控制、分布式锁的使用等。

0 人点赞