系统里的“特种部队”——缓存

作为程序员------高阶牛马:你眼里的缓存不应该只是一个存 Key-Value 的字典,它应该是你系统里的**"特种部队"**------反应极快,但脾气暴躁,稍不留神还会炸伤自己人(数据库)。

咱们把 Redis 当成一个只会听指令的"笨蛋天才",来看看怎么通过架构设计把它玩出花来。

三大"死法":穿透、击穿、雪崩

这三个词听起来像灾难片,实际上就是数据库在向你求救:"大哥,别让我干了,我想死!"这个词是不是很熟悉,*天默念*遍,看完咱博客、你会发现这个要学的东西太多了,扶我起来、还能肝!

1. 缓存穿透 ------ "恶意找茬"

场景 :黑客或者爬虫,专门查一些根本不存在 的 ID(比如 user_id = -195270000)。缓存里没有,数据库里也没有。结果就是:所有请求都像穿针引线一样,直接扎到了数据库身上。

  • 后果:数据库 CPU 飙到 100%,当场去世。

药方:

  • 方案 A(空值缓存) :虽然数据不存在,我也给你记一笔!Key 是 user:-1,Value 是 NULL,过期时间设短点(比如 5 分钟)。下次再来,看到 NULL 就直接滚蛋,不用查库了。

  • 方案 B(布隆过滤器 Bloom Filter):这是数学家的魔法。在请求到达缓存之前,先过一个位图算法。如果布隆过滤器说"这玩意儿不存在",那它就绝对不存在,直接拦截。如果说"存在",那可能是真的,也可能是误判,放行去查。

    // 伪代码:布隆过滤器逻辑
    if (!bloomFilter.mightContain(userId)) {
    return null; // 肯定没有,直接拦截,保护数据库
    }
    // 可能存在,继续查缓存...

2. 缓存击穿 ------ "热点引爆"

场景 :某个超级热点数据(比如微博热搜第一),在某一瞬间过期了。就在这毫秒之间,几万个并发请求发现缓存没了,于是它们商量好了一样,同时转身冲向数据库。

  • 后果:数据库瞬间被几万根"针"扎穿。

药方:

  • 互斥锁(Mutex Lock):也就是"单机飞行"。发现缓存没了?别急,大家抢一把分布式锁(Redis SETNX)。抢到锁的那个倒霉蛋去查库,其他人等着。等它查完回填缓存,释放锁,其他人再读缓存就命中了。

    public User getUser(String id) {
    User user = redis.get(id);
    if (user == null) {
    // 尝试获取分布式锁
    if (redis.setNx("lock:" + id, "1")) {
    try {
    // 双重检查!万一别人已经查完了呢
    user = redis.get(id);
    if (user == null) {
    user = db.query(id); // 只有一个人能进这里
    redis.set(id, user);
    }
    } finally {
    redis.del("lock:" + id); // 必须释放锁
    }
    } else {
    // 没抢到锁,睡一觉重试
    Thread.sleep(50);
    return getUser(id);
    }
    }
    return user;
    }

3. 缓存雪崩 ------ "集体自杀"

场景 :你偷懒,把所有缓存的过期时间都设成了同一个值(比如 1 小时)。好巧不巧,1 小时后,几百万个 Key 同时过期。那一刻,流量如海啸般拍向数据库。

  • 后果:数据库直接宕机,甚至导致整个机房瘫痪。

药方:

  • 随机扰动(Jitter) :别让大家的死期一样。在原定 TTL 基础上,加个随机数。比如 TTL = 60分钟 + random(1~10分钟)。这样它们就会陆陆续续过期,数据库就能喘口气。
  • 多级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis)。Redis 挂了,还有本地内存顶着。

双写一致性:先删还是先更?

这是面试必问,也是线上最容易翻车的地方。
核心矛盾:数据库(DB)和缓存(Cache)就像两个异地恋的情侣,你怎么保证他们手里的账本永远一样?

