互联网三高-高性能之多级缓存架构

缓存是性能优化的第一手段。但只用一层 Redis 就够了吗?当 QPS 从 1000 涨到 10 万,单层缓存的网络开销、热 Key 问题、缓存雪崩风险都会暴露出来。本文从"为什么需要多级缓存"讲起,到本地缓存 + 分布式缓存 + CDN 的三级架构落地,再到缓存一致性方案,帮你建立完整的缓存知识体系。

一、为什么单层 Redis 不够?

大部分项目的缓存方案是这样的:

请求 → 查 Redis → 命中返回 / 未命中查数据库 → 回填 Redis

这个方案在 QPS 几千的时候完全没问题。但当流量上来之后,几个问题会暴露:

以下是整理后的表格格式:

问题 场景 后果
网络开销 每次请求都要走一次网络 RTT(通常 0.5-2ms) QPS 10 万时,Redis 网络 I/O 成为瓶颈
热 Key 某个商品被疯狂访问,所有请求打到同一个 Redis 节点 单节点 CPU 打满,甚至宕机
雪崩风险 Redis 集群故障或大面积 key 过期 数据库被打崩,服务雪崩
序列化开销 每次从 Redis 取出来都要反序列化 CPU 消耗叠加,P99 延迟上升

多级缓存的核心思想:越靠近用户的缓存越快、命中率越高,越靠后的缓存容量越大、数据越全。

二、多级缓存架构全景

subunit 复制代码
┌─────────────┐
│   客户端    │
└──────┬──────┘
       │
┌──────▼───────┐
│   CDN        │  • 静态资源 + 接口缓存
│              │  • 延迟 < 5ms
│ 命中率60-80% │
└──────┬───────┘
       │ 未命中
┌──────▼───────┐
│ 本地缓存     │  • Caffeine/Guava
│              │  • 延迟 < 0.1ms
│ 命中率30-50% │  • 容量:百MB级
└──────┬───────┘
       │ 未命中
┌──────▼───────┐
│ 分布式缓存   │  • Redis Cluster
│              │  • 延迟 0.5-2ms
│ 命中率95%+   │  • 容量:百GB级
└──────┬───────┘
       │ 未命中
┌──────▼───────┐
│  数据库      │  • MySQL/PostgreSQL
│              │  • 延迟 5-50ms
└──────────────┘
 

三级命中后的综合效果 :假设总 QPS 10 万,CDN 挡住 70%,本地缓存再挡住剩余的 40%,Redis 再挡住 95%------最终打到数据库的请求只有 ++10 万 × 30% × 60% × 5% = 900 QPS++,数据库毫无压力。

三、第一级:本地缓存(Caffeine)

为什么把本地缓存放在第一个讲?因为 CDN 不是所有场景都有,但本地缓存几乎每个 Java 项目都用得上。

3.1 为什么选 Caffeine?

以下是整理后的表格格式:

对比项 Guava Cache Caffeine HashMap
淘汰算法 LRU W-TinyLFU
性能 极高(Guava 的 2-3 倍) 最高但无淘汰
过期策略 支持 支持 不支持
统计 支持 支持 不支持
Spring 集成 支持 默认 CacheManager 手动

Caffeine 是目前 Java 生态最强的本地缓存库。Spring Boot 默认的 `CacheManager` 实现就是 Caffeine。

3.2 W-TinyLFU:为什么 Caffeine 这么快

传统 LRU 的问题:**偶发访问会挤掉高频数据**。比如一次全表扫描会把热点数据全部挤出缓存。

Caffeine 用的 W-TinyLFU(Window TinyLFU)算法,本质是"频率 + 新鲜度"双维度淘汰:

面试这样答:Caffeine 用 W-TinyLFU 算法,结合了 LRU 和 LFU 的优点。新数据先进入 Window 区(LRU),要晋升到 Main 区需要和候选淘汰项比频率,频率高的才能留下。这样既能快速接纳新数据,又不会让低频的突发访问挤掉高频热点。

3.3 Caffeine 实战代码

(1)基础用法:

