Skip to content
Charles Shao
Go back

缓存机制:命中率、更新策略与穿透击穿雪崩

views

应用服务器和数据库的资源总是有限的,用户量和访问量却在涨。一个有效办法是引入缓存:在链路各环节直接返回已算好的结果,减少计算量与数据库 I/O,用有限资源服务更多请求。

本篇从命中率与缓存分层讲起,覆盖本地/分布式缓存、更新与淘汰策略,以及穿透、击穿、雪崩的成因与常见解法——也提醒何时不该用缓存。

缓存系列(本篇为开篇总览,下面六篇逐一深挖)

  1. 缓存机制:命中率、更新策略与穿透击穿雪崩(本篇 · 全景)
  2. 本地缓存深挖:Caffeine、W-TinyLFU 与堆外缓存
  3. Redis 深挖(一)· 数据结构与内存模型
  4. Redis 深挖(二)· 持久化与高可用
  5. Redis 深挖(三)· 线程模型与性能
  6. 缓存一致性深挖:双删、Binlog 订阅与多级失效
  7. Redis 布隆过滤器实现:位图、RedisBloom 与缓存穿透

TL;DR

Table of contents

Open Table of contents

1. 缓存分层与命中率

缓存的核心思路是:在请求链路的各个环节,尽可能早地返回已计算好的结果,避免重复计算和 I/O

如下图所示,缓存可以嵌入在链路的多个层次,每个层次的缓存方案与特点各不相同。

请求链路中的多层缓存示意:可在浏览器、CDN、应用、数据库前等环节引入缓存

缓存可嵌在浏览器、CDN、应用、数据库前等链路各层。

1.1 命中率

命中率 = 命中数 / (命中数 + 未命中数)

命中率是衡量缓存有效性的核心指标,受以下因素影响:

1. 业务场景:缓存适合读多写少的场景;写多读少时命中率低,引入缓存意义不大。更本质的一条判据是——实时性要求越低,越适合缓存:能容忍的数据陈旧窗口越大,就能设越长的 TTL、越少的失效与回源,命中率越高、一致性负担越轻;反之实时性要求越高(金额、库存、余额),TTL 只能越短甚至绕过缓存,缓存收益递减、成本递增。这条判据贯穿全系列——缓存一致性 的选型路径正是按「能接受多大的不一致窗口」展开的。

2. 缓存粒度:粒度越细,命中率通常越高。缓存单个对象比缓存整个集合更稳定——集合中任何一个成员变更都会导致缓存整体失效。

3. 缓存容量:容量不足会触发频繁淘汰,降低命中率。需结合目标 QPS 与热点数据体量合理规划容量。

4. 淘汰策略:不同算法对命中率的影响不同,需根据访问模式(热点稳定 vs 时效性强)选型。

1.2 缓存介质

从存储介质来看,主要分为三类:

1.3 淘汰策略:FIFO / LFU / LRU

策略淘汰依据适用场景
FIFO写入时间最早数据时效性优先,保留最新数据
LFU(最近最不常用)历史访问次数最少热点固定、高频数据需长期保留
LRU(最近最少使用)最后访问时间最早热点会随时间漂移,保留近期活跃数据

工程中 LRU 最为常见,Redis 等主流缓存框架均提供 LRU 及其变体(如 LRU-K、近似 LRU)作为默认或可选策略。

1.4 链路中的缓存位置

缓存不只存在于应用层面,系统各层均有其身影:

理解「缓存无处不在」有助于在排查性能问题时快速定位瓶颈在哪一层。

2. 本地缓存、分布式缓存与更新策略

根据缓存与应用的耦合程度,可以分为本地缓存(local cache)和分布式缓存(remote cache)两大类。

2.1 本地缓存

方式一:成员变量或局部变量

最简单的方式,用 HashMap 等在方法或类内部缓存中间结果,减少重复数据库 I/O:

public void processItems() {
    Map<String, Object> localCache = new HashMap<>();
    for (Object item : getItemList()) {
        if (localCache.containsKey(item)) {
            // 命中:直接使用缓存
        } else {
            Object value = loadFromDB(item.toString());
            localCache.put(item.toString(), value);
        }
    }
}

缺点:作用域仅限于当前方法/实例,类间无法共享。

方式二:静态变量

通过静态 Map 在类加载时一次性预热,适合城市列表、枚举字典等低频变更的基础数据:

