Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

中间件 / Middleware

1. Web 中间件全景 / The Web Middleware Landscape

2. 中间件——流量洪峰下的系统保护 / Middleware for Protecting Systems Under Traffic Spikes

cache layer

1. 缓存架构全景 / Cache Architecture Overview

2. Redis 深入——为什么你的缓存总是出问题 / Redis Internals and Cache Failure Modes

3. Redis 数据结构场景应用——什么时候用什么 / Choosing Redis Data Structures for Real Applications

4. 缓存策略——库存变了,缓存怎么处理 / Cache Strategies and Inventory Consistency

5. Redis 缓存三剑客——穿透/击穿/雪崩 + 一致性策略 / Redis Cache Penetration, Breakdown, Avalanche, and Consistency

6. Redis 分布式锁——从 SETNX 到 Redisson / Redis Distributed Locks from SETNX to Redisson

7. Redis Cluster 与 Sentinel 高可用架构 / Redis Cluster and Sentinel High-Availability Architecture

8. Redis 高级特性——Stream / PubSub / Module / LLM 应用

9. Redis 架构深度分析——为什么 Redis 能这么快 / Redis Architecture and the Sources of Its Performance

10. Redis 全景——为什么你的系统需要一个缓存层 / The Redis Landscape and Why Systems Need a Cache Layer

message queue

1. 消息队列全景——为什么你的系统需要一个中间人 / Message Queue Overview and Why Your System Needs a Middleman

2. Kafka 核心——为什么你的消息总是"丢"了 / Kafka Fundamentals and Message Delivery Semantics

3. 消息队列高级——死信队列、延迟消息、消息积压 / Advanced Messaging with Dead Letters, Delays, and Backlogs

4. Kafka Streams 与 Connect —— 让数据自己流动起来 / Kafka Streams and Connect for Streaming Data Pipelines

5. RabbitMQ 深度解析 —— 灵活路由与消息可靠性 / RabbitMQ Deep Dive into Flexible Routing and Reliability

6. 消息队列对比——为什么最终选了 Kafka / Comparing Message Queues and Choosing Kafka

search engine

1. 搜索引擎知识体系 / Search Engine Knowledge System

2. Elasticsearch——为什么 Like 查询总是那么慢 / Elasticsearch for Full-Text Search at Scale

3. Elasticsearch 查询 DSL 深入——为什么你的搜索总是不准 / Elasticsearch Query DSL Deep Dive

4. Elasticsearch 集群规划与运维——为什么你的集群总是"黄" / Elasticsearch Cluster Planning and Operations

5. Meilisearch 与轻量搜索替代方案——当 ES 太重时 / Meilisearch and Lightweight Search Alternatives

infrastructure

1. 基础设施组件全景 / Infrastructure Components Landscape

2. Nginx 与反向代理 / Nginx and Reverse Proxy

3. 服务发现 / Service Discovery

4. 配置中心 / Configuration Center

本页目录

缓存架构全景 / 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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

1.2 每一层在解决什么问题 ​

┌─────────────────────────────────────────────────────────────┐
│                    各层缓存的代价与收益                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  层级          │ 收益                │ 代价                  │
│  ─────────────┼────────────────────┼─────────────────────  │
│  浏览器缓存    │ 零网络开销          │ 不可控(用户清缓存)   │
│  CDN          │ 减少源站压力        │ 缓存失效有传播延迟     │
│  反向代理      │ 保护应用层          │ 仅适用于可缓存的页面   │
│  本地缓存      │ 纳秒/微秒级命中     │ 容量小、多实例不一致   │
│  分布式缓存    │ 大容量、数据共享    │ 网络开销、运维复杂度   │
│  数据库缓存    │ 透明加速查询        │ 受内存限制、不可定制   │
│                                                             │
│  核心原则:离用户越近越快,离数据源越近越准。                │
│  架构设计是在"快"和"准"之间找平衡点。                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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 可能缓存了热点数据页            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

第2部分:缓存的核心概念 ​

2.1 命中率(Hit Ratio) ​

命中率 = 缓存命中次数 / 总查询次数

这是衡量缓存价值的唯一核心指标。但数字本身会骗人:

┌─────────────────────────────────────────────────────────────┐
│                    命中率的三个陷阱                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  陷阱1:分母幻觉                                            │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  统计显示命中率 99%,看似完美。                       │   │
│  │  但 1% 的 miss 全是秒杀商品的高并发请求。             │   │
│  │  这 1% 打垮了数据库。                                │   │
│  │                                                      │   │
│  │  对策:按 key 粒度统计,关注 top-N 热点 key 的命中率  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  陷阱2:空值命中算不算命中?                                │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  缓存了 NULL 值,命中率提高但业务无意义。              │   │
│  │                                                      │   │
│  │  对策:区分"有效命中"和"空值命中"                     │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  陷阱3:缓存预热期的命中率                                  │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  系统刚启动,缓存为空,命中率 0%。                    │   │
│  │  此时大量请求直接打库 → "缓存预热风暴"                │   │
│  │                                                      │   │
│  │  对策:启动时预热热点数据、灰度放量                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

2.2 TTL(Time To Live) ​

TTL 是缓存条目的过期时间,决定了"准"与"快"的平衡。

┌─────────────────────────────────────────────────────────────┐
│                    TTL 设置策略                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  长 TTL(分钟~小时级)                                      │
│  ├── 适用:变更频率低的数据                                │
│  │   商品分类、字典数据、配置项、用户权限                    │
│  ├── 收益:命中率高                                        │
│  └── 风险:数据不一致的时间窗口大                           │
│                                                             │
│  短 TTL(秒级)                                             │
│  ├── 适用:实时性要求高的数据                              │
│  │   库存、价格、竞拍出价                                   │
│  ├── 收益:不一致窗口小                                    │
│  └── 风险:命中率下降、缓存频繁重建                        │
│                                                             │
│  永不过期 + 主动更新                                        │
│  ├── 适用:核心热点数据                                    │
│  │   首页推荐位、大促活动配置                               │
│  ├── 实现:物理不设 TTL,逻辑过期后异步刷新                 │
│  └── 风险:内存持续增长,需要主动淘汰                      │
│                                                             │
│  TTL 抖动(Jitter)                                         │
│  ├── 问题:大量 key 在同一时刻过期 → "缓存雪崩"            │
│  └── 解决:TTL = base_TTL + random(0, 0.1 * base_TTL)      │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
typescript
// 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 秒之间随机分布
1
2
3
4
5
6
7
8
9

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      │ 希望自然过期       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

第3部分:四种缓存读写模式 ​

这四种模式描述的是:当读写请求来临时,缓存和数据库之间的数据流向是怎么走的。

3.1 模式全景对比 ​

┌─────────────────────────────────────────────────────────────┐
│              四种缓存模式的读写路径                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Cache-Aside(旁路缓存)                                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  应用 ──读──→ 缓存(miss) ──→ 数据库 ──→ 回填缓存     │   │
│  │  应用 ──写──→ 数据库 ──→ 删除缓存                    │   │
│  │                                                      │   │
│  │  控制方:应用代码(显式控制缓存)                     │   │
│  │  典型协议:Redis GET → MySQL SELECT → Redis SET      │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Read-Through(读穿透)                                      │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  应用 ──读──→ 缓存层 ──miss──→ 自动查库 ──→ 自动回填 │   │
│  │                                                      │   │
│  │  控制方:缓存层(应用只读缓存,缓存层透明加载)       │   │
│  │  典型方案:Redis + 自定义 CacheLoader                │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Write-Through(写穿透)                                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  应用 ──写──→ 缓存层 ──同步──→ 数据库                │   │
│  │                                                      │   │
│  │  控制方:缓存层(写缓存时同步写库)                   │   │
│  │  特点:缓存和数据库始终保持一致                       │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Write-Behind(写回,也叫 Write-Back)                       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  应用 ──写──→ 缓存层 ──异步批量──→ 数据库             │   │
│  │                                                      │   │
│  │  控制方:缓存层(先写缓存,异步批量刷盘)             │   │
│  │  特点:写入极快但有数据丢失风险                       │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

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 加载,最终一致            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typescript
// 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);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

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写入,写慢                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

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 宕机 → 未刷盘的数据永久丢失                     │
│  ├── 写入顺序难以保证(需要额外机制)                       │
│  └── 不适合金融、交易等严格一致性场景                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

3.5 四种模式的选择决策树 ​

