CC 咖啡猫的工作空间 Coding Space

缓存架构与策略

原始素材:4-服务器与后端/4.12-caching.md

为什么有些网站打开只需 50 毫秒,而有些却要等 5 秒? 这就像问:为什么从书包拿书只要 1 秒,而要去图书馆找书要 10 分钟?答案就是——缓存。本章将带你深入理解缓存的核心原理、设计模式和实战技巧,让你的系统性能提升 100 倍。

1. 为什么要"缓存"?

1.1 从"每次都查"到"记住常用数据"的演变

在计算机世界的早期,程序员每次需要数据时都会去硬盘或数据库查询。这就像你每次做数学题都要翻书查公式,虽然准确,但效率很低。随着系统规模增大,这种"每次都查"的方式开始暴露出严重的问题:数据库 CPU 飙升到 95%,响应时间从 100 毫秒暴涨到 8 秒,最终整个系统崩溃。

这就像一个学生每天上课都要从宿舍跑到图书馆查资料,一天跑 50 次,最后累瘫在半路。解决方案很简单:在书包里放一本常用公式手册,需要时直接翻书包,不用每次都跑图书馆。缓存就是计算机系统的"公式手册",它把常用数据存储在快速访问的地方,让系统不用每次都去"图书馆"(数据库)。

🐌 没有缓存

  • 每次请求都查数据库
  • 数据库 CPU 使用率 95%
  • 响应时间 5-8 秒
  • 系统容易崩溃

🚀 有缓存

  • 95% 请求直接返回
  • 数据库 CPU 使用率 < 20%
  • 响应时间 50 毫秒
  • 系统稳定运行

这就是"缓存"要解决的核心问题:通过存储常用数据的副本,减少对慢速存储(数据库)的访问,让系统更快、更稳定。

1.2 一个真实的踩坑故事:为什么缓存是救命稻草

你可能会想:"我的系统现在还行,为什么要提前设计缓存?"让我讲一个真实的故事,你就会明白为什么缓存不是"可选项",而是"必选项"。

阿强的数据库崩溃记

阿强是一个创业公司的全栈工程师,公司做了一个社交 App。早期用户少(几百人),系统运行正常,阿强觉得没必要搞缓存,直接查数据库就行。

半年后,用户增长到 10 万人,某天有个明星在 App 上发了一条动态,瞬间涌来 10 万用户访问。结果数据库直接撑爆了:CPU 100%,响应时间从 100ms 变成 30 秒,最后整个 App 崩溃,用户大量流失。

事后复盘:如果当时有一个简单的缓存层(比如 Redis),把热门动态缓存起来,数据库压力至少能降低 95%,系统完全能撑住这次流量洪峰。

阿强从此明白了一个道理:缓存不是锦上添花,而是高并发系统的保命符。不加缓存,就像开车不系安全带——平时没事,出事就晚了。

缓存的价值不只是"更快",更重要的是"保护"。它保护数据库不被压垮,保护系统在高流量下依然稳定运行。当你设计系统时,不要等到出事才想起缓存,要从一开始就把它作为核心架构的一部分。

2. 核心概念:什么是缓存?

简单来说,缓存就是数据副本的存储空间 。就像你在书桌前贴了一张便利贴,记着常用电话号码,这样就不需要每次都翻手机通讯录。

三个关键点

  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 万倍 硬盘数据库查询

性能差距触目惊心 :内存操作比 MySQL 查询快 10 万倍!这就像从书桌拿书(1 秒)和去图书馆找书(10 万秒,约 28 小时)的差距。

三层性能阶梯

  1. 本地缓存(内存) :最快,但容量小,适合热点数据
  2. Redis 缓存 :中等速度,容量大,适合分布式场景
  3. 数据库 :最慢,但容量无限,是数据的最终来源

实战启示 :你的系统应该让 95% 以上的请求在缓存层就返回,只有不到 5% 的请求需要查数据库。这样数据库压力小,系统整体性能就会大幅提升。

