高性能(一):多级缓存架构与一致性
高性能(一):多级缓存架构与一致性
导语:Redis 篇已经把「穿透 / 击穿 / 雪崩、Cache-Aside、延迟双删、更新 DB 再删缓存」这些应用与单库视角的题讲透了(见《Redis(四)》)。本篇只谈架构视角:缓存该分几层、本地 + 分布式多级缓存怎么组织、多副本 / 多机房下一致性怎么兜底、热点与容量怎么规划、缓存整体宕掉时如何保护 DB,共 10 题。
一、缓存的分层与多级架构
1. 从架构视角看,缓存应该放在哪些层?各自解决什么问题?
答: 缓存是一条从用户到 DB 的多级链路,每一层解决的是不同量级的"距离"问题:
| 层级 | 载体 | 命中延迟 | 典型内容 | 一致性手段 |
|---|---|---|---|---|
| 客户端 | 浏览器 HTTP 缓存、本地存储 | 0(不发请求) | 静态资源、字典配置 | Cache-Control / ETag / 版本号 |
| 边缘 / CDN | CDN 节点 | 10~50ms | 图片、JS/CSS、活动页 | 刷新 / 预热 / 版本化 URL |
| 接入层 | Nginx(proxy_cache) | 1~10ms | 静态化页面、接口 GET 结果 | 过期时间 + 主动 purge |
| 应用本地缓存 | JVM 进程内(Caffeine) | 百纳秒~微秒 | 配置、元数据、热点商品 | TTL + 变更广播 |
| 分布式缓存 | Redis / Tair / Memcached | 0.5~2ms | 会话、热点业务数据 | 主动失效 + TTL + binlog 驱动 |
| 数据库缓冲 | InnoDB Buffer Pool | 微秒 | 数据页、索引页 | 由存储引擎保证 |
架构原则:
- 越靠近用户,延迟与带宽收益越大,但一致性越难保证;越靠近 DB,一致性越容易,收益越小。
- 按数据特性分层——静态资源上 CDN + 强缓存;半静态(商品详情、配置)上"网关 + 本地 + Redis"多级;强一致数据(余额、库存实时值)要么不缓存,要么只做短 TTL 或版本校验。
- 缓存是"加速器"而不是"唯一数据源":架构上必须假设"任何一层缓存都可能失效或返回旧值",数据真值永远在 DB。
答题加分点:面试问"缓存放哪层"时,不要只答"放 Redis"——要给出"按数据变更频率与一致性要求分层"的决策框架,并点出 CDN/本地缓存这两层是很多人忽略的高收益项。
2. 多级缓存(本地 + 分布式)的架构怎么设计?
答: 多级缓存的核心是 L1 进程内 + L2 分布式,把"绝大部分读"挡在离 CPU 最近的地方:
请求 → L1 本地缓存(Caffeine,纳秒级,命中率 80%+)
↓ miss
L2 Redis(毫秒级,命中率 95%+)
↓ miss
L3 DB(回源 + 回填 L2 + L1)设计要点:
| 要素 | 做法 |
|---|---|
| L1 载体 | Caffeine(W-TinyLFU 算法,高并发低锁)/ Guava Cache(老项目);只放小、热、可容忍短暂不一致的数据 |
| 容量控制 | L1 必须有界(按 maximumSize / maximumWeight + expireAfterWrite),否则堆内存膨胀 → Full GC → 雪崩 |
| L1 + L2 的 TTL | L1 短(秒级~分钟)、L2 长(分钟~小时),让 L1 自然过期降低一致性压力 |
| 回填策略 | 只回填"真值来源"读到的最新值;回源时用版本号判断是否比本地更新 |
| 失效 | 变更时先失效 L2,再广播失效 L1(见第 4 题) |
必须接受的三个代价(面试常追问):
- 一致性:N 个 JVM 副本各自持有一份 L1,变更后存在"各副本失效时间不一致"的窗口。
- 成本:每台机器都要多占内存,且大对象复制会加重 GC。
- 冷启动:发布重启后 L1 全空,瞬时都回源到 L2/DB(需要预热 + 回源限流)。
一句话:多级缓存是"用可控的不一致窗口,换掉绝大部分远程调用",所以它只适合"读多写少 + 能容忍秒级不一致"的数据。
3. 本地缓存怎么选型?Caffeine 为什么比 Guava Cache 强?
答:
| 维度 | Guava Cache | Caffeine |
|---|---|---|
| 淘汰算法 | LRU(分段 + 锁) | W-TinyLFU(Window LRU + Segmented LRU + 频率素描) |
| 并发 | 分段锁,竞争明显 | 无锁读、异步维护、写缓冲 |
| 命中率 | 一般,易被偶发扫描污染 | 高,能同时兼顾新鲜度与高频 |
| 性能 | 中等 | 接近理论最优(官方基准高数倍) |
原理一句话:W-TinyLFU 用"频率素描(Count-Min Sketch)+ 窗口 LRU"判断"新进来的 key 值不值得留",既有 LRU 的新鲜度、又有 LFU 的抗污染,避免了"一次性全表扫描把热 key 挤出去"。
架构选型补充:
- 堆内 vs 堆外:堆内(Caffeine)读取最快但受 GC 影响;堆外(OHC / Ehcache off-heap)可放大容量、降低 GC 压力,但有序列化成本。大容量、大对象场景才考虑堆外。
- 本地缓存只适合"能接受各副本不一致"的数据;配置类数据可以用,交易数据不行。
二、缓存一致性(架构视角)
4. 多级缓存的一致性如何保证?(重点是本地缓存的失效广播)
答: Redis 篇讲的是"应用与 Redis 之间"的一致性,多级缓存多了一个更难的问题——怎么让 N 个 JVM 的 L1 同时失效。常用四板斧:
| 手段 | 原理 | 优点 | 代价 / 风险 |
|---|---|---|---|
| TTL 兜底 | L1 设短过期时间,靠自然过期收敛 | 最简单、无额外组件 | 窗口 = TTL,期间读到旧值 |
| 主动失效广播 | DB 变更后发 MQ / Redis Pub-Sub / 配置中心推送,各实例订阅并清除本地 key | 收敛快(毫秒~秒) | 广播可能丢(实例离线、重启、订阅漏) |
| 版本号校验 | 缓存值带全局版本,读时与"最新版本"比对,过期则丢弃 | 能发现陈旧副本 | 需要中心化的版本源 |
| Redis 维护失效版本表 | 变更时 INCR 一个版本号,各实例定期拉取对比,版本变化则整块失效 | 解决"广播丢失" | 有轮询间隔延迟 |
工程上的标准组合(推荐):
DB 变更(唯一写入口)
→ 订阅 binlog(Canal) 或业务发事件
→ 删除 L2(Redis)
→ 广播失效 L1(MQ 广播模式)
→ L1 仍保留短 TTL 作为"广播丢失"的兜底关键认知:广播是"优化",TTL 是"兜底"。任何依赖"消息一定送达"的一致性设计都是脆弱的——因为分布式消息必然可能丢(两军问题,见《分布式(一)》)。
5. 架构上怎么在「强一致 / 最终一致 / 不缓存」之间做决策?
答: 先按数据定"一致性等级",再选手段,而不是先选技术:
| 数据类型 | 一致性要求 | 架构选择 |
|---|---|---|
| 余额、库存实扣、账户状态 | 强一致 | 不缓存或"缓存只做展示 + 真实扣减走 DB/分布式锁";读走主库 |
| 订单状态、物流、用户资料 | 最终一致(秒级) | Cache-Aside + 先写 DB 再删缓存 + TTL + binlog 驱动失效 |
| 商品详情、分类、配置 | 最终一致(分钟级) | 多级缓存 + TTL + 主动失效 |
| 榜单、统计、推荐 | 允许延迟 | 定时/异步刷新,不做实时失效 |
决策的三维模型:
数据变更频率(低/中/高) × 一致性要求(强/最终/宽松) × 读放大倍数(1x/100x/10000x)
→ 变更低 + 读放大高 → 缓存收益最大,值得上多级缓存
→ 变更加读都高 + 强一致 → 别缓存,考虑分库分表 / 读写分离 / 热点分离加分点:能说出"缓存不是所有数据的默认选项"——对高变更 + 强一致的场景,正确架构是不用缓存或"读写分离 + 分片",而不是硬啃一致性。
6. 为什么"缓存与 DB 双写"总有窗口期?架构上怎么根治?
答: 因为这是两个独立系统的两次写,没有分布式事务,且中间还夹着"并发读写交错 + 主从复制延迟":
- 先写 DB 后删缓存:删缓存失败 / 删完又被旧值回填 → 脏数据
- 并发下"读旧值 → 写新值 → 回填旧值" → 长期脏数据(《Redis(四)》第 9 题)
工程手段按代价递增:
| 层级 | 方案 | 说明 |
|---|---|---|
| 1 | Cache-Aside + 先写 DB 再删缓存 + TTL | 覆盖 90% 场景,靠 TTL 兜底 |
| 2 | 延迟双删 + 失败重试 | 缓解"删后被回填",但仍非严格 |
| 3 | binlog 订阅(Canal / Debezium)异步失效 | 把"应用双写"降级为"DB 单写 + 下游同步",从架构上消除双写窗口 |
| 4 | 强一致:读走 DB / 分布式锁 / 单数据源 | 牺牲性能与可用性,仅用于核心链路 |
架构结论:与其在应用层死磕缓存一致性,不如消灭双写——让 DB 成为唯一写入口,缓存只做"读加速",一致性由"变更事件 + TTL 兜底"保证。这也是大厂缓存一致性方案(Canal + MQ 广播失效)成为主流的根本原因。
7. 多机房 / 多副本下,缓存一致性怎么处理?
答: 为了降低跨机房延迟,通常每个机房一套 Redis(就近读),于是问题变成"一次写如何让所有机房的缓存失效":
- 写入口唯一:所有写只走主机房主库(或单元内主库)。
- 变更事件跨机房广播:binlog → MQ → 每个机房的消费者各删各的缓存(MQ 本身跨机房同步)。
- 回源与主从延迟:本机房缓存 miss 回源时,可能读到"尚未同步过来的旧值"→ 用版本号判断,版本更新才回填。
- 读己之所写:对"用户刚写完就要读"的场景,用会话粘性 / 路由到主 / 本地标记短暂绕过缓存(《分布式(一)》第 3 题)。
- 兜底:TTL + 对账(定期比对缓存与 DB 版本)。
一句话:一旦做到"写入口唯一 + 变更事件广播",多机房缓存一致性问题就退化成了一个更简单的问题——事件投递的可靠性。
三、热点、容量与故障兜底
8. 热点数据(hot key)在架构上怎么处理?
答: Redis 篇讲了单机 hot key 的发现与处理(本地缓存、key 打散、读写分离),架构视角要把热点当成"流量在数据维度的高度集中",分三步治理:
| 阶段 | 手段 |
|---|---|
| 探测 | 代理层 / Redis --hotkeys(LFU)/ 客户端采样统计;滑动窗口统计 key 维度 QPS |
| 隔离(读热点) | L1 本地缓存承接绝大部分读、key 加前缀打散到多副本、只读副本扩容 |
| 削峰(写热点) | 请求合并(合并同 key 的多请求为一次批量写)、异步批量落库、Redis 预扣减 + 队列串行化 |
| 保护 | 热点 key 单独限流/熔断,避免单 key 打爆整个实例(热 key 与普通 key 隔离到不同实例/集群) |
典型场景落法:
- 秒杀库存:库存预热到 Redis,
Lua原子预扣减 + 队列异步落库,只有少量请求真正打到 DB。 - 大促爆品:读路径走"CDN 静态化 + 本地缓存",写路径"合并 + 异步"。
- 反例:把热点 key 缓存在本地会放大不一致窗口,所以要配合主动失效。
9. 缓存容量怎么规划?淘汰策略怎么选?
答:
容量规划:
所需容量 ≈ 热点数据集大小 × 安全系数(1.5~2) × (1 + 碎片率)
关注指标:命中率、淘汰速率(evicted_keys)、内存碎片率、慢查询淘汰策略选择(架构视角的关键是「实例用途」):
| 策略 | 适用 |
|---|---|
allkeys-lru | 纯缓存实例的通用选择 |
allkeys-lfu | 抗"偶发批量写入污染",热点集中时更优 |
volatile-lru / volatile-ttl | 实例里混有持久 key(有过期时间的才淘汰) |
noeviction | 锁 / 队列 / 配置所在实例,绝不能静默淘汰 |
架构决策:不要让同一个 Redis 实例既当缓存又当锁/队列。混用时一旦触发
allkeys-*淘汰,可能把锁 key 删掉导致互斥失效——正确做法是按用途拆分实例(缓存实例允许淘汰、状态实例禁止淘汰)。
分层 TTL 策略:热数据长 TTL + 主动失效;长尾数据短 TTL,靠自然过期回收内存。
10. 缓存整体故障时,如何保护 DB 不被击穿?
答: 这是"缓存雪崩"的架构级应对——核心思路是假设缓存一定会挂,并让 DB 活下来:
| 手段 | 说明 |
|---|---|
| 快速失败 + 降级 | 缓存不可用时立即走降级(返回兜底数据 / 静态页),而不是全部回源 |
| 多层限流保护 DB | 缓存层故障后,对"DB 访问"做并发限流/信号量,宁可拒绝部分请求,也不能压垮 DB——DB 是最难恢复的一环 |
| 本地缓存兜底 | 缓存故障时 L1 仍能承接一部分热点读 |
| 缓存集群高可用 | 哨兵 / Cluster / 多机房副本(《Redis(三)》),降低整体不可用概率 |
| 预案 + 演练 | 配置中心一键开关:切换到"只读 + 降级"模式;提前用混沌工程验证 |
| 请求合并 / 单飞(singleflight) | 同一 key 的大量并发 miss 只放一个请求回源,其余等待结果 |
一句话:架构上要建立"缓存是加速器、DB 是最后的堡垒"的共识——所有缓存故障预案的最终目标都是保护 DB,为此可以牺牲一部分请求的成功率(有损服务,即 BASE 的"基本可用")。
本章小结:多级缓存的价值在于"用可控的不一致换掉远程调用";一致性的正解是"唯一写入口 + 变更事件广播 + TTL 兜底";而稳定性的底线是"缓存挂了也不能让 DB 挂"。