java 复制代码
// 1. 手动创建
Cache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)                // 最大条目数
    .expireAfterWrite(Duration.ofMinutes(5))  // 写入后 5 分钟过期
    .expireAfterAccess(Duration.ofMinutes(1)) // 1 分钟没访问就过期
    .recordStats()                      // 开启统计(命中率等)
    .build();

// 写入
cache.put("user:1001", user);

// 读取(手动模式)
User user = cache.getIfPresent("user:1001");

// 读取(自动加载模式,缓存未命中时自动调用 loader)
LoadingCache<String, User> loadingCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .build(key -> userMapper.selectById(extractId(key)));  // loader

User user = loadingCache.get("user:1001");  // 未命中时自动查库

(2)Spring Boot 集成:

java 复制代码
# application.yml
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=300s
java 复制代码
@Service
public class UserService {

    @Cacheable(value = "users", key = "#id")
    public User getUser(Long id) {
        // 缓存未命中时才执行
        return userMapper.selectById(id);
    }

    @CacheEvict(value = "users", key = "#user.id")
    public void updateUser(User user) {
        userMapper.updateById(user);
    }
}

3.4 本地缓存的坑

问题表格整理

问题 原因 解决方案
数据不一致 多个实例各自缓存,A 实例更新了 B 实例不知道 使用 Redis Pub/Sub 通知其他节点清除本地缓存
内存溢出 缓存对象太大或太多 设置 maximumSize/maximumWeight,避免将大对象存入本地缓存
GC 压力 大量缓存对象增加 GC 负担 控制本地缓存总大小在堆内存的 10-20% 以内
冷启动 重启后本地缓存为空,流量全打到 Redis 启动时预热:主动加载热点数据

四、第二级:分布式缓存(Redis)

Redis 的基础就不重复了(前面的文章已经讲过),这里聚焦在多级缓存架构中 Redis 的定位和优化

4.1 Redis 在多级缓存中的角色

本地缓存未命中 → 查 Redis → 命中则回填本地缓存 → 未命中则查数据库 → 回填 Redis + 本地缓存

Redis 是**数据一致性的锚点**。多个应用实例的本地缓存可能各不相同,但 Redis 里的数据是统一的。

4.2 热 Key 问题与解决方案

什么是热 Key?

某个 key 被大量请求集中访问,所有请求打到 Redis 集群的同一个节点,单节点扛不住。

典型场景:秒杀商品详情、热搜榜单、明星八卦文章。

如何发现热 Key?

方法一:Redis 4.0+ 内置命令(线上慎用,会遍历所有 key)

redis-cli --hotkeys

方法二:monitor + 统计(采样分析)

redis-cli monitor | head -n 10000 > monitor.log

方法三:客户端统计(推荐)

在 RedisTemplate 中包装一层,统计每个 key 的访问次数

超过阈值的自动标记为热 Key

解决方案

热 Key 解决思路

├── 方案一:本地缓存兜底(最推荐)

│ 热 Key 自动提升到本地缓存,不再走网络

├── 方案二:Key 分散

│ 将 hot_product:1001 拆成 hot_product:1001:0 ~ :9

│ 读取时随机选一个副本,写入时更新所有副本

│ 分散到不同 Redis 节点

└── 方案三:读写分离

热 Key 走从节点读,分散读压力

==> 方案一代码实现(本地缓存 + Redis 二级联动):

java 复制代码
@Service
public class MultiLevelCacheService {

    private final LoadingCache<String, String> localCache;
    private final StringRedisTemplate redis;
    private final JdbcTemplate jdbc;

    public MultiLevelCacheService(StringRedisTemplate redis, JdbcTemplate jdbc) {
        this.redis = redis;
        this.jdbc = jdbc;
        this.localCache = Caffeine.newBuilder()
            .maximumSize(5000)
            .expireAfterWrite(Duration.ofSeconds(30))  // 本地缓存过期短一点
            .build(this::loadFromRedis);               // 未命中走 Redis
    }

    /**
     * 多级缓存读取:本地缓存 → Redis → 数据库
     */
    public String get(String key) {
        return localCache.get(key);
    }