让我们用代码对比这两种情况:


// 场景:查询用户信息

// ===== 缓存命中 (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 缓存的生命周期

一个缓存条目从创建到销毁,会经历完整的生命周期。理解这个过程对设计缓存系统至关重要。

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) :删除最早写入的数据

缓存生命周期的完整过程

缓存项的生命周期:从创建 → 使用 → 过期 → 淘汰

时间轴→

┌─────────────────────────────────────────────────────────────┐
│ 阶段 1:写入 (Write)                                         │
├─────────────────────────────────────────────────────────────┤
│ • 懒加载:第一次查询时,从 DB 加载到缓存                       │
│ • 主动写入:系统启动时批量预热热点数据                        │
│ ✅ 缓存: 空 → 有值                                            │
│ ✅ 容量: 0/6 → 1/6                                            │
└─────────────────────────────────────────────────────────────┘
                         ↓
┌─────────────────────────────────────────────────────────────┐
│ 阶段 2:使用 (In Use) — 持续命中                              │
├─────────────────────────────────────────────────────────────┤
│ • 每次请求都会查缓存,绝大多数命中                           │
│ • 命中率通常 > 90%                                            │
│ • 最后访问时间 (LRU) 不断更新                                │
│ ✅ 状态: 缓存命中(0.1 毫秒内返回)                           │
│ ✅ 命中率: 90% 以上                                           │
└─────────────────────────────────────────────────────────────┘
                         ↓ (TTL 倒计时)
┌─────────────────────────────────────────────────────────────┐
│ 阶段 3:过期 (Expiration)                                    │
├─────────────────────────────────────────────────────────────┤
│ • TTL (Time To Live) 计时器到期                              │
│ • 缓存项自动标记为"即将过期"                                │
│ • 下次访问时发现过期,必须重新加载                          │
│ ⚠️ 状态: 缓存即将过期                                        │
│ ⚠️ 处理: 查询数据库重新加载(缓存未命中)                    │
└─────────────────────────────────────────────────────────────┘
                         ↓
┌─────────────────────────────────────────────────────────────┐
│ 阶段 4:淘汰 (Eviction)                                      │
├─────────────────────────────────────────────────────────────┤
│ • 缓存空间满了(如 Redis 内存达到上限)                      │
│ • 根据淘汰策略删除旧数据                                    │
│ • LRU:删除最久未使用的项(最常用)                         │
│ • LFU:删除访问频率最低的项                                 │
│ ❌ 状态: 缓存被删除                                          │
│ ❌ 容量: 6/6 → 5/6(空出空间)                               │
└─────────────────────────────────────────────────────────────┘
                         ↓
                     重新进入阶段 1

缓存状态监控表

指标 描述 典型值 告警阈值
缓存容量使用率 已使用空间 / 总容量 60% > 80% 需要扩容
缓存命中率 命中次数 / 总查询次数 95% < 70% 需要优化
平均 TTL 缓存项平均存活时间 30 分钟 < 5 分钟 说明数据变化快
淘汰速率 单位时间内被淘汰的项数 100 items/min > 1000 items/min 说明容量不足
回源频率 缓存未命中转向数据库的频率 5% > 30% 缓存策略需调整

缓存项生命周期事件时间线

缓存项 "product:123" 的完整生命周期示例

时间    事件                状态                  容量        操作
────────────────────────────────────────────────────────────────
T0:00   缓存未命中          空                    0/6        从 DB 查询
T0:05   写入缓存            缓存新写入            1/6        SET product:123 = {...}
T0:10   用户查询 1          ✅ 命中               1/6        缓存直接返回(0.1ms)
T0:15   用户查询 2          ✅ 命中               1/6        缓存直接返回(0.1ms)
T0:20   用户查询 3          ✅ 命中               1/6        缓存直接返回(0.1ms)
        ...(大量命中)
