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