一文讲透Redis缓存穿透、缓存击穿与缓存雪崩

一、引言

在使用 Redis 缓存的过程中,也会遇到一些问题,其中缓存穿透、缓存击穿和缓存雪崩被称为缓存的三大经典问题 ,它们就像隐藏在暗处的 “杀手”,随时可能对系统的性能和稳定性造成严重影响。接下来我们就一起看看这些问题和对应的解决方案。

二、缓存穿透

在电商系统中,商品信息通常会被缓存起来,以提高查询效率。当用户查询商品时,系统会先在缓存中查找该商品的信息,如果缓存中存在,则直接返回给用户;如果缓存中不存在,再去数据库中查询,并将查询结果存入缓存。然而,当用户查询一个不存在的商品 ID 时,问题就出现了。由于缓存中没有该商品的信息,系统会去数据库中查询,而数据库中也没有该商品的数据,这就导致这次查询既没有命中缓存,也没有命中数据库 。如果这种查询不存在数据的请求大量出现,就会形成缓存穿透。这些请求会直接穿透缓存,到达数据库,给数据库带来巨大的压力,严重时甚至可能导致数据库崩溃。​

2.1 产生原因​

造成缓存穿透的原因主要有两个方面。一方面是恶意攻击,黑客可能会利用系统的漏洞,故意构造大量不存在的商品 ID,向系统发送查询请求。这些请求会绕过缓存,直接访问数据库,从而对数据库进行攻击,试图使数据库因承受过多的压力而瘫痪。另一方面,业务逻辑漏洞也可能导致缓存穿透。例如,在某些情况下,系统没有对用户输入的参数进行严格的校验,当用户输入一个不合理的商品 ID(如负数或非常大的随机数)时,系统会将其作为合法的查询参数,去缓存和数据库中查询,由于这些参数对应的商品在现实中并不存在,就会导致缓存和数据库都无法命中,进而引发缓存穿透。​

2.2 解决方案​

为了解决缓存穿透问题,我们可以采取以下几种方法。​

(1)缓存空对象:当查询数据库发现数据不存在时,我们可以将空值缓存起来,并设置一个较短的过期时间。这样,当后续再有相同的查询请求时,系统可以直接从缓存中获取空值,而无需再次访问数据库,从而减轻数据库的压力。例如,在 Java 中使用 Redis 作为缓存,可以这样实现:

public String getData(String key) {
    String value = redis.get(key);
    if (value == null) {
        // 查询数据库
        String dbValue = queryDatabase(key);
        if (dbValue == null) {
            // 缓存空值,设置过期时间为5分钟
            redis.setex(key, 300, "");
            return "";
        } else {
            // 缓存数据
            redis.setex(key, 300, dbValue);
            return dbValue;
        }
    } else {
        return value;
    }
}

(2) 布隆过滤器:布隆过滤器是一种概率型数据结构,它可以用来判断一个元素是否在一个集合中。在缓存穿透的场景中,我们可以在查询缓存之前,先使用布隆过滤器判断查询的商品 ID 是否可能存在。如果布隆过滤器判断该 ID 不存在,那么我们可以直接返回,无需查询缓存和数据库;如果布隆过滤器判断该 ID 可能存在,再去查询缓存和数据库。这样可以有效地减少无效的数据库查询,防止缓存穿透。以使用 Redisson 实现布隆过滤器为例,代码如下:

// 引入Redisson依赖

    org.redisson
    redisson
    3.16.6


// 配置RedissonClient
@Configuration
public class RedissonConfig {
    @Bean(destroyMethod = "shutdown")
    public RedissonClient redisson() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

// 布隆过滤器的初始化和使用
@Service
public class ProductService {
    private static final String BLOOM_FILTER_NAME = "productIdsBloomFilter";
    private static final double FALSE_PROBABILITY = 0.01; // 误判率
    private static final int EXPECTED_INSERTIONS = 10000; // 预计插入的元素数量

    @Autowired
    private RedissonClient redissonClient;

    public void initBloomFilter(List productIds) {
        RBloomFilter bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
        // 初始化布隆过滤器
        bloomFilter.tryInit(EXPECTED_INSERTIONS, FALSE_PROBABILITY);
        // 将所有商品ID插入布隆过滤器
        for (Integer productId : productIds) {
            bloomFilter.add(productId);
        }
    }