T29:50  TTL 倒计时          即将过期              1/6        还有 10 秒过期
T30:00  TTL 到期            已过期                1/6        缓存失效
T30:05  用户查询 4          ❌ 未命中             1/6        查 DB,加载新值
T30:10  缓存容量满          满容                  6/6        无法写入新数据
T30:15  触发淘汰            LRU 淘汰              6/6        删除最久未使用项
T30:20  淘汰完成            空间释放              5/6        可继续写入

四个阶段的详细对比

阶段 触发条件 缓存状态 系统行为 用户体验 持续时间
阶段 1:写入 首次查询或预热 从无到有 从 DB 加载,写入缓存 首次查询 10ms 一次性
阶段 2:使用 缓存存在且未过期 活跃状态 直接返回缓存值 极快 0.1ms TTL 秒
阶段 3:过期 TTL 计时器到期 失效状态 标记过期,等待清理 下次查询慢 10ms 过渡瞬间
阶段 4:淘汰 容量满 + 新写入 被删除 根据策略删除旧数据 无影响 瞬间

缓存淘汰策略对比

策略 算法思路 优点 缺点 适用场景
LRU (Least Recently Used) 删除最久未访问的数据 逻辑简单,命中率高 不考虑访问频率 最常用 — 一般缓存系统
LFU (Least Frequently Used) 删除访问次数最少的数据 能保留热数据 需要记录访问频率,开销大 访问模式差异大的场景
FIFO (First In First Out) 删除最早写入的数据 实现简单 不考虑使用情况,可能删除热数据 简单场景(如消息队列)
TTL (Time To Live) 按过期时间删除 数据新鲜度高 可能浪费空间(数据过期但未淘汰) 数据有时效性的场景

实战案例:监控缓存生命周期

// Redis 客户端监控缓存项的生命周期

class CacheLifecycleMonitor {
  constructor(redis) {
    this.redis = redis
  }

  // 记录缓存事件
  async logEvent(key, event, metadata = {}) {
    const timestamp = new Date().toISOString()
    const eventLog = {
      key,
      event,
      timestamp,
      ...metadata
    }
    console.log(`[缓存事件] ${JSON.stringify(eventLog)}`)
  }

  // 阶段 1:写入缓存
  async write(key, value, ttl = 1800) {
    await this.redis.setex(key, ttl, JSON.stringify(value))
    await this.logEvent(key, 'WRITE', { ttl, size: JSON.stringify(value).length })
  }

  // 阶段 2:读取缓存(命中或未命中)
  async read(key) {
    const cached = await this.redis.get(key)
    
    if (cached) {
      await this.logEvent(key, 'HIT', { type: 'cache' })
      return JSON.parse(cached)
    } else {
      await this.logEvent(key, 'MISS', { type: 'cache' })
      return null
    }
  }

  // 阶段 3:检查过期
  async checkExpiration(key) {
    const ttl = await this.redis.ttl(key)
    
    if (ttl === -2) {
      // 键不存在
      await this.logEvent(key, 'EXPIRED', { reason: 'key_not_found' })
      return true
    } else if (ttl === -1) {
      // 键存在但没有过期时间
      await this.logEvent(key, 'NO_EXPIRATION', { ttl: -1 })
      return false
    } else if (ttl < 60) {
      // 即将过期(< 60 秒)
      await this.logEvent(key, 'EXPIRING_SOON', { ttl_seconds: ttl })
      return false
    }
  }

  // 阶段 4:手动淘汰
  async evict(key) {
    const result = await this.redis.del(key)
    if (result === 1) {
      await this.logEvent(key, 'EVICTED', { reason: 'manual' })
    }
  }

  // 获取缓存状态
  async getCacheStats() {
    const info = await this.redis.info('memory')
    const lines = info.split('\r\n')
    const stats = {}
    
    lines.forEach(line => {
      const [key, value] = line.split(':')
      if (key && value) {
        stats[key] = value
      }
    })

    return {
      totalMemory: stats.total_system_memory,
      usedMemory: stats.used_memory,
      usagePercentage: `${(stats.used_memory / stats.total_system_memory * 100).toFixed(2)}%`,
      evictedKeys: stats.evicted_keys,
      expiredKeys: stats.expired_keys
    }
  }
}

