亿级流量系统的多级缓存设计

0 阅读11分钟

亿级流量系统的多级缓存设计

写在前面:这篇文章不是理论堆砌,而是我过去五年在58同城做程序化广告、在阿里搞打车价格引擎时,被线上问题“打脸”后总结出来的血泪经验。如果你正在设计一个高并发系统,希望它能少走点弯路。


一、我们曾经以为 Redis 就够了

2021年,我在58同城负责程序化广告竞价服务。系统刚上线时,架构很简单:Nginx → Tomcat → Redis → MySQL。当时觉得:“Redis 响应 0.2ms,官方数据支持10万QPS,而我们的服务只有2万 QPS, 肯定没问题。”

结果大促第一天,凌晨1点,电话炸了。

监控显示:Redis 集群 CPU 打满,竞价接口 TP99 从 50ms 飙到 2s+。查日志发现,90% 的请求都在查同一个广告主 ID——头部客户在投爆款商品,成了绝对热点。

我们紧急扩容 Redis,但没用。因为一致性哈希把所有流量都打到了同一个节点上,加机器只是分摊了冷数据,热点还是单点过载。

那一刻我意识到:光靠 Redis,根本扛不住热点数据的流量洪峰

二、引入多级缓存架构

后来我们引入了本地缓存(L1) + Redis(L2) + DB(L3) 的三级架构。这不是为了“技术先进”,而是被逼出来的生存策略。

核心思想就一条:让 99% 的请求别出 JVM

flowchart LR
    A[客户端] --> B{L1: Caffeine<br/>微秒级}
    B -- 命中 --> C[直接返回]
    B -- Miss --> D{L2: Redis<br/>毫秒级}
    D -- 命中 --> E[回填 L1 + 返回]
    D -- Miss --> F{L3: MySQL<br/>百毫秒级}
    F --> G[回填 L2/L1 + 返回]

这套架构上线后,效果立竿见影:

  • Redis QPS 从 2万降到 3 千
  • 出价服务 TP99(99%请求的响应时间) 稳定在 30ms 以内
  • 即使 Redis 整个集群挂掉,系统仍能靠本地缓存兜底(虽然数据稍旧)

三、为什么选 Caffeine?

早期我们用的是 Guava Cache,但在高并发下频繁 Full GC。后来换成 Caffeine,原因很实在:

  1. 内存效率更高:它的 Window TinyLFU 算法对长尾分布(比如广告创意 ID)命中率比 LRU 高 15%+
  2. 支持异步刷新refreshAfterWrite 让热点数据在后台自动更新,避免集中失效
  3. 并发写入性能更好:Guava 的 LRU 在高并发写入时锁竞争严重,而 Caffeine 的 Window TinyLFU 是无锁设计。
  4. 和 Spring Boot 无缝集成@Cacheable 注解开箱即用

我们的核心配置长这样:

@Bean
public Cache<Long, AdCreative> adCreativeCache() {
    return Caffeine.newBuilder()
        .maximumSize(10_000)            // 只缓存 Top 1 万创意(覆盖 99% 流量)
        .expireAfterWrite(Duration.ofMinutes(10))
        .refreshAfterWrite(Duration.ofMinutes(5)) // 5分钟后后台刷新
        .recordStats()                  // 开启命中率统计
        .build();
}

注意maximumSize 不是越大越好。我们曾设成 10 万,结果 JVM 堆内存吃紧,GC 停顿反而拖慢了服务。缓存是手段,不是目的

Guava Cache与Caffeine详细对比如下:

特性Guava CacheCaffeine说明与解释
出身与关系Google Guava 项目的一部分独立的顶级缓存库,Guava Cache 的精神续作Caffeine 的 API 深受 Guava 影响,学习成本低。
性能核心:算法LRU (最近最少使用) 的变体W-TinyLFU (一种高级的 LFU 变体)这是最根本的区别。W-TinyLFU 能提供高得多的命中率,尤其是在大量、频繁变化的缓存工作负载下。
读性能良好极佳Caffeine 使用了 ConcurrentHashMap 的优化特性和更高效的无锁算法,并发读性能更强。
写性能良好(写入会触发回收操作)极佳(异步写入,使用环形缓冲区)Caffeine 将写入操作排队并异步处理,大幅减少了对用户线程的阻塞时间,写入吞吐量更高。
内存占用较高较低Caffeine 的数据结构更紧凑,并且 W-TinyLFU 中的“频率草图”结构比维护访问顺序队列更省内存。
过期策略支持基于大小、时间(访问后、写入后)支持所有 Guava 的策略,并新增了基于java.time的API功能上类似,但 Caffeine 的实现更高效。
缓存加载支持 CacheLoader支持 CacheLoader,并支持异步版本 AsyncCacheLoaderCaffeine 对异步原生的支持更好,可以与 CompletableFuture 无缝集成。
显式清除invalidate, invalidateAll相同两者都支持。
统计信息通过 recordStats() 开启通过 recordStats() 开启,统计信息更丰富都提供命中率、加载时间等统计。Caffeine 的统计开销更小。
异步接口不支持原生支持Caffeine 提供了 AsyncCache 接口,可以直接返回 CompletableFuture,非常适合异步编程模型。
刷新机制支持 refreshAfterWrite支持 refreshAfterWrite,且实现更优在 Guava 中,刷新会阻塞请求的线程;在 Caffeine 中,刷新是异步的,不会阻塞并发访问。
JDK 要求JDK 6+JDK 8+Caffeine 充分利用了 JDK 8 的新特性(如 lambda、CompletableFuture),这也是其性能卓越的原因之一。
维护状态维护模式积极维护和更新Guava Cache 处于仅修复关键 bug 的状态,新特性和发展重点都在 Caffeine 上。

