# 缓存系统设计:从零到高性能架构 ::: tip 🎯 核心问题 **为什么有些网站打开只需 50 毫秒,而有些却要等 5 秒?** 这就像问:为什么从书包拿书只要 1 秒,而要去图书馆找书要 10 分钟?答案就是——缓存。本章将带你深入理解缓存的核心原理、设计模式和实战技巧,让你的系统性能提升 100 倍。 ::: --- ## 1. 为什么要"缓存"? ### 1.1 从"每次都查"到"记住常用数据"的演变 在计算机世界的早期,程序员每次需要数据时都会去硬盘或数据库查询。这就像你每次做数学题都要翻书查公式,虽然准确,但效率很低。随着系统规模增大,这种"每次都查"的方式开始暴露出严重的问题:数据库 CPU 飙升到 95%,响应时间从 100 毫秒暴涨到 8 秒,最终整个系统崩溃。 这就像一个学生每天上课都要从宿舍跑到图书馆查资料,一天跑 50 次,最后累瘫在半路。解决方案很简单:在书包里放一本常用公式手册,需要时直接翻书包,不用每次都跑图书馆。缓存就是计算机系统的"公式手册",它把常用数据存储在快速访问的地方,让系统不用每次都去"图书馆"(数据库)。
**🐌 没有缓存** - 每次请求都查数据库 - 数据库 CPU 使用率 95% - 响应时间 5-8 秒 - 系统容易崩溃
**🚀 有缓存** - 95% 请求直接返回 - 数据库 CPU 使用率 < 20% - 响应时间 50 毫秒 - 系统稳定运行
**这就是"缓存"要解决的核心问题:通过存储常用数据的副本,减少对慢速存储(数据库)的访问,让系统更快、更稳定。** ### 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)**:删除最早写入的数据 👇 **动手看看**: 下面这个演示展示了缓存的生命周期。点击"新增缓存",观察缓存如何经历写入、命中、过期、淘汰的全过程: --- ## 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] ) // 回填 Redis(30 分钟过期) 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 } ``` 👇 **动手看看**: 下面这个演示对比了缓存穿透、击穿、雪崩三种问题的场景和解决方案: --- ## 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** | 弱(不一致窗口大) | 低 | 低 | ❌ 不推荐,易出现不一致 | 👇 **动手看看**: 下面这个演示对比了三种一致性策略的效果。点击"更新数据",观察缓存和数据库的一致性变化: --- ## 6. 实战:构建一个完整的缓存系统 讲了这么多原理,让我们看一个真实案例:如何为一个电商商品详情页设计完整的缓存系统。 ### 6.1 业务场景分析 **需求**:用户访问商品详情页,需要展示商品基础信息、价格、库存、评价等数据。 **特点**: - **读多写少**:100 次查询,1 次更新(读写比 100:1) - **热点集中**:20% 的商品贡献 80% 的流量 - **数据复杂**:商品基础信息 + 价格 + 库存 + 评价聚合 - **一致性要求**:价格、库存强一致,其他可最终一致 **性能指标**: - P99 响应时间 < 100ms(99% 的请求在 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) { // 写入 Redis(30 分钟 + 随机 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('更新完成,缓存已删除') } ``` 👇 **动手看看**: 下面这个演示展示了多级缓存系统的完整工作流程。点击"查询商品",观察请求如何在各级缓存中流转: --- ## 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 💡 写在最后 缓存是高并发系统的基石。从淘宝的商品详情页到微博的热搜榜,从微信的朋友圈到抖音的视频流,所有高性能系统背后都有一套精心设计的缓存架构。 理解缓存,不只是学会一个技术,更是理解**用空间换时间、用副本保护主数据**的架构思想。当你真正掌握缓存,你的系统性能将从"能用"跨越到"好用",最终达到"极致"。 希望这篇文章能帮助你建立起对缓存系统的完整认知。当你在实际项目中遇到性能问题时,能够想到:"是否可以用缓存来解决?" :::