一句话速览:缓存的价值是”用内存换数据库压力”,代价是引入了一致性问题——本篇的主线是两条:① 缓存自身的三大经典风险(穿透/击穿/雪崩)各有标准解法;② 在分布式场景下,任何状态变更只清本地缓存等于没清,必须广播

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P0-02、P0-03;
07-面试问题/验证问题/01-业务逻辑问题.md P0-02、P2-01(数据与环境问题)

最近修订:2026-08-07

目录


技术点 1:布隆过滤器 —— 防缓存穿透

难度:⭐⭐ | 掌握要求:能说清”只能加不能删””假阳性无假阴性”两个特性及其后果

核心概念

布隆过滤器解决的问题是:当查询一个根本不存在的 ID 时,不要让这个请求打到 DB(比如恶意扫描 houseId=99999999),它是查询链路的第一道关卡,用极小的内存代价快速判断”这个 ID 一定不存在”或”这个 ID 可能存在”。

原理深挖:它凭什么用极小的内存做判断

布隆过滤器的底层是一个位数组 + k 个哈希函数:加入一个元素时,用 k 个哈希函数算出 k 个位置,把这些位置的二进制位置为 1;查询时同样算 k 个位置,只要有一位是 0,这个元素就绝对没加入过;全是 1 则”可能”加入过——因为别的元素可能恰好也把这些位置成了 1(哈希碰撞),这就是误判(假阳性)的来源。

两个关键特性必须记住,它们决定了布隆过滤器的适用边界:

  • “不存在 = 100% 准确,存在 = 有一定概率误判”(无假阴性、有假阳性)。误判率由位数组大小、哈希函数个数、元素数量共同决定,Guava 的 BloomFilter.create(funnel, expectedInsertions, fpp) 里那个 fpp 参数就是你能容忍的误判率。
  • 只能”添加”,不能”删除”单个元素——因为置为 1 的位可能被多个元素共享,贸然清零会误伤其他元素。要支持删除得用计数布隆过滤器(Counting Bloom Filter)或定期重建。

这个特性决定了它只适合用来防”查询一个从来没有出现过的 ID”,不适合用来判断”这个 ID 当前有效还是已失效”。

项目里布隆过滤器是两层的:本地 Guava BloomFilter(纯 JVM 内存,重启即清空)+ Redisson 分布式 RBloomFilter(数据在 Redis,跨节点共享,重启不丢)。

我们踩过的坑

P0-03:忘记做启动预热,服务重启后存量数据全部被误杀

BloomFilterConfig 类的注释写着”启动时从 DB 加载所有有效 houseId”,但代码里从来没有真正实现这段逻辑。布隆过滤器的数据只在 publishHouse 这个写入接口被调用时才会追加

1
2
3
4
5
6
public Long publishHouse(House house, Long landlordId) {
houseMapper.insert(house);
houseBloomFilter.add(house.getId()); // 只有这里会写入布隆过滤器
localHouseBloomFilter.put(house.getId());
return house.getId();
}

本地 Guava 过滤器是纯内存结构,服务一重启就是全新的空过滤器。所有”重启前已经存在、但重启后没有被重新调用过写入接口”的房源,查询详情接口的第一步 mightContain 判断就会返回 false,直接短路返回 null,根本走不到后面的 Caffeine/Redis/DB 任何一层

1
2
3
4
5
6
public HouseDetailVO getHouseDetail(Long houseId) {
if (!localHouseBloomFilter.mightContain(houseId)) {
return null; // 存量房源被误杀在这一步
}
// ... 后续多级缓存逻辑,根本走不到
}

修复方式是新增一个 ApplicationRunner,在服务启动阶段主动查一遍 DB 把全部有效 ID 灌入过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class BloomFilterWarmUpRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
List<Long> ids = houseMapper.selectList(
new LambdaQueryWrapper<House>().select(House::getId)
).stream().map(House::getId).toList();
ids.forEach(id -> {
houseBloomFilter.add(id);
localHouseBloomFilter.put(id);
});
}
}

关联的验证问题(业务逻辑问题 P0-02):验证阶段直接用 SQL 插入测试房源数据(绕过 publishHouse 接口),同样会导致布隆过滤器里没有这条数据,查询被误判为不存在。这不是 bug,而是验证方式违反了”数据必须通过接口产生”的前提