四、Redis 层:防雪崩、防穿透、防击穿

光有本地缓存还不够。如果所有实例同时启动,或批量预热数据,Redis 会集体失效——这就是缓存雪崩

我们的对策很土但有效:

1、TTL 加随机偏移

// 基础TTL 1小时,再加 0~10 分钟随机值
long ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.setex(key, ttl, value);

2、布隆过滤器拦非法请求

广告系统常被刷无效 creativeId(比如 -1、99999999)。我们在 Redis 前加了一层布隆过滤器:

if (!bloomFilter.mightContain(id)) {
     // 直接拒绝,不查 Redis
    return null; 
}

3、空值也缓存

对 DB 查不到的数据,缓存一个 NULL(TTL=2分钟),防止恶意攻击打穿 DB。

五、一致性保障:先删缓存,还是先更新 DB?

这是个老问题,但我们踩过坑。

最初我们用“先更新缓存,再写 DB”。结果某次网络抖动,DB 写失败了,但缓存已更新——脏数据持续了10分钟,导致广告主多花了几十万。

现在我们严格遵守:

  1. 先更新 DB
  2. 再删除 Redis 缓存
  3. 发 MQ 消息通知其他实例清除本地缓存

为什么不直接更新本地缓存? 因为可能有多个字段变更,且本地缓存结构和 DB 不完全一致。删除比更新更安全

六、热点 Key:打散 + 本地兜底

即使有了多级缓存,极端热点仍是难题。比如打车早高峰,北京区域的预估价请求 QPS 超过 10 万。

我们的解法是 “热点分片 + 本地全量存储”

  • 将 Key 拆成 8 个分片:pricing:beijing:shard0 ~ shard7
  • 客户端随机读一个分片
  • 但本地缓存存储完整数据(不分片)

这样:

  • Redis 层流量被打散,单节点压力降低 8 倍
  • 即使某个 Redis 分片宕机,本地缓存仍能提供完整服务

七、监控与应急:没有观测,等于裸奔

再好的设计,没监控就是瞎子。我们盯死这几个指标:

指标告警阈值工具
L1 命中率< 90%Micrometer + Prometheus
L2 命中率< 95%Redis INFO
缓存加载延迟TP99 > 50ms应用日志

还准备了应急预案:

  • 一键降级开关:关闭 Redis,仅用本地缓存 + DB(牺牲一致性保可用)
  • 预热脚本:大促前自动加载 Top 1 万热点数据

八、多级缓存架构带来的新挑战

引入 Caffeine + Redis 的多级架构后,系统性能确实上去了,但新问题接踵而至。

1、 本地缓存导致集群数据不一致

场景: 在广告系统中,运营同学修改了一个广告创意的出价规则。服务A收到更新请求,删了Redis缓存,也清了自己的本地缓存。但服务B、C、D还在用旧的本地缓存——最终返回的价格不一致!

根因: Caffeine 是进程内缓存,天然不具备跨实例同步能力。即使你发了 MQ 消息通知清理,网络延迟或消息丢失仍会导致短暂不一致。

我们的解法

  1. 短 TTL + 主动刷新:对强一致性要求高的数据(如价格),TTL 设为 1~2 分钟,并开启 refreshAfterWrite,让数据在后台自动拉新。

  2. 版本号控制:在缓存 Value 中加入版本号。读取时若发现本地版本 < Redis 版本,则强制回源。

    // 缓存结构
    class CachedAd {
        long version;      // 数据版本
        AdCreative data;
    }
    
    // 读取时校验
    if (local.version < remote.version) {
        return loadFromDB(id); // 强制刷新
    }
    
  3. 接受最终一致:对非核心数据(如广告展示次数),容忍 5 分钟内的不一致。

教训不要试图用本地缓存实现强一致性。它的定位是“高性能兜底”,不是“权威数据源”。