3. 缓存的演进之路:从单机到分布式

就像你学习时会在不同地方放资料:书桌上放最常用的(便利贴),书包里放常用的(笔记本),图书馆放所有资料(书库)。

缓存系统也一样

  • 本地缓存(书桌) :最快,容量小,放超级热点数据
  • 分布式缓存(公共储物柜) :较快,容量大,放常用数据
  • 数据库(图书馆) :最慢,容量无限,放所有数据

为什么要分层? 因为不同层次的性能和成本不同,合理组合才能达到最优效果。

讲了这么多概念,让我们看一个真实的案例:某电商系统是如何从"没有缓存"一步步进化到"多级缓存架构"的。通过这个案例,你会更直观地理解缓存设计的重要性。

3.1 阶段一:无缓存时代——数据库裸奔

背景 :早期系统用户少(几百人),所有请求直接查数据库,没有任何缓存层。

技术栈

  • 数据库:MySQL
  • 无缓存:没有 Redis,没有本地缓存

系统架构

用户请求 → 应用服务器 → MySQL 数据库

这个阶段的特点

  • 优点 :架构简单,开发快速
  • 缺点 :数据库压力大,性能差,用户量上千就崩

查看当时的代码和遇到的问题

代码示例 (每次都查数据库):


// 获取商品详情——每次都查数据库
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 单点故障,缓存和数据库可能不一致

查看 Redis 缓存的实现代码

代码示例 (增加 Redis 缓存):


// 获取商品详情——先查 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 宕机不影响热点数据)
  • 缺点 :架构复杂,多级缓存的一致性难以保证

查看多级缓存的实现代码

代码示例 (本地缓存 + Redis 两级缓存):


// 使用 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 倍

阶段一 → 阶段二 :质的飞跃。引入 Redis 后,性能提升 10 倍,数据库压力降低 90%。这是从"能用"到"够用"的关键一步。

阶段二 → 阶段三 :极致优化。引入本地缓存后,性能再提升 5 倍。这是从"够用"到"极致"的进阶,适合超大流量场景。

实战建议

  • 用户量 < 1 万:阶段一(无缓存)够用,但建议引入 Redis(阶段二)
  • 用户量 1-10 万 :阶段二(Redis 缓存)是最佳选择
  • 用户量 > 10 万:考虑阶段三(多级缓存),但要注意一致性复杂度

总结一下 :缓存架构演进不只是"加更多缓存层",而是根据流量规模选择合适的架构 ——过度设计会增加复杂度,设计不足会导致性能瓶颈。

4. 缓存的三大经典问题:穿透、击穿、雪崩

在实战中,缓存会引入三类经典问题。如果不了解它们,你的系统可能在某个时刻突然崩溃。让我们用生活化的比喻来理解这些问题。

4.1 缓存穿透:查询不存在数据

问题定义 :查询一个不存在的数据 (如 id=-1),缓存中没有(因为没有存过),数据库中也没有,导致每次请求都直接穿透到数据库。

想象你在图书馆查一本书,你问管理员:"有没有《不存在之书》?"

正常流程

  • 管理员查目录:"没有这本书"
  • 你离开

缓存穿透场景

  • 你第 1 次来问,管理员查数据库:"没有",告诉你
  • 你第 2 次来问,管理员又查一遍数据库:"没有"
  • 你第 100 次来问,管理员还是查数据库:"没有"

问题 :管理员(数据库)被烦死了,每次都要查数据库,即使答案永远是"没有"。

解决 :管理员记住"《不存在之书》不存在",下次你问,直接说"没有",不用查数据库。这就是缓存空对象

真实场景

  • 恶意攻击者构造大量不存在的 ID 进行查询(如 id=-1, id=999999999)
  • 爬虫遍历不存在的资源路径(如 /api/products/invalid-id)
  • 业务逻辑错误导致查询无效数据