必须记住的结论

  • 凡是”注释写了但代码没实现”的预热/初始化逻辑,必须假设它根本不存在,尤其是涉及内存结构(本地缓存、本地过滤器)这种重启即失效的东西,一定要亲自确认有没有启动阶段的预热代码。
  • 本地内存态的过滤器/缓存,只要服务重启就是空的,上线新版本、扩容加节点、任何形式的重启都会触发这个问题,所以预热逻辑不是可选项,是这类结构的必需品。
  • 排查”明明数据库里有数据,但接口说不存在”这类问题时,如果链路里有布隆过滤器,第一步永远是确认过滤器是不是刚重启过、有没有做预热。
  • 测试数据必须通过业务接口创建,不能走 SQL 直接插入——这条规则在本项目里因为布隆过滤器、ES 同步、缓存预热等多个旁路逻辑的存在而变得格外重要,绕过接口写库约等于制造出一份”看起来存在但对系统不可见”的脏数据。
  • 布隆过滤器的两个特性要能脱口而出:无假阴性、有假阳性;只增不删

技术点 2:多级缓存 —— Caffeine(本地)+ Redis(分布式)

难度:⭐⭐⭐ | 掌握要求:能画出查询链路,说清回填机制、广播失效、DCL 双检锁

核心概念

多级缓存的分层逻辑是:本地缓存(Caffeine)解决同一节点的重复请求,不用每次都走网络;分布式缓存(Redis)解决跨节点共享、以及本地缓存过期后的兜底。一次查询命中的位置决定了这次请求的延迟:本地缓存命中最快(微秒级,无网络开销),Redis 命中次之(有一次网络往返),DB 命中最慢。

flowchart TD
    A[查询请求 houseId] --> B&#123;布隆过滤器
mightContain?&#125; B -->|一定不存在| C[直接返回 null
挡住穿透] B -->|可能存在| D&#123;Caffeine 本地缓存命中?&#125; D -->|命中| E[返回 微秒级] D -->|未命中| F&#123;Redis 命中?&#125; F -->|命中| G[回填 Caffeine
再返回 毫秒级] F -->|未命中| H[DCL 双检锁
查 DB] H --> I[回填 Redis + Caffeine
再返回]

多级缓存要真正发挥效果,有一个容易被忽视的细节:命中了下层缓存之后,要回填到上层缓存。如果只是”Redis 命中就直接返回”,没有把结果写回本地 Caffeine,那么本地缓存永远只在”写入时”才会有数据,之后过期了就再也不会被填充,多级缓存事实上退化成了单级。

原理深挖:两个组件为什么这样分工

  • Caffeine 的淘汰策略是 W-TinyLFU:传统的 LRU(最近最少使用)有个缺陷——突发的一波扫描式访问(比如定时任务全量遍历)会把真正的热点数据挤出缓存。W-TinyLFU 引入频率统计(用 Count-Min Sketch 近似记录访问频次),新候选元素要和受害者元素比频率才能换入,抗”突发流量污染”能力显著强于 LRU。这是 Caffeine 成为本地缓存首选的核心原因。
  • Redis 的高性能来自三个设计:① 纯内存操作;② 单线程处理命令(避免锁竞争和线程切换开销,单线程不是瓶颈因为瓶颈在网络 IO 和内存;6.0 后 IO 读写用了多线程但命令执行仍单线程);③ IO 多路复用(epoll)。理解”单线程”能解释两个实践原则:不要在 Redis 里执行 O(N) 的大 key 操作(会阻塞所有其他命令)、Lua 脚本/事务是原子执行的(因为天然没有并发交错)。

DCL 双检锁:防”缓存失效瞬间大量请求同时打到 DB”

当热点 key 过期,大量并发请求同时发现缓存未命中,如果都去查 DB 就是缓存击穿。DCL(Double-Checked Locking)模式:第一个线程拿到锁查 DB 并回填,其他线程拿到锁后再检查一次缓存(此时已被回填),直接返回。这个模式和单例模式的 DCL 是同一个思想,但注意分布式的正确姿势——本地锁只能挡本节点,多节点下要么接受”每节点各回源一次”(通常可接受),要么用 Redisson 分布式锁(详见 10-JVM与并发编程.md)。