2、本地缓存吃光 JVM 内存

场景: 在做打车业务初期,我们将所有城市定价规则全量加载到 Caffeine。结果某次节假日,新开了 50 个县级市,缓存条目暴增,JVM 堆内存从 4GB 飙到 7GB,频繁 Full GC,服务差点雪崩。

根因: 我们错误地认为“本地缓存越大越好”,忽略了内存成本与收益的边际递减

我们的解法

  1. 严格容量限制:根据业务热度分层缓存:

    • L1 只存 Top 1000 热点城市(覆盖 95% 请求)
    • 冷门城市走 Redis,不进本地缓存
  2. 动态淘汰策略:用 Caffeine 的 maximumWeight 而非 maximumSize,按数据大小加权淘汰。

    1.maximumWeight(100_000_000) // 总字节限制
    2.weigher((key, value) -> value.toString().getBytes().length)
    
  3. 压测验证:每次大促前进行压测,监控 GC 日志,确保 Young GC < 10ms。

3、问题排查变得极其困难

场景: 某天用户反馈“北京打车价格突然变贵”。我们查 Redis,数据正常;查 DB,也正常。最后发现是某个实例的本地缓存没清理干净。

根因: 多级缓存引入了状态分散。同一个 Key 在不同层级可能有不同值,且本地缓存无法被外部观测。

我们的解法

  1. 统一查询接口:提供 /debug/cache/{key} 接口,返回 L1/L2/L3 的当前值及时间戳。通过对比三层数据,能一目了然找到问题。

    {
      "l1": { "value": "...", "expireAt": "2026-02-28T15:00" },
      "l2": { "value": "...", "ttl": 1800 },
      "l3": { "value": "...", "updatedAt": "2026-02-28T14:50" }
    }
    
  2. 埋点追踪:在日志中记录命中层级:

    [INFO] Get pricing for BJ, hit=L1, latency=12μs
    [WARN] Get pricing for XZ, hit=L3, latency=120ms
    
  3. 禁止直接操作缓存:所有读写必须走封装好的 Service 方法,杜绝“绕过缓存逻辑”的野调用。

4、运维复杂度指数级上升

场景: 服务重启后,所有本地缓存清空。瞬间大量请求穿透到 Redis 和 DB,形成“缓存击穿”风暴。

根因: 本地缓存是易失性存储,生命周期与进程绑定。滚动发布、扩缩容都会导致缓存集体失效。

我们的解法

  1. 预热机制:服务启动时,异步加载 Top N 热点数据。

    @Component
    public class CachePreloader implements CommandLineRunner {
        @Override
        public void run(String... args) {
            executor.submit(() -> preloadTopCities());
        }
    }
    
  2. 渐进式发布:K8s 滚动更新时,设置 maxUnavailable=1,确保总有实例带着热缓存在线。

  3. 降级预案:当 DB 压力过大时,临时关闭 L3 查询,返回 L2 的旧数据(即使过期)。

九、多级缓存:优点、缺点与适用边界

经过多年实战,我对多级缓存有了更清醒的认识。它不是万能药,而是一把双刃剑。

✅ 核心优势

优势说明
极致性能L1 命中延迟 < 100μs,比 Redis 快 100 倍
抗 Redis 故障即使 Redis 集群宕机,系统仍可降级运行
降低带宽成本减少 90%+ 的跨机房/跨网络调用

❌ 核心代价

代价说明
数据最终一致集群内各实例缓存内容可能短暂不一致
内存开销每个实例需预留 10%~20% 堆内存给缓存
调试复杂度问题定位需同时检查 L1/L2/L3 三层状态
运维负担需设计预热、降级、监控等配套机制

🎯 适用场景(建议使用)

  • 读多写少(如商品详情、广告创意、定价规则)
  • 热点集中(20% 的 Key 承载 80% 流量)
  • 可容忍短暂不一致(如展示类数据)

🚫 不适用场景(慎用!)

  • 强一致性要求(如账户余额、订单状态)
  • 数据极度稀疏(每个 Key 只被访问一次)
  • 内存极度受限(如 Serverless 场景)

结语:缓存不是银弹,稳定才是

没有放之四海皆准的缓存方案,只有不断适配业务的工程权衡

多级缓存的本质,是在性能、一致性、成本、复杂度之间找平衡点。它不能消除所有问题,但能让你在流量洪峰来临时,睡个安稳觉

如果你也在设计高并发系统,不妨问问自己:

  • 我的热点数据是什么?
  • 如果 Redis 挂了,服务还能跑吗?
  • 缓存不一致时,业务能容忍吗?

答案,往往藏在你的真实场景里。

本文所有方案已在生产环境验证,支撑日均 10 亿+ 广告请求、千万级动态定价调用。 欢迎评论区交流你的缓存实战故事!