Redis 作为分布式缓存:缓存穿透、雪崩、击穿的解决方案

好的,各位听众,欢迎来到今天的“Redis缓存那些事儿”讲座。今天咱们要聊的是Redis作为分布式缓存时,那些让人头疼的“穿透”、“雪崩”和“击穿”,以及怎么用各种姿势优雅地解决它们。

开场白:缓存,你又爱又恨的小妖精

缓存,这玩意儿就像你家冰箱,放点常用的东西进去,拿的时候嗖嗖快,但用不好,它也能变成细菌滋生的温床。在分布式系统中,Redis就是这个冰箱,它能加速你的数据访问,减轻数据库压力,但如果姿势不对,就会引发各种奇奇怪怪的问题。

第一幕:缓存穿透,查无此人的尴尬

啥是缓存穿透?简单来说,就是用户请求的数据,Redis里没有,数据库里也没有。每次请求都直奔数据库,就像你每次都饿着肚子去超市买菜,冰箱永远是空的!

问题描述:

  • 恶意攻击:黑客故意请求大量不存在的数据,让数据库不堪重负。
  • 数据异常:程序bug导致请求的数据ID永远不存在。

解决方案:

  1. 布隆过滤器 (Bloom Filter):事前过滤,防止坏人进门

    这玩意儿就像你家门口的保安,能快速告诉你这个人是不是“可疑人员”(可能存在于数据库)。它是一种概率型数据结构,能告诉你某个元素“可能存在”或“绝对不存在”。

    • 优点: 占用空间小,判断速度快。
    • 缺点: 有误判率(False Positive),可能会把不存在的元素判断为存在。

    代码示例 (Java + Redisson):

    import org.redisson.Redisson;
    import org.redisson.api.RBloomFilter;
    import org.redisson.config.Config;
    
    public class BloomFilterExample {
    
        public static void main(String[] args) {
            // 1. 配置 Redisson
            Config config = new Config();
            config.useSingleServer().setAddress("redis://127.0.0.1:6379"); // 替换成你的 Redis 地址
            Redisson redisson = (Redisson) Redisson.create(config);
    
            // 2. 获取 Bloom Filter 对象
            RBloomFilter<String> bloomFilter = redisson.getBloomFilter("myBloomFilter");
    
            // 3. 初始化 Bloom Filter (预计元素数量和误判率)
            bloomFilter.tryInit(100000, 0.01); // 预计10万元素,误判率1%
    
            // 4. 将数据库中的数据添加到 Bloom Filter
            // 假设我们从数据库中查询到了一些ID
            String[] existingIds = {"1", "2", "3", "4", "5"};
            for (String id : existingIds) {
                bloomFilter.add(id);
            }
    
            // 5. 检查某个ID是否存在
            String idToCheck = "6";
            if (bloomFilter.contains(idToCheck)) {
                System.out.println("ID " + idToCheck + " 可能存在"); // 可能会误判
                // 进一步查数据库,如果数据库不存在,则进行缓存空对象的操作
            } else {
                System.out.println("ID " + idToCheck + " 绝对不存在"); // 确定不存在,直接返回
                // 直接返回,避免访问数据库
            }
    
            // 6. 关闭 Redisson
            redisson.shutdown();
        }
    }

    解释:

    • 首先,我们需要配置并连接到 Redis。
    • 然后,我们获取一个 RBloomFilter 对象,并初始化它,指定预计的元素数量和误判率。
    • 接着,我们将数据库中已存在的ID添加到 Bloom Filter 中。
    • 最后,当我们收到一个请求时,先检查该ID是否存在于 Bloom Filter 中。如果不存在,则直接返回,避免访问数据库。如果存在,则进一步查询数据库,以确认数据是否存在。
  2. 缓存空对象 (Null Object Caching):就算啥也没有,也先占个位子

    当数据库中不存在请求的数据时,我们可以在 Redis 中缓存一个空对象(null 或特定标记值),下次再请求相同的数据时,Redis 直接返回空对象,避免访问数据库。

    • 优点: 简单有效,避免了频繁访问数据库。
    • 缺点: 缓存了无意义的数据,占用 Redis 空间。需要设置合理的过期时间。

    代码示例 (Java):

    import redis.clients.jedis.Jedis;
    
    public class NullObjectCachingExample {
    
        public static void main(String[] args) {
            Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址
    
            String key = "product:100";
            String value = jedis.get(key);
    
            if (value == null) {
                // Redis 中不存在,查询数据库
                String product = getProductFromDatabase(100); // 假设从数据库查询商品信息
    
                if (product == null) {
                    // 数据库中也不存在,缓存空对象
                    jedis.setex(key, 60, "NULL"); // 设置过期时间为 60 秒
                    System.out.println("缓存空对象");
                } else {
                    // 数据库中存在,缓存数据
                    jedis.setex(key, 3600, product); // 设置过期时间为 3600 秒
                    System.out.println("缓存商品信息");
                }
            } else {
                if ("NULL".equals(value)) {
                    System.out.println("从 Redis 获取到空对象");
                    // 返回空对象,或者根据业务逻辑进行处理
                } else {
                    System.out.println("从 Redis 获取到商品信息");
                    // 返回商品信息
                }
            }
    
            jedis.close();
        }
    
        // 模拟从数据库查询商品信息
        private static String getProductFromDatabase(int productId) {
            // 假设数据库中不存在 productId 为 100 的商品
            return null;
        }
    }

    解释:

    • 首先,我们尝试从 Redis 中获取数据。
    • 如果 Redis 中不存在,则查询数据库。
    • 如果数据库中也不存在,则在 Redis 中缓存一个值为 "NULL" 的字符串,并设置一个较短的过期时间。
    • 下次再请求相同的数据时,Redis 直接返回 "NULL",我们可以根据业务逻辑进行处理,例如返回一个默认值或错误信息。