    private String loadFromRedis(String key) {
        // 第二级:查 Redis
        String value = redis.opsForValue().get(key);
        if (value != null) {
            return value;
        }
        // 第三级:查数据库
        value = loadFromDB(key);
        if (value != null) {
            // 回填 Redis,过期时间加随机偏移防雪崩
            int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
            redis.opsForValue().set(key, value, Duration.ofSeconds(ttl));
        }
        return value;
    }

    private String loadFromDB(String key) {
        // 实际查数据库逻辑
        return jdbc.queryForObject(
            "SELECT data FROM product WHERE cache_key = ?",
            String.class, key
        );
    }
}

4.3 大 Key 问题

什么是大 Key?

  • String 类型 value > 10KB

  • Hash/List/Set/ZSet 元素数量 > 5000

大 Key 的危害

1). 网络传输慢,阻塞其他请求

2). 删除大 Key 会导致 Redis 主线程阻塞(DEL 一个几百 MB 的 key 可能卡几秒)

3). 迁移和持久化变慢

解决方案

java 复制代码
// 拆分大 Key:将一个大 Hash 按维度拆成多个小 Hash
// 之前:user:1001 → {name, age, address, orders, cart, history, ...}
// 之后:
//   user:1001:basic   → {name, age, address}
//   user:1001:orders  → {order1, order2, ...}
//   user:1001:cart    → {item1, item2, ...}

// 删除大 Key:用 UNLINK 代替 DEL(异步删除,不阻塞主线程)
redis.unlink("big_key");  // Redis 4.0+

五、第三级:CDN 缓存

5.1 CDN 能缓存什么?

不只是静态资源。动态接口也可以用 CDN 缓存:

缓存类型与应用场景对比表

缓存类型 示例 缓存时间 适用场景
静态资源 JS/CSS/图片/字体 长期(配合文件指纹) 所有前端项目
页面 HTML 首页、活动页 分钟级 内容更新不频繁的页面
API 响应 商品列表、热搜榜 秒级~分钟级 高 QPS + 容忍短暂延迟的接口

说明

  • 静态资源 :通常采用文件哈希(如 main.a1b2c3.js)确保内容变更后缓存失效,适合长期缓存以提升加载性能。
  • 页面 HTML:适用于内容更新频率较低的页面,短时间缓存可降低服务器负载。
  • API 响应:针对高并发但允许短暂数据延迟的场景,如榜单类数据,缓存时间较短以保证数据时效性。

5.2 CDN 缓存控制

通过 HTTP 响应头控制 CDN 和浏览器的缓存行为:

(1)Cache-Control 常用指令

复制代码
┌───────────────────────────────────────────────────────┐
│ public      CDN 和浏览器都可以缓存                     │
│ private     只有浏览器可以缓存,CDN 不缓存             │
│ max-age=300  缓存 300 秒                              │
│ s-maxage=60  CDN 缓存 60 秒(覆盖 max-age)            │
│ no-cache     每次都要回源验证(不是不缓存!)           │
│ no-store     真正的不缓存                             │
└───────────────────────────────────────────────────────┘

(2)实际配置示例(Nginx)

静态资源:长缓存 + 文件指纹

nginx 复制代码
location /assets/ {
  add_header Cache-Control "public, max-age=31536000";
}

API 接口:CDN 缓存 10 秒,浏览器不缓存

nginx 复制代码
location /api/hot-list {
  add_header Cache-Control "public, s-maxage=10, max-age=0";
}

用户私有数据:不走 CDN

nginx 复制代码
location /api/user/ {
  add_header Cache-Control "private, no-store";
}

六、缓存一致性:最难的部分

多级缓存最大的挑战不是性能,而是数据一致性。数据在数据库、Redis、本地缓存、CDN 四个地方都有副本,更新时怎么保持一致?

6.1 经典方案对比

方案 做法 一致性 性能 复杂度
Cache Aside 先更新 DB,再删缓存 最终一致 低(推荐)
Read/Write Through 缓存层代理所有读写 强一致
Write Behind 只写缓存,异步刷 DB 弱一致 最高
双写 先更新 DB,再更新缓存 问题多 中(不推荐)

