18.Redis 缓存三兄弟 之 穿透

缓存穿透

  1. 为什么只要访问 Redis,最后却搞挂了数据库?
  • 因为在代码逻辑里,程序员通常是这样写的(这叫 Cache-Aside Pattern 旁路缓存模式):
正常的流程是这样的:
1. 用户来了:我想查 id=100 的用户信息。
2. 先问 Redis(前台):你手里有 id=100 的资料吗?
3. Redis 回答:有!给你。(请求结束,不打扰数据库)
但如果 Redis 里没有(这就是关键点):
1. 用户来了:我想查 id=100 的用户信息。
2. 先问 Redis:你有吗?
3. Redis 回答:我没有啊(可能是过期了,或者本来就没存)。
4. 代码会自动执行下一步:既然前台没有,那我必须去后面的仓库(数据库)里翻箱倒柜找出来,不然怎么给用户看?
5. 查询数据库:找到了!
6. 回填 Redis:把这份资料复印一份放 Redis 前台,下次就不用去仓库找了。

2.回到“缓存穿透”的场景

现在的恶意攻击是这样的:
• 黑客:我要查 id = -1 (一个根本不存在的人)。
• Step 1 问 Redis:你有 id = -1 吗?
• Redis:没有。(非常快)
• Step 2 问 数据库:因为 Redis 说没有,代码逻辑被迫去查数据库。
• 数据库:我去硬盘里翻了一遍,也没找到。(非常慢,而且消耗大量 CPU)
• Step 3:这次查询结束,返回“空”。
重点来了: 因为 id = -1 根本不存在,所以数据库永远查不到,也永远没机会把数据“回填”给 Redis。
下一次黑客再问 id = -1,Redis 还是说“没有”,代码还是得去查数据库。
如果黑客每秒发 1 万个 id = -1 的请求:
• Redis 只是不断摇头说“没有”(它扛得住)。
• 你的代码就发起了 1 万次数据库查询。
• 数据库本来每秒只能处理 2000 次,直接被这 1 万次无效查询拖垮了。