第二幕:缓存雪崩,集体罢工的危机

缓存雪崩就像多米诺骨牌,如果大量的缓存同时失效,导致所有请求都涌向数据库,数据库瞬间崩溃。

问题描述:

  • 缓存失效:大量缓存同时过期,例如设置了相同的过期时间。
  • Redis 宕机:Redis 集群整体故障。

解决方案:

  1. 随机过期时间:错峰出行,避免堵车

    给缓存设置过期时间时,加上一个随机数,避免大量缓存同时过期。

    • 优点: 简单有效,能有效避免缓存同时过期。
    • 缺点: 无法完全避免少量缓存同时过期。

    代码示例 (Java):

    import redis.clients.jedis.Jedis;
    import java.util.Random;
    
    public class RandomExpirationExample {
    
        public static void main(String[] args) {
            Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址
            Random random = new Random();
    
            String key = "product:1";
            String value = "product data";
    
            // 设置过期时间,基础时间加上一个 0 到 10 分钟的随机数
            int baseExpiration = 3600; // 基础过期时间 1 小时
            int randomExpiration = random.nextInt(600); // 随机过期时间 0 到 10 分钟
            int expiration = baseExpiration + randomExpiration;
    
            jedis.setex(key, expiration, value);
    
            System.out.println("设置 key " + key + " 的过期时间为 " + expiration + " 秒");
    
            jedis.close();
        }
    }

    解释:

    • 我们生成一个 0 到 600 秒(10 分钟)的随机数。
    • 将基础过期时间(1 小时)加上随机数,得到最终的过期时间。
    • 使用 setex 命令设置 key 的过期时间。
  2. 互斥锁 (Mutex Lock):排队通行,保护数据库

    当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。

    • 优点: 保证只有一个线程访问数据库,避免数据库压力过大。
    • 缺点: 增加了请求的响应时间,因为需要等待锁的释放。

    代码示例 (Java):

    import redis.clients.jedis.Jedis;
    import redis.clients.jedis.params.SetParams;
    
    public class MutexLockExample {
    
        private static final String LOCK_KEY = "product:1:lock";
        private static final String LOCK_VALUE = "lock";
        private static final int LOCK_EXPIRE_TIME = 5; // 锁的过期时间 5 秒
    
        public static void main(String[] args) {
            Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址
    
            String key = "product:1";
            String value = jedis.get(key);
    
            if (value == null) {
                // 缓存失效,尝试获取锁
                if (tryGetLock(jedis)) {
                    try {
                        // 获取到锁,查询数据库
                        String product = getProductFromDatabase(1); // 假设从数据库查询商品信息
    
                        if (product != null) {
                            // 数据库中存在,缓存数据
                            jedis.setex(key, 3600, product); // 设置过期时间为 3600 秒
                            System.out.println("缓存商品信息");
                        } else {
                            // 数据库中不存在,缓存空对象
                            jedis.setex(key, 60, "NULL"); // 设置过期时间为 60 秒
                            System.out.println("缓存空对象");
                        }
                    } finally {
                        // 释放锁
                        releaseLock(jedis);
                    }
                } else {
                    // 没有获取到锁,等待一段时间后重试
                    System.out.println("没有获取到锁,稍后重试");
                    try {
                        Thread.sleep(100); // 睡眠 100 毫秒
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 可以递归调用当前方法,或者直接返回一个默认值
                }
            } else {
                if ("NULL".equals(value)) {
                    System.out.println("从 Redis 获取到空对象");
                    // 返回空对象,或者根据业务逻辑进行处理
                } else {
                    System.out.println("从 Redis 获取到商品信息");
                    // 返回商品信息
                }
            }
    
            jedis.close();
        }
    
        // 尝试获取锁
        private static boolean tryGetLock(Jedis jedis) {
            SetParams params = SetParams.setParams().nx().ex(LOCK_EXPIRE_TIME);
            String result = jedis.set(LOCK_KEY, LOCK_VALUE, params);
            return "OK".equals(result);
        }
    
        // 释放锁
        private static void releaseLock(Jedis jedis) {
            jedis.del(LOCK_KEY);
        }
    
        // 模拟从数据库查询商品信息
        private static String getProductFromDatabase(int productId) {
            // 假设数据库中存在 productId 为 1 的商品
            return "product data from database";
        }
    }

    解释:

    • 我们使用 Redis 的 SETNX 命令来尝试获取锁。SETNX 命令只有在 key 不存在时才会设置 key 的值,并返回 1,否则返回 0。
    • 如果获取到锁,则查询数据库,并将结果写入缓存。
    • 最后,释放锁,使用 DEL 命令删除 key。
    • 为了防止死锁,我们设置了锁的过期时间。即使获取锁的线程发生异常,锁也会在过期时间后自动释放。
  3. 服务降级 (Service Degradation):丢卒保车,保证核心服务

    在缓存雪崩发生时,可以暂时关闭一些非核心服务,释放资源,保证核心服务的正常运行。

    • 优点: 保证核心服务的可用性。
    • 缺点: 用户体验可能会受到影响。

    实现方式:

    • 使用熔断器:当某个服务的错误率超过一定阈值时,自动熔断该服务,防止雪崩效应蔓延。
    • 限制流量:限制访问量,防止数据库压力过大。
    • 返回默认值:当缓存失效时,直接返回一个默认值,而不是访问数据库。
  4. 构建高可用 Redis 集群:多条腿走路,防止摔倒

    使用 Redis 集群,例如 Redis Sentinel 或 Redis Cluster,提高 Redis 的可用性。

    • 优点: 提高了 Redis 的可用性,降低了 Redis 宕机的风险。
    • 缺点: 增加了部署和维护的复杂度。

第三幕:缓存击穿,明星数据的烦恼

缓存击穿是指一个热点 key(访问频率非常高的 key)失效时,大量的请求同时访问数据库,导致数据库压力过大。

问题描述:

  • 热点 key 过期:例如秒杀商品,在秒杀活动结束后,缓存失效。
  • 热点 key 被意外删除。

解决方案:

  1. 永不过期 (Never Expire):爱它,就让它永远存在

    对于热点 key,可以设置永不过期,或者设置一个非常长的过期时间。

    • 优点: 简单有效,能有效避免缓存击穿。
    • 缺点: 如果数据发生变化,需要手动更新缓存。

    代码示例 (Java):

    import redis.clients.jedis.Jedis;
    
    public class NeverExpireExample {
    
        public static void main(String[] args) {
            Jedis jedis = new Jedis("127.0.0.1", 6379); // 替换成你的 Redis 地址
    
            String key = "hot_product:1";
            String value = "hot product data";
    
            // 设置永不过期
            jedis.set(key, value);
    
            System.out.println("设置 key " + key + " 永不过期");
    
            jedis.close();
        }
    }

    解释:

    • 使用 set 命令设置 key 的值,不设置过期时间。
  2. 提前更新缓存:未雨绸缪,防患于未然

    在热点 key 即将过期时,提前更新缓存,避免在过期后大量请求同时访问数据库。

    • 优点: 能有效避免缓存击穿。
    • 缺点: 需要监控 key 的过期时间,并提前更新缓存。

    实现方式:

    • 使用定时任务:定时检查热点 key 的过期时间,并在即将过期时更新缓存。
    • 使用消息队列:当数据发生变化时,发送消息到消息队列,由消费者更新缓存。
  3. 互斥锁 (Mutex Lock):同缓存雪崩解决方案

    当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。

    • 优点: 保证只有一个线程访问数据库,避免数据库压力过大。
    • 缺点: 增加了请求的响应时间,因为需要等待锁的释放。

总结:

问题 描述 解决方案 优点 缺点
穿透 请求的数据在缓存和数据库中都不存在,导致所有请求都直接访问数据库。 1. 布隆过滤器:在访问缓存之前,先使用布隆过滤器判断数据是否存在,如果不存在,则直接返回。 2. 缓存空对象:当数据库中不存在请求的数据时,在缓存中缓存一个空对象,避免每次都访问数据库。 1. 布隆过滤器:占用空间小,判断速度快。 2. 缓存空对象:简单有效,避免了频繁访问数据库。 1. 布隆过滤器:有误判率。 2. 缓存空对象:缓存了无意义的数据,占用缓存空间。需要设置合理的过期时间。
雪崩 大量缓存同时失效,导致所有请求都涌向数据库,数据库瞬间崩溃。 1. 随机过期时间:给缓存设置过期时间时,加上一个随机数,避免大量缓存同时过期。 2. 互斥锁:当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。 3. 服务降级:在缓存雪崩发生时,可以暂时关闭一些非核心服务,释放资源,保证核心服务的正常运行。 4. 构建高可用 Redis 集群:使用 Redis 集群,例如 Redis Sentinel 或 Redis Cluster,提高 Redis 的可用性。 1. 随机过期时间:简单有效,能有效避免缓存同时过期。 2. 互斥锁:保证只有一个线程访问数据库,避免数据库压力过大。 3. 服务降级:保证核心服务的可用性。 4. 构建高可用 Redis 集群:提高了 Redis 的可用性,降低了 Redis 宕机的风险。 1. 随机过期时间:无法完全避免少量缓存同时过期。 2. 互斥锁:增加了请求的响应时间。 3. 服务降级:用户体验可能会受到影响。 4. 构建高可用 Redis 集群:增加了部署和维护的复杂度。
击穿 一个热点 key 失效时,大量的请求同时访问数据库,导致数据库压力过大。 1. 永不过期:对于热点 key,可以设置永不过期,或者设置一个非常长的过期时间。 2. 提前更新缓存:在热点 key 即将过期时,提前更新缓存,避免在过期后大量请求同时访问数据库。 3. 互斥锁:当缓存失效时,只允许一个线程去查询数据库,并将结果写入缓存,其他线程等待。 1. 永不过期:简单有效,能有效避免缓存击穿。 2. 提前更新缓存:能有效避免缓存击穿。 3. 互斥锁:保证只有一个线程访问数据库,避免数据库压力过大。 1. 永不过期:如果数据发生变化,需要手动更新缓存。 2. 提前更新缓存:需要监控 key 的过期时间,并提前更新缓存。 3. 互斥锁:增加了请求的响应时间。

结尾:缓存之路,道阻且长,行则将至

缓存的使用是一门艺术,需要根据具体的业务场景选择合适的解决方案。没有银弹,只有不断地学习和实践,才能掌握缓存的精髓。希望今天的讲座能对大家有所帮助,谢谢!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注