19.redis之缓存击穿

缓存击穿

1.什么是缓存击穿??

缓存击穿,是指一个key "异常火爆"的热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
🧠 记忆钩子(批注):就像防弹衣上破了一个洞,子弹集火穿过这个洞打中数据库。

2.怎样解决?

  • 穿透后线程访问数据库前加一个锁,称为分布式锁

    public Product getProduct(String id) {
     // === 第一重检查 ===
     // 刚进来,先看看缓存有没有
     String value = redis.get(id);
     if (value != null) {
         return value;
     }
    
     // 缓存没有,准备抢锁
     lock.lock();
     try {
         System.out.println("我是第一个拿到锁的人,我去查库。");
         Product p = db.query(id);
         
         // 写回 Redis
         redis.set(id, p);
         return p;
    
     } finally {
         lock.unlock();
     }
    }
    

    但是有一种情况:多个线程同时走到访问数据库这一步的话,只有一个线程能够访问,其他线程阻塞,那么当其他线程醒来的话,不也还是会进入访问数据库逻辑吗?

  • 双重检查锁 (Double Check Lock, DCL)

    public Product getProduct(String id) {
    // === 第一重检查 ===
    // 刚进来,先看看缓存有没有
    String value = redis.get(id);
    if (value != null) {
        return value;
    }
    
    // 缓存没有,准备抢锁
    lock.lock();
    try {
        // === 【重点】第二重检查 (Double Check) ===
        // 拿到锁之后,必须!必须!再查一次 Redis!
        // 为什么?因为可能在我排队的时候,前一个人已经把数据填进去了。
        value = redis.get(id); 
        if (value != null) {
            System.out.println("好险!前一个人已经查回来了,我直接用,不用去数据库了。");
            return value;
        }
    
        // === 只有通过了两次检查,才真的去查数据库 ===
        System.out.println("我是第一个拿到锁的人,我去查库。");
        Product p = db.query(id);
        
        // 写回 Redis
        redis.set(id, p);
        return p;
    
    } finally {
        lock.unlock();
    }
    }
    

3.其他锁的缺点

  • 最直觉的想法,就是用 Redis 的 SETNX (Set if Not Exists) 命令。谁设置成功,谁就拿到锁。

    //这里的锁并不是并不是我们需要访问的数据,而是固定的锁,在缓存未命中时便会出发这把锁
    //String userId = 1;未命中,则进入访问数据库阶段,即下述代码
        // 1. 抢锁
    if (redis.setnx("lock", "1") == 1) {
        // 2. 抢到了,干活
        doSomething();  //例如,redis.setnx("userId","ylf",3600),这里是从数据库获取的值
        // 3. 释放锁
        redis.del("lock");
    }
    

    ❌ 致命 Bug: 如果代码执行到 doSomething() 的时候,服务器突然断电了,或者程序抛出异常崩了,会发生什么? redis.del("lock") 永远不会被执行! 这就叫死锁。这个 Key 会永远留在 Redis 里,以后任何人都抢不到锁,系统直接瘫痪。
    ✅ 修正方案: 必须给锁加一个“自动过期时间”。哪怕服务器炸了,过 10 秒锁也会自动消失。 (注意:要用原子命令 SET ... NX EX ...,不能分成两行写,否则两行中间断电了也是死锁)

    // 正确:设置锁的同时,指定 10秒 后自动过期
    redis.set("lock", "1", "NX", "EX", 10);
    
  • 解决“误删” (The Wrong Delete)

    现在我们加了过期时间(比如 10 秒)。但新的问题来了。

    ❌ 致命 Bug: 假如线程 A 的业务太复杂,卡顿了 15 秒才做完。
    1. T=0s:A 拿到锁(有效期 10s)。
    2. T=10s:A 还在干活,但锁自动过期了!
    3. T=11s:线程 B 来了,一看没锁,它顺理成章拿到了锁。
    4. T=15s:A 终于干完活了,它执行 redis.del("lock")。重点来了:A 删的是谁的锁?是 B 的!
    5. T=16s:线程 C 来了,一看没锁(被 A 误删了),也拿到了锁。
    • 结果:B 和 C 同时在干活,锁完全失效了。
    
    

    ✅ 修正方案: 解铃还须系铃人。谁加的锁,只能由谁来删。 我们在 value 里不存 "1" 了,存一个唯一的 UUID。

      String myUUID = UUID.randomUUID().toString();
    
      // 加锁时,把自己的签名 (UUID) 存进去
      redis.set("lock", myUUID, "NX", "EX", 10);
    
      // ... 干活 ...
    
      // 释放时,先检查一下:这是不是我签名的锁?
      if (myUUID.equals(redis.get("lock"))) {
          redis.del("lock"); // 是我的,才删
      }
    
  • 解决“原子性” (Atomicity)

    看起来刚才的代码很完美了?不,还有一个极其隐蔽的 Bug。

    ❌ 致命 Bug: 看最后释放锁的那两行代码:
    1. if (myUUID.equals(redis.get("lock"))) <-- 这一步判断通过了。
    2. (就在这毫秒之间,JVM 发生了一次垃圾回收 GC,程序停顿了)
    3. (或者锁刚好在这时候过期了,线程 B 抢到了新锁)
    4. redis.del("lock") <-- A 醒过来,执行删除。完蛋,又把 B 的新锁删掉了!
    因为“判断”和“删除”是两个动作,不是原子的。
    

    ✅ 修正方案: 必须把这两步合并成一步。Redis 自身没有这种命令,所以我们需要用 Lua 脚本(Redis 会把 Lua 脚本作为一个整体执行,中间绝不会被打断)。

    -- 这段脚本是原子的
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    
posted @ 2025-12-12 21:30  那就改变世界吧  阅读(2)  评论(0)    收藏  举报