项目缓存实战:热点 Key 击穿、缓存穿透与多级缓存架构

1. 引言

文章目录

    • [1. 引言](#1. 引言)
    • [2. 缓存优化](#2. 缓存优化)
      • [2.1 缓存击穿:逻辑过期方案](#2.1 缓存击穿:逻辑过期方案)
      • [2.2 缓存穿透:缓存空值方案](#2.2 缓存穿透:缓存空值方案)
      • [2.3 布隆过滤器:缓存穿透的进阶防线](#2.3 布隆过滤器:缓存穿透的进阶防线)
        • [2.3.1 布隆过滤器的原理](#2.3.1 布隆过滤器的原理)
        • [2.3.2 为什么存在误判?如何优化?](#2.3.2 为什么存在误判?如何优化?)
        • [2.3.3 为什么无法删除数据?如何解决?](#2.3.3 为什么无法删除数据?如何解决?)
    • [3. 数据一致性](#3. 数据一致性)
      • [3.1 更新数据库后删除缓存](#3.1 更新数据库后删除缓存)
      • [3.2 删除失败:消息队列补偿重试](#3.2 删除失败:消息队列补偿重试)
      • [3.3 TTL 兜底](#3.3 TTL 兜底)
    • [4. 多级缓存:Caffeine + Redis](#4. 多级缓存:Caffeine + Redis)
      • [4.1 为什么需要多级缓存](#4.1 为什么需要多级缓存)
      • [4.2 二级缓存架构](#4.2 二级缓存架构)
      • [4.3 代码实现](#4.3 代码实现)
      • [4.4 多级缓存注意事项](#4.4 多级缓存注意事项)
    • [5. 总结](#5. 总结)

在高并发场景下,缓存是提升系统性能、降低数据库压力的核心手段。然而,缓存的使用并非"一放了之",如果设计不当,反而会引入缓存击穿、缓存穿透、数据不一致等一系列问题。本文结合生产实践,围绕以下三个主题展开:

  • 缓存优化:使用逻辑过期方案防止 Redis 热点 Key 的缓存击穿问题;使用缓存空值方案解决 Redis Key 的缓存穿透问题。
  • 数据一致性:更新数据库后删除缓存,若删除失败则通过消息队列补偿重试,并以 TTL 兜底,共同保证数据一致性。
  • 多级缓存:使用 Caffeine 本地缓存和 Redis 缓存搭建二级缓存架构,提高热点数据访问速度,降低 Redis 压力。

2. 缓存优化

2.1 缓存击穿:逻辑过期方案

缓存击穿指的是某个热点 Key 在缓存过期的瞬间,大量并发请求同时打到数据库,导致数据库压力骤增甚至宕机。

常见的解决方案有互斥锁和逻辑过期。这里重点介绍逻辑过期方案。

逻辑过期的核心思想是:不设置真正的 TTL,而是将过期时间作为业务字段写入缓存值中。读取时判断逻辑时间是否过期,若过期则尝试获取互斥锁,由单个线程重建缓存,其他线程先返回旧数据。

java 复制代码
public class RedisCacheService {

    private static final String LOCK_PREFIX = "lock:";

    /**
     * 写入缓存(逻辑过期)
     */
    public void setWithLogicalExpire(String key, Object value, long expireSeconds) {
        // 封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(System.currentTimeMillis() + expireSeconds * 1000);
        stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(redisData));
    }

    /**
     * 读取缓存(逻辑过期 + 互斥锁重建)
     */
    public Object getWithLogicalExpire(String key, Class<?> type, IDomainService service) {
        String json = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(json)) {
            return null;
        }
        RedisData redisData = JSON.parseObject(json, RedisData.class);
        Object value = redisData.getData();
        long expireTime = redisData.getExpireTime();

        // 1. 未过期,直接返回
        if (expireTime > System.currentTimeMillis()) {
            return value;
        }

        // 2. 已过期,尝试获取互斥锁
        String lockKey = LOCK_PREFIX + key;
        boolean locked = tryLock(lockKey);
        if (locked) {
            try {
                // 重建缓存
                Object newValue = service.queryFromDatabase();
                setWithLogicalExpire(key, newValue, 30L);
                return newValue;
            } finally {
                unlock(lockKey);
            }
        }
        // 3. 未获取到锁,返回旧数据
        return value;
    }
}

优点:在缓存重建期间,其他线程仍能读到旧数据,不会直接打到数据库,也不会出现"缓存雪崩"式的空窗期。

2.2 缓存穿透:缓存空值方案

缓存穿透 指的是请求查询一个根本不存在的数据,缓存和数据库中都没有,导致每次请求都穿透到数据库。

解决方案之一是缓存空值:当数据库查询结果为空时,仍然将该 Key 写入缓存,但 Value 为空值,并设置较短的 TTL(如 2~5 分钟),防止恶意请求反复穿透。

java 复制代码
public Object queryWithCacheThrough(String key, Class<?> type, IDomainService service) {
    // 1. 查询缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isNotBlank(json)) {
        return JSON.parseObject(json, type);
    }

    // 2. 缓存未命中,查询数据库
    Object value = service.queryFromDatabase();

    // 3. 数据库为空,缓存空值
    if (value == null) {
        stringRedisTemplate.opsForValue().set(key, "", 5L, TimeUnit.MINUTES);
        return null;
    }

    // 4. 数据库有值,写入缓存
    stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(value), 30L, TimeUnit.MINUTES);
    return value;
}

注意 :缓存空值只能缓解穿透,无法完全杜绝。对于极端恶意攻击,还应配合布隆过滤器在缓存之前做一层拦截。

2.3 布隆过滤器:缓存穿透的进阶防线

缓存空值只能缓解穿透,对于极端恶意攻击,更推荐在缓存之前加一层布隆过滤器(Bloom Filter),把大量不存在的 Key 直接拦截在缓存层之外。

2.3.1 布隆过滤器的原理

布隆过滤器是一种空间效率极高的概率型数据结构,主要用于快速判断某个元素是否属于某个集合。它的底层由两个极简组件构成:

  • 二进制位数组(Bit Array):一个长度为 m 的位数组,初始化时所有位置均置为 0。
  • k 个相互独立的哈希函数:能将任意输入元素均匀地映射到位数组的不同下标位置。

工作流程如下:

  1. 添加元素:将元素依次输入 k 个哈希函数,得到 k 个数组下标,并将位数组中这些位置的 Bit 值从 0 置为 1。
  2. 查询元素 :同样使用这 k 个哈希函数计算目标元素的 k 个位置。只要这 k 个位置中有任何一个为 0,就 100% 确定该元素不在集合中;如果这 k 个位置全部为 1,则判定该元素"可能存在"于集合中。
java 复制代码
public class BloomFilterDemo {

    private final BitSet bits = new BitSet(1 << 20); // 位数组
    private final int k = 3; // 哈希函数个数

    // 模拟 k 个哈希函数
    private int hash(String value, int seed) {
        int h = value.hashCode() ^ (seed * 0x9e3779b9);
        return Math.abs(h) % bits.size();
    }

    // 添加元素
    public void add(String value) {
        for (int i = 0; i < k; i++) {
            bits.set(hash(value, i));
        }
    }

    // 查询元素:任一位置为 0 则一定不存在
    public boolean mightContain(String value) {
        for (int i = 0; i < k; i++) {
            if (!bits.get(hash(value, i))) {
                return false;
            }
        }
        return true;
    }
}
2.3.2 为什么存在误判?如何优化?

误判原因(假阳性):误判的根源在于哈希碰撞。由于位数组长度有限,不同元素经过哈希计算后可能映射到相同的 Bit 位置,导致一个从未录入的元素,其对应的 k 个位置碰巧都被其他元素置为 1,从而被误判为存在。

优化/解决方案:

  • 增加位数组长度(m):增大空间是最直接有效的方法,能显著降低哈希碰撞概率。
  • 优化哈希函数数量(k) :哈希函数并非越多越好,存在理论最优值 k ≈ 0.693 × (m / n)(n 为元素数量),此时误判率最低。
  • 使用高质量哈希函数:采用分布更均匀的哈希算法(如 MurmurHash3、xxHash),避免位聚集。
  • 业务层二次校验(回源查询):布隆过滤器仅保证"一定不存在"的确定性。当它返回"可能存在"时,再回源查询数据库或精确数据结构进行最终确认。
  • 动态扩展(Scalable Bloom Filter):当元素数量接近设计容量时,创建新的一层布隆过滤器进行扩容,以维持设定的误判率。
2.3.3 为什么无法删除数据?如何解决?

无法删除原因:布隆过滤器的底层是位映射结构,位数组中的每一个 Bit 并不是独占的,而是被多个元素共享。如果尝试删除某个元素,需要将其对应的 k 个 Bit 位清零,但这会不可避免地清除掉其他共享这些位置的元素的存在痕迹,导致后续查询其他元素时出现"假阴性"(把存在的元素误判为不存在)。

优化/解决方案:

  • 计数布隆过滤器(Counting Bloom Filter):将原本的 1 个 Bit 扩展为计数器(通常使用 4 bits)。插入时对应位置计数加 1,删除时计数减 1,查询时检查计数器是否均大于 0。代价是内存消耗会增加 3~4 倍。
  • 布谷鸟过滤器(Cuckoo Filter):基于布谷鸟哈希算法,使用"指纹存储"和"两个候选桶"机制。它不仅空间利用率更高,而且天然支持高效的删除操作。
  • 定期重建:在业务低谷期(如每天凌晨),基于最新的有效数据重新构建一个全新的布隆过滤器,以清除已删除元素带来的影响。
  • 结合其他数据结构:将布隆过滤器作为前置的"快速否定器",配合哈希表等精确数据结构使用。布隆过滤器负责过滤大量不存在的元素,精确结构负责处理实际的删除和最终判定。

3. 数据一致性

3.1 更新数据库后删除缓存

为了保证数据库与缓存的一致性,业界常用 Cache Aside(旁路缓存) 模式:先更新数据库,再删除缓存。这样即使删除缓存失败,也只会短暂出现脏数据,而不会长期不一致。

3.2 删除失败:消息队列补偿重试

如果删除缓存失败,直接忽略会导致缓存长期为脏数据。因此引入消息队列补偿重试机制:

  1. 更新数据库成功后,发送一条"删除缓存"的消息到 MQ;
  2. 消费者收到消息后尝试删除缓存;
  3. 若删除失败,则进行重试(可设置最大重试次数);
  4. 重试仍失败,则记录日志并告警,人工介入。
java 复制代码
public void updateData(Long id, Object newData) {
    // 1. 更新数据库
    databaseMapper.update(id, newData);

    // 2. 发送删除缓存消息(补偿机制)
    MQMessage message = new MQMessage();
    message.setKey("cache:user:" + id);
    mqProducer.send("cache.delete.topic", message);
}

为啥会删除失败

失败类型 典型原因 结果
网络/调用失败 应用调用 Redis 时网络抖动、超时、连接池耗尽 DEL 没执行或响应丢失
Redis 服务异常 Redis OOM、主从切换、集群故障、持久化阻塞 删除指令失败或未被正确传播
应用侧异常 更新 DB 后进程崩溃、Pod 重启、try-catch 吞掉异常 删除代码根本没执行
删除成功但数据仍残留 主从切换后删除未同步;并发读请求把旧值重新写回缓存 看起来删了,但缓存还是旧数据
Key 算错/漏删 同一数据有多个缓存 Key,只删了主键缓存,没删列表、聚合缓存 部分缓存仍是旧值

为啥先更新数据库

  1. 数据库是事实源,必须先落库
    缓存只是副本。先保证 MySQL 提交成功,再让缓存失效,下一次读请求会按统一读路径重新从数据库加载。
  2. 比"先删缓存再更新 DB"安全得多
    如果先删缓存,在 DB 还没更新完时,读请求会命中空缓存,从数据库读到旧值并回填。结果就是:DB 已更新,缓存却长期保留旧值,直到 TTL 过期。
  3. 删除比更新缓存更不容易出错
    更新缓存需要重新计算读模型,并发写时容易出现"谁后写谁赢"的覆盖问题。删除缓存只是让旧副本失效,幂等、简单、失败后重试也安全。
  4. 删除失败更容易兜底
    先更新 DB 后删缓存,如果删除失败,缓存里是旧值,但可以通过 MQ 重试、Canal 监听 Binlog、TTL 兜底恢复。

3.3 TTL 兜底

即便消息队列补偿也失败,缓存 Key 仍带有 TTL,到期后自动失效,最终会重新从数据库加载,从而保证最终一致性 。因此,TTL 是数据一致性的最后一道兜底防线。

4. 多级缓存:Caffeine + Redis

4.1 为什么需要多级缓存

Redis 虽然快,但每次访问仍有一次网络开销。对于超高并发的热点数据,可以让请求优先命中本地缓存(Caffeine),未命中再访问 Redis,最后才回源数据库。这样既能大幅降低 Redis 压力,又能显著提升响应速度。

4.2 二级缓存架构

#mermaid-svg-zMCSS8Zwh4TC7zk6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .error-icon{fill:#552222;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .marker.cross{stroke:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 p{margin:0;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster-label text{fill:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster-label span{color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster-label span p{background-color:transparent;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .label text,#mermaid-svg-zMCSS8Zwh4TC7zk6 span{fill:#333;color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node rect,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node circle,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node ellipse,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node polygon,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .rough-node .label text,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .label text,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .rough-node .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape .label{text-align:center;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node.clickable{cursor:pointer;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .arrowheadPath{fill:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster text{fill:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster span{color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape p,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape rect,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zMCSS8Zwh4TC7zk6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未命中
未命中
回填
回填
命中
命中
客户端请求
Caffeine 本地缓存
Redis 缓存
数据库
返回响应

4.3 代码实现

java 复制代码
@Component
public class MultiLevelCacheService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private final Cache<String, Object> caffeineCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();

    public Object query(String key, IDomainService service) {
        // 1. 查本地缓存
        Object local = caffeineCache.getIfPresent(key);
        if (local != null) {
            return local;
        }

        // 2. 查 Redis
        String json = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(json)) {
            Object value = JSON.parseObject(json, Object.class);
            caffeineCache.put(key, value); // 回填本地缓存
            return value;
        }

        // 3. 查数据库
        Object value = service.queryFromDatabase();
        if (value != null) {
            stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(value), 30L, TimeUnit.MINUTES);
            caffeineCache.put(key, value);
        }
        return value;
    }
}

4.4 多级缓存注意事项

  • 本地缓存一致性:Caffeine 是 JVM 级缓存,多实例部署时各节点数据可能不一致,需配合消息通知或短 TTL 控制。
  • 内存占用:本地缓存不宜过大,建议设置最大容量和过期时间。
  • 更新策略:更新数据时,需要同时删除本地缓存和 Redis 缓存,避免脏读。

5. 总结

本文围绕项目缓存的核心痛点,给出了三套可落地的实践方案:

问题 方案 关键点
缓存击穿 逻辑过期 + 互斥锁 过期期间返回旧数据,单线程重建
缓存穿透 缓存空值 空值短 TTL,配合布隆过滤器更佳
数据一致性 删缓存 + MQ 补偿 + TTL 兜底 最终一致性
高并发热点 Caffeine + Redis 二级缓存 降低 Redis 压力,提升响应速度

缓存设计没有银弹,需要结合业务场景、并发量、一致性要求综合权衡。希望本文的实践能为你提供参考。

相关推荐
Dawson Zhu1 小时前
《Agentic Design Patterns》第 20 章导读:优先级排序
人工智能·语言模型·架构·aigc·agi
用户094248568031 小时前
第37章:JVM极端场景调优与生产故障复盘方法论
java·jvm
知守观1 小时前
同事的 SNAPSHOT 更新了,我三天后才拉到:Maven 更新检查被 _remote.repositories 静默跳过
java·后端·maven
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 项目模块划分与依赖关系
java·开发语言·交友
努力进修1 小时前
时序数据长期承载难题:从压缩、批量写入到数据组织优化
java·开发语言·数据库
Dovis(誓平步青云)1 小时前
总用电下降了,为什么家庭能源看板仍然提示异常
java·开发语言·数据库·人工智能·算法·电脑·能源
程序猿乐锅1 小时前
【从0-1学习Python | 第四篇】Python 字符串和元组
java·开发语言·笔记·后端·python·学习
野生技术架构师1 小时前
Dubbo 还是 Spring Cloud?从架构本质到落地选型
spring cloud·架构·dubbo
开开心心就好1 小时前
Windows视频播放器推荐,精简版体积更小
java·服务器·开发语言·eclipse·pdf·ocr·音视频