关键说明

  • Cache Aside 是业界最常用的模式,通过删除缓存而非更新缓存避免并发写导致的脏数据问题,适合大多数读多写少场景。

  • Read/Write Through 将缓存作为主要数据源,由缓存系统自行维护数据库同步,适合需要强一致性的业务场景,但会损失部分性能。

  • Write Behind 通过异步批量写入数据库获得最高吞吐量,但存在数据丢失风险,适合允许数据临时不一致的高吞吐场景(如点赞计数)。

  • 双写模式因并发时易出现缓存与数据库不一致问题,通常不建议采用。

6.2 Cache Aside 详解(最常用)

读流程:

查缓存 → 命中返回 → 未命中查 DB → 回填缓存

写流程:

更新 DB → 删除缓存(不是更新缓存!)

(1)为什么是"删除"缓存而不是"更新"缓存?

因为"更新"在并发下有问题:

线程 A 更新 DB → price = 100

线程 B 更新 DB → price = 200

线程 B 更新缓存 → 缓存 = 200

线程 A 更新缓存 → 缓存 = 100 ← 脏数据!DB 是 200 但缓存是 100

删除缓存就没这个问题:不管谁先删,下次读取都会从 DB 重新加载最新数据。

(2)为什么是先更新 DB 再删缓存,而不是反过来?

如果先删缓存再更新 DB:

线程 A 删除缓存

线程 B 读缓存(未命中)→ 读 DB(旧值)→ 回填缓存(旧值)

线程 A 更新 DB(新值)

结果:DB 是新值,缓存是旧值 → 不一致

先更新 DB 再删缓存,这个问题出现的概率极低(需要"读请求在 DB 更新之前读到旧值,且在缓存删除之后才回填",条件非常苛刻)。

6.3 延迟双删

即使用了 Cache Aside,极端并发下仍有小概率不一致。延迟双删进一步降低风险:

java 复制代码
public void updateProduct(Product product) {
    // 第一次删除缓存
    redis.delete("product:" + product.getId());

    // 更新数据库
    productMapper.updateById(product);

    // 延迟第二次删除(等读请求的旧值回填完成后再删一次)
    executor.schedule(() -> {
        redis.delete("product:" + product.getId());
    }, 500, TimeUnit.MILLISECONDS);  // 延迟 500ms
}

延迟时间怎么定?大于"一次读请求从 DB 加载并回填缓存"的时间即可,通常 200ms-1s。

6.4 多级缓存一致性方案

当有本地缓存时,问题更复杂:Redis 删了,但各个应用实例的本地缓存还是旧的。

方案:Redis Pub/Sub 广播失效通知

java 复制代码
// ========= 缓存更新时发布通知 =========
public void updateProduct(Product product) {
    productMapper.updateById(product);
    redis.delete("product:" + product.getId());

    // 广播通知所有实例清除本地缓存
    redis.convertAndSend("cache:invalidate",
        "product:" + product.getId());
}

// ========= 所有实例监听通知 =========
@Component
public class CacheInvalidateListener implements MessageListener {

    @Autowired
    private Cache<String, Object> localCache;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = new String(message.getBody());
        localCache.invalidate(key);  // 清除本地缓存
        log.info("本地缓存已清除: {}", key);
    }
}

// ========= 配置 Redis 监听 =========
@Configuration
public class RedisListenerConfig {

    @Bean
    public RedisMessageListenerContainer container(
            RedisConnectionFactory factory,
            CacheInvalidateListener listener) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);
        container.addMessageListener(listener,
            new ChannelTopic("cache:invalidate"));
        return container;
    }
}

完整的多级缓存更新流程

数据更新

├── 1. 更新数据库

├── 2. 删除 Redis 缓存

├── 3. 通过 Redis Pub/Sub 广播失效消息

├── 4. 各实例收到消息,清除本地缓存

└── 5.(可选)延迟再删一次 Redis

七、缓存预热与降级

7.1 缓存预热

应用启动时缓存是空的,如果流量一上来全部穿透到数据库,就是冷启动问题

java 复制代码
@Component
public class CacheWarmUp implements ApplicationRunner {

    @Autowired
    private StringRedisTemplate redis;
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private Cache<String, String> localCache;