public class CityUtils {
    private static final Map<Integer, String> CITY_NAME_MAP = new HashMap<>();

    static {
        // 应用启动时从数据库或远程接口加载,之后直接读内存
        loadCityData(CITY_NAME_MAP);
    }

    public static String getCityName(int cityId) {
        return CITY_NAME_MAP.getOrDefault(cityId, "未知");
    }
}

缺点:数据实时性差;若需动态更新,通常需结合配置中心(如 Nacos、ZooKeeper)做变更通知与热刷新。

2.2 Ehcache

Ehcache 是 Java 生态中成熟的本地缓存框架,支持内存与磁盘两级存储,提供完善的 TTL、容量限制、监听事件等能力。相比裸 HashMap,Ehcache 内置淘汰策略(LRU/LFU/FIFO)、溢出落盘与序列化支持,适合在单机或少量节点、缓存量稍大且需要持久化的场景中使用。Spring Cache 抽象与 Ehcache 集成开箱即用。

2.3 Guava Cache

Guava Cache 是 Google Guava 提供的进程内轻量级缓存,API 简洁,功能实用:

Guava Cache 的继任者 Caffeine 用 W-TinyLFU 淘汰算法把命中率又拔高了一截,还支持异步加载与堆外缓存——本地缓存的选型、淘汰算法与调优详见 本地缓存深挖:Caffeine、W-TinyLFU 与堆外缓存

2.4 分布式缓存:Redis 与 Memcached

分布式缓存以 RedisMemcached 为代表。

Redis 是目前最主流的分布式缓存选型,支持丰富的数据结构(String / Hash / List / Set / ZSet / Stream 等),单线程 I/O 模型保证原子性,支持 AOF/RDB 持久化与主从复制/集群高可用,适用于绝大多数场景。

Memcached 纯内存、多线程,数据结构简单(只有 KV),适合纯缓存、不需持久化的高吞吐场景,工程中已逐渐被 Redis 替代。

Redis 作为分布式缓存事实标准,本系列用三篇深挖它:底层数据结构与内存模型见 Redis 深挖(一)、持久化与高可用(RDB/AOF、主从、Sentinel、Cluster)见 Redis 深挖(二)、单线程 + IO 多线程模型与性能见 Redis 深挖(三)

2.5 更新策略:Cache-Aside / Write-Through / Write-Behind

缓存与数据库的一致性是分布式缓存的核心挑战。常见三种更新模式:

Cache-Aside(旁路缓存)

最常用的模式,应用代码显式管理缓存:

为什么写时删除而不是更新?更新缓存需要先查数据库再计算再写入,并发时若顺序颠倒(旧值覆盖新值)会导致数据不一致;删除操作简单,只要保证「删了就会触发下次回源拉最新值」即可,代价仅是下一次读有一次 miss。

适用:读多写少、可接受短暂不一致(最终一致)的场景;绝大多数互联网业务的首选。

Cache-Aside 旁路缓存读写流程示意:读先查缓存再回源,写先更新库再失效或更新缓存

旁路缓存:读 miss 回源再写入;写通常先更新库再失效/更新缓存。

Write-Through(穿透写)

应用写入时同步更新数据库和缓存,让两者在写成功后尽量保持一致。

注意:Write-Through 常被简单标注为”强一致”,但这是过度简化。它只保证「写成功那一刻缓存 = DB」,仍有几处不一致窗口:① 缓存条目通常仍带 TTL,过期后到重新填充前读到的是回源值而非缓存值;② 双写非原子——若”写 DB 成功但写缓存失败”(或反之),两者会短暂背离,需要重试或补偿;③ 绕过缓存直改 DB(如运维批量脚本、其他服务)时缓存不会自动更新。真正的强一致需要事务或分布式一致性协议,缓存层一般只能做到”写后最新 + 最终一致”。

适用:写频率不高、且对读一致性要求较高的场景(如用户配置、配置中心数据)。

Write-Behind / Write-Back(异步写)

应用只写缓存,由后台异步批量刷新到数据库。

适用:对写延迟极度敏感、允许短暂数据丢失的场景(如计数器、点赞数,丢几个可接受);通常配合 WAL 或消息队列做补偿。

策略读性能写性能一致性实现复杂度典型场景
Cache-Aside高(Miss 有回源延迟)中(删缓存)最终一致商品详情、用户信息
Write-Through最高(命中率高)低(同步双写)写后最新(受 TTL/双写失败影响,非绝对强一致)配置数据、用户设置
Write-Behind最高(异步)弱一致计数器、统计数据

