缓存与数据一致性:多级缓存 / 布隆过滤器 / 状态广播
一句话速览:缓存的价值是”用内存换数据库压力”,代价是引入了一致性问题——本篇的主线是两条:① 缓存自身的三大经典风险(穿透/击穿/雪崩)各有标准解法;② 在分布式场景下,任何状态变更只清本地缓存等于没清,必须广播。
涉及原始记录:
07-面试问题/开发问题/02-框架与中间件集成问题.mdP0-02、P0-03;07-面试问题/验证问题/01-业务逻辑问题.mdP0-02、P2-01(数据与环境问题)最近修订:2026-08-07
目录
- 技术点 1:布隆过滤器 —— 防缓存穿透
- 技术点 2:多级缓存 —— Caffeine(本地)+ Redis(分布式)
- 技术点 3:缓存三大经典问题 —— 穿透 / 击穿 / 雪崩
- 技术点 4:缓存与数据库的一致性模式
- 术语表
技术点 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 | public Long publishHouse(House house, Long landlordId) { |
本地 Guava 过滤器是纯内存结构,服务一重启就是全新的空过滤器。所有”重启前已经存在、但重启后没有被重新调用过写入接口”的房源,查询详情接口的第一步 mightContain 判断就会返回 false,直接短路返回 null,根本走不到后面的 Caffeine/Redis/DB 任何一层:
1 | public HouseDetailVO getHouseDetail(Long houseId) { |
修复方式是新增一个 ApplicationRunner,在服务启动阶段主动查一遍 DB 把全部有效 ID 灌入过滤器:
1 |
|
关联的验证问题(业务逻辑问题 P0-02):验证阶段直接用 SQL 插入测试房源数据(绕过 publishHouse 接口),同样会导致布隆过滤器里没有这条数据,查询被误判为不存在。这不是 bug,而是验证方式违反了”数据必须通过接口产生”的前提。
必须记住的结论
- 凡是”注释写了但代码没实现”的预热/初始化逻辑,必须假设它根本不存在,尤其是涉及内存结构(本地缓存、本地过滤器)这种重启即失效的东西,一定要亲自确认有没有启动阶段的预热代码。
- 本地内存态的过滤器/缓存,只要服务重启就是空的,上线新版本、扩容加节点、任何形式的重启都会触发这个问题,所以预热逻辑不是可选项,是这类结构的必需品。
- 排查”明明数据库里有数据,但接口说不存在”这类问题时,如果链路里有布隆过滤器,第一步永远是确认过滤器是不是刚重启过、有没有做预热。
- 测试数据必须通过业务接口创建,不能走 SQL 直接插入——这条规则在本项目里因为布隆过滤器、ES 同步、缓存预热等多个旁路逻辑的存在而变得格外重要,绕过接口写库约等于制造出一份”看起来存在但对系统不可见”的脏数据。
- 布隆过滤器的两个特性要能脱口而出:无假阴性、有假阳性;只增不删。
技术点 2:多级缓存 —— Caffeine(本地)+ Redis(分布式)
难度:⭐⭐⭐ | 掌握要求:能画出查询链路,说清回填机制、广播失效、DCL 双检锁
核心概念
多级缓存的分层逻辑是:本地缓存(Caffeine)解决同一节点的重复请求,不用每次都走网络;分布式缓存(Redis)解决跨节点共享、以及本地缓存过期后的兜底。一次查询命中的位置决定了这次请求的延迟:本地缓存命中最快(微秒级,无网络开销),Redis 命中次之(有一次网络往返),DB 命中最慢。
flowchart TD
A[查询请求 houseId] --> B{布隆过滤器
mightContain?}
B -->|一定不存在| C[直接返回 null
挡住穿透]
B -->|可能存在| D{Caffeine 本地缓存命中?}
D -->|命中| E[返回 微秒级]
D -->|未命中| F{Redis 命中?}
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 | public void freezeHouse(Long houseId) { |
evictCache 只能清掉当前处理这个请求的这个节点的本地缓存,对以下两类东西完全无能为力:
- 其他节点的本地 Caffeine 缓存(多节点部署下,A 节点冻结了房源,B 节点的本地缓存对此一无所知)
- ES 索引(
HouseSearchSyncConsumer只监听消息队列的状态变更事件,没有事件就不会有任何动作)
后果是:已经出租的房源,在其他节点的缓存里、以及搜索引擎里,会一直显示”可租”的旧状态,直到缓存自然过期(这里配置的 TTL 长达 2 小时+随机因子)。用户能搜索到、点进去看到一个实际已经租出去的房源,这是真实影响业务正确性的问题,不是性能优化类的小问题。
修复方式是让状态变更的方法都去广播一个事件,让所有关心这个状态的下游(跨节点缓存清理、ES 同步)都能收到通知去做自己的事:
1 | public void freezeHouse(Long houseId) { |
必须记住的结论
- 多级缓存要做到”命中下层就回填上层”,不然多级缓存名存实亡。
- 在分布式/多节点场景下,”清除自己的缓存”永远不等于”数据保持一致”。任何状态变更,只要有可能被其他节点的缓存、或者搜索引擎等旁路系统缓存过,就必须通过消息广播的方式通知所有关心的下游,而不能只操作当前进程能直接访问到的那份缓存。
- 排查”缓存不一致”问题的标准动作:先确认这个状态变更的入口方法,除了改 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 里带过期时间,到期后异步线程刷新,避免击穿 |



