Files
test-repo/docs/zh-cn/appendix/cache-design.md
T
sanbuphy d174ceea32 feat(docs): enhance interactive demos and improve documentation
- Add new interactive components for frontend routing, browser rendering pipeline, and database transactions
- Improve existing demos with better visuals, explanations, and examples
- Update documentation structure and content for better clarity
- Add new utility scripts and update package.json with new commands
- Fix formatting and alignment in documentation tables
2026-02-13 22:10:03 +08:00

1142 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 缓存系统设计:从零到高性能架构
::: tip 🎯 核心问题
**为什么有些网站打开只需 50 毫秒,而有些却要等 5 秒?** 这就像问:为什么从书包拿书只要 1 秒,而要去图书馆找书要 10 分钟?答案就是——缓存。本章将带你深入理解缓存的核心原理、设计模式和实战技巧,让你的系统性能提升 100 倍。
:::
---
## 1. 为什么要"缓存"
### 1.1 从"每次都查"到"记住常用数据"的演变
在计算机世界的早期,程序员每次需要数据时都会去硬盘或数据库查询。这就像你每次做数学题都要翻书查公式,虽然准确,但效率很低。随着系统规模增大,这种"每次都查"的方式开始暴露出严重的问题:数据库 CPU 飙升到 95%,响应时间从 100 毫秒暴涨到 8 秒,最终整个系统崩溃。
这就像一个学生每天上课都要从宿舍跑到图书馆查资料,一天跑 50 次,最后累瘫在半路。解决方案很简单:在书包里放一本常用公式手册,需要时直接翻书包,不用每次都跑图书馆。缓存就是计算机系统的"公式手册",它把常用数据存储在快速访问的地方,让系统不用每次都去"图书馆"(数据库)。
<div style="display: flex; gap: 20px; margin: 20px 0;">
<div style="flex: 1; padding: 16px; border: 1px solid #e4e7ed; border-radius: 12px;">
**🐌 没有缓存**
- 每次请求都查数据库
- 数据库 CPU 使用率 95%
- 响应时间 5-8 秒
- 系统容易崩溃
</div>
<div style="flex: 1; padding: 16px; border: 1px solid #e4e7ed; border-radius: 12px;">
**🚀 有缓存**
- 95% 请求直接返回
- 数据库 CPU 使用率 < 20%
- 响应时间 50 毫秒
- 系统稳定运行
</div>
</div>
**这就是"缓存"要解决的核心问题:通过存储常用数据的副本,减少对慢速存储(数据库)的访问,让系统更快、更稳定。**
<CachePerformanceComparisonDemo />
### 1.2 一个真实的踩坑故事:为什么缓存是救命稻草
你可能会想:"我的系统现在还行,为什么要提前设计缓存?"让我讲一个真实的故事,你就会明白为什么缓存不是"可选项",而是"必选项"。
::: warning 阿强的数据库崩溃记
阿强是一个创业公司的全栈工程师,公司做了一个社交 App。早期用户少(几百人),系统运行正常,阿强觉得没必要搞缓存,直接查数据库就行。
半年后,用户增长到 10 万人,某天有个明星在 App 上发了一条动态,瞬间涌来 10 万用户访问。结果数据库直接撑爆了:CPU 100%,响应时间从 100ms 变成 30 秒,最后整个 App 崩溃,用户大量流失。
事后复盘:如果当时有一个简单的缓存层(比如 Redis),把热门动态缓存起来,数据库压力至少能降低 95%,系统完全能撑住这次流量洪峰。
阿强从此明白了一个道理:**缓存不是锦上添花,而是高并发系统的保命符。不加缓存,就像开车不系安全带——平时没事,出事就晚了。**
:::
::: info 💡 核心启示
缓存的价值不只是"更快",更重要的是"保护"。它保护数据库不被压垮,保护系统在高流量下依然稳定运行。当你设计系统时,不要等到出事才想起缓存,要从一开始就把它作为核心架构的一部分。
:::
---
## 2. 核心概念:什么是缓存?
::: tip 🤔 缓存到底是什么?
简单来说,**缓存就是数据副本的存储空间**。就像你在书桌前贴了一张便利贴,记着常用电话号码,这样就不需要每次都翻手机通讯录。
**三个关键点**
1. **副本**:缓存里的数据是原始数据(数据库)的副本,不是主数据
2. **快速访问**:缓存通常在内存中,读取速度比硬盘快 10 万倍
3. **有限容量**:缓存空间有限,只能存储最常用的数据
所以,**缓存就是用空间换时间**——牺牲一些内存空间,换取极快的数据访问速度。
:::
在深入具体技术之前,我们需要先搞清楚几个核心概念。为了帮助你理解,我们用一个"学生的书包"来类比缓存系统。
### 2.1 用"书包比喻"理解缓存的核心概念
想象你是一个学生,每天需要查各种资料。这个过程和缓存系统惊人地相似:
| 概念 | 🎒 书包比喻 | 技术含义 | 真实例子 |
|------|-----------|----------|----------|
| **缓存命中 (Cache Hit)** | 你要找的公式正好在便利贴上 | 请求的数据在缓存中找到 | 查询用户信息,Redis 中有,直接返回 |
| **缓存未命中 (Cache Miss)** | 便利贴上没有,得翻书 | 请求的数据不在缓存中 | 查询用户信息,Redis 中没有,需要查数据库 |
| **命中率 (Hit Ratio)** | 100 次查公式中,有 95 次在便利贴上 | 缓存命中的比例 | 命中率 95%,说明 95% 的请求不用查数据库 |
| **TTL (Time To Live)** | 便利贴写上"3 天后撕掉" | 缓存的过期时间 | 设置用户信息缓存 30 分钟后自动失效 |
| **淘汰 (Eviction)** | 书包装满了,把最旧的一张便利贴扔掉 | 缓存满时删除旧数据 | Redis 内存满了,自动删除最少使用的数据 |
### 2.2 缓存命中 vs 缓存未命中
缓存命中和未命中的性能差异是巨大的。让我们看看具体的数据:
| 操作类型 | 响应时间 | 相对速度 | 适合场景 |
|---------|---------|----------|----------|
| **CPU L1 缓存** | ~0.5 纳秒 | 极快(基准) | CPU 内部运算 |
| **内存读取** | ~100 纳秒 | 快 200 倍 | 本地缓存(如 Caffeine |
| **Redis 查询** | ~1 毫秒 | 慢 200 万倍 | 分布式缓存 |
| **MySQL 查询** | ~10 毫秒 | 慢 2000 万倍 | 硬盘数据库查询 |
::: tip 📊 从表格中你能看到什么?
**性能差距触目惊心**:内存操作比 MySQL 查询快 10 万倍!这就像从书桌拿书(1 秒)和去图书馆找书(10 万秒,约 28 小时)的差距。
**三层性能阶梯**
1. **本地缓存(内存)**:最快,但容量小,适合热点数据
2. **Redis 缓存**:中等速度,容量大,适合分布式场景
3. **数据库**:最慢,但容量无限,是数据的最终来源
**实战启示**:你的系统应该让 95% 以上的请求在缓存层就返回,只有不到 5% 的请求需要查数据库。这样数据库压力小,系统整体性能就会大幅提升。
:::
::: details 🔍 看看一次"缓存命中"和"缓存未命中"的真实代码
让我们用代码对比这两种情况:
```javascript
// 场景:查询用户信息
// ===== 缓存命中 (Cache Hit) =====
// 1. 先查 Redis 缓存
const userFromCache = await redis.get('user:123')
if (userFromCache) {
// 命中!直接返回,耗时约 1 毫秒
return JSON.parse(userFromCache)
}
// ===== 缓存未命中 (Cache Miss) =====
// 2. 缓存没有,查数据库
const userFromDB = await db.query('SELECT * FROM users WHERE id = 123')
// 未命中!需要查数据库,耗时约 10 毫秒,慢了 10 倍
// 3. 查到后写入缓存,下次命中
await redis.set('user:123', JSON.stringify(userFromDB), 'EX', 1800)
return userFromDB
```
**关键点**
- 缓存命中:1 毫秒返回,用户体验极佳
- 缓存未命中:10 毫秒返回,用户体验稍差
- **缓存的价值**:把未命中变成命中,性能提升 10 倍
:::
### 2.3 缓存的生命周期
一个缓存条目从创建到销毁,会经历完整的生命周期。理解这个过程对设计缓存系统至关重要。
**四个阶段**
**阶段一:写入 (Write)**
- **主动写入**:系统启动时,预先把热点数据加载到缓存(缓存预热)
- **懒加载**:首次访问时从数据库加载并写入缓存(最常用)
**阶段二:命中/未命中 (Hit/Miss)**
- 每次请求都会先查缓存
- 命中则直接返回,未命中则查数据库
**阶段三:过期 (Expiration)**
- **TTL (Time To Live)**:设置缓存存活时间(如 30 分钟)
- 到期后缓存自动失效,下次访问需要重新加载
**阶段四:淘汰 (Eviction)**
- 缓存空间有限,满了之后需要删除旧数据
- 常见淘汰策略:
- **LRU (Least Recently Used)**:删除最久没有被使用的数据(最常用)
- **LFU (Least Frequently Used)**:删除访问频率最低的数据
- **FIFO (First In First Out)**:删除最早写入的数据
👇 **动手看看**
下面这个演示展示了缓存的生命周期。点击"新增缓存",观察缓存如何经历写入、命中、过期、淘汰的全过程:
<CacheLifecycleDemo />
---
## 3. 缓存的演进之路:从单机到分布式
::: tip 🤔 为什么需要不同类型的缓存?
就像你学习时会在不同地方放资料:书桌上放最常用的(便利贴),书包里放常用的(笔记本),图书馆放所有资料(书库)。
**缓存系统也一样**
- **本地缓存(书桌)**:最快,容量小,放超级热点数据
- **分布式缓存(公共储物柜)**:较快,容量大,放常用数据
- **数据库(图书馆)**:最慢,容量无限,放所有数据
**为什么要分层?** 因为不同层次的性能和成本不同,合理组合才能达到最优效果。
:::
讲了这么多概念,让我们看一个真实的案例:某电商系统是如何从"没有缓存"一步步进化到"多级缓存架构"的。通过这个案例,你会更直观地理解缓存设计的重要性。
### 3.1 阶段一:无缓存时代——数据库裸奔
**背景**:早期系统用户少(几百人),所有请求直接查数据库,没有任何缓存层。
**技术栈**
- 数据库:MySQL
- 无缓存:没有 Redis,没有本地缓存
**系统架构**
```
用户请求 → 应用服务器 → MySQL 数据库
```
**这个阶段的特点**
-**优点**:架构简单,开发快速
-**缺点**:数据库压力大,性能差,用户量上千就崩
::: details 查看当时的代码和遇到的问题
**代码示例**(每次都查数据库):
```javascript
// 获取商品详情——每次都查数据库
async function getProduct(productId) {
// 直接查数据库,没有任何缓存
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
return product
}
```
**遇到的问题**
1. **数据库 CPU 飙升**:每次请求都查数据库,CPU 使用率 80%+
2. **响应慢**:复杂查询要 50-100 毫秒,用户体验差
3. **并发能力差**:数据库 QPS(每秒查询数)上限只有 2000,再多就崩溃
4. **热点商品问题**:热门商品详情页被频繁查询,数据库成为瓶颈
**当时的临时解决方案**
- 买更贵的服务器(加 CPU、内存)——成本高,效果有限
- 数据库读写分离 —— 能缓解读压力,但写压力依然存在
- SQL 优化 —— 能提升 20-30%,但无法解决根本问题
:::
这种"裸奔"模式在用户量 < 1000 时还能应付,但随着用户增长到 1 万、10 万,数据库开始频繁崩溃,团队迫切需要引入缓存。
### 3.2 阶段二:引入 Redis 缓存——性能提升 10 倍
**背景**:用户增长到 1 万人,数据库撑不住了,团队决定引入 Redis 作为缓存层。
**技术栈**
- 数据库:MySQL
- 缓存:Redis(单机版)
**系统架构**
```
用户请求 → 应用服务器 → Redis 缓存(未命中才查) → MySQL 数据库
```
**这个阶段的特点**
-**优点**:性能提升 10 倍,数据库压力降低 90%
-**缺点**:Redis 单点故障,缓存和数据库可能不一致
::: details 查看 Redis 缓存的实现代码
**代码示例**(增加 Redis 缓存):
```javascript
// 获取商品详情——先查 Redis,没有再查数据库
async function getProduct(productId) {
// 1. 先查 Redis 缓存
const cacheKey = `product:${productId}`
const cached = await redis.get(cacheKey)
if (cached) {
// 缓存命中!直接返回,约 1 毫秒
return JSON.parse(cached)
}
// 2. 缓存未命中,查数据库
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
// 3. 查到后写入 Redis,设置 30 分钟过期
await redis.setex(
cacheKey,
1800, // 30 分钟 = 1800 秒
JSON.stringify(product)
)
return product
}
```
**性能提升对比**
| 场景 | 无缓存 | 有 Redis 缓存 | 提升倍数 |
|------|-------|--------------|---------|
| 普通商品查询 | 50ms | 5ms(缓存命中时) | **10 倍** |
| 热门商品查询 | 80ms | 1ms(命中率 95% | **80 倍** |
| 数据库 QPS | 2000(满载) | 200(缓存拦截 90%) | **数据库压力降低 10 倍** |
| 系统最大并发 | 2000 用户 | 20000 用户 | **10 倍** |
**带来的改善**
1. **响应速度**:缓存命中时,响应时间从 50ms 降到 1-5ms
2. **并发能力**:系统能支撑的用户量从 2000 提升到 20000
3. **数据库压力**:90% 的请求被 Redis 拦截,数据库 CPU 从 80% 降到 20%
4. **用户体验**:页面加载速度明显提升,用户投诉减少
**新的挑战**
1. **缓存一致性问题**:商品价格变了,数据库更新了,但缓存还是旧的
2. **缓存穿透**:有人恶意查询不存在的商品 ID(如 id=-1),每次都穿透到数据库
3. **缓存雪崩**:系统重启后,所有缓存同时失效,瞬间大量请求打到数据库
4. **Redis 单点故障**:Redis 宕机,所有请求直接打到数据库,系统可能崩溃
**解决方案**
- **缓存一致性**:更新数据库时,同步删除缓存
- **缓存穿透**:对不存在的数据也在 Redis 中缓存(value 为空,TTL 设置短一些,如 5 分钟)
- **缓存雪崩**:给缓存过期时间加随机值,避免同时失效
:::
引入 Redis 后,系统性能大幅提升,但新问题也随之而来。团队开始研究如何解决这些缓存相关问题。
### 3.3 阶段三:多级缓存架构——性能再提升 5 倍
**背景**:用户增长到 10 万人,即使是 Redis 缓存也开始成为瓶颈(单机 Redis QPS 上限约 10 万),团队决定引入多级缓存。
**技术栈**
- L1 缓存:应用本地缓存(Caffeine)
- L2 缓存:Redis 集群
- 数据库:MySQL 主从集群
**系统架构**
```
用户请求 → CDN 缓存(静态资源) → 应用服务器
L1: 本地缓存(Caffeine → 未命中 → L2: Redis → 未命中 → MySQL
```
**这个阶段的特点**
-**优点**:极致性能(本地缓存只需 0.1 毫秒),高可用(Redis 宕机不影响热点数据)
-**缺点**:架构复杂,多级缓存的一致性难以保证
::: details 查看多级缓存的实现代码
**代码示例**(本地缓存 + Redis 两级缓存):
```javascript
// 使用 Caffeine 本地缓存
const caffeine = require('caffeine')
const localCache = new caffeine.Cache({
max: 1000, // 最多缓存 1000 条
ttl: 30, // 30 秒过期
})
// 获取商品详情——两级缓存
async function getProduct(productId) {
const cacheKey = `product:${productId}`
// L1: 先查本地缓存(最快,约 0.1 毫秒)
const localCached = localCache.get(cacheKey)
if (localCached) {
console.log('L1 命中')
return localCached
}
// L2: 本地缓存未命中,查 Redis(较快,约 1 毫秒)
const redisCached = await redis.get(cacheKey)
if (redisCached) {
console.log('L2 命中,回填 L1')
const product = JSON.parse(redisCached)
// 回填本地缓存
localCache.set(cacheKey, product)
return product
}
// L3: Redis 也未命中,查数据库(最慢,约 10 毫秒)
console.log('L3 命中,回填 L2 和 L1')
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
// 回填 Redis30 分钟过期)
await redis.setex(cacheKey, 1800, JSON.stringify(product))
// 回填本地缓存
localCache.set(cacheKey, product)
return product
}
```
**多级缓存性能对比**
| 缓存层级 | 响应时间 | 命中率 | 适合存储的数据 |
|---------|---------|--------|--------------|
| **L1: 本地缓存** | ~0.1 毫秒 | 70%(超级热点) | 热门商品、系统配置、用户会话 |
| **L2: Redis 缓存** | ~1 毫秒 | 25%(一般热点) | 大部分商品数据、评论聚合 |
| **L3: 数据库** | ~10 毫秒 | 5%(冷数据) | 所有商品的全量数据 |
**整体性能提升**
- **平均响应时间**:5ms(阶段二) → 1ms(阶段三),**再提升 5 倍**
- **系统最大并发**:2 万用户(阶段二) → 10 万用户(阶段三),**提升 5 倍**
- **数据库 QPS**:200(阶段二) → 50(阶段三),**再降低 4 倍**
**这个阶段解决的新问题**
1. **本地缓存一致性**:多个应用实例的本地缓存可能不一致(A 实例缓存了旧价格,B 实例是新价格)
- **解决**:本地缓存 TTL 设置短一些(30 秒),让不一致的时间窗口变小
2. **缓存预热**:系统重启后,本地缓存是空的,大量请求会穿透到 Redis
- **解决**:系统启动时,主动加载热点数据到本地缓存
:::
多级缓存架构在大型互联网公司(如淘宝、京东)广泛应用,它能支撑百万级 QPS 的访问。
### 3.4 缓存架构演进全景图
| 阶段 | 架构 | 响应时间 | 最大并发 | 核心变化 |
|------|------|---------|---------|---------|
| **阶段一:无缓存** | 应用 → 数据库 | 50ms | 2000 用户 | 数据库裸奔,性能差 |
| **阶段二:单级缓存** | 应用 → Redis → 数据库 | 5ms | 20000 用户 | 引入 Redis,性能提升 10 倍 |
| **阶段三:多级缓存** | 应用 → 本地缓存 → Redis → 数据库 | 1ms | 100000 用户 | 本地缓存 + Redis,性能再提升 5 倍 |
::: tip 📊 从表格中你能看到什么?
**阶段一 → 阶段二**:质的飞跃。引入 Redis 后,性能提升 10 倍,数据库压力降低 90%。这是从"能用"到"够用"的关键一步。
**阶段二 → 阶段三**:极致优化。引入本地缓存后,性能再提升 5 倍。这是从"够用"到"极致"的进阶,适合超大流量场景。
**实战建议**
- **用户量 < 1 万**:阶段一(无缓存)够用,但建议引入 Redis(阶段二)
- **用户量 1-10 万**:阶段二(Redis 缓存)是最佳选择
- **用户量 > 10 万**:考虑阶段三(多级缓存),但要注意一致性复杂度
**总结一下**:缓存架构演进不只是"加更多缓存层",而是**根据流量规模选择合适的架构**——过度设计会增加复杂度,设计不足会导致性能瓶颈。
:::
---
## 4. 缓存的三大经典问题:穿透、击穿、雪崩
在实战中,缓存会引入三类经典问题。如果不了解它们,你的系统可能在某个时刻突然崩溃。让我们用生活化的比喻来理解这些问题。
### 4.1 缓存穿透:查询不存在数据
**问题定义**:查询一个**不存在的数据**(如 id=-1),缓存中没有(因为没有存过),数据库中也没有,导致每次请求都直接穿透到数据库。
::: tip 🤔 用"查书"比喻缓存穿透
想象你在图书馆查一本书,你问管理员:"有没有《不存在之书》?"
**正常流程**
- 管理员查目录:"没有这本书"
- 你离开
**缓存穿透场景**
- 你第 1 次来问,管理员查数据库:"没有",告诉你
- 你第 2 次来问,管理员又查一遍数据库:"没有"
- 你第 100 次来问,管理员还是查数据库:"没有"
**问题**:管理员(数据库)被烦死了,每次都要查数据库,即使答案永远是"没有"。
**解决**:管理员记住"《不存在之书》不存在",下次你问,直接说"没有",不用查数据库。这就是**缓存空对象**。
:::
**真实场景**
- 恶意攻击者构造大量不存在的 ID 进行查询(如 id=-1, id=999999999
- 爬虫遍历不存在的资源路径(如 /api/products/invalid-id
- 业务逻辑错误导致查询无效数据
**解决方案 1:缓存空对象**
```javascript
async function getProduct(productId) {
const cacheKey = `product:${productId}`
// 1. 先查缓存
const cached = await redis.get(cacheKey)
if (cached !== null) {
// 注意:cached 可能是字符串 "null"
if (cached === 'null') {
// 缓存的是"空对象",说明数据库中没有这个数据
return null
}
return JSON.parse(cached)
}
// 2. 查数据库
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
// 3. 即使数据库没有,也缓存"null"TTL 设置短一些(如 5 分钟)
if (!product) {
await redis.setex(cacheKey, 300, 'null')
return null
}
// 4. 查到数据,正常缓存
await redis.setex(cacheKey, 1800, JSON.stringify(product))
return product
}
```
**解决方案 2:布隆过滤器 (Bloom Filter)**
布隆过滤器是一个"快速判断数据是否存在"的工具,它像一个"超级索引":
::: tip 📖 布隆过滤器是什么?
想象你有一个"神奇的黑盒":
- 你问它:"ID 为 123 的商品存在吗?"
- 它说:"**肯定不存在**" → 那就真不存在,不用查数据库
- 它说:"**可能存在**" → 那就去查数据库确认
**特点**
- **绝对不会漏判**:如果它说不存在,那就真不存在
- **可能误判**:如果它说可能存在,有可能实际不存在(概率很低,可调)
**价值**:布隆过滤器能在查缓存之前,就把 99% 的"不存在"请求拦截掉,保护数据库。
:::
```javascript
// 使用布隆过滤器
const { BloomFilter } = require('bloom-filters')
// 初始化布隆过滤器(假设最多有 100 万个商品 ID)
const bloomFilter = new BloomFilter(1000000, 0.01) // 误判率 1%
// 系统启动时,把所有商品 ID 加入布隆过滤器
async function initBloomFilter() {
const allIds = await db.query('SELECT id FROM products')
allIds.forEach(row => {
bloomFilter.add(row.id)
})
}
// 查询商品前,先用布隆过滤器判断
async function getProduct(productId) {
// 1. 先用布隆过滤器判断
if (!bloomFilter.has(productId)) {
// 肯定不存在,直接返回 null,不用查数据库
console.log('布隆过滤器拦截:商品不存在')
return null
}
// 2. 布隆过滤器说"可能存在",查缓存
const cached = await redis.get(`product:${productId}`)
if (cached) {
return JSON.parse(cached)
}
// 3. 缓存未命中,查数据库
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
if (!product) {
// 布隆过滤器误判(概率很低),实际不存在
await redis.setex(`product:${productId}`, 300, 'null')
return null
}
// 4. 查到数据,写入缓存
await redis.setex(`product:${productId}`, 1800, JSON.stringify(product))
return product
}
```
### 4.2 缓存击穿:热点数据过期
**问题定义**:某个**热点数据**(如热门商品、热搜新闻)在缓存中过期(TTL 到期),此时大量并发请求同时到达,都去查询数据库,导致数据库压力骤增。
::: tip 🤔 用"抢书"比喻缓存击穿
想象图书馆有本《哈利波特》,超热门,100 个人都想借。
**正常情况**
- 图书馆把《哈利波特》放在"借阅台"(缓存)
- 大家直接从借阅台拿,不用去书架找
**缓存击穿场景**
- 借阅台的《哈利波特》到期了(被还回书架)
- 100 个人同时来借,发现借阅台没有
- 100 个人都冲去书架找(数据库)
- 书架管理员(数据库)被挤爆了
**问题**:不是"不存在的书",而是"超热门的书"突然从缓存消失了,导致瞬间大量请求打到数据库。
:::
**真实场景**
- 微博热搜榜过期瞬间,几万人同时访问
- 明星八卦新闻缓存失效,粉丝疯狂访问
- 秒杀活动开始时的库存数据过期
**解决方案 1:互斥锁 (Mutex Lock)**
```javascript
async function getProduct(productId) {
const cacheKey = `product:${productId}`
// 1. 先查缓存
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
// 2. 缓存未命中,获取分布式锁
const lockKey = `lock:${productId}`
const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10) // 锁 10 秒
if (lock === 'OK') {
// 3. 获取到锁,查数据库
console.log('获取锁成功,查询数据库')
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
// 4. 写入缓存
await redis.setex(cacheKey, 1800, JSON.stringify(product))
// 5. 释放锁
await redis.del(lockKey)
return product
} else {
// 6. 没获取到锁,等待 50ms 后重试
console.log('获取锁失败,等待后重试')
await new Promise(resolve => setTimeout(resolve, 50))
return getProduct(productId) // 递归重试
}
}
```
**解决方案 2:逻辑过期 (Logical Expiration)**
```javascript
async function getProduct(productId) {
const cacheKey = `product:${productId}`
// 1. 查缓存
const cached = await redis.get(cacheKey)
if (cached) {
const data = JSON.parse(cached)
// 2. 检查逻辑过期时间
if (Date.now() < data.expireTime) {
// 未过期,直接返回
return data.product
} else {
// 3. 逻辑过期,异步重建缓存,同时返回旧数据
console.log('逻辑过期,异步重建缓存')
rebuildCacheAsync(productId) // 异步重建
return data.product // 返回旧数据
}
}
// 4. 缓存不存在(首次加载),同步查数据库
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
// 5. 写入缓存(包含逻辑过期时间)
const cacheData = {
product: product,
expireTime: Date.now() + 30 * 60 * 1000 // 30 分钟后逻辑过期
}
await redis.set(cacheKey, JSON.stringify(cacheData))
return product
}
// 异步重建缓存
async function rebuildCacheAsync(productId) {
const lockKey = `rebuild:${productId}`
const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10)
if (lock === 'OK') {
console.log('异步重建缓存开始')
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
const cacheData = {
product: product,
expireTime: Date.now() + 30 * 60 * 1000
}
await redis.set(`product:${productId}`, JSON.stringify(cacheData))
await redis.del(lockKey)
console.log('异步重建缓存完成')
}
}
```
### 4.3 缓存雪崩:大量数据同时过期
**问题定义**:大量缓存数据在**同一时间点集中过期**(或 Redis 宕机),导致所有请求同时穿透到数据库,瞬间压垮数据库。
::: tip 🤔 用"图书馆批量还书"比喻缓存雪崩
想象图书馆的"借阅台"(缓存)有 1000 本书。
**正常情况**
- 这些书的还书时间是分散的:有的今天还,有的明天还,有的后天还
- 每天只有几十本书到期,管理员(数据库)能轻松处理
**缓存雪崩场景**
- 系统重启后,管理员把 1000 本书都设置"30 天后到期"
- 30 天后,这 1000 本书同时到期
- 1000 个人同时来借书,发现借阅台没有
- 1000 个人都冲去书架找
- 书架管理员(数据库)瞬间被挤爆
**问题**:不是一本书的问题,而是**大量数据同时过期**,导致数据库瞬间压力暴增。
:::
**真实场景**
- 系统重启后,所有缓存从 0 开始重建,同时设置相同 TTL(如 30 分钟)
- 定时任务批量刷新缓存,设置相同的过期时间
- 缓存服务(Redis)宕机或网络分区
**解决方案 1:随机 TTL**
```javascript
async function getProduct(productId) {
const cacheKey = `product:${productId}`
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
// 关键:在基础 TTL(30 分钟)上加随机值(±5 分钟)
const baseTTL = 1800 // 30 分钟
const randomOffset = Math.floor(Math.random() * 600) - 300 // -5 到 +5 分钟
const finalTTL = baseTTL + randomOffset
console.log(`缓存 TTL: ${finalTTL} 秒(${Math.floor(finalTTL / 60)} 分钟)`)
await redis.setex(cacheKey, finalTTL, JSON.stringify(product))
return product
}
```
**解决方案 2:缓存预热 (Cache Preheating)**
```javascript
// 系统启动时,主动加载热点数据到缓存
async function cacheWarmup() {
console.log('开始缓存预热...')
// 1. 查询最热门的 1000 个商品(根据访问量排序)
const hotProducts = await db.query(`
SELECT * FROM products
ORDER BY view_count DESC
LIMIT 1000
`)
// 2. 批量写入 Redis
for (const product of hotProducts) {
const cacheKey = `product:${product.id}`
const ttl = 1800 + Math.floor(Math.random() * 600) // 30 分钟 ± 5 分钟
await redis.setex(cacheKey, ttl, JSON.stringify(product))
}
console.log(`缓存预热完成,已加载 ${hotProducts.length} 个热门商品`)
}
// 应用启动时执行
cacheWarmup()
```
**解决方案 3:熔断降级 (Circuit Breaker)**
```javascript
// 使用熔断器保护数据库
const CircuitBreaker = require('opossum')
// 设置熔断器
const dbQueryBreaker = new CircuitBreaker(
async (productId) => {
return await db.query('SELECT * FROM products WHERE id = ?', [productId])
},
{
timeout: 3000, // 3 秒超时
errorThresholdPercentage: 50, // 错误率超过 50% 时熔断
resetTimeout: 30000 // 30 秒后尝试恢复
}
)
// 熔断后的降级处理
dbQueryBreaker.fallback(() => {
console.log('数据库熔断,返回降级数据')
return {
id: productId,
name: '服务繁忙,请稍后重试',
status: 'degraded'
}
})
async function getProduct(productId) {
const cacheKey = `product:${productId}`
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
// 通过熔断器查数据库
const product = await dbQueryBreaker.fire(productId)
if (product.status === 'degraded') {
return product // 返回降级数据
}
await redis.setex(cacheKey, 1800, JSON.stringify(product))
return product
}
```
👇 **动手看看**
下面这个演示对比了缓存穿透、击穿、雪崩三种问题的场景和解决方案:
<CacheProblemsDemo />
---
## 5. 缓存一致性策略:如何让缓存和数据库保持同步
缓存的本质是数据的副本,副本和原始数据(数据库)之间必然存在不一致的时间窗口。如何控制这个时间窗口,是缓存设计的核心挑战。
### 5.1 为什么缓存和数据库会不一致?
::: tip 🤔 用"便利贴和书"比喻不一致
想象你在便利贴上记着:"小明电话:123456",这是你通讯录(数据库)的副本。
**不一致的场景**
- 你更新通讯录,把小明电话改成 "7654321"
- 但你忘记更新便利贴
- 下次你查电话,看便利贴,还是旧的 "123456"
**问题**:便利贴(缓存)和通讯录(数据库)不一致了。
**原因**:更新了原始数据,但没有同步更新副本。在计算机系统中,这是因为"更新数据库"和"更新缓存"是两个独立的操作,中间有时间窗口,可能被其他操作打乱。
:::
**真实的并发场景**
| 时间 | 线程 A(更新用户年龄) | 线程 B(查询用户) | 数据库 | 缓存 |
|------|---------------------|------------------|--------|------|
| T1 | 开始更新数据库 | - | age=20 | age=20 |
| T2 | 数据库更新为 age=25 | 查询缓存,命中 age=20 | age=25 | age=20 ❌ |
| T3 | 删除缓存 | - | age=25 | - |
| T4 | - | - | age=25 | 从 DB 加载 age=25 ✅ |
**问题**:在 T2 时刻,线程 B 读到了缓存中的旧值 20,而数据库已经是 25。这就是**缓存不一致**。
### 5.2 最佳实践:先更新数据库,再删除缓存
::: tip 🤔 为什么是"删除"而不是"更新"缓存?
你可能会想:为什么不直接"更新缓存",而是"删除缓存"
**更新缓存的问题**
- 并发更新时,可能出现 A 线程先更新缓存,B 线程后更新数据库但缓存没更新
- 更新缓存的成本可能很高(比如需要聚合多个表的数据)
- 如果更新后数据又被删除了,白费力气
**删除缓存的优势**
- 下次查询时自动从数据库加载最新数据(懒加载)
- 避免并发更新导致的脏数据
- 简单可靠,是业界最佳实践
:::
**标准流程**
```javascript
// 更新商品信息
async function updateProduct(productId, updateData) {
// 1. 先更新数据库
await db.query(
'UPDATE products SET name = ?, price = ? WHERE id = ?',
[updateData.name, updateData.price, productId]
)
// 2. 再删除缓存(不是更新缓存!)
await redis.del(`product:${productId}`)
// 3. 下次查询时,缓存未命中,自动从数据库加载最新数据
console.log('更新完成,缓存已删除')
}
```
::: details 查看为什么"先更新 DB,再删缓存"是最优方案
对比三种更新策略:
**策略 1:先更新缓存,再更新数据库** ❌ 不推荐
```javascript
// 问题:如果更新数据库失败,缓存是新值,数据库是旧值,不一致
await redis.set('product:1', newProduct) // 缓存更新成功
await db.query('UPDATE products SET ...') // 数据库更新失败!
// 结果:缓存是新值,数据库是旧值,永久不一致!
```
**策略 2:先删除缓存,再更新数据库** ❌ 不推荐
```javascript
// 问题:删除和更新之间,有其他线程查询,会加载旧数据到缓存
await redis.del('product:1') // 缓存删除
// 此时线程 B 来查询,发现缓存没有,查数据库(还是旧值),写入缓存
await db.query('UPDATE products SET ...') // 更新数据库
// 结果:缓存是旧值,数据库是新值,不一致!
```
**策略 3:先更新数据库,再删除缓存** ✅ 推荐
```javascript
// 优点:数据库更新时加行锁,其他线程必须等待,避免脏数据
await db.query('UPDATE products SET ...') // 更新数据库(加行锁)
await redis.del('product:1') // 删除缓存
// 即使删除缓存失败,只是下次查询会回源,不会导致脏数据长期存在
```
**为什么策略 3 最优?**
1. **数据库锁保护**:更新操作会获取行锁,其他读写操作必须等待
2. **删除失败影响小**:即使删除缓存失败,只是下次读取会回源,不会导致脏数据
3. **简单可靠**:不需要额外的复杂逻辑
:::
### 5.3 延迟双删:极端场景的一致性保障
**场景**:在高并发场景下,即使是"先更新 DB,再删缓存",仍有极小概率出现不一致。延迟双删通过两次删除,最大限度保证一致性。
**流程**
```
1. 删除缓存
2. 更新数据库
3. 等待一段时间(如 500ms
4. 再次删除缓存
```
```javascript
async function updateProduct(productId, updateData) {
const cacheKey = `product:${productId}`
// 1. 第一次删除缓存
await redis.del(cacheKey)
// 2. 更新数据库
await db.query(
'UPDATE products SET name = ?, price = ? WHERE id = ?',
[updateData.name, updateData.price, productId]
)
// 3. 等待 500ms(让其他线程的查询完成)
await new Promise(resolve => setTimeout(resolve, 500))
// 4. 第二次删除缓存(删除可能被其他线程加载的旧数据)
await redis.del(cacheKey)
console.log('延迟双删完成,数据已同步')
}
```
**三种一致性策略对比**
| 策略 | 一致性级别 | 性能影响 | 复杂度 | 适用场景 |
|------|-----------|---------|--------|---------|
| **先更新 DB,再删缓存** | 最终一致(不一致窗口 < 100ms) | 低 | 低 | 大多数场景,推荐作为默认方案 |
| **延迟双删** | 强最终一致(不一致窗口 < 10ms) | 中(延迟 500ms) | 中 | 对一致性要求较高的场景(如金融、库存) |
| **先删缓存,再更新 DB** | 弱(不一致窗口大) | 低 | 低 | ❌ 不推荐,易出现不一致 |
👇 **动手看看**
下面这个演示对比了三种一致性策略的效果。点击"更新数据",观察缓存和数据库的一致性变化:
<CacheConsistencyDemo />
---
## 6. 实战:构建一个完整的缓存系统
讲了这么多原理,让我们看一个真实案例:如何为一个电商商品详情页设计完整的缓存系统。
### 6.1 业务场景分析
**需求**:用户访问商品详情页,需要展示商品基础信息、价格、库存、评价等数据。
**特点**
- **读多写少**:100 次查询,1 次更新(读写比 100:1)
- **热点集中**:20% 的商品贡献 80% 的流量
- **数据复杂**:商品基础信息 + 价格 + 库存 + 评价聚合
- **一致性要求**:价格、库存强一致,其他可最终一致
**性能指标**
- P99 响应时间 < 100ms99% 的请求在 100ms 内返回)
- 数据库 QPS 峰值 < 5000
- 缓存命中率 > 95%
### 6.2 架构设计
**多级缓存架构**
```
用户请求
CDN 缓存(静态资源:图片、CSS、JS)
↓ 未命中
Nginx 本地缓存(商品基础信息聚合)
↓ 未命中
应用服务器
├─ L1: 本地缓存(Caffeine,热点商品)
│ ↓ 未命中
├─ L2: Redis 缓存(所有商品数据)
│ ↓ 未命中
└─ L3: MySQL 数据库(全量数据)
```
### 6.3 核心代码实现
**完整的多级缓存实现(简化版)**
```javascript
const caffeine = require('caffeine')
// L1: 本地缓存(30 秒过期)
const localCache = new caffeine.Cache({
max: 1000,
ttl: 30,
})
// 获取商品详情(多级缓存)
async function getProduct(productId) {
const cacheKey = `product:${productId}`
// L1: 本地缓存(约 0.1 毫秒)
const localCached = localCache.get(cacheKey)
if (localCached) {
console.log('L1 命中')
return localCached
}
// L2: Redis 缓存(约 1 毫秒)
const redisCached = await redis.get(cacheKey)
if (redisCached) {
console.log('L2 命中,回填 L1')
const product = JSON.parse(redisCached)
localCache.set(cacheKey, product)
return product
}
// L3: 数据库(约 10 毫秒,带分布式锁防击穿)
const lockKey = `lock:${productId}`
const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10)
if (lock === 'OK') {
console.log('L3 命中,查询数据库')
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
)
if (product) {
// 写入 Redis30 分钟 + 随机 TTL
const ttl = 1800 + Math.floor(Math.random() * 600) - 300
await redis.setex(cacheKey, ttl, JSON.stringify(product))
// 回填本地缓存
localCache.set(cacheKey, product)
}
await redis.del(lockKey)
return product
} else {
// 获取锁失败,等待后重试
await new Promise(resolve => setTimeout(resolve, 50))
return getProduct(productId)
}
}
// 更新商品信息(先更新 DB,再删除缓存)
async function updateProduct(productId, updateData) {
const cacheKey = `product:${productId}`
// 1. 更新数据库
await db.query(
'UPDATE products SET name = ?, price = ? WHERE id = ?',
[updateData.name, updateData.price, productId]
)
// 2. 删除本地缓存
localCache.del(cacheKey)
// 3. 删除 Redis 缓存
await redis.del(cacheKey)
console.log('更新完成,缓存已删除')
}
```
👇 **动手看看**
下面这个演示展示了多级缓存系统的完整工作流程。点击"查询商品",观察请求如何在各级缓存中流转:
<EcommerceCacheArchitectureDemo />
---
## 7. 总结与学习路径
### 7.1 核心知识点回顾
| 知识点 | 一句话解释 | 解决的问题 | 实战要点 |
|--------|-----------|-----------|----------|
| **缓存命中** | 数据在缓存中找到 | 性能提升 10-100 倍 | 命中率目标 > 95% |
| **缓存穿透** | 查询不存在数据,每次都查数据库 | 数据库被恶意查询拖垮 | 布隆过滤器 + 缓存空对象 |
| **缓存击穿** | 热点数据过期,大量请求打到数据库 | 数据库瞬间压力暴增 | 互斥锁 + 逻辑过期 |
| **缓存雪崩** | 大量数据同时过期 | 数据库被压垮 | 随机 TTL + 缓存预热 |
| **多级缓存** | 本地缓存 + Redis + 数据库 | 性能极致优化 | L1 本地缓存命中率 70%L2 Redis 命中率 25% |
| **缓存一致性** | 缓存和数据库同步 | 数据准确性 | 先更新 DB,再删除缓存 |
| **延迟双删** | 更新前后各删除一次缓存 | 极端场景的一致性 | 等待 500ms 后再删除 |
### 7.2 学习路径建议
**阶段 1:理解原理(1-2 天)**
- 掌握缓存的本质(数据副本,用空间换时间)
- 理解缓存命中率、TTL、淘汰等核心概念
- 了解不同存储介质的性能差异(内存 vs 硬盘)
**阶段 2:掌握基础(2-3 天)**
- 学会使用 Redis 做缓存(SET、GET、SETEX 命令)
- 实现简单的缓存读写逻辑(先查缓存,未命中再查数据库)
- 理解为什么"更新时删除缓存而不是更新缓存"
**阶段 3:解决经典问题(1 周)**
- 解决缓存穿透:实现布隆过滤器或缓存空对象
- 解决缓存击穿:实现互斥锁或逻辑过期
- 解决缓存雪崩:实现随机 TTL 和缓存预热
**阶段 4:多级缓存(1-2 周)**
- 引入本地缓存(Caffeine/Guava
- 设计本地缓存 + Redis 的两级架构
- 处理多级缓存的一致性问题
**阶段 5:生产级实战(持续)**
- 设计完整的商品详情页缓存系统
- 搭建监控(缓存命中率、响应时间)
- 进行压测验证和性能调优
::: info 💡 写在最后
缓存是高并发系统的基石。从淘宝的商品详情页到微博的热搜榜,从微信的朋友圈到抖音的视频流,所有高性能系统背后都有一套精心设计的缓存架构。
理解缓存,不只是学会一个技术,更是理解**用空间换时间、用副本保护主数据**的架构思想。当你真正掌握缓存,你的系统性能将从"能用"跨越到"好用",最终达到"极致"。
希望这篇文章能帮助你建立起对缓存系统的完整认知。当你在实际项目中遇到性能问题时,能够想到:"是否可以用缓存来解决?"
:::