2.6 延迟双删

Cache-Aside 模式下的经典一致性问题:两个并发请求可能导致旧值重新写入缓存——

  1. 请求 A:先删缓存;
  2. 请求 B(并发读):缓存 miss,查库拿到旧值,准备写入缓存;
  3. 请求 A:更新数据库;
  4. 请求 B:将旧值写入缓存 → 缓存被污染

延迟双删 是实践中常用的补偿方案:

1. 删除缓存
2. 更新数据库
3. 延迟 500ms–1s 后再次删除缓存(清除步骤 1–2 期间并发读写入的旧值)

延迟时长需大于一次「数据库读 + 缓存写」的最大时间。延迟删除可通过消息队列异步执行,避免阻塞业务线程。

注意:延迟双删仍有短暂不一致窗口(步骤 2–3 之间);若数据库已配置 binlog → Canal/Debezium 同步链路,也可由消费端触发缓存失效,比手动双删更可靠且可重放。

2.7 热 Key 与大 Key

热 Key(Hot Key)

单个 Key 被极高频率访问(如爆款商品详情页),Redis 单节点可能成为瓶颈,单核 CPU 跑满,其余 Key 的请求也受影响(Redis 单线程)。

解法

大 Key(Big Key)

单个 Key 的 Value 体积过大(String > 10KB,或 Hash/List/Set 元素数量超万)。

危害

解法

2.8 多级缓存一致性

本地缓存 + 分布式缓存的多级架构能大幅提升吞吐,但带来了一致性联动的挑战

当数据库数据更新时,需要同时失效:

  1. 所有应用节点的进程内缓存(L1 本地缓存);
  2. 分布式缓存(L2,如 Redis)。

常见方案

缓存与 DB、L1 与 L2 之间的一致性是本系列最难啃的一块——延迟双删的竞态边界、Binlog/Canal 订阅驱动失效、多级缓存广播失效的可靠性对比,详见 缓存一致性深挖:双删、Binlog 订阅与多级失效

多级缓存的读取遵循「L1 本地 → L2 分布式 → 回源」逐层下探、逐层回填的顺序:

public Product getProduct(long id) {
    String key = "product:" + id;
    // L1:进程内本地缓存(Caffeine),TTL 极短,吸收绝大多数读
    Product p = localCache.getIfPresent(key);
    if (p != null) return p;

    // L2:分布式缓存(Redis),多节点共享
    p = redisGet(key);
    if (p != null) {
        localCache.put(key, p);        // 回填 L1
        return p;
    }

    // 回源:查库并逐层回填(此处应叠加 §3.2 的 singleflight/锁防击穿)
    p = loadFromDB(id);
    if (p != null) {
        redisSet(key, p, Duration.ofMinutes(10));
        localCache.put(key, p);
    }
    return p;
}

多级缓存带来的性能收益显著,但一致性方案复杂度成倍增加;一致性要求越高,多级缓存的实现成本越高,需权衡是否值得。广告竞价路径正是「L1 Caffeine + L2 Redis」多级缓存的典型场景,详见 §4。

2.9 注解式缓存(Spring Cache)

Spring Cache 提供统一的缓存抽象,通过注解与业务逻辑解耦,底层可替换为 Ehcache、Redis、Caffeine 等任意实现:

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#id")
    public Product getById(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    @CacheEvict(value = "products", key = "#product.id")
    public void update(Product product) {
        productRepository.save(product);
    }
}

注解式缓存简化了代码,但需注意自调用失效(Spring AOP 代理限制)和缓存 key 设计。

3. 穿透、击穿与雪崩

穿透、击穿、雪崩是缓存三大经典失效模式:它们的共同后果都是缓存这道防线被绕过,流量完整地压到数据库上(数据库扛不住时的扩展手段见 数据库性能与扩展),区别只在于失效的是「不存在的 key」「单个热 key」还是「大批 key / 整个节点」。

穿透、击穿、雪崩三种失效对比示意:分别是查询不存在的 key、热点 key 恰好过期、大量 key 同时失效,最终都把流量压到数据库

三者都让流量绕过缓存打到 DB,区别在于失效的是「不存在的 key」「单个热 key」还是「大批 key / 整个节点」。

3.1 缓存穿透