    @Override
    public void run(ApplicationArguments args) {
        log.info("开始缓存预热...");

        // 预热热门商品 Top 1000
        List<Product> hotProducts = productMapper.selectTopByViewCount(1000);
        for (Product p : hotProducts) {
            String key = "product:" + p.getId();
            String value = JSON.toJSONString(p);

            // 同时预热 Redis 和本地缓存
            redis.opsForValue().set(key, value, Duration.ofHours(1));
            localCache.put(key, value);
        }

        log.info("缓存预热完成,共预热 {} 条", hotProducts.size());
    }
}

7.2 缓存降级

当 Redis 不可用时,不能让服务直接挂掉:

java 复制代码
public String getWithFallback(String key) {
    // 第一级:本地缓存
    String value = localCache.getIfPresent(key);
    if (value != null) return value;

    // 第二级:Redis(带降级)
    try {
        value = redis.opsForValue().get(key);
        if (value != null) {
            localCache.put(key, value);
            return value;
        }
    } catch (Exception e) {
        // Redis 不可用,降级:直查数据库,结果只放本地缓存
        log.warn("Redis 不可用,降级到数据库直查: {}", e.getMessage());
    }

    // 第三级:数据库
    value = loadFromDB(key);
    if (value != null) {
        localCache.put(key, value);
        // Redis 可用时尝试回填
        trySetRedis(key, value);
    }
    return value;
}

八、生产环境参数配置参考

java 复制代码
# ============ 本地缓存(Caffeine) ============
# 热点数据:容量小、过期快
caffeine.hot:
  maximum-size: 5000
  expire-after-write: 30s    # 30 秒,容忍极短延迟

# 基础数据:容量大、过期慢
caffeine.base:
  maximum-size: 50000
  expire-after-write: 300s   # 5 分钟

# ============ Redis ============
spring.data.redis:
  cluster:
    nodes: redis-1:6379,redis-2:6379,redis-3:6379
  lettuce:
    pool:
      max-active: 50          # 最大连接数
      max-idle: 20
      min-idle: 5
      max-wait: 2000ms        # 获取连接最大等待
    cluster:
      refresh:
        period: 30s            # 集群拓扑刷新
        adaptive: true

# Redis key 过期时间规范
# 热点数据:60-300s(+ 随机偏移)
# 普通数据:1800-3600s(+ 随机偏移)
# 基础配置:86400s(1天)

九、面试高频问题

Q1:说说你们项目中的缓存架构?

> 我们用的是三级缓存架构。第一级是 Caffeine 本地缓存,存热点数据,过期时间 30 秒,命中后零网络开销。第二级是 Redis Cluster,做分布式缓存,过期时间几分钟到几小时。第三级是 CDN,缓存静态资源和部分接口响应。数据更新时采用 Cache Aside 模式,先更新数据库再删 Redis,同时通过 Redis Pub/Sub 广播失效消息清除各实例的本地缓存。

Q2:Cache Aside 为什么是先更新数据库再删缓存?

> 如果先删缓存再更新数据库,并发场景下读请求可能把旧值重新写回缓存,导致持续不一致。先更新数据库再删缓存虽然理论上也有不一致窗口,但需要的条件极其苛刻:读请求必须恰好在数据库更新之前读到旧值、且在缓存删除之后才完成回填。这个概率远低于前者。如果要进一步降低风险,可以加延迟双删。

Q3:本地缓存和 Redis 的数据不一致怎么办?

> 我们用 Redis Pub/Sub 做缓存失效广播。数据更新时,除了删 Redis 缓存,还会向一个固定 channel 发送失效消息,所有应用实例监听这个 channel,收到消息后清除对应的本地缓存。同时本地缓存的过期时间设得比较短(30 秒),即使广播丢失,最多 30 秒后也会自动过期重新加载。

Q4:热 Key 怎么处理?

