亿级流量系统的多级缓存设计
写在前面:这篇文章不是理论堆砌,而是我过去五年在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,原因很实在:
- 内存效率更高:它的 Window TinyLFU 算法对长尾分布(比如广告创意 ID)命中率比 LRU 高 15%+
- 支持异步刷新:
refreshAfterWrite让热点数据在后台自动更新,避免集中失效 - 并发写入性能更好:Guava 的 LRU 在高并发写入时锁竞争严重,而 Caffeine 的 Window TinyLFU 是无锁设计。
- 和 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 Cache | Caffeine | 说明与解释 |
|---|---|---|---|
| 出身与关系 | 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,并支持异步版本 AsyncCacheLoader | Caffeine 对异步原生的支持更好,可以与 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分钟,导致广告主多花了几十万。
现在我们严格遵守:
- 先更新 DB
- 再删除 Redis 缓存
- 发 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 消息通知清理,网络延迟或消息丢失仍会导致短暂不一致。
我们的解法:
-
短 TTL + 主动刷新:对强一致性要求高的数据(如价格),TTL 设为 1~2 分钟,并开启
refreshAfterWrite,让数据在后台自动拉新。 -
版本号控制:在缓存 Value 中加入版本号。读取时若发现本地版本 < Redis 版本,则强制回源。
// 缓存结构 class CachedAd { long version; // 数据版本 AdCreative data; } // 读取时校验 if (local.version < remote.version) { return loadFromDB(id); // 强制刷新 } -
接受最终一致:对非核心数据(如广告展示次数),容忍 5 分钟内的不一致。
教训:不要试图用本地缓存实现强一致性。它的定位是“高性能兜底”,不是“权威数据源”。
2、本地缓存吃光 JVM 内存
场景: 在做打车业务初期,我们将所有城市定价规则全量加载到 Caffeine。结果某次节假日,新开了 50 个县级市,缓存条目暴增,JVM 堆内存从 4GB 飙到 7GB,频繁 Full GC,服务差点雪崩。
根因: 我们错误地认为“本地缓存越大越好”,忽略了内存成本与收益的边际递减。
我们的解法:
-
严格容量限制:根据业务热度分层缓存:
- L1 只存 Top 1000 热点城市(覆盖 95% 请求)
- 冷门城市走 Redis,不进本地缓存
-
动态淘汰策略:用 Caffeine 的
maximumWeight而非maximumSize,按数据大小加权淘汰。1.maximumWeight(100_000_000) // 总字节限制 2.weigher((key, value) -> value.toString().getBytes().length) -
压测验证:每次大促前进行压测,监控 GC 日志,确保 Young GC < 10ms。
3、问题排查变得极其困难
场景: 某天用户反馈“北京打车价格突然变贵”。我们查 Redis,数据正常;查 DB,也正常。最后发现是某个实例的本地缓存没清理干净。
根因: 多级缓存引入了状态分散。同一个 Key 在不同层级可能有不同值,且本地缓存无法被外部观测。
我们的解法:
-
统一查询接口:提供
/debug/cache/{key}接口,返回 L1/L2/L3 的当前值及时间戳。通过对比三层数据,能一目了然找到问题。{ "l1": { "value": "...", "expireAt": "2026-02-28T15:00" }, "l2": { "value": "...", "ttl": 1800 }, "l3": { "value": "...", "updatedAt": "2026-02-28T14:50" } } -
埋点追踪:在日志中记录命中层级:
[INFO] Get pricing for BJ, hit=L1, latency=12μs [WARN] Get pricing for XZ, hit=L3, latency=120ms -
禁止直接操作缓存:所有读写必须走封装好的 Service 方法,杜绝“绕过缓存逻辑”的野调用。
4、运维复杂度指数级上升
场景: 服务重启后,所有本地缓存清空。瞬间大量请求穿透到 Redis 和 DB,形成“缓存击穿”风暴。
根因: 本地缓存是易失性存储,生命周期与进程绑定。滚动发布、扩缩容都会导致缓存集体失效。
我们的解法:
-
预热机制:服务启动时,异步加载 Top N 热点数据。
@Component public class CachePreloader implements CommandLineRunner { @Override public void run(String... args) { executor.submit(() -> preloadTopCities()); } } -
渐进式发布:K8s 滚动更新时,设置
maxUnavailable=1,确保总有实例带着热缓存在线。 -
降级预案:当 DB 压力过大时,临时关闭 L3 查询,返回 L2 的旧数据(即使过期)。
九、多级缓存:优点、缺点与适用边界
经过多年实战,我对多级缓存有了更清醒的认识。它不是万能药,而是一把双刃剑。
✅ 核心优势
| 优势 | 说明 |
|---|---|
| 极致性能 | L1 命中延迟 < 100μs,比 Redis 快 100 倍 |
| 抗 Redis 故障 | 即使 Redis 集群宕机,系统仍可降级运行 |
| 降低带宽成本 | 减少 90%+ 的跨机房/跨网络调用 |
❌ 核心代价
| 代价 | 说明 |
|---|---|
| 数据最终一致 | 集群内各实例缓存内容可能短暂不一致 |
| 内存开销 | 每个实例需预留 10%~20% 堆内存给缓存 |
| 调试复杂度 | 问题定位需同时检查 L1/L2/L3 三层状态 |
| 运维负担 | 需设计预热、降级、监控等配套机制 |
🎯 适用场景(建议使用)
- 读多写少(如商品详情、广告创意、定价规则)
- 热点集中(20% 的 Key 承载 80% 流量)
- 可容忍短暂不一致(如展示类数据)
🚫 不适用场景(慎用!)
- 强一致性要求(如账户余额、订单状态)
- 数据极度稀疏(每个 Key 只被访问一次)
- 内存极度受限(如 Serverless 场景)
结语:缓存不是银弹,稳定才是
没有放之四海皆准的缓存方案,只有不断适配业务的工程权衡。
多级缓存的本质,是在性能、一致性、成本、复杂度之间找平衡点。它不能消除所有问题,但能让你在流量洪峰来临时,睡个安稳觉。
如果你也在设计高并发系统,不妨问问自己:
- 我的热点数据是什么?
- 如果 Redis 挂了,服务还能跑吗?
- 缓存不一致时,业务能容忍吗?
答案,往往藏在你的真实场景里。
本文所有方案已在生产环境验证,支撑日均 10 亿+ 广告请求、千万级动态定价调用。 欢迎评论区交流你的缓存实战故事!