3.解决办法

  • 缓存空值

    • 有漏洞的代码

      public class UserService {
      
          // 模拟 Redis 客户端
          RedisClient redis = new RedisClient();
          // 模拟 数据库 DAO
          UserDao database = new UserDao();
      
          /**
           * 这是一个有“缓存穿透”漏洞的方法
           */
          public User getUserByIdVulnerable(String userId) {
              // 定义 Redis 的 Key,比如 "user:100"
              String cacheKey = "user:" + userId;
      
              // === Step 1: 先问 Redis (前台) ===
              String userJson = redis.get(cacheKey);
      
              if (userJson != null) {
                  // 命中缓存!直接把字符串转成对象返回。
                  System.out.println("Redis里有,直接返回,不打扰数据库。");
                  return jsonToUser(userJson);
              }
      
              // === Step 2: Redis没有,只好去问数据库 (仓库) ===
              // 【警报】如果 userId 是 -1,这里就会产生一次昂贵的数据库查询
              System.out.println("Redis没货了,正在去仓库翻找...");
              User user = database.findUserById(userId);
      
              // === Step 3: 判断数据库有没有查到 ===
              if (user != null) {
                  // 查到了!赶紧复印一份放到 Redis 前台,过期时间 1 小时
                  redis.setEx(cacheKey, 3600, userToJson(user));
              }
              // 【关键漏洞点】:如果 user 是 null (没查到),这里什么都没做!
              // 这意味着下次再来查这个不存在的 ID,流程又会重新走一遍 Step 2。
      
              return user;
          }
      }
      
    • 加上防弹衣后的“聪明”代码(解决穿透)
      现在我们将修复漏洞。核心思路是:数据库查不到,也要在 Redis 里记一笔“查不到”!
      为了区分“真的存了个空对象”和“没命中缓存”,我们通常会在 Redis 里存一个特殊的空标记字符串,比如 "@@NULL@@" 或者简单的空字符串 ""。

      public class UserService {
          // ... redis 和 database 变量省略 ...
      
          // 定义一个特殊的标记,表示“数据库里也没这个人”
          private static final String NULL_MARKER = "@@NULL@@";
      
          /**
           * 这是一个修复了“缓存穿透”的方法
           */
          public User getUserByIdSafe(String userId) {
              String cacheKey = "user:" + userId;
      
              // === Step 1: 先问 Redis ===
              String cacheValue = redis.get(cacheKey);
      
              // 1.1 如果 Redis 返回的不是 null (说明有点东西)
              if (cacheValue != null) {
                  // 【关键判断】:看看这个东西是不是我们之前做的“空标记”
                  if (NULL_MARKER.equals(cacheValue)) {
                      // 哎哟,是空标记!说明之前已经有人去数据库确认过了,确实没有。
                      System.out.println("Redis防弹衣生效:拦截了一个恶意请求,直接返回空。");
                      // 直接返回 null,坚决不去查数据库!
                      return null;
                  }
      
                  // 不是空标记,那就是真的用户数据了
                  System.out.println("Redis命中正常数据。");
                  return jsonToUser(cacheValue);
              }
      
              // === Step 2: Redis 什么都没有 (null),才去查数据库 ===
              System.out.println("Redis完全没记录,正在去仓库翻找...");
              User user = database.findUserById(userId);
      
              if (user != null) {
                  // 3.1 数据库查到了,正常缓存,保存 1 小时
                  redis.setEx(cacheKey, 3600, userToJson(user));
              } else {
                  // === Step 3.2 【修复的核心】数据库也没查到!===
                  // 千万不能什么都不做!要在 Redis 里记下一笔空账。
                  System.out.println("数据库也没有!在 Redis 里记录一个空标记,防止下次再被查。");
                  // 【注意】:空值的过期时间一定要设置得短一点(比如 60秒)
                  // 为什么?万一这 60秒内,真的有个用户注册了这个 ID 呢?
                  // 存太久会导致新数据长时间查不出来。
                  redis.setEx(cacheKey, 60, NULL_MARKER);
              }
      
              return user;
          }
      }
      
  • 布隆过滤器 (Bloom Filter)

    布隆过滤器的底层依赖 二进制数组 + 多个独立哈希函数 实现

    第一步:初始化(一张无限长的白纸)
    它本质上是一个很长很长的二进制数组(Bit Array),里面只存 0 或 1。 最开始,全是 0。
    [0, 0, 0, 0, 0, 0, 0, ...]
    
    第二步:添加数据(按指印)
    假设我们要记录 id=100 存在。 布隆过滤器不会直接把 "100" 存进去,而是用 K 个哈希函数(比如 3 个)对 "100" 进行运算,算出 3 个位置下标。
    • Hash1(100) = 2
    • Hash2(100) = 5
    • Hash3(100) = 9
    然后,把数组里下标为 2、5、9 的位置全都改成 1。
    [0, 0, 1, 0, 0, 1, 0, 0, 0, 1, ...] (对应位置变黑了)
    
    第三步:判断是否存在(查指印)
    现在来了个请求,查 id=999。 我们用同样的 3 个哈希函数算一下:
    • Hash1(999) = 2
    • Hash2(999) = 8
    • Hash3(999) = 9
    我们去数组里看:
    • 位置 2 是 1(对上了)
    • 位置 8 是 0(没对上!)
    • 位置 9 是 1(对上了)
    只要有任何一个位置是 0,布隆过滤器就敢拍着胸脯保证:“这东西绝对不存在!” —— 直接拦截,不用去查数据库。
    
    
    
      布隆过滤器的“死穴”:误判 (False Positive)
    这也就是我为什么说它“视力有点模糊”。
    假设我们又存了个 id=200,它把位置 8 也涂成了 1。 现在数组变成了:位置 2, 5, 8, 9 都是 1。
      这时候,回头再查刚才那个 id=999 (哈希值是 2, 8, 9)。
    • 位置 2 是 1 (id=100 干的)
    • 位置 8 是 1 (id=200 干的)
    • 位置 9 是 1 (id=100 干的)
    坏了! 3 个位置全是 1。布隆过滤器会说:“id=999 可能存在。” 程序放行,去查 Redis/数据库,结果发现数据库里根本没有 id=999。
    
    
    布隆过滤器还有一个致命缺点
    它很难删除数据。
    为什么? 比如你想删除 id=100。 你把位置 2, 5, 9 从 1 改回 0? 不行! 万一 id=888 刚好也映射到了位置 5 怎么办?你把位置 5 改成 0,id=888 就被你误删了。
    解决方案:
    1. 定期重建:每隔一段时间,生成一个新的布隆过滤器,把数据库里的数据全量重新导一遍。
    2. 布谷鸟过滤器 (Cuckoo Filter):这是布隆过滤器的升级版,支持删除,但实现更复杂
    
posted @ 2025-12-12 15:54  那就改变世界吧  阅读(0)  评论(0)    收藏  举报