问题描述:查询一个数据库中也不存在的 key,缓存无法命中,每次请求都落到数据库;若这类请求量大,数据库承受无意义的查询压力,严重时引发崩溃。

常见解法

布隆过滤器可用 Redisson 的 RBloomFilter(分布式共享)或 Guava 的 BloomFilter(进程内)实现。以 Redisson 为例:

RBloomFilter<String> filter = redisson.getBloomFilter("product:exists");
// 初始化:预计元素数 1000 万,期望误判率 1%
filter.tryInit(10_000_000L, 0.01);
// 全量合法 key 预建(新增数据时同步 add)
for (long id : allProductIds()) filter.add("product:" + id);

public Product getProduct(long id) {
    String key = "product:" + id;
    // 不在过滤器中 → 库里必然不存在,直接短路,不打缓存/DB
    if (!filter.contains(key)) return null;
    // 可能存在(含误判)→ 走正常缓存 + 回源流程
    Product p = cacheGet(key);
    if (p != null) return p;
    p = loadFromDB(id);
    cacheSet(key, p == null ? EMPTY : p, p == null ? Duration.ofSeconds(60) : Duration.ofMinutes(10));
    return p;
}

布隆过滤器有假阳性(判定”可能存在”但实际不存在)而无假阴性——被它放行的少量不存在 key 仍需靠”缓存空对象”兜底,两者常配合使用。

布隆过滤器在 Redis 上到底怎么实现(原生 bitmap 手写 / RedisBloom 模块 / Redisson)、误判率与 m/k 参数怎么算、不支持删除怎么破(计数布隆 / 布谷鸟过滤器),详见 Redis 布隆过滤器实现:位图、RedisBloom 与缓存穿透

3.2 缓存击穿

问题描述:某个热点 key 在高并发访问期间恰好过期,瞬间有大量请求同时穿透缓存,全部打到数据库,形成并发回源冲击。

常见解法

互斥锁 / singleflight 实现:缓存 miss 时用分布式锁(或单机 singleflight)保证同一 key 只有一个请求回源,其余请求短暂等待后复用结果,把”并发回源 N 次”收敛为”回源 1 次”:

public Product getProductWithLock(long id) {
    String key = "product:" + id;
    Product p = cacheGet(key);
    if (p != null) return p;

    String lockKey = "lock:" + key;
    // SET NX PX:只有一个请求能抢到锁去回源
    boolean locked = redis.set(lockKey, "1", "NX", "PX", 3000);
    if (!locked) {
        Thread.sleep(50);              // 未抢到锁,短暂等待后重试读缓存
        return getProductWithLock(id); // 此时大概率已被持锁者回填
    }
    try {
        p = cacheGet(key);             // double-check,避免刚好被别人填过
        if (p != null) return p;
        p = loadFromDB(id);
        cacheSet(key, p, Duration.ofMinutes(10));
        return p;
    } finally {
        redis.del(lockKey);            // 生产中应校验持有者并用 Lua 原子释放
    }
}

Java 单机可用 Guava/Caffeine 的 LoadingCache(自带 per-key 加载互斥),Go 直接用 golang.org/x/sync/singleflight——核心都是同一 key 的并发回源只放行一个。分布式锁释放务必校验持有者(用 Lua 原子比对 value 再删),否则可能误删他人的锁。

3.3 缓存雪崩

问题描述:大量缓存 key 同时过期,或缓存节点集中宕机,导致大量请求同时打到数据库,引发系统整体崩溃。

一句话:穿透、击穿、雪崩——任一失控都可能将流量压力完整地压在数据库上;限流、降级、熔断与多级缓存是架构侧的关键兜底。

常见解法

三大问题各有主攻方向,也共享「限流 / 降级 / 熔断 + 多级缓存」这道最后兜底:

对症下药:穿透用布隆过滤器/空对象缓存,击穿用互斥锁/逻辑过期/热点 key 永不过期,雪崩用 TTL 随机抖动/多级缓存/限流降级/高可用集群

穿透防「查不存在」、击穿防「热 key 过期」、雪崩防「集中失效」;限流、降级、熔断与多级缓存是三者共同的最后兜底。

4. 广告系统中的缓存(AdTech)

广告竞价系统是缓存的极端练兵场:单机数万到数十万 QPS,一次广告请求(tmax 通常 80–150ms)里要读预算、频控、创意、画像等多份数据,任何一项慢一点都可能超时导致 no-bid(放弃这次竞价 = 直接的收入损失)。这里缓存不是”锦上添花的加速”,而是竞价能否在 tmax 内完成的前提