解决方案 1:缓存空对象


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)

布隆过滤器是一个"快速判断数据是否存在"的工具,它像一个"超级索引":

想象你有一个"神奇的黑盒":

  • 你问它:"ID 为 123 的商品存在吗?"
  • 它说:"肯定不存在 " → 那就真不存在,不用查数据库
  • 它说:"可能存在 " → 那就去查数据库确认

特点

  • 绝对不会漏判 :如果它说不存在,那就真不存在
  • 可能误判 :如果它说可能存在,有可能实际不存在(概率很低,可调)

价值 :布隆过滤器能在查缓存之前,就把 99% 的"不存在"请求拦截掉,保护数据库。


// 使用布隆过滤器
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 到期),此时大量并发请求同时到达,都去查询数据库,导致数据库压力骤增。

想象图书馆有本《哈利波特》,超热门,100 个人都想借。

正常情况

  • 图书馆把《哈利波特》放在"借阅台"(缓存)
  • 大家直接从借阅台拿,不用去书架找

缓存击穿场景

  • 借阅台的《哈利波特》到期了(被还回书架)
  • 100 个人同时来借,发现借阅台没有
  • 100 个人都冲去书架找(数据库)
  • 书架管理员(数据库)被挤爆了

问题 :不是"不存在的书",而是"超热门的书"突然从缓存消失了,导致瞬间大量请求打到数据库。

真实场景

  • 微博热搜榜过期瞬间,几万人同时访问
  • 明星八卦新闻缓存失效,粉丝疯狂访问
  • 秒杀活动开始时的库存数据过期

解决方案 1:互斥锁 (Mutex Lock)


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)


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 宕机),导致所有请求同时穿透到数据库,瞬间压垮数据库。

想象图书馆的"借阅台"(缓存)有 1000 本书。

正常情况

  • 这些书的还书时间是分散的:有的今天还,有的明天还,有的后天还
  • 每天只有几十本书到期,管理员(数据库)能轻松处理

缓存雪崩场景

  • 系统重启后,管理员把 1000 本书都设置"30 天后到期"
  • 30 天后,这 1000 本书同时到期
  • 1000 个人同时来借书,发现借阅台没有
  • 1000 个人都冲去书架找
  • 书架管理员(数据库)瞬间被挤爆

问题 :不是一本书的问题,而是大量数据同时过期 ,导致数据库瞬间压力暴增。

真实场景

  • 系统重启后,所有缓存从 0 开始重建,同时设置相同 TTL(如 30 分钟)
  • 定时任务批量刷新缓存,设置相同的过期时间
  • 缓存服务(Redis)宕机或网络分区

解决方案 1:随机 TTL


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)


// 系统启动时,主动加载热点数据到缓存
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)


// 使用熔断器保护数据库
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 秒后尝试恢复
  }
)

// 熔断后的降级处理
  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
}

4. 缓存的三大经典问题:穿透、击穿、雪崩

在实战中,缓存会引入三类经典问题。如果不了解它们,你的系统可能在某个时刻突然崩溃。让我们用生活化的比喻来理解这些问题。


4.1 缓存穿透:查询不存在的数据

问题定义 :查询一个不存在的数据 (如恶意请求 id=-1),缓存中没有(因为没有存过),数据库中也没有,导致每次请求都直接穿透到数据库。

缓存穿透的流程

用户请求 id=-999
   ↓
┌──────────────────────┐
│ L1:查本地缓存        │  ❌ 未命中
└──────────────────────┘
   ↓
┌──────────────────────┐
│ L2:查 Redis         │  ❌ 未命中
└──────────────────────┘
   ↓
┌──────────────────────┐
│ L3:查数据库         │  ❌ 不存在
│ 浪费数据库资源        │
└──────────────────────┘
   ↓
返回 null → 缓存什么都没有
   ↓
下一个相同请求重复以上过程 ❌❌❌

问题的本质 :数据库在做"无用功"——多次查询都返回空,消耗 CPU 和磁盘 IO 却无任何收益。