❌ 错误示范:先更新缓存,再更新数据库

结局:并发情况下,A 线程改了缓存,B 线程也改了缓存。最后数据库是新的,缓存却是旧的(因为 B 改得慢)。千万别这么干。

❌ 也不太行:先删缓存,再更新数据库

结局

  1. 线程 A 删了缓存。
  2. 线程 B 来读数据,发现缓存没了,去 DB 读了旧值,并填回缓存。
  3. 线程 A 才慢悠悠地把新值写入 DB。
  4. 结果:缓存里永远是脏数据,直到下一次过期。
业界标准:Cache Aside Pattern(旁路缓存)

口诀读的时候先读缓存,没有就读库然后回填;写的时候先更库,成功后再删缓存。

为什么是删缓存 而不是更缓存

  1. 省事:如果你只改了用户名字,但缓存里存的是个巨大的对象(包含头像、简介等),你还要重新把整个对象组装一遍去更新缓存吗?直接删了让读请求去重建多香。
  2. 并发安全:两个线程同时改缓存,谁先谁后很难控制。

但是! Cache Aside 也有极端情况下的 Bug(并发读写竞态)。所以有了进阶版 👇

终极方案:延迟双删(Delayed Double Delete)

为了防止上面提到的"读旧值回填"问题,我们搞个"回马枪"。

  1. 先删缓存(防止有人读到旧缓存)。

  2. 更新数据库。

  3. 休眠一小会儿(比如 500ms,确保所有正在进行的读操作都结束了)。

  4. 再次删除缓存(把那个可能产生的脏数据清理掉)。

    public void updateUser(User user) {
    // 1. 先删缓存
    redis.delete("user:" + user.getId());

    复制代码
     // 2. 更新数据库
     userMapper.update(user);
     
     // 3. 延迟双删 (实际生产建议用 MQ 延时队列,别在这里 sleep 阻塞线程!)
     // delayQueue.send(new DeleteTask(user.getId()), 500, TimeUnit.MILLISECONDS);
     try { Thread.sleep(500); } catch (Exception e) {}
     
     // 4. 再次删除
     redis.delete("user:" + user.getId());

    }

骨灰级方案:Canal 订阅 Binlog

如果你觉得"延迟双删"太丑,或者怕删失败了怎么办?那就引入阿里开源的 Canal。
原理 :Canal 伪装成 MySQL 的从库,监听 Binlog(数据库的流水账)。
流程

  1. 业务代码只管更新数据库(DB 是唯一真理)。
  2. Canal 监听到变动,把数据扔到 MQ(消息队列)。
  3. 消费者收到消息,去删除/更新 Redis。

优点:代码解耦,最终一致性极高。哪怕 Redis 挂了,消息还在 MQ 里,重启还能消费

本地缓存Caffeine怎么和Redis结合用

  • Redis中央大冷库:东西全,所有分店(服务实例)都能去拿,但得开车过去(网络IO),还得排队(网络延迟)。
  • Caffeine灶台上的调料盒:就在手边,伸手就能拿到(内存读取,纳秒级),但容量小,而且只有这一个灶台能用(进程内共享)。

把 Caffeine 和 Redis 结合起来,就是构建多级缓存(Multi-Level Cache) 。核心思想就一句话:"能不动脚就不动脚,实在没辙了再去跑断腿。"

架构原理:流量是怎么被拦截的?

在一个标准的"Caffeine + Redis"二级缓存架构中,读数据的流程是这样的:

  1. 第一道防线 (L1 - Caffeine) :请求来了,先看灶台(JVM 堆内存)。如果有,直接返回。(耗时:纳秒级)
  2. 第二道防线 (L2 - Redis) :如果灶台没有,再去中央冷库(Redis)。如果有,拿回来,顺手放在灶台上 (回填 L1),然后返回。(耗时:毫秒级)
  3. 最后的手段 (DB) :如果冷库也没有,那就只能去原产地采购(查数据库),查回来后,先放冷库,再放灶台

