缓存架构全景 / Cache Architecture Overview
📅 创建时间:2026-07-28 🏷️ 标签:#Cache #Redis #Memcached #缓存架构 #Cache-Aside #缓存一致性 📚 前置知识:[[/03-web/07-middleware/00-overview]]
📋 本章目标
- 理解缓存在系统架构中的位置与分层模型(浏览器 / CDN / 应用 / Redis / DB)
- 掌握缓存的核心概念:命中率、TTL、淘汰策略(LRU / LFU / TTL / FIFO)及其适用场景
- 能画出并解释 Cache-Aside、Read-Through、Write-Through、Write-Behind 四种模式的读写路径
- 能区分缓存穿透、缓存击穿、缓存雪崩的成因并给出对应解决方案
- 能在 Redis、Memcached、本地缓存三者间做出合理的选型决策
- 理解缓存一致性策略的本质:先删缓存还是先更新 DB、延迟双删、Canal binlog 订阅
第1部分:缓存在系统架构中的位置
1.1 缓存不是一个组件,是一个分层体系
大多数工程师提到"缓存"时想到的是 Redis。但真实系统中,缓存是一个从用户浏览器一直延伸到数据库的多层结构。每一层解决不同的问题,也带来不同的代价。
┌─────────────────────────────────────────────────────────────┐
│ 缓存分层全景图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户 ──────→ [浏览器缓存] 第1层:客户端 │
│ │ HTTP Cache-Control │
│ │ Service Worker │
│ │ LocalStorage / IndexedDB │
│ │ 命中时延:0ms(本地) │
│ ↓ │
│ [CDN / 边缘缓存] 第2层:边缘网络 │
│ │ CloudFront / CloudFlare / 阿里云 CDN │
│ │ 静态资源 / 半动态页面 │
│ │ 命中时延:~10-50ms │
│ ↓ │
│ [反向代理缓存] 第3层:接入层 │
│ │ Nginx proxy_cache / Varnish │
│ │ 整页缓存 / 片段缓存 │
│ │ 命中时延:~1-5ms │
│ ↓ │
│ [应用层本地缓存] 第4层:进程内 │
│ │ Caffeine / Guava / node-cache │
│ │ 热点数据 / 配置 / 元数据 │
│ │ 命中时延:~μs 级 │
│ ↓ │
│ [分布式缓存] 第5层:独立服务 │
│ │ Redis / Memcached │
│ │ 业务数据 / Session / 分布式锁 │
│ │ 命中时延:~0.5-2ms │
│ ↓ │
│ [数据库] 第6层:持久存储 │
│ │ MySQL / PostgreSQL / MongoDB │
│ │ Buffer Pool / Page Cache(数据库自带缓存) │
│ │
└─────────────────────────────────────────────────────────────┘1.2 每一层在解决什么问题
┌─────────────────────────────────────────────────────────────┐
│ 各层缓存的代价与收益 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 层级 │ 收益 │ 代价 │
│ ─────────────┼────────────────────┼───────────────────── │
│ 浏览器缓存 │ 零网络开销 │ 不可控(用户清缓存) │
│ CDN │ 减少源站压力 │ 缓存失效有传播延迟 │
│ 反向代理 │ 保护应用层 │ 仅适用于可缓存的页面 │
│ 本地缓存 │ 纳秒/微秒级命中 │ 容量小、多实例不一致 │
│ 分布式缓存 │ 大容量、数据共享 │ 网络开销、运维复杂度 │
│ 数据库缓存 │ 透明加速查询 │ 受内存限制、不可定制 │
│ │
│ 核心原则:离用户越近越快,离数据源越近越准。 │
│ 架构设计是在"快"和"准"之间找平衡点。 │
│ │
└─────────────────────────────────────────────────────────────┘1.3 一次请求经过多层缓存的全路径
下面追踪一次典型的电商商品详情页请求,看它经过哪些缓存层:
┌─────────────────────────────────────────────────────────────┐
│ 商品详情页请求的缓存路径 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户请求 GET /product/12345 │
│ │ │
│ ├── 1. 浏览器缓存 │
│ │ Cache-Control: max-age=60 │
│ │ 命中 → 直接渲染,0 网络请求 │
│ │ │
│ ├── 2. CDN 边缘节点 │
│ │ 静态资源 (CSS/JS/图片) 直接从 CDN 返回 │
│ │ 半动态 HTML(商品标题/价格)CDN 缓存 30s │
│ │ │
│ ├── 3. Nginx 反向代理 │
│ │ proxy_cache 缓存完整页面片段 │
│ │ │
│ ├── 4. 应用层本地缓存 (Caffeine/node-cache) │
│ │ 类目树、导航配置等不常变数据 │
│ │ │
│ ├── 5. Redis 分布式缓存 │
│ │ 商品基本信息(名称/价格/图片) │
│ │ 库存信息(实时性要求高,TTL=5s) │
│ │ 用户 Session │
│ │ │
│ └── 6. MySQL │
│ 所有层都 miss,最终查库 │
│ InnoDB Buffer Pool 可能缓存了热点数据页 │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:缓存的核心概念
2.1 命中率(Hit Ratio)
命中率 = 缓存命中次数 / 总查询次数
这是衡量缓存价值的唯一核心指标。但数字本身会骗人:
┌─────────────────────────────────────────────────────────────┐
│ 命中率的三个陷阱 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 陷阱1:分母幻觉 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 统计显示命中率 99%,看似完美。 │ │
│ │ 但 1% 的 miss 全是秒杀商品的高并发请求。 │ │
│ │ 这 1% 打垮了数据库。 │ │
│ │ │ │
│ │ 对策:按 key 粒度统计,关注 top-N 热点 key 的命中率 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 陷阱2:空值命中算不算命中? │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 缓存了 NULL 值,命中率提高但业务无意义。 │ │
│ │ │ │
│ │ 对策:区分"有效命中"和"空值命中" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 陷阱3:缓存预热期的命中率 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 系统刚启动,缓存为空,命中率 0%。 │ │
│ │ 此时大量请求直接打库 → "缓存预热风暴" │ │
│ │ │ │
│ │ 对策:启动时预热热点数据、灰度放量 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.2 TTL(Time To Live)
TTL 是缓存条目的过期时间,决定了"准"与"快"的平衡。
┌─────────────────────────────────────────────────────────────┐
│ TTL 设置策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 长 TTL(分钟~小时级) │
│ ├── 适用:变更频率低的数据 │
│ │ 商品分类、字典数据、配置项、用户权限 │
│ ├── 收益:命中率高 │
│ └── 风险:数据不一致的时间窗口大 │
│ │
│ 短 TTL(秒级) │
│ ├── 适用:实时性要求高的数据 │
│ │ 库存、价格、竞拍出价 │
│ ├── 收益:不一致窗口小 │
│ └── 风险:命中率下降、缓存频繁重建 │
│ │
│ 永不过期 + 主动更新 │
│ ├── 适用:核心热点数据 │
│ │ 首页推荐位、大促活动配置 │
│ ├── 实现:物理不设 TTL,逻辑过期后异步刷新 │
│ └── 风险:内存持续增长,需要主动淘汰 │
│ │
│ TTL 抖动(Jitter) │
│ ├── 问题:大量 key 在同一时刻过期 → "缓存雪崩" │
│ └── 解决:TTL = base_TTL + random(0, 0.1 * base_TTL) │
│ │
└─────────────────────────────────────────────────────────────┘// TTL 抖动示例:避免缓存雪崩
function getTTLWithJitter(baseTTL: number): number {
const jitter = Math.floor(Math.random() * baseTTL * 0.1);
return baseTTL + jitter;
}
// 使用
await redis.setex(`product:${id}`, getTTLWithJitter(3600), data);
// 实际 TTL 在 3600 ~ 3960 秒之间随机分布2.3 淘汰策略(Eviction Policy)
缓存满了怎么办?淘汰策略决定踢掉谁。
┌─────────────────────────────────────────────────────────────┐
│ 淘汰策略对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 策略 │ 规则 │ 适用场景 │
│ ─────────────┼───────────────────────┼───────────────── │
│ noeviction │ 满了就报错,不淘汰 │ 绝对不能丢数据 │
│ allkeys-lru │ 淘汰最久未使用的任意key │ 通用场景(推荐) │
│ volatile-lru │ 只淘汰设了TTL的key中LRU │ 需要持久化部分key │
│ allkeys-lfu │ 淘汰使用频率最低的key │ 热点数据明显 │
│ volatile-lfu │ 只淘汰设了TTL的key中LFU │ 同volatile-lru │
│ allkeys-rand │ 随机淘汰任意key │ 所有key价值均等 │
│ volatile-ttl │ 淘汰最接近过期的key │ 希望自然过期 │
│ │
└─────────────────────────────────────────────────────────────┘LRU vs LFU 的内部原理——关键区别:
┌─────────────────────────────────────────────────────────────┐
│ LRU vs LFU 行为对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ LRU(Least Recently Used) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 记录:上次被访问的时间戳 │ │
│ │ 淘汰:时间戳最小的(最久未访问) │ │
│ │ │ │
│ │ 问题:偶发性批量扫描会污染 LRU 链表 │ │
│ │ 示例:凌晨跑批扫了 100 万个 key,它们都变成"最近" │ │
│ │ 访问过的,真正的热点数据反而被挤出去 │ │
│ │ │ │
│ │ Redis 实现:近似 LRU(抽样 N 个 key,踢走最老的) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ LFU(Least Frequently Used) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 记录:访问次数计数器 │ │
│ │ 淘汰:计数器最小的(被访问次数最少) │ │
│ │ │ │
│ │ 优势:不会被批量扫描污染 │ │
│ │ 问题:曾经的热点(计数器很高)冷下来了也难淘汰 │ │
│ │ │ │
│ │ Redis 解决:计数器定期衰减(lfu-decay-time) │ │
│ │ 每过 N 分钟,计数器减 1,让冷掉的数据能被淘汰 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 选择建议: │
│ • 无明显热点模式 → LRU │
│ • 有明显冷热分离(如电商大促)→ LFU │
│ │
└─────────────────────────────────────────────────────────────┘第3部分:四种缓存读写模式
这四种模式描述的是:当读写请求来临时,缓存和数据库之间的数据流向是怎么走的。
3.1 模式全景对比
┌─────────────────────────────────────────────────────────────┐
│ 四种缓存模式的读写路径 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Cache-Aside(旁路缓存) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用 ──读──→ 缓存(miss) ──→ 数据库 ──→ 回填缓存 │ │
│ │ 应用 ──写──→ 数据库 ──→ 删除缓存 │ │
│ │ │ │
│ │ 控制方:应用代码(显式控制缓存) │ │
│ │ 典型协议:Redis GET → MySQL SELECT → Redis SET │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Read-Through(读穿透) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用 ──读──→ 缓存层 ──miss──→ 自动查库 ──→ 自动回填 │ │
│ │ │ │
│ │ 控制方:缓存层(应用只读缓存,缓存层透明加载) │ │
│ │ 典型方案:Redis + 自定义 CacheLoader │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Write-Through(写穿透) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用 ──写──→ 缓存层 ──同步──→ 数据库 │ │
│ │ │ │
│ │ 控制方:缓存层(写缓存时同步写库) │ │
│ │ 特点:缓存和数据库始终保持一致 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Write-Behind(写回,也叫 Write-Back) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用 ──写──→ 缓存层 ──异步批量──→ 数据库 │ │
│ │ │ │
│ │ 控制方:缓存层(先写缓存,异步批量刷盘) │ │
│ │ 特点:写入极快但有数据丢失风险 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘3.2 Cache-Aside:最广泛使用的模式
┌─────────────────────────────────────────────────────────────┐
│ Cache-Aside 读路径详解 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 应用代码: │
│ │ │
│ ├── 1. data = cache.get(key) │
│ │ │ │
│ │ ├── 命中 (hit) ──→ return data │
│ │ │ │
│ │ └── 未命中 (miss) │
│ │ │ │
│ │ ├── 2. data = db.query(key) │
│ │ │ │
│ │ ├── 3. cache.set(key, data, ttl) │
│ │ │ │
│ │ └── 4. return data │
│ │
│ 写路径: │
│ │ │
│ ├── 1. db.update(key, newValue) // 先更新数据库 │
│ │ │
│ └── 2. cache.del(key) // 删除缓存(不是更新!) │
│ │
│ 为什么删除而不是更新?—— │
│ 如果更新缓存失败 → 缓存和 DB 不一致 │
│ 如果删除缓存失败 → 下次读会从 DB 加载,最终一致 │
│ │
└─────────────────────────────────────────────────────────────┘// Cache-Aside 模式的 TypeScript 实现
class CacheAsideService {
constructor(
private cache: Redis,
private db: Database,
) {}
async get<T>(key: string, ttl: number, dbQuery: () => Promise<T>): Promise<T | null> {
// Step 1: 查缓存
const cached = await this.cache.get(key);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Step 2: 缓存未命中,查数据库
const data = await dbQuery();
if (data === null) {
// 缓存空值防止穿透
await this.cache.setex(key, 60, 'NULL');
return null;
}
// Step 3: 回填缓存
await this.cache.setex(key, ttl, JSON.stringify(data));
return data;
}
async update(key: string, updateFn: () => Promise<void>): Promise<void> {
// Step 1: 先更新数据库
await updateFn();
// Step 2: 删除缓存(不是更新)
await this.cache.del(key);
}
}3.3 Read-Through / Write-Through:缓存层透明代理
┌─────────────────────────────────────────────────────────────┐
│ Read-Through + Write-Through 模式 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 应用视角:我只和缓存对话,数据库对我透明。 │
│ │
│ ┌──────────┐ ┌──────────────────┐ ┌──────────┐ │
│ │ │ 读写 │ │ 读写 │ │ │
│ │ 应用代码 │ ───→ │ 缓存层 │ ───→ │ 数据库 │ │
│ │ │ ←─── │ (带CacheLoader) │ ←─── │ │ │
│ └──────────┘ └──────────────────┘ └──────────┘ │
│ │
│ Read-Through 流程: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用: cache.get(key) │ │
│ │ 缓存层: │ │
│ │ 有 → 直接返回 │ │
│ │ 无 → 调用 CacheLoader 查库 → 缓存 → 返回 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Write-Through 流程: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用: cache.put(key, value) │ │
│ │ 缓存层: │ │
│ │ 1. 更新缓存 │ │
│ │ 2. 同步写数据库(这一步完成才返回) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 优点:应用代码简洁,缓存与DB总是同步 │
│ 缺点:写入延迟 = 缓存写入 + DB写入,写慢 │
│ │
└─────────────────────────────────────────────────────────────┘3.4 Write-Behind:极致写入性能的代价
┌─────────────────────────────────────────────────────────────┐
│ Write-Behind(异步写回)模式 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 应用 ──写──→ 缓存层 ──立即返回 OK │
│ │ │
│ └── 异步线程批量合并写入 ──→ 数据库 │
│ │
│ 关键优化: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 同一个 key 的多次写入会被合并: │ │
│ │ │ │
│ │ t=0: cache.set("counter", 1) │ │
│ │ t=1: cache.set("counter", 2) ─┐ │ │
│ │ t=2: cache.set("counter", 3) ├── 这三次只触发 │ │
│ │ t=3: 刷盘 ───→ UPDATE counter=3 ─┘ 一次DB写入 │ │
│ │ │ │
│ │ 写入量:应用层 3 次,DB 层 1 次(合并写入) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 优点: │
│ ├── 写入延迟极低(只写缓存,微秒级) │
│ ├── 数据库写入压力大幅降低(合并 + 批处理) │
│ └── 适合写密集型场景(计数器、日志、指标) │
│ │
│ 风险: │
│ ├── Redis 宕机 → 未刷盘的数据永久丢失 │
│ ├── 写入顺序难以保证(需要额外机制) │
│ └── 不适合金融、交易等严格一致性场景 │
│ │
└─────────────────────────────────────────────────────────────┘3.5 四种模式的选择决策树
┌─────────────────────────────────────────────────────────────┐
│ 缓存模式选型决策 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你要解决什么问题? │
│ │ │
│ ├── 读加速(绝大多数场景) │
│ │ ├── 你能控制缓存代码吗? │
│ │ │ ├── 能 → Cache-Aside(推荐) │
│ │ │ └── 不能 → Read-Through(用框架/中间件) │
│ │ │ │
│ │ └── 特殊情况: │
│ │ ├── 数据一致性极其重要 → Read-Through + │
│ │ │ Write-Through 组合 │
│ │ └── 需要缓存层抽象 → 统一用 Read/Write-Through │
│ │ │
│ ├── 写加速(计数器、日志、指标) │
│ │ └── Write-Behind │
│ │ │
│ └── 读 + 写都重要(如电商库存) │
│ └── Cache-Aside(读)+ 主动失效(写) │
│ │
└─────────────────────────────────────────────────────────────┘第4部分:缓存三大经典问题
4.1 问题全景
这三个问题经常被混淆,核心区别在于 "谁"、"什么数据"、"什么时机":
┌─────────────────────────────────────────────────────────────┐
│ 缓存三大问题:一图区分 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 穿透 │ 击穿 │ 雪崩 │
│ ────────┼─────────────────┼────────────────┼────────────── │
│ 攻击对象│ 不存在的数据 │ 一个热key │ 大面积key │
│ 现象 │ 每次都查库 │ key过期瞬间 │ 同时过期 │
│ │ 库也查不到 │ 并发全打库 │ 全打库 │
│ 比喻 │ 拿着假书单找书 │ 热门书刚被借走 │ 图书馆停电 │
│ 典型 │ 恶意ID刷接口 │ 大V微博过期 │ 批量设TTL │
│ │ 爬虫扫不存在页 │ 秒杀商品key过期 │ 无随机抖动 │
│ │
│ 穿透:查不存在的数据 → 缓存形同虚设 │
│ 击穿:热key过期 → 并发抢锁查库 │
│ 雪崩:大面积过期 → Redis扛不住,全线溃败 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 缓存穿透:请求不存在的数据
┌─────────────────────────────────────────────────────────────┐
│ 缓存穿透的攻击路径与防御 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 攻击路径: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 攻击者批量请求: │ │
│ │ GET /product/99999999 │ │
│ │ GET /product/99999998 │ │
│ │ ... (10万个不存在的ID) │ │
│ │ │ │
│ │ 每次: Redis miss → MySQL查询 → 返回空 → 不缓存 │ │
│ │ 结果: 10万次数据库查询 → MySQL CPU爆炸 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 防御方案: │
│ │
│ 方案1:缓存空值(最简单) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ db 返回 null → cache.set(key, "NULL", ttl=60s) │ │
│ │ 下次请求 → cache.get(key) → "NULL" → 直接返回空 │ │
│ │ │ │
│ │ 代价:占用缓存空间(但可以设短 TTL 控制) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方案2:布隆过滤器(Bloom Filter) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 原理:用位图 + 多个哈希函数,快速判断 key 是否存在 │ │
│ │ │ │
│ │ 布隆过滤器说"不存在" → 一定不存在(放心返回空) │ │
│ │ 布隆过滤器说"存在" → 可能存在(再查缓存/DB) │ │
│ │ │ │
│ │ 空间效率:1 亿条数据 ≈ 几百 MB 内存 │ │
│ │ 误判率:可配置(如 1%),不影响正确性 │ │
│ │ │ │
│ │ 请求 → 布隆过滤器检查 │ │
│ │ ├── 不存在 → 直接返回 404(缓存和DB都不用查) │ │
│ │ └── 可能存在 → 查缓存 → 查DB → 正常流程 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方案3:参数校验前置 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ID 范围校验(如商品ID 1~100000,超出直接拒绝) │ │
│ │ 格式校验(如只允许数字ID) │ │
│ │ 这是成本最低的第一道防线 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘4.3 缓存击穿:热点 Key 瞬间失效
┌─────────────────────────────────────────────────────────────┐
│ 缓存击穿:热点Key过期的冲击 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ "iPhone 17" 的详情页,QPS = 10000 │ │
│ │ Redis key = "product:iphone17", TTL = 300s │ │
│ │ │ │
│ │ T=300s: key 过期 │ │
│ │ T=300.001s: │ │
│ │ 10000 个请求同时到达 │ │
│ │ 全部发现 Redis miss │ │
│ │ 全部同时去查 MySQL │ │
│ │ → 相当于 10000 QPS 的瞬时流量全打到 DB │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 核心矛盾: │
│ "热点 key 过期" 和 "高并发重建" 同时发生 │
│ │
│ 解决方案: │
│ │
│ 方案1:互斥锁(Mutex Lock)——最常用 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 请求1 ──Redis miss──→ 抢到锁 ──→ 查DB ──→ 写缓存 │ │
│ │ ↓ │ │
│ │ 请求2 ──Redis miss──→ 抢锁失败 ──→ sleep(50ms) │ │
│ │ 请求3 ──Redis miss──→ 抢锁失败 ──→ sleep(50ms) │ │
│ │ ... │ │
│ │ 请求N ──Redis miss──→ 抢锁失败 ──→ sleep(50ms) │ │
│ │ ↓ │ │
│ │ 等请求1写完缓存后 │ │
│ │ ↓ │ │
│ │ 请求2~N 都从缓存命中 │ │
│ │ │ │
│ │ 关键:只有 1 个请求查DB,其他等待缓存重建 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方案2:逻辑过期(永不过期 + 异步刷新) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 物理 TTL = 永不过期 │ │
│ │ 逻辑 TTL = value.expireAt(存在 value 里的字段) │ │
│ │ │ │
│ │ 读请求: │ │
│ │ 缓存命中 → 检查 value.expireAt │ │
│ │ ├── 未逻辑过期 → 直接返回 │ │
│ │ └── 已逻辑过期 → 先返回旧值,同时异步刷新 │ │
│ │ │ │
│ │ 好处: │ │
│ │ 缓存永远不物理过期 → 永远不会击穿 │ │
│ │ 用户永远拿到数据(即使是旧的)→ 体验不中断 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘// 互斥锁防止缓存击穿的 TypeScript 实现
async function getWithMutex<T>(
key: string,
ttl: number,
dbQuery: () => Promise<T>,
): Promise<T | null> {
// 1. 查缓存
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
// 2. 缓存 miss,尝试获取互斥锁
const lockKey = `lock:${key}`;
const locked = await redis.set(lockKey, '1', 'NX', 'EX', 10); // 10s 超时
if (locked) {
try {
// 3. 双重检查(其他线程可能已重建缓存)
const recheck = await redis.get(key);
if (recheck !== null) {
return JSON.parse(recheck);
}
// 4. 查 DB 并回填缓存
const data = await dbQuery();
if (data !== null) {
await redis.setex(key, ttl, JSON.stringify(data));
}
return data;
} finally {
await redis.del(lockKey);
}
} else {
// 5. 没抢到锁,等待后重试
await sleep(100);
return getWithMutex(key, ttl, dbQuery);
}
}4.4 缓存雪崩:大面积 Key 同时失效
┌─────────────────────────────────────────────────────────────┐
│ 缓存雪崩:全线溃败的连锁反应 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 触发条件(二选一或叠加): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 大量 key 同一时刻过期 │ │
│ │ 例如:所有商品缓存在整点批量设了 TTL=3600 │ │
│ │ 下一秒:全部过期 → 全部 miss → 全部打 DB │ │
│ │ │ │
│ │ 2. Redis 服务本身宕机或重启 │ │
│ │ 所有 key 全丢 → 所有请求全 miss → 直接打 DB │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 连锁反应: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 缓存大面积 miss │ │
│ │ → 所有请求涌向数据库 │ │
│ │ → 数据库 CPU 飙升至 100% │ │
│ │ → 数据库响应变慢甚至宕机 │ │
│ │ → 应用层超时、重试 │ │
│ │ → 重试进一步加重数据库压力 │ │
│ │ → 整个系统雪崩式崩溃 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 解决方案: │
│ │
│ 方案1:TTL 加随机抖动(基础防御) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ TTL = base + random(0, base * 0.2) │ │
│ │ 将过期时间打散到时间轴上 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方案2:多级缓存 + 降级(纵深防御) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ L1: 本地缓存 (Caffeine/node-cache) │ │
│ │ L2: Redis 分布式缓存 │ │
│ │ L3: 数据库 │ │
│ │ │ │
│ │ Redis 崩了 → L1 本地缓存的旧数据还能撑一段时间 │ │
│ │ 同时触发告警 → 人工/自动恢复 Redis │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方案3:限流 + 熔断(自动保护) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 当检测到缓存命中率骤降: │ │
│ │ 1. 触发限流(每 IP/每接口 QPS 上限) │ │
│ │ 2. 触发熔断(直接返回降级数据,不再查 DB) │ │
│ │ 3. 异步恢复缓存(后台线程重建热点数据) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方案4:Redis 高可用(防止单点) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Redis Sentinel / Cluster → 见后面章节 │ │
│ │ Redis 不是单机 → 一台挂了自动切换 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第5部分:Redis vs Memcached vs 本地缓存 选型对比
5.1 分布式缓存:Redis vs Memcached
┌─────────────────────────────────────────────────────────────┐
│ Redis vs Memcached 全面对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 维度 │ Redis │ Memcached │
│ ──────────────┼──────────────────────┼───────────────── │
│ 数据结构 │ String/List/Set/ZSet │ 纯 String/二进制 │
│ │ Hash/Stream/Geo/Hyper │ │
│ │ LogLog/Bitmap/Bitfield│ │
│ │ │ │
│ 持久化 │ RDB + AOF │ 不支持 │
│ │ 可定期快照 + 增量日志 │ 重启=数据全丢 │
│ │ │ │
│ 集群 │ Sentinel + Cluster │ 客户端一致性哈希 │
│ │ 原生支持分片与高可用 │ 无官方集群方案 │
│ │ │ │
│ 过期策略 │ 惰性删除 + 定期删除 │ 惰性删除(仅get) │
│ │ 内存淘汰策略丰富 │ LRU 淘汰 │
│ │ │ │
│ 内存管理 │ jemalloc(碎片少) │ Slab Allocation │
│ │ 淘汰策略灵活 │ 固定大小Slab │
│ │ │ (可能浪费) │
│ │ │ │
│ 线程模型 │ 单线程 (6.0+ I/O多线程│ 多线程 │
│ │ 但仍单线程处理命令) │ 多核利用好 │
│ │ │ │
│ Lua脚本/事务 │ 支持 │ 不支持 │
│ Pub/Sub │ 支持 │ 不支持 │
│ Stream │ 支持 (5.0+) │ 不支持 │
│ │
│ 选型建议: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 纯 KV 缓存、读写简单、追求极致 RT → Memcached │ │
│ │ 需要数据结构、持久化、高可用、发布订阅 → Redis │ │
│ │ 现代项目默认选 Redis(Memcached 生态在萎缩) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘5.2 本地缓存 vs 分布式缓存
┌─────────────────────────────────────────────────────────────┐
│ 本地缓存 vs 分布式缓存 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 维度 │ 本地缓存 │ 分布式缓存 │
│ ──────────────┼──────────────────────┼───────────────── │
│ 访问延迟 │ 纳秒~微秒级 │ 0.5~2ms(网络) │
│ 容量 │ 受 JVM/进程 内存限制 │ 可横向扩展 │
│ 数据一致性 │ 多实例数据独立 │ 所有实例看到同一 │
│ │ 需要额外同步机制 │ 份数据 │
│ 可用性 │ 进程存活即可用 │ 独立服务的可用性 │
│ 运维 │ 零运维(随进程) │ 独立部署运维 │
│ │
│ 典型选型: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 本地缓存适用: │ │
│ │ ├── 配置/字典数据(极少变化) │ │
│ │ ├── 元数据(类目树、权限规则) │ │
│ │ └── 极致性能场景(高频读取的计数器/限流器) │ │
│ │ │ │
│ │ 分布式缓存适用: │ │
│ │ ├── 业务数据(商品/用户/订单) │ │
│ │ ├── Session(多实例共享) │ │
│ │ ├── 分布式锁 │ │
│ │ └── 需要跨实例一致性 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 多级缓存是大多数系统的最优解: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ L1 本地缓存: 热点数据 (容量小, 极速) │ │
│ │ ↓ miss │ │
│ │ L2 Redis: 业务数据 (容量大, 共享) │ │
│ │ ↓ miss │ │
│ │ L3 DB: 全量数据 (慢, 但完整) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第6部分:缓存一致性策略
6.1 问题的本质
缓存一致性问题的根源:缓存和数据库是两个独立的存储系统,任何写操作都只能在某一个系统上先发生,中间必然有一个"不一致窗口"。
┌─────────────────────────────────────────────────────────────┐
│ 缓存一致性的根本矛盾 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 写操作有两条路径可选: │
│ │
│ 路径A:先更新缓存,再更新数据库 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ T1: 写缓存成功 (库存=9) │ │
│ │ T2: 写数据库... 失败!(网络/锁/约束) │ │
│ │ T3: 缓存=9, DB=10 ← 不一致,且缓存是错的 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 路径B:先更新数据库,再更新缓存 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ T1: 写数据库成功 (库存=9) │ │
│ │ T2: 写缓存... 失败!(Redis挂了) │ │
│ │ T3: 缓存=10, DB=9 ← 不一致,缓存是旧值 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 两条路径都会产生不一致。没有银弹。 │
│ 真正的工程问题是:你接受多大窗口的不一致? │
│ │
└─────────────────────────────────────────────────────────────┘6.2 Cache-Aside 模式下的经典问题
Cache-Aside 模式下,写操作的标准做法是"先更新 DB,再删除缓存"。但这仍有并发问题:
┌─────────────────────────────────────────────────────────────┐
│ 先更新DB再删缓存——并发下的不一致窗口 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 并发时序(极端情况): │
│ │
│ 时间线 线程A(写) 线程B(读) │
│ ─────── ───────────────────── ─────────────────────── │
│ T1 缓存 miss │
│ T2 读 DB → value=100 │
│ T3 更新 DB → value=99 │
│ T4 删除缓存 │
│ T5 写缓存 → value=100 ← 脏数据! │
│ │
│ 最终状态:DB=99, Cache=100 → 不一致! │
│ │
│ 触发条件苛刻: │
│ ├── 读线程在读 DB 后、写缓存前,写线程完成了写DB和删缓存 │
│ ├── 概率极低但理论上确实存在 │
│ └── 读操作本身比写慢时(如复杂查询),窗口变宽 │
│ │
└─────────────────────────────────────────────────────────────┘6.3 延迟双删:给并发留出缓冲
┌─────────────────────────────────────────────────────────────┐
│ 延迟双删(Delayed Double Delete) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 流程: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 先删除缓存 │ │
│ │ 2. 更新数据库 │ │
│ │ 3. 等待 N 毫秒(如 500ms) │ │
│ │ 4. 再次删除缓存 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 时序分析: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 时间线 线程A(写) 线程B(读) │ │
│ │ ─────── ─────────────────────── ────────────────── │ │
│ │ T1 删除缓存 │ │
│ │ T2 缓存 miss │ │
│ │ T3 读 DB → value=100 │ │
│ │ T4 更新 DB → value=99 │ │
│ │ T5 写缓存 → value=100 │ │
│ │ T6 等待 500ms... │ │
│ │ T7 再次删除缓存 ← 清理脏数据! │ │
│ │ │ │
│ │ T5 写入的脏数据在 T7 被第二次删除清理掉。 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 代价: │
│ ├── 增加了写操作的延迟(等 N ms) │
│ ├── N 的选择:大于"一次读 DB 的时长"即可 │
│ └── 仍不是 100% 保证(第二次删除也可能失败) │
│ │
└─────────────────────────────────────────────────────────────┘// 延迟双删的 TypeScript 实现
async function updateWithDoubleDelete(
key: string,
updateFn: () => Promise<void>,
delayMs: number = 500,
): Promise<void> {
// 第1次删除
await redis.del(key);
// 更新数据库
await updateFn();
// 延迟后第2次删除
setTimeout(async () => {
await redis.del(key);
}, delayMs);
}6.4 Canal + binlog 订阅:最终一致性的工程解法
延迟双删仍然有概率出问题。更大的系统采用的方案是:监听数据库的变更日志,异步同步到缓存。
┌─────────────────────────────────────────────────────────────┐
│ Canal + binlog 缓存同步架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ MySQL │────→│ Canal │────→│ 消息队列 (Kafka) │ │
│ │ (Master) │ │ (伪装Slave│ │ │ │
│ │ │ │ 订阅binlog│ │ topic: cache-sync │ │
│ └──────────┘ └──────────┘ └──────────┬───────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ ↓ ↓ │
│ ┌──────────────┐ ┌───────────┐ │
│ │ 消费者 A │ │ 消费者 B │ │
│ │ 解析 binlog │ │ 解析binlog│ │
│ │ 更新Redis │ │ 更新ES │ │
│ └──────────────┘ └───────────┘ │
│ │
│ 原理: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. Canal 伪装成 MySQL 的 Slave │ │
│ │ 2. MySQL 把 binlog 推给 Canal(原生主从复制协议) │ │
│ │ 3. Canal 解析 binlog,得到 row 级别的变更 │ │
│ │ 4. 投递到 Kafka,消费者订阅后更新 Redis/ES │ │
│ │ │ │
│ │ 语义:UPDATE product SET stock=99 WHERE id=12345 │ │
│ │ → binlog: {table:"product", id:12345, stock:99} │ │
│ │ → 消费者: redis.del("product:12345") │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 优点: │
│ ├── 业务代码解耦:写 DB 的代码不用关心缓存 │
│ ├── 最终一致性:binlog 保证按序投递 │
│ └── 多个消费者:同一份 binlog 多个下游(Redis, ES, ...) │
│ │
│ 代价: │
│ ├── 架构复杂度↑↑↑(新增 Canal + Kafka) │
│ ├── 延迟:秒级延迟(不是实时) │
│ └── 运维成本:Canal 本身可能出故障 │
│ │
└─────────────────────────────────────────────────────────────┘6.5 一致性策略选择总结
┌─────────────────────────────────────────────────────────────┐
│ 缓存一致性策略选型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景 │ 推荐策略 │
│ ─────────────────────────────┼───────────────────────── │
│ 单机/小型项目 │ Cache-Aside + 先更新DB │
│ │ 再删缓存 │
│ │ │
│ 一般并发项目 │ 延迟双删 │
│ │ │
│ 高并发 + 可接受短暂不一致 │ Cache-Aside + 短TTL │
│ │ │
│ 高并发 + 强一致性要求 │ Canal binlog 订阅 │
│ │ + 分布式事务 │
│ │ │
│ 最终一致性可接受 │ 先更新DB,MQ异步删缓存 │
│ │ │
│ 核心原则:越简单越可靠。 │
│ 不要为了"彻底解决一致性"把架构搞到无法维护。 │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
总结1:缓存分层
浏览器 → CDN → 反向代理 → 本地缓存 → 分布式缓存 → 数据库
越靠近用户越快,越靠近数据源越准。一切架构都在"快"和"准"之间做权衡。总结2:四种缓存读写模式
| 模式 | 读路径 | 写路径 | 适用场景 |
|---|---|---|---|
| Cache-Aside | 应用控制:查缓存 → 查DB → 回填 | 更新DB → 删缓存 | 通用(推荐首选) |
| Read-Through | 缓存层透明加载 | — | 需要缓存层抽象 |
| Write-Through | — | 写缓存 → 同步写DB | 数据一致性要求高 |
| Write-Behind | — | 写缓存 → 异步批量写DB | 写密集型、可容忍丢失 |
总结3:三大缓存问题的本质
穿透 = 查不存在的数据(攻:缓存永远miss)
→ 缓存空值 / 布隆过滤器 / 参数校验
击穿 = 热key瞬间失效(攻:并发流量干穿DB)
→ 互斥锁 / 永不过期 + 异步刷新
雪崩 = 大面积同时过期(攻:全线miss)
→ TTL抖动 / 多级缓存 / 限流熔断 / 高可用总结4:一致性策略的工程选择
没有100%的一致性方案,只有对不一致窗口的容忍度。
量级从小到大:
单机 → Cache-Aside + 删除缓存
一般并发 → 延迟双删
高并发+短暂不一致OK → 短TTL兜底
高并发+强一致 → Canal binlog 订阅章节测试
测试1:缓存分层
一个用户请求 GET /product/12345,请按照请求经过的顺序,列出可能经过的缓存层(从近到远)。
测试2:淘汰策略
什么场景下 LFU 比 LRU 更合适?什么场景下 LRU 比 LFU 更合适?
测试3:缓存模式
Cache-Aside 模式下,写操作为什么要"删除缓存"而不是"更新缓存"?
测试4:缓存穿透
布隆过滤器判断一个 key "可能存在",实际它不存在。这个误判会影响正确性吗?为什么?
测试5:缓存击穿
互斥锁方案中,第一个线程拿到了锁正在查DB重建缓存,第二个线程没拿到锁,它应该怎么做?
测试6:缓存雪崩
列出至少三种防止缓存雪崩的手段。
测试7:一致性
延迟双删为什么能解决"先更新DB再删缓存"的并发不一致问题?延迟双删的代价是什么?
测试8:选型
一个系统需要:缓存用户 Session(多服务器共享)、存储计数器(高并发自增)、实现分布式锁、发布订阅通知。你应该选 Redis 还是 Memcached?为什么?
参考答案
测试1答案
答案:浏览器缓存 → CDN → Nginx反向代理缓存 → 应用层本地缓存(Caffeine/node-cache) → Redis分布式缓存 → MySQL(Buffer Pool)。
测试2答案
答案:
- LFU更合适:有明显冷热数据分离的场景,如电商大促期间的热门商品。LFU不会被批量扫描污染。
- LRU更合适:无明显热点模式,或热数据频繁轮换的场景。LRU实现更简单,不需要衰减机制。
测试3答案
答案:如果更新缓存失败(如网络问题),缓存中的旧值会和数据库不一致。而删除缓存即使失败,下次读操作会从数据库加载最新数据重新缓存,最终达到一致。核心原则:删除是幂等的安全操作,更新不是。
测试4答案
答案:不会影响正确性。布隆过滤器的特性是"不存在一定正确,存在可能误判"。误判只是多走一步去查缓存/DB,DB也查不到后返回空并缓存空值,不会返回错误数据。多消耗一点性能,但不影响业务正确性。
测试5答案
答案:sleep一小段时间后重新查缓存。因为第一个线程重建缓存后,缓存就是最新数据,后续线程直接命中。实现上可以用自旋重试(有限次数+指数退避),避免无限等待。
测试6答案
答案:至少三种:
- TTL加随机抖动(打散过期时间)
- 多级缓存(本地缓存+Redis+DB,层层兜底)
- 限流+熔断(检测命中率骤降时自动保护DB)
- Redis高可用(Sentinel/Cluster,避免单点故障导致全量miss)
测试7答案
答案:
- 原理:第1次删除清理旧缓存,第2次延迟删除清理并发读线程可能写入的脏数据。
- 代价:(1) 写操作增加了延迟(等待N毫秒)(2) 第二次删除可能失败导致不一致(3) 延迟时间难以精确确定。
测试8答案
答案:选Redis。Session共享需要分布式缓存(Redis/Memcached都行),但计数器自增、分布式锁、发布订阅是Redis独有的数据结构能力,Memcached不支持这些。一个系统如果同时需要这些能力,选Redis可以统一技术栈。
相关笔记
- [[/03-web/07-middleware/01-redis-deep]] - Redis 深入:缓存三大问题的故事线演绎
- [[/03-web/07-middleware/05-cache-strategy]] - 缓存一致性策略深入
- [[06-redis-cluster-sentinel]] - Redis Cluster 与 Sentinel 高可用架构
- [[/03-web/06-databases-and-data-access/05-redis]] - Redis 数据结构基础
下一步学习
学习状态:🟡 开始学习