真实场景

  • 恶意攻击者构造大量不存在的 ID 进行查询(如 id=-1, id=999999999)
  • 爬虫遍历不存在的资源路径(如 /api/products/invalid-id)
  • 业务逻辑错误导致查询无效数据

解决方案 1:缓存空对象(最简单)

方案 原理 实现 优点 缺点
缓存空对象 对不存在的数据也在缓存中记录一条"null"记录 redis.setex('product:-1', 300, 'null') 实现简单,无需额外工具 浪费缓存空间,数据新增后需清理
// 缓存空对象的实现

async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  // 1. 先查缓存
  const cached = await redis.get(cacheKey)
  if (cached !== null) {
    // 注意:cached 可能是字符串 "null"
    if (cached === 'null') {
      // 缓存的是"空对象",说明数据库中没有这个数据
      console.log('缓存空对象,直接返回 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) {
    console.log('数据库中不存在,缓存 null 值(5 分钟)')
    await redis.setex(cacheKey, 300, 'null')  // 5 分钟后自动清理
    return null
  }

  // 4. 查到数据,正常缓存
  console.log('数据库中存在,缓存 30 分钟')
  await redis.setex(cacheKey, 1800, JSON.stringify(product))
  return product
}

解决方案 2:布隆过滤器(最优雅)

方案 原理 实现 优点 缺点
布隆过滤器 在缓存前加一层"魔法过滤器",绝对判断数据不存在 系统启动时把所有有效 ID 加入过滤器 不浪费缓存空间,能拦截 99% 的无效请求 需要额外维护过滤器,有极小误判率

布隆过滤器的工作原理

系统启动:把所有有效商品 ID 加入布隆过滤器
  ↓
用户请求 id=123(存在)
  ↓
布隆过滤器判断:✅ 可能存在
  ↓
继续查缓存 → 查数据库(正常流程)

用户请求 id=-999(不存在)
  ↓
布隆过滤器判断:❌ 肯定不存在
  ↓
直接返回 null,不查缓存和数据库(保护数据库)

4.2 缓存击穿:热点数据过期

问题定义 :某个热点数据 (如热门商品、热搜新闻)在缓存中过期(TTL 到期),此时大量并发请求同时到达,都去查询数据库,导致数据库压力骤增。

缓存击穿的流程

热门商品缓存 TTL 到期
   ↓
100 个并发请求同时到达
   ↓
┌──────────────────────┐
│ 都查缓存              │  ❌❌❌ 全部未命中
└──────────────────────┘
   ↓
┌──────────────────────┐
│ 都查数据库            │  😨 瞬间 100 个查询
│ 数据库 CPU 飙升        │
└──────────────────────┘

问题的本质 :不是"无效请求",而是"超热点数据"突然失效,导致瞬间大量并发请求打到数据库。

真实场景

  • 微博热搜榜过期瞬间,几万人同时访问
  • 明星八卦新闻缓存失效,粉丝疯狂访问
  • 秒杀活动开始时的库存数据过期

解决方案 1:互斥锁(最强一致性)

方案 原理 优点 缺点
互斥锁 只允许一个线程查数据库,其他线程等待 数据一致性最强 性能略差,需要等待时间
// 互斥锁实现

async function getProduct(productId) {
  const cacheKey = `product:${productId}`
  const lockKey = `lock:${productId}`

  // 1. 先查缓存
  const cached = await redis.get(cacheKey)
  if (cached) {
    return JSON.parse(cached)
  }

  // 2. 缓存未命中,尝试获取锁
  const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10)  // 非阻塞锁,10秒自动释放

  if (lock === 'OK') {
    // 获取锁成功,由我来查数据库
    console.log('获取锁成功,查询数据库并重建缓存')
    const product = await db.query(
      'SELECT * FROM products WHERE id = ?',
      [productId]
    )
    
    await redis.setex(cacheKey, 1800, JSON.stringify(product))
    await redis.del(lockKey)  // 释放锁
    return product
  } else {
    // 获取锁失败,等待后重试(等待其他线程更新缓存)
    console.log('获取锁失败,等待 50ms 后重试')
    await new Promise(resolve => setTimeout(resolve, 50))
    return getProduct(productId)  // 递归重试
  }
}