为什么要这么麻烦?

因为 Redis 虽然快,但它毕竟是远程调用(RPC/Network)。在高并发下,网卡带宽和网络延迟是巨大的瓶颈。Caffeine 能帮你挡掉 90% 以上的热点流量,让 Redis 甚至感觉不到压力。

实战代码:手写一个"多级缓存管理器"

别光看理论,咱们直接上代码。这里我不使用复杂的 Spring Cache 注解,而是手写一个通用的 TwoLevelCache 工具类,让你看清底层的逻辑流转。

我们需要两个依赖:

  • com.github.ben-manes.caffeine:caffeine

  • org.springframework.boot:spring-boot-starter-data-redis

    import com.github.benmanes.caffeine.cache.Cache;
    import com.github.benmanes.caffeine.cache.Caffeine;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Component;

    import javax.annotation.Resource;
    import java.util.concurrent.TimeUnit;
    import java.util.function.Supplier;

    @Component
    public class TwoLevelCache<K, V> {

    复制代码
      // 1. 本地缓存 (L1):Caffeine
      // 最大存 1000 个对象,写入后 10 分钟过期
      private final Cache<K, V> localCache = Caffeine.newBuilder()
              .maximumSize(1000)
              .expireAfterWrite(10, TimeUnit.MINUTES)
              .recordStats() // 开启监控统计
              .build();
    
      @Resource
      private RedisTemplate<K, V> redisTemplate;
    
      /**
       * 核心读取逻辑
       * @param key 键
       * @param dbLoader 数据库加载器 (Lambda表达式)
       */
      public V get(K key, Supplier<V> dbLoader) {
          // === 第一步:查本地缓存 (L1) ===
          V value = localCache.getIfPresent(key);
          if (value != null) {
              System.out.println(" [L1 Hit] 命中本地缓存: " + key);
              return value;
          }
    
          // === 第二步:查分布式缓存 (L2) ===
          // 注意:这里需要加分布式锁防止"缓存击穿",为了代码简洁此处省略锁逻辑
          value = redisTemplate.opsForValue().get(key);
          if (value != null) {
              System.out.println("[L2 Hit] 命中Redis缓存: " + key);
              // 【关键动作】:回填到本地缓存!不然下次还得查Redis
              localCache.put(key, value);
              return value;
          }
    
          // === 第三步:查数据库 ===
          System.out.println("[Cache Miss] 缓存未命中,查询数据库...");
          value = dbLoader.get(); 
    
          if (value != null) {
              // 【回填策略】:双写回环
              // 1. 写入 Redis (假设过期时间 1 小时)
              redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
              // 2. 写入 Caffeine
              localCache.put(key, value);
          }
    
          return value;
      }
    
      /**
       * 更新/删除时的清理逻辑
       */
      public void evict(K key) {
          // 删库之后,必须把两级缓存都清空!
          localCache.invalidate(key);
          redisTemplate.delete(key);
          System.out.println("🧹 已清理两级缓存: " + key);
      }

    }

    // 在你的 Service 层调用
    User user = twoLevelCache.get("user:1001", () -> {
    // 这个 Lambda 里的代码只有在缓存都没命中的时候才会执行
    return userRepository.findById(1001L);
    });

最大的坑:数据一致性(如何保证大家看到的是同一份数据?)

这是架构师必须面对的灵魂拷问。
场景:你有 10 台服务器(10 个 JVM),每台都有自己的 Caffeine。

  1. 管理员在后台修改了用户信息(更新了 DB 和 Redis)。
  2. 问题来了:那 10 台服务器里的 Caffeine 还在傻乎乎地拿着旧数据,因为它们根本不知道 DB 变了!

这就导致了:你刚改完名字,刷新页面发现还是旧的,或者有的服务器是新的,有的是旧的。

解决方案:Redis Pub/Sub 广播机制