┌─────────────────────────────────────────────────────────────┐
│                    缓存模式选型决策                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  你要解决什么问题?                                         │
│     │                                                       │
│     ├── 读加速(绝大多数场景)                               │
│     │   ├── 你能控制缓存代码吗?                             │
│     │   │   ├── 能 → Cache-Aside(推荐)                     │
│     │   │   └── 不能 → Read-Through(用框架/中间件)         │
│     │   │                                                   │
│     │   └── 特殊情况:                                       │
│     │       ├── 数据一致性极其重要 → Read-Through +         │
│     │       │   Write-Through 组合                           │
│     │       └── 需要缓存层抽象 → 统一用 Read/Write-Through  │
│     │                                                       │
│     ├── 写加速(计数器、日志、指标)                         │
│     │   └── Write-Behind                                    │
│     │                                                        │
│     └── 读 + 写都重要(如电商库存)                          │
│         └── Cache-Aside(读)+ 主动失效(写)                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

第4部分:缓存三大经典问题 ​

4.1 问题全景 ​

这三个问题经常被混淆,核心区别在于 "谁"、"什么数据"、"什么时机":

┌─────────────────────────────────────────────────────────────┐
│               缓存三大问题:一图区分                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│          │  穿透            │  击穿           │  雪崩        │
│  ────────┼─────────────────┼────────────────┼────────────── │
│  攻击对象│  不存在的数据     │  一个热key       │  大面积key   │
│  现象    │  每次都查库       │  key过期瞬间     │  同时过期     │
│          │  库也查不到       │  并发全打库      │  全打库      │
│  比喻    │  拿着假书单找书   │  热门书刚被借走  │  图书馆停电   │
│  典型    │  恶意ID刷接口    │  大V微博过期     │  批量设TTL   │
│          │  爬虫扫不存在页  │  秒杀商品key过期 │  无随机抖动   │
│                                                             │
│  穿透:查不存在的数据 → 缓存形同虚设                         │
│  击穿:热key过期 → 并发抢锁查库                              │
│  雪崩:大面积过期 → Redis扛不住,全线溃败                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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)                            │   │
│  │  这是成本最低的第一道防线                              │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

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                    │   │
│  │    ├── 未逻辑过期 → 直接返回                          │   │
│  │    └── 已逻辑过期 → 先返回旧值,同时异步刷新          │   │
│  │                                                      │   │
│  │  好处:                                              │   │
│  │    缓存永远不物理过期 → 永远不会击穿                  │   │
│  │    用户永远拿到数据(即使是旧的)→ 体验不中断         │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
typescript
// 互斥锁防止缓存击穿的 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);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

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 不是单机 → 一台挂了自动切换                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

第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 生态在萎缩)         │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

5.2 本地缓存 vs 分布式缓存 ​

┌─────────────────────────────────────────────────────────────┐
│              本地缓存 vs 分布式缓存                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  维度           │ 本地缓存               │ 分布式缓存        │
│  ──────────────┼──────────────────────┼─────────────────  │
│  访问延迟       │ 纳秒~微秒级            │ 0.5~2ms(网络)  │
│  容量           │ 受 JVM/进程 内存限制   │ 可横向扩展         │
│  数据一致性     │ 多实例数据独立         │ 所有实例看到同一   │
│                │ 需要额外同步机制        │ 份数据            │
│  可用性         │ 进程存活即可用         │ 独立服务的可用性   │
│  运维           │ 零运维(随进程)       │ 独立部署运维       │
│                                                             │
│  典型选型:                                                 │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  本地缓存适用:                                       │   │
│  │  ├── 配置/字典数据(极少变化)                        │   │
│  │  ├── 元数据(类目树、权限规则)                       │   │
│  │  └── 极致性能场景(高频读取的计数器/限流器)          │   │
│  │                                                      │   │
│  │  分布式缓存适用:                                     │   │
│  │  ├── 业务数据(商品/用户/订单)                       │   │
│  │  ├── Session(多实例共享)                            │   │
│  │  ├── 分布式锁                                         │   │
│  │  └── 需要跨实例一致性                                 │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  多级缓存是大多数系统的最优解:                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  L1 本地缓存: 热点数据 (容量小, 极速)                 │   │
│  │       ↓ miss                                         │   │
│  │  L2 Redis: 业务数据 (容量大, 共享)                    │   │
│  │       ↓ miss                                         │   │
│  │  L3 DB: 全量数据 (慢, 但完整)                         │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

第6部分:缓存一致性策略 ​

6.1 问题的本质 ​