解决方案 2:逻辑过期(最高性能)

方案 原理 优点 缺点
逻辑过期 虽然缓存逻辑过期了,但先返回旧值,然后异步重建缓存 用户无需等待,性能最高 用户短时间内看到旧数据
// 逻辑过期实现

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 {
      // 逻辑过期,异步重建,同时返回旧值
      console.log('逻辑过期,异步重建缓存,先返回旧值')
      rebuildCacheAsync(productId)  // 异步,不等待
      return data.product  // 立即返回旧数据
    }
  }

  // 3. 首次访问,同步查数据库
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )

  // 4. 包含逻辑过期时间写入缓存
  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 宕机),导致所有请求同时穿透到数据库,瞬间压垮数据库。

缓存雪崩的流程

系统启动,设置所有数据 TTL = 30 分钟
   ↓
30 分钟后,所有缓存同时过期
   ↓
所有用户的请求同时到达
   ↓
┌──────────────────────┐
│ 所有请求都未命中      │  ❌ 1 万个并发请求
└──────────────────────┘
   ↓
┌──────────────────────┐
│ 全部打到数据库        │  💥 瞬间压垮
│ 数据库 QPS 从 200    │
│ 升到 10000          │
└──────────────────────┘
   ↓
数据库连接池耗尽 → 所有查询超时 → 服务不可用

问题的本质 :不是一个数据的问题,而是大量数据同时失效导致数据库瞬间压力暴增。

真实场景

  • 系统重启后,所有缓存从 0 开始重建,同时设置相同 TTL(如 30 分钟)
  • 定时任务批量刷新缓存,设置相同的过期时间
  • Redis 宕机或网络分区导致所有缓存不可用

解决方案 1:随机 TTL(最简单)

// 在基础 TTL 上加随机值,分散过期时间

async function setProductCache(productId, product) {
  const cacheKey = `product:${productId}`
  
  // 基础 TTL = 30 分钟 = 1800 秒
  const baseTTL = 1800
  
  // 随机偏移 = ±5 分钟 = ±300 秒
  const randomOffset = Math.floor(Math.random() * 600) - 300
  
  // 最终 TTL = 1500~2100 秒(25~35 分钟)
  const finalTTL = baseTTL + randomOffset
  
  console.log(`缓存 TTL: ${finalTTL} 秒(${(finalTTL / 60).toFixed(1)} 分钟)`)
  await redis.setex(cacheKey, finalTTL, JSON.stringify(product))
}

效果

无随机 TTL:
  T30:00 → 所有缓存同时过期 ❌
  
有随机 TTL:
  T25:00 → 100 个缓存过期
  T26:00 → 120 个缓存过期
  T27:00 → 110 个缓存过期
  ...
  T35:00 → 105 个缓存过期
  (分散过期,压力平缓)✅

解决方案 2:缓存预热(最主动)

// 系统启动时,主动加载热点数据到缓存

async function cacheWarmup() {
  console.log('开始缓存预热...')

  // 1. 查询最热门的 1000 个商品(根据访问量排序)
  const hotProducts = await db.query(`
    SELECT * FROM products
    ORDER BY view_count DESC
    LIMIT 1000
  `)

  // 2. 批量写入 Redis(使用随机 TTL)
  for (const product of hotProducts) {
    const cacheKey = `product:${product.id}`
    const baseTTL = 1800
    const randomOffset = Math.floor(Math.random() * 600) - 300
    const finalTTL = baseTTL + randomOffset
    
    await redis.setex(cacheKey, finalTTL, JSON.stringify(product))
  }

  console.log(`缓存预热完成,已加载 ${hotProducts.length} 个热门商品`)
}

// 应用启动时执行
cacheWarmup()

**三大问题对比