4.1 缓存了什么

数据用途缓存位置特点
预算快照(budget snapshot)竞价前校验 campaign 是否还有预算、是否超投L1 Caffeine + L2 Redis读极频繁、要求 tmax 内返回;短 TTL + 变更主动刷新
campaign / 创意元数据定向、出价策略、素材信息L1 Caffeine + L2 Redis读多写少,变更走配置中心广播失效
用户画像 / 定向标签人群定向、相关性打分Redis(大容量)key 多、value 中等;命中率决定召回质量
频控计数(frequency cap)限制同一用户对同一广告的曝光次数Redis(原子 INCR + TTL)写频繁、需原子性;天然适合 Redis 计数

4.2 竞价路径的多级缓存:Caffeine(L1)+ Redis(L2)

竞价读预算/元数据必须在 tmax 内完成,因此走「本地 Caffeine(L1)→ 分布式 Redis(L2)→ 回源配置中心 / DB」的多级缓存:L1 亚毫秒吸收绝大多数读,L2 兜底跨节点共享,回源只在冷启动或未命中时发生。

广告竞价路径的多级缓存:Bid 请求先查本地 Caffeine(L1,命中约 90%、亚毫秒),miss 再查 Redis 集群(L2),仍 miss 才回源配置中心/DB

竞价读预算必须在 tmax 内完成:L1 亚毫秒吸收绝大多数读,L2 兜底,回源仅冷启动/未命中;本地 TTL 极短 + 预算变更主动刷新避免超投。

一组有代表性的量级(因业务而异,用于建立直觉):

预算快照的一致性尤其敏感:读到旧快照会导致已花光预算的 campaign 继续参竞、造成超投(超出广告主预算 = 平台亏损)。因此 L1 的 TTL 必须极短,且预算扣减到阈值时要主动推送失效给各竞价节点,而不是被动等 TTL 到期。

4.3 热 key = 热门 campaign / 大广告主

广告场景的热 key 不是抽象概念,而是具体的爆量广告主定向过宽的 campaign:它相关的预算、频控、元数据 key 会被绝大多数竞价请求同时读,把承载它的单个 Redis 节点 CPU 打满(Redis 单线程),进而拖慢所有落在该节点的请求。应对手段就是 §2.7 与 §3.2 的组合:

5. 什么时候不该用缓存

缓存不是银弹,以下场景需谨慎评估:

6.1 频繁修改的数据

若数据写入速度接近读取速度(读写比低于 2:1),缓存数据刚写入就过期,命中率极低,反而徒增系统复杂度。

反例:实时库存扣减——每笔订单都修改库存,若同时缓存库存值,读到的几乎总是旧值,且写时还要维护缓存一致性,得不偿失。此类场景应直接操作数据库,配合乐观锁或 Redis DECR 做原子扣减。

6.2 没有热点的访问

若访问分布均匀,不符合「少量 key 承担大部分流量」的规律,引入缓存收益有限。

反例:用户档案系统,1000 万用户中每天均匀访问约 100 万不同用户的数据——命中率约 10%,缓存只能覆盖少量热点用户,其余请求仍打到数据库;此时优先考虑数据库索引优化与读副本(参见 数据库性能与扩展),而非缓存。若冷热差异明显,更适合走 数据冷热分离 而非一层通用缓存。

6.3 强一致性要求

缓存天然存在短暂的数据延迟窗口。若业务对一致性要求极高,需仔细权衡是否适合引入缓存。

典型场景

6.4 缓存可用性

缓存会承担数据库的大部分访问压力。一旦缓存服务故障,数据库将承受全量流量,极易宕机。通过分布式缓存集群(多分片 + 副本)分散风险,单节点故障时仅影响部分数据,不至于引发整体雪崩。同时配合限流熔断(参见 服务限流)做最后防线。

6.5 缓存预热

新部署或重启后,缓存中无任何数据,所有请求都会打到数据库(冷启动)。对于已知的热点数据(如商品列表、城市字典),在服务启动时主动预加载到缓存(warm up),可显著缓解冷启动压力。

5. 生产落地清单

给某个读接口引入缓存前逐项确认:

参考


views
Share this post on:

Previous Post
本地缓存深挖:Caffeine、W-TinyLFU 与堆外缓存
Next Post
Netty 深挖 · 背压、水位线与百万连接调优