缓存一致性问题的根源:缓存和数据库是两个独立的存储系统,任何写操作都只能在某一个系统上先发生,中间必然有一个"不一致窗口"。

┌─────────────────────────────────────────────────────────────┐
│              缓存一致性的根本矛盾                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  写操作有两条路径可选:                                      │
│                                                             │
│  路径A:先更新缓存,再更新数据库                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  T1: 写缓存成功 (库存=9)                              │   │
│  │  T2: 写数据库... 失败!(网络/锁/约束)               │   │
│  │  T3: 缓存=9, DB=10  ← 不一致,且缓存是错的           │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  路径B:先更新数据库,再更新缓存                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  T1: 写数据库成功 (库存=9)                            │   │
│  │  T2: 写缓存... 失败!(Redis挂了)                    │   │
│  │  T3: 缓存=10, DB=9 ← 不一致,缓存是旧值              │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  两条路径都会产生不一致。没有银弹。                          │
│  真正的工程问题是:你接受多大窗口的不一致?                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

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和删缓存    │
│  ├── 概率极低但理论上确实存在                                │
│  └── 读操作本身比写慢时(如复杂查询),窗口变宽              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

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% 保证(第二次删除也可能失败)                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
typescript
// 延迟双删的 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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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 本身可能出故障                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

6.5 一致性策略选择总结 ​

┌─────────────────────────────────────────────────────────────┐
│              缓存一致性策略选型                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  场景                          │ 推荐策略                    │
│  ─────────────────────────────┼─────────────────────────   │
│  单机/小型项目                 │ Cache-Aside + 先更新DB     │
│                               │ 再删缓存                    │
│                               │                             │
│  一般并发项目                  │ 延迟双删                    │
│                               │                             │
│  高并发 + 可接受短暂不一致      │ Cache-Aside + 短TTL        │
│                               │                             │
│  高并发 + 强一致性要求          │ Canal binlog 订阅          │
│                               │ + 分布式事务                │
│                               │                             │
│  最终一致性可接受              │ 先更新DB,MQ异步删缓存      │
│                               │                             │
│  核心原则:越简单越可靠。                                    │
│  不要为了"彻底解决一致性"把架构搞到无法维护。                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

核心总结 ​

总结1:缓存分层 ​

浏览器 → CDN → 反向代理 → 本地缓存 → 分布式缓存 → 数据库
越靠近用户越快,越靠近数据源越准。一切架构都在"快"和"准"之间做权衡。
1
2

总结2:四种缓存读写模式 ​

模式读路径写路径适用场景
Cache-Aside应用控制:查缓存 → 查DB → 回填更新DB → 删缓存通用(推荐首选)
Read-Through缓存层透明加载—需要缓存层抽象
Write-Through—写缓存 → 同步写DB数据一致性要求高
Write-Behind—写缓存 → 异步批量写DB写密集型、可容忍丢失

总结3:三大缓存问题的本质 ​

穿透 = 查不存在的数据(攻:缓存永远miss)
  → 缓存空值 / 布隆过滤器 / 参数校验

击穿 = 热key瞬间失效(攻:并发流量干穿DB)
  → 互斥锁 / 永不过期 + 异步刷新

雪崩 = 大面积同时过期(攻:全线miss)
  → TTL抖动 / 多级缓存 / 限流熔断 / 高可用
1
2
3
4
5
6
7
8

总结4:一致性策略的工程选择 ​

没有100%的一致性方案,只有对不一致窗口的容忍度。

量级从小到大:
  单机 → Cache-Aside + 删除缓存
  一般并发 → 延迟双删
  高并发+短暂不一致OK → 短TTL兜底
  高并发+强一致 → Canal binlog 订阅
1
2
3
4
5
6
7

章节测试 ​

测试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答案 ​

答案:至少三种:

  1. TTL加随机抖动(打散过期时间)
  2. 多级缓存(本地缓存+Redis+DB,层层兜底)
  3. 限流+熔断(检测命中率骤降时自动保护DB)
  4. 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 数据结构基础

下一步学习 ​

  • [ ] 阅读 05 - Redis Cluster 与 Sentinel

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇2. 中间件——流量洪峰下的系统保护 / Middleware for Protecting Systems Under Traffic Spikes
下一篇2. Redis 深入——为什么你的缓存总是出问题 / Redis Internals and Cache Failure Modes

持续记录,持续成长

Copyright © Tidenflow