问题 原因 影响 主要解决方案
缓存穿透 查询不存在的数据 数据库压力增加 布隆过滤器、缓存空对象
缓存击穿 热点数据过期 数据库瞬间压力 互斥锁、逻辑过期
缓存雪崩 大量缓存同时过期 数据库被打爆 随机 TTL、缓存预热

5. 缓存一致性策略:如何让缓存和数据库保持同步

缓存的本质是数据的副本,副本和原始数据(数据库)之间必然存在不一致的时间窗口。如何控制这个时间窗口,是缓存设计的核心挑战。

5.1 为什么缓存和数据库会不一致?

想象你在便利贴上记着:"小明电话: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 最佳实践:先更新数据库,再删除缓存

你可能会想:为什么不直接"更新缓存",而是"删除缓存"?

更新缓存的问题

  • 并发更新时,可能出现 A 线程先更新缓存,B 线程后更新数据库但缓存没更新
  • 更新缓存的成本可能很高(比如需要聚合多个表的数据)
  • 如果更新后数据又被删除了,白费力气

删除缓存的优势

  • 下次查询时自动从数据库加载最新数据(懒加载)
  • 避免并发更新导致的脏数据
  • 简单可靠,是业界最佳实践

标准流程


// 更新商品信息
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('更新完成,缓存已删除')
}

查看为什么"先更新 DB,再删缓存"是最优方案

对比三种更新策略:

策略 1:先更新缓存,再更新数据库 ❌ 不推荐


// 问题:如果更新数据库失败,缓存是新值,数据库是旧值,不一致
await redis.set('product:1', newProduct)  // 缓存更新成功
await db.query('UPDATE products SET ...')  // 数据库更新失败!
// 结果:缓存是新值,数据库是旧值,永久不一致!

策略 2:先删除缓存,再更新数据库 ❌ 不推荐


// 问题:删除和更新之间,有其他线程查询,会加载旧数据到缓存
await redis.del('product:1')  // 缓存删除
// 此时线程 B 来查询,发现缓存没有,查数据库(还是旧值),写入缓存
await db.query('UPDATE products SET ...')  // 更新数据库
// 结果:缓存是旧值,数据库是新值,不一致!

策略 3:先更新数据库,再删除缓存 ✅ 推荐


// 优点:数据库更新时加行锁,其他线程必须等待,避免脏数据
await db.query('UPDATE products SET ...')  // 更新数据库(加行锁)
await redis.del('product:1')  // 删除缓存
// 即使删除缓存失败,只是下次查询会回源,不会导致脏数据长期存在

为什么策略 3 最优?

  1. 数据库锁保护 :更新操作会获取行锁,其他读写操作必须等待
  2. 删除失败影响小 :即使删除缓存失败,只是下次读取会回源,不会导致脏数据
  3. 简单可靠 :不需要额外的复杂逻辑

5.3 延迟双删:极端场景的一致性保障

场景 :在高并发场景下,即使是"先更新 DB,再删缓存",仍有极小概率出现不一致。延迟双删通过两次删除,最大限度保证一致性。

流程

1. 删除缓存
2. 更新数据库
3. 等待一段时间(如 500ms)
4. 再次删除缓存

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 核心代码实现

完整的多级缓存实现(简化版)


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:生产级实战(持续)

  • 设计完整的商品详情页缓存系统
  • 搭建监控(缓存命中率、响应时间)
  • 进行压测验证和性能调优

缓存是高并发系统的基石。从淘宝的商品详情页到微博的热搜榜,从微信的朋友圈到抖音的视频流,所有高性能系统背后都有一套精心设计的缓存架构。

理解缓存,不只是学会一个技术,更是理解用空间换时间、用副本保护主数据 的架构思想。当你真正掌握缓存,你的系统性能将从"能用"跨越到"好用",最终达到"极致"。

希望这篇文章能帮助你建立起对缓存系统的完整认知。当你在实际项目中遇到性能问题时,能够想到:"是否可以用缓存来解决?"