我们踩过的坑(阶段8验证记录,未单独建 P 编号,属于本项目自查发现的优化点)

getHouseDetail 命中 Redis 缓存的分支(包括双检锁 DCL 分支)只是把 Redis 里的 JSON 反序列化后直接返回,没有执行 houseLocalCache.put(houseId, vo) 这一步回填。结果是:Caffeine 缓存一旦因为 TTL 到期失效,之后同一节点的所有请求都会退化成”每次都要走一次 Redis 网络 IO”,而不是”第一次走 Redis,之后重新在本地缓存住”。

修复很简单,在 Redis 命中的分支补上回填这一行代码即可。这个问题的价值不在于修复难度,而在于提醒自己:写多级缓存代码时,”命中下层就返回”和”命中下层就返回、并且回填上层”是两种完全不同的效果,写代码时容易只想到前者

数据一致性问题:状态变更必须广播,不能只清本地缓存

P0-02:房源冻结/解冻只清了当前节点的缓存,没有通知其他节点和 ES

这是缓存一致性里最容易犯、也是本项目里代价最大的一类错误。freezeHouse(房源出租后冻结,防止重复出租)和 unfreezeHouse(退租后解冻)这两个方法,只调用了 evictCache(houseId)

1
2
3
4
5
6
public void freezeHouse(Long houseId) {
house.setStatus(OCCUPIED);
houseMapper.updateById(house);
evictCache(houseId); // 只清了当前节点自己的 Caffeine/Redis 缓存
// 漏了这一步:houseEventProducer.publishStatusChange(houseId, "ONLINE", "OCCUPIED");
}

evictCache 只能清掉当前处理这个请求的这个节点的本地缓存,对以下两类东西完全无能为力:

  1. 其他节点的本地 Caffeine 缓存(多节点部署下,A 节点冻结了房源,B 节点的本地缓存对此一无所知)
  2. ES 索引HouseSearchSyncConsumer 只监听消息队列的状态变更事件,没有事件就不会有任何动作)

后果是:已经出租的房源,在其他节点的缓存里、以及搜索引擎里,会一直显示”可租”的旧状态,直到缓存自然过期(这里配置的 TTL 长达 2 小时+随机因子)。用户能搜索到、点进去看到一个实际已经租出去的房源,这是真实影响业务正确性的问题,不是性能优化类的小问题。

修复方式是让状态变更的方法都去广播一个事件,让所有关心这个状态的下游(跨节点缓存清理、ES 同步)都能收到通知去做自己的事:

1
2
3
4
5
6
public void freezeHouse(Long houseId) {
house.setStatus(OCCUPIED);
houseMapper.updateById(house);
evictCache(houseId);
houseEventProducer.publishStatusChange(houseId, "ONLINE", "OCCUPIED"); // 补上广播
}

必须记住的结论

  • 多级缓存要做到”命中下层就回填上层”,不然多级缓存名存实亡
  • 在分布式/多节点场景下,”清除自己的缓存”永远不等于”数据保持一致”。任何状态变更,只要有可能被其他节点的缓存、或者搜索引擎等旁路系统缓存过,就必须通过消息广播的方式通知所有关心的下游,而不能只操作当前进程能直接访问到的那份缓存。
  • 排查”缓存不一致”问题的标准动作:先确认这个状态变更的入口方法,除了改 DB 和清本地缓存之外,有没有对外广播事件;再确认所有监听这个事件的消费者,有没有把这次变更的所有状态值都覆盖到(比如本项目里 HouseSearchSyncConsumer 最初只处理了 OFFLINE/ONLINE 两种状态,忘了处理 OCCUPIED,也是同一类疏漏)。
  • 单机部署(单节点)测不出”没有广播状态变更”这类问题,因为当前节点自己清了缓存,看起来完全正常。这类 bug 只有在多节点部署,或者引入了另一个独立系统(如 ES)依赖同一份状态时才会暴露,这也是为什么这个问题一直隐藏到阶段8做搜索验证时才被发现。
  • 记住两个组件的设计要点:Caffeine 用 W-TinyLFU(频率优先,抗扫描污染);Redis 命令执行是单线程(别放大 key 操作、Lua 天然原子)。

技术点 3:缓存三大经典问题 —— 穿透 / 击穿 / 雪崩