既然 Caffeine 是"井底之蛙",看不见外面的世界,那我们就通过 Redis 给它传话。

架构设计:

  1. 当数据发生变更时(比如更新 DB 后),除了删除 Redis 缓存,还要向 Redis 发送一条广播消息(Pub/Sub)。

  2. 所有服务器的 Caffeine 监听这个频道。

  3. 一旦收到消息:"嘿,user:1001 变了!",所有服务器立刻把自己本地的 Caffeine 里的 user:1001 删掉。

  4. 下次请求进来,发现 L1 没了,自然会去查 L2(Redis),从而拿到最新数据。

    // 1. 订阅者:每个服务启动时都要订阅
    @EventListener
    public void onMessage(RedisMessageEvent event) {
    if ("CACHE_CLEAR".equals(event.getType())) {
    // 收到清理指令,干掉本地缓存
    localCache.invalidate(event.getKey());
    log.info("收到广播,清理本地缓存: {}", event.getKey());
    }
    }

    // 2. 发布者:在更新数据的服务里调用
    public void updateUserData(User user) {
    // 1. 更新数据库
    userRepo.save(user);

    复制代码
     // 2. 删除 Redis 集中式缓存
     redisTemplate.delete("user:" + user.getId());
     
     // 3. 【关键一步】发布广播,通知所有兄弟节点清理本地缓存
     redisTemplate.convertAndSend("CACHE_TOPIC", new CacheClearEvent("user:" + user.getId()));

    }

决策清单
  1. 什么时候用 Caffeine + Redis?

    • 高频读、低频写的数据。比如:商品详情、配置信息、字典表。
    • 如果是频繁变动的数据(如库存、秒杀状态),千万别用本地缓存,否则你会超卖到哭。
  2. Caffeine 的配置要点

    • 一定要设上限 (maximumSize):别让本地缓存把 JVM 堆内存撑爆了(OOM)。
    • 一定要设过期时间 (expireAfterWrite):作为兜底策略,万一广播丢了,数据最终也会过期自动修复。
  3. 核心口诀

    • 读:先 L1,再 L2,最后 DB。L2 命中要回填 L1。
    • 写:先 DB,删 L2,发广播删 L1。

这就是 Caffeine 和 Redis 结合的终极形态:用 Redis 做全量兜底和数据同步通道,用 Caffeine 做极致的性能加速。

总结:"防弹指南"

问题 解决方案 核心逻辑
穿透 布隆过滤器 / 空值缓存 别让垃圾请求摸到数据库
击穿 互斥锁 (Redisson) 只有一个勇士能打怪,其他人围观
雪崩 随机 TTL / 多级缓存 别让大家一起死
一致性 延迟双删 / Canal 删比更要紧,异步兜底最靠谱
相关推荐
Y3815326629 小时前
SERP API + Redis 缓存层:4 种方案对比与选型
数据库·redis·缓存
海兰11 小时前
【高速缓存】RedisVL 高级查询(全文搜索、混合搜索和 多向量搜索)
数据库·人工智能·redis·缓存
流星白龙1 天前
【Redis】4.基本全局命令
数据库·redis·缓存
流星白龙1 天前
【Redis】3.Redis安装与命令行客户端
数据库·redis·缓存
我不想名字重复1 天前
redis缓存和数据库数据保持一致
数据库·redis·缓存
时空无限1 天前
vllm 大模型启动缓存相关环境变量 export
linux·缓存·vllm
流星白龙2 天前
【Redis】6.计数其他命令
数据库·redis·缓存
正儿八经的少年2 天前
redis 的大 key 和热 key 详解
数据库·redis·缓存
X-⃢_⃢-X2 天前
十、Redis之布隆过滤器
数据库·redis·缓存
Database_Cool_2 天前
阿里云 Tair(企业级内存数据库)vs 腾讯云 Redis 云缓存深度对比:性能/数据结构/成本全维度 Benchmark
数据库·阿里云·缓存