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

作为程序员------高阶牛马:你眼里的缓存不应该只是一个存 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 删比更要紧,异步兜底最靠谱
相关推荐
snow@li2 小时前
数据库-Redis:常用语法 / Redis 核心知识技能梳理
数据库·redis·缓存
fuquxiaoguang3 小时前
金蝶天燕AMDC:当企业级缓存遇见Redis 8.2,国产中间件的“性能+易用”双飞跃
redis·缓存·中间件
AI木马人12 小时前
9.【AI任务队列实战】如何在高并发下保证系统不崩?(Redis + Celery完整方案)
数据库·人工智能·redis·神经网络·缓存
CyrusCJA21 小时前
在Windows系统上将Redis注册为系统服务使其实现开机自启
数据库·windows·redis·缓存
逆境不可逃1 天前
一篇速通Redis 从原理到Java实战(含缓存问题解决方案+集群配置)
数据库·redis·缓存
studytosky1 天前
【高并发内存池】线程缓存核心原理与实现
linux·服务器·git·缓存
Lanren的编程日记1 天前
Flutter 鸿蒙应用内存管理优化实战:对象池+智能缓存+泄漏检测,全方位提升应用稳定性
flutter·缓存·华为·harmonyos
耳边轻语9991 天前
Hermes 如何省token-配置
人工智能·缓存