难度:⭐⭐ | 掌握要求:三者定义、区别、标准解法要能背出来(面试必考)

项目实际踩过的是”穿透”(布隆过滤器),另外两个属于同主题必须掌握的扩展知识:

问题 定义 本项目对应 标准解法
穿透 查询一定不存在的数据,缓存永远不命中,请求全部打到 DB(恶意攻击场景) 布隆过滤器防恶意 houseId 扫描 ① 布隆过滤器前置拦截;② 空值也缓存(短 TTL)
击穿 某个热点 key 过期瞬间,大量并发同时回源 DB DCL 双检锁分支就是为此设计 ① 互斥锁/DCL(只放一个请求回源);② 热点 key 逻辑过期(不物理过期,异步刷新)
雪崩 大量 key 在同一时刻集中过期,或 Redis 整体宕机,DB 被打垮 TTL 配置”2 小时+随机因子“就是防这个 ① TTL 加随机抖动;② 多级缓存兜底;③ Redis 高可用 + 熔断降级

记忆口诀:穿透是”查不存在的”,击穿是”一个热点的过期了”,雪崩是”一大片同时过期了”。三者解法里反复出现的思想是”拦截(布隆过滤器)、互斥(锁)、分散(随机 TTL)、兜底(多级缓存/降级)”。


技术点 4:缓存与数据库的一致性模式

难度:⭐⭐⭐ | 掌握要求:能说清为什么”先更新DB再删缓存”是主流、延迟双删解决什么、最终方案是什么

项目用的是 Cache-Aside 模式但没有深入讨论过”为什么是删缓存而不是更新缓存”,这是面试高频深挖点,必须补全:

sequenceDiagram
    participant W as 写请求
    participant DB as 数据库
    participant C as 缓存
    participant R as 读请求

    W->>DB: 1. 先更新数据库
    W->>C: 2. 再删除缓存(不是更新!)
    Note over R,C: 后续读请求缓存未命中
回源DB读到新值并回填
  • 为什么是”删缓存”而不是”更新缓存”:① 懒加载思想——如果这个 key 短期内没人读,更新缓存是浪费;② 写场景复杂时(一个缓存值由多表 join 组装),更新缓存成本高且容易算错,删掉让下次读重新组装最稳。
  • 为什么是”先更 DB 再删缓存”而不是反过来:先删缓存再更 DB,中间窗口内一个读请求会把旧值重新回填进缓存,这个脏缓存会存活整个 TTL。先更 DB 再删缓存,脏窗口只有”读请求恰好在删缓存前读到旧值”的极短时间,且要求读比写慢才出问题,概率低得多。
  • 残余的不一致窗口怎么补延迟双删——更新 DB 前先删一次缓存,更新完隔几百毫秒再删一次,把窗口内被回填的脏数据清掉。工程上更彻底的做法是订阅 binlog(Canal/MQ)异步删缓存,把缓存操作和主业务流程解耦,本项目的状态广播消息本质上就是这个思路的手工版。
  • 强一致是做不到的:只要”DB 和缓存两次写”不是原子的,就必然存在不一致窗口,所有方案都是把窗口缩小到业务可接受的范围。追求强一致的场景应该不走缓存直接读 DB,或用分布式锁串行化读写——成本极高,先想清楚业务是否真的需要。

术语表

术语 含义
假阳性 / 假阴性 布隆过滤器”可能存在”的误判 / “一定不存在”的误判(布隆过滤器无假阴性)
W-TinyLFU Caffeine 的淘汰算法,用频率统计抵御突发扫描流量对热点的污染
回填(Backfill) 命中下层缓存后把数据写回上层缓存的动作,多级缓存生效的关键
DCL 双检锁 拿锁后再检查一次缓存的模式,防止缓存击穿时重复回源
穿透 / 击穿 / 雪崩 查不存在的数据 / 热点 key 集中回源 / 大量 key 同时失效
Cache-Aside 旁路缓存模式:读——先查缓存、未命中回源并回填;写——先更 DB 再删缓存
延迟双删 更新 DB 前后各删一次缓存,缩小脏数据窗口的工程技巧
逻辑过期 热点 key 不设物理 TTL,value 里带过期时间,到期后异步线程刷新,避免击穿