> 首先通过客户端统计或 Redis monitor 采样发现热 Key。处理方案有三种:一是热 Key 自动提升到本地缓存,直接在进程内命中,不走网络;二是 Key 分散,把 `hot:1001` 拆成 `hot:1001:0` 到 `hot:1001:9`,读取时随机选一个,分散到不同 Redis 节点;三是读写分离,热 Key 走从节点读。我们一般优先用第一种,因为本地缓存 + Pub/Sub 失效通知的方案已经能覆盖大部分热 Key 场景。

Q5:缓存穿透、击穿、雪崩在多级缓存架构下怎么防?

> 多级缓存本身就大幅降低了这三个问题的风险。穿透方面,在 Redis 前加布隆过滤器,同时缓存空值。击穿方面,热 Key 已经在本地缓存了,即使 Redis 过期也有本地兜底。雪崩方面,过期时间加随机偏移防集中过期,本地缓存作为第二道防线,即使 Redis 全挂了本地缓存还能顶一阵。加上缓存降级逻辑,可以做到 Redis 故障对用户几乎无感知。

十、总结

多级缓存核心架构要点

(1)三级缓存体系架构

本地缓存(Caffeine) + Redis 分布式缓存 + CDN 边缘缓存构成三级防御体系,逐层拦截请求。

(2)缓存选型策略

本地缓存采用 Caffeine,基于 W-TinyLFU 淘汰算法,兼顾高频访问与空间利用率。Redis 作为分布式缓存层,支持高可用与数据持久化。

(3)数据一致性方案

Cache Aside 模式为主,结合延迟双删(写入后异步二次删除)确保最终一致性。通过 Redis Pub/Sub 实现节点间缓存失效广播。

(4)热 Key 处理机制

本地缓存主动加载热 Key 减轻 Redis 压力,采用 Key 哈希分散策略避免单点过热。动态识别热点数据并优先缓存。

(5)数据预热流程

系统启动时异步加载 Top N 高频访问数据至本地与 Redis 缓存,结合历史访问日志预测热点。

(6)降级容错设计

Redis 故障时自动降级至本地缓存与数据库查询,本地缓存设置短 TTL(如 30 秒)避免脏数据长期留存。

(7)过期时间优化

基础过期时间叠加随机偏移(如 ±10%)防止雪崩。本地缓存设为秒级(30s),Redis 设为分钟级(5-30min),形成阶梯过期。

缓存不是万能药,但在高性能架构中一定是第一个要考虑的手段。关键是分层设计、控制一致性、做好降级,让每一级缓存各司其职。

光看不练假把式。缓存架构、Redis 集群、一致性方案这些话题,面试官一定会追着问细节。推荐用 **面霸**(AI 模拟面试平台)实战模拟几轮,AI 面试官会根据你的回答深度追问"Cache Aside 并发下会不会不一致""热 Key 你们怎么发现的"这类问题,练到能自然表达出来才算真会。

体验地址:http://106.12.14.47:8090/

懒得注册?直接用测试账号体验:

手机号:`18088889999`

密码:`test123#$qaz`

*觉得有帮助的话,点赞收藏不迷路。有问题评论区见。*

相关推荐
222you2 小时前
线程池的三个方法,七个参数,四个拒绝策略
java·开发语言
m0_716765232 小时前
C++提高编程--仿函数、常用遍历算法(for_each、transform)详解
java·开发语言·c++·经验分享·算法·青少年编程·visual studio
Java源码jdk2 小时前
基于javaweb和mysql的springboot校园二手书交易管理系统(java+springboot+vue+elementui+layui+mysql)
java·spring boot·mysql
无名-CODING2 小时前
Docker 部署 Redis:小白一键安装、配置与迁移教程
数据库·redis·bootstrap
毕设源码-邱学长2 小时前
【开题答辩全过程】以 校园博客系统 为例,包含答辩的问题和答案
java
毕设源码-赖学姐2 小时前
【开题答辩全过程】以 小学体育用品管理系统为例,包含答辩的问题和答案
java
SimonKing2 小时前
GitHub热榜1k星影视壳(OuonnkiTV)遇上AI影视源
java·后端·程序员
创梦流浪人2 小时前
Soli Admin:面向复杂业务场景的模块化企业后台基础平台
java·erp·后台管理系统
程序员侠客行2 小时前
Tomcat 网络I/O模型浅析
java·tomcat·web