    public Product getProductById(Integer productId) {
        // 使用布隆过滤器检查商品ID是否可能存在
        RBloomFilter bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
        if (!bloomFilter.contains(productId)) {
            return null; // 布隆过滤器判断不存在,直接返回null
        }
        // 以下是从缓存或数据库中获取商品信息的逻辑...
    }
}

(3)参数校验:对用户输入的参数进行严格的校验是非常重要的。在接收用户的查询请求时,我们应该检查参数的合法性,比如商品 ID 是否为正整数、是否在合理的范围内等。只有合法的参数才允许进入后续的查询流程,这样可以从源头上避免因不合理参数导致的缓存穿透。例如,在 Spring Boot 中,可以使用@Validated注解对方法参数进行校验:

@RestController
public class ProductController {
    @Autowired
    private ProductService productService;

    @GetMapping("/products/{id}")
    public Product getProduct(@PathVariable @Validated @Min(1) Long id) {
        return productService.getProductById(id);
    }
}

     在上述代码中,@Min(1)注解表示参数id必须是大于等于 1 的正整数,如果用户传入的参数不符合要求,系统会直接返回错误信息,不会进行后续的查询操作,从而有效地防止了缓存穿透的发生。

    三、缓存击穿

    3.1 现象描述​

    在电商系统的抢购活动中,某款热门手机成为了众多用户关注的焦点。这款手机的相关信息,如价格、库存、配置等,都被存储在 Redis 缓存中,以满足大量用户的查询需求。由于其极高的人气,该手机的缓存数据成为了热点数据,被频繁访问。然而,当缓存中这款热门手机的数据过期的瞬间,大量用户的并发请求同时到达。由于此时缓存中没有该手机的信息,这些请求就像决堤的洪水一样,直接涌向数据库,试图获取手机的最新数据。这就导致数据库在短时间内承受了巨大的压力,可能出现响应变慢甚至无法响应的情况。​

    3.2 产生原因​

    缓存击穿的产生主要是由于热点数据过期高并发访问这两个因素共同作用。一方面,热点数据由于被频繁访问,其过期时间一旦到达,就会导致大量请求无法从缓存中获取数据。另一方面,高并发场景下,众多用户同时对热点数据发起请求,在缓存失效的瞬间,这些请求会毫无阻挡地直接访问数据库,使得数据库面临巨大的查询压力 。如果数据库无法及时处理这些大量的请求,就可能导致系统性能急剧下降,甚至出现系统崩溃的严重后果。​

    3.3 解决方案

    (1)设置热点数据永不过期:对于那些非常重要且访问频率极高的热点数据,我们可以考虑不设置它们的过期时间,让它们一直存在于缓存中。这样可以确保在任何时候,用户的请求都能从缓存中获取到数据,从而避免了因缓存过期而导致的缓存击穿问题。为了保证数据的实时性和准确性,我们可以结合定时任务来定期更新这些热点数据。例如,在电商系统中,对于一些长期热门且价格相对稳定的商品,我们可以将其缓存设置为永不过期。同时,通过定时任务,每天凌晨对这些商品的价格、库存等信息进行更新,以确保用户获取到的是最新的数据。在 Java 中,可以使用ScheduledExecutorService来实现定时任务:

    ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
    executorService.scheduleAtFixedRate(() -> {
        // 从数据库中获取最新数据
        String newData = queryDatabase("hot_product_key");
        // 更新Redis缓存
        redis.set("hot_product_key", newData);
    }, 0, 1, TimeUnit.DAYS);

    (2)互斥锁(Mutex)机制:互斥锁机制的核心原理是在缓存失效的瞬间,通过加锁的方式,保证同一时刻只有一个线程能够访问数据库来获取数据并更新缓存,其他线程则需要等待该线程完成操作后,才能从缓存中获取数据。这样就避免了大量线程同时访问数据库,从而有效地防止了缓存击穿。以使用 Redis 实现互斥锁为例,代码如下:

    public String getData(String key) {
        String value = redis.get(key);
        if (value == null) {
            String lockKey = "lock:" + key;
            try {
                // 获取互斥锁,设置过期时间为10秒,防止死锁
                if (redis.set(lockKey, "1", SetParams.setParams().nx().ex(10))) {
                    try {
                        // 再次检查缓存,防止在获取锁期间其他线程已更新缓存
                        value = redis.get(key);
                        if (value == null) {
                            // 查询数据库
                            value = queryDatabase(key);
                            if (value != null) {
                                // 更新缓存,设置过期时间为60秒
                                redis.setex(key, 60, value);
                            }
                        }
                    } finally {
                        // 释放互斥锁
                        redis.del(lockKey);
                    }
                } else {
                    // 未获取到锁,等待一段时间后重试
                    Thread.sleep(100);
                    return getData(key);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        return value;
    }

    (3)提前异步更新缓存:提前异步更新缓存的思路是在热点数据即将过期之前,提前启动一个异步任务来刷新缓存,从而避免在缓存过期的瞬间发生缓存击穿。我们可以通过监测热点数据的访问频率和剩余过期时间,当发现某个热点数据的访问频率较高且即将过期时,就触发异步任务来更新缓存。这样,在缓存过期时,新的数据已经被更新到缓存中,用户的请求仍然可以从缓存中获取到数据,不会对数据库造成冲击。例如,在 Java 中,可以使用CompletableFuture来实现异步更新缓存:

    public void asyncUpdateCache(String key) {
        CompletableFuture.runAsync(() -> {
            String newData = queryDatabase(key);
            if (newData != null) {
                redis.setex(key, 60, newData);
            }
        });
    }

    在上述代码中,asyncUpdateCache方法通过CompletableFuture.runAsync启动一个异步任务,在任务中从数据库获取最新数据并更新到 Redis 缓存中。在实际应用中,可以结合定时任务或者事件驱动机制,在热点数据即将过期时调用该方法,提前更新缓存,从而有效地防止缓存击穿的发生。​

    四、缓存雪崩

    4.1 现象描述​

    在电商大促活动中,为了应对海量的用户请求,我们通常会将大量商品的信息存储在 Redis 缓存中。假设我们为这些商品设置了相同的缓存过期时间,比如活动结束后的凌晨 2 点。当时间一到,这些商品的缓存同时失效。此时,大量用户仍在继续浏览和查询商品信息,这些请求就会像潮水一般,在没有缓存的阻挡下,直接涌向数据库。数据库在短时间内需要处理如此大量的请求,很可能会因为不堪重负而导致响应速度变慢,甚至出现系统崩溃的情况 ,这就是缓存雪崩现象。​

    4.2 产生原因​

    缓存雪崩的产生主要有两个关键原因。一是大量缓存集中过期。在实际业务中,为了方便管理和设置,有时会将一批相关的缓存设置相同的过期时间。就像前面提到的电商大促活动,很多与活动相关的商品缓存都在同一时间过期,导致瞬间大量请求穿透缓存。二是缓存服务器故障。如果 Redis 缓存服务器出现宕机、网络故障等问题,导致缓存数据全部丢失或不可用,那么所有原本依赖缓存的请求都会立即转向数据库,这同样会引发缓存雪崩,对数据库造成巨大的冲击,严重影响系统的正常运行。​

    4.3 解决方案

    (1)缓存过期时间分散化:为了避免大量缓存同时过期,我们可以在设置缓存过期时间时,为每个缓存项添加一个随机的时间偏移量。例如,原本设置缓存过期时间为 60 分钟,我们可以改为在 50 到 70 分钟之间随机取值。这样,缓存的过期时间就会分散开来,不会在同一时间大量失效,从而减轻数据库的压力。在 Java 中,可以这样实现:

    import java.util.Random;
    import redis.clients.jedis.Jedis;
    
    public class CacheUtils {
        private static final Random random = new Random();
        private static final int BASE_TTL = 3600; // 基础过期时间,单位秒
        private static final int RANDOM_RANGE = 600; // 随机范围,单位秒
    
        public static void set(String key, String value, Jedis jedis) {
            int randomTTL = BASE_TTL + random.nextInt(RANDOM_RANGE * 2) - RANDOM_RANGE;
            jedis.setex(key, randomTTL, value);
        }
    }

    (2)缓存预热:缓存预热是指在系统上线前,或者在业务低峰期,提前将热点数据加载到缓存中。这样,当系统进入高峰期时,大部分请求都可以直接从缓存中获取数据,而不会因为缓存未命中而访问数据库。以电商系统为例,在大促活动开始前,可以通过脚本或定时任务,将热门商品的信息提前加载到 Redis 缓存中。在 Spring Boot 中,可以使用CommandLineRunner接口来实现缓存预热:

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.CommandLineRunner;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Component;
    
    @Component
    public class CacheWarmup implements CommandLineRunner {
    
        @Autowired
        private RedisTemplate redisTemplate;
    
        @Override
        public void run(String... args) throws Exception {
            // 假设从数据库获取热门商品列表
            Object[] hotProducts = getHotProductsFromDatabase();
            for (Object product : hotProducts) {
                // 假设商品的唯一标识为id
                String key = "product:" + ((Product) product).getId();
                redisTemplate.opsForValue().set(key, product);
            }
        }
    
        private Object[] getHotProductsFromDatabase() {
            // 模拟从数据库查询热门商品的逻辑
            return new Object[0];
        }
    }

    (3)多级缓存架构:采用多级缓存架构,结合本地缓存和分布式缓存,可以有效地降低数据库的压力。本地缓存(如 Caffeine、Guava 等)可以存储一些热点数据,由于其位于应用程序内部,访问速度非常快。当本地缓存未命中时,再访问分布式缓存(如 Redis)。如果分布式缓存也未命中,最后才访问数据库。这样,大部分请求可以在本地缓存和分布式缓存中得到处理,减少了对数据库的直接访问。以使用 Caffeine 和 Redis 实现多级缓存为例,代码如下:

    import com.github.benmanes.caffeine.cache.Cache;
    import com.github.benmanes.caffeine.cache.Caffeine;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Service;
    
    import java.util.concurrent.TimeUnit;
    
    @Service
    public class MultiLevelCacheService {
    
        private final Cache localCache;
        private final RedisTemplate redisTemplate;
    
        @Autowired
        public MultiLevelCacheService(RedisTemplate redisTemplate) {
            this.redisTemplate = redisTemplate;
            this.localCache = Caffeine.newBuilder()
                   .maximumSize(1000)
                   .expireAfterWrite(10, TimeUnit.MINUTES)
                   .build();
        }
    
        public Object getData(String key) {
            // 先从本地缓存获取
            Object value = localCache.getIfPresent(key);
            if (value != null) {
                return value;
            }
    
            // 本地缓存未命中,从Redis获取
            value = redisTemplate.opsForValue().get(key);
            if (value != null) {
                // 更新本地缓存
                localCache.put(key, value);
                return value;
            }
    
            // Redis未命中,从数据库获取(假设为getDataFromDatabase方法)
            value = getDataFromDatabase(key);
            if (value != null) {
                // 更新Redis和本地缓存
                redisTemplate.opsForValue().set(key, value);
                localCache.put(key, value);
            }
            return value;
        }
    
        private Object getDataFromDatabase(String key) {
            // 模拟从数据库查询数据的逻辑
            return null;
        }
    }

    (4)Redis 高可用:部署 Redis 主从集群、使用哨兵模式或 Redis Cluster 可以实现 Redis 的高可用。在主从集群中,主节点负责处理写操作,从节点复制主节点的数据,当主节点出现故障时,从节点可以晋升为主节点,继续提供服务。哨兵模式则用于监控 Redis 主节点和从节点的状态,当主节点发生故障时,自动进行故障转移。Redis Cluster 是一种分布式的 Redis 解决方案,它将数据分布在多个节点上,实现了数据的分片和高可用。通过这些方式,可以有效地防止因缓存服务器单点故障而导致的缓存雪崩,确保系统的稳定性和可靠性。​

    五、总结

    Redis 缓存穿透、击穿和雪崩是在使用 Redis 缓存时需要重点关注的三个问题。缓存穿透是指查询不存在的数据导致请求直接穿透缓存到达数据库;缓存击穿是热点数据过期瞬间大量请求直接访问数据库;缓存雪崩则是大量缓存集中过期或缓存服务器故障,引发大量请求涌向数据库 。这些问题一旦发生,都可能对系统性能和稳定性造成严重影响,甚至导致系统崩溃。​

    为了有效地预防和应对这些问题,我们可以采取多种解决方案。缓存空对象、布隆过滤器和参数校验能很好地解决缓存穿透问题;设置热点数据永不过期、使用互斥锁机制和提前异步更新缓存是应对缓存击穿的有效手段;缓存过期时间分散化、缓存预热、采用多级缓存架构和确保 Redis 高可用则可以帮助我们解决缓存雪崩问题 。在实际项目中,我们需要根据具体的业务场景和需求,综合运用这些解决方案,制定出适合自己项目的缓存策略。​

    同时,我们还要不断地进行性能测试和监控,及时发现并解决可能出现的问题。比如,通过设置合理的缓存过期时间,既能保证缓存数据的有效性,又能避免大量缓存同时过期引发的雪崩问题;定期对缓存进行清理和优化,删除不再使用的数据,释放缓存空间,提高缓存的利用率 。只有这样,我们才能充分发挥 Redis 缓存的优势,提升系统的性能和稳定性,为用户提供更加优质的服务体验。希望大家在实践中能够不断探索和优化,让 Redis 缓存成为提升系统性能的强大助力。​

    你可能感兴趣的:(缓存,redis,数据库,缓存)