缓存分层策略完全指南:本地缓存、Redis、CDN 到底该怎么配

缓存不是‘加个 Redis’就结束。本文从真实高并发场景出发,系统拆解本地缓存、Redis、CDN 的职责边界、回源策略、失效治理、热点保护与成本权衡,帮你建立一套可落地的多层缓存体系。

18 分钟阅读
小明

缓存分层策略完全指南:本地缓存、Redis、CDN 到底该怎么配

很多团队第一次遇到高并发,脑子里冒出来的第一句往往是:

上 Redis。

第二次再出问题,会说:

再加一层 CDN。

第三次故障复盘时,会议室里通常会出现一句更诚实的话:

为什么我们明明有缓存,系统还是被打穿了?

这句话很关键。

因为缓存从来不是“有”或“没有”的问题,而是:你的每一层缓存,到底在拦截什么流量,承受什么一致性要求,以及在失效时会把压力转嫁给谁。

很多线上事故都不是因为没有缓存,而是因为缓存体系不成体系:

  • 浏览器缓存、CDN、网关缓存、应用本地缓存、Redis 各自为战
  • 缓存 key 命名混乱,删不干净
  • 热点 key 失效后瞬间回源,把数据库打满
  • 静态资源明明适合长缓存,却每次都重新请求
  • 本地缓存命中率很高,但实例扩容后一致性全乱了

缓存真正难的地方,不在于 API 调用,而在于边界设计

这篇文章想解决的,不是“Redis 怎么 set/get”,而是更接近真实系统的几个问题:

  1. 什么数据该放浏览器 / CDN,什么该放本地缓存,什么该放 Redis
  2. 多层缓存的职责怎么拆,才不会互相打架
  3. 怎样处理热点 key、缓存穿透、击穿、雪崩
  4. 怎样设计失效、预热、回源和降级策略
  5. 什么时候缓存值得做,什么时候只是在掩盖底层设计问题

如果你正在做一个访问量持续上涨的系统,这篇比“缓存面试八股”更接近实际战场。


一、先把问题说透:缓存不是优化插件,而是流量分流系统

很多人把缓存理解成“让数据读取更快”。

这句话不能算错,但不够完整。

在工程上,缓存最核心的价值其实是两件事:

  1. 把重复请求挡在更靠前的地方
  2. 把昂贵依赖保护在更靠后的地方

所谓“更靠前”,是离用户更近;所谓“更靠后”,是离数据库、搜索引擎、对象存储这些昂贵资源更近。

1.1 一个直观的分层视角

你可以把一次请求理解成一条向后传播的压力链:

用户浏览器
CDN / Edge
网关 / BFF
应用实例本地缓存
Redis
数据库 / 搜索 / 下游服务

链路越往后:

  • 单次成本越高
  • 响应越慢
  • 资源越贵
  • 被打穿后的恢复越麻烦

所以缓存分层的本质,不是“堆更多缓存”,而是:

尽量让更多请求在更前面的层级被消化掉。

1.2 为什么“只有 Redis”不够

很多团队把缓存建设简化成 Redis 一层。这在项目早期是够用的,但规模上来后会暴露 4 个问题:

  • 所有请求仍然要进应用层,应用 CPU 和连接数压力没有被前移消化
  • Redis 成为新的中心瓶颈
  • 跨地域访问时网络 RTT 抵消一部分收益
  • 静态资源和弱动态内容没有利用浏览器 / CDN 的天然能力

Redis 很重要,但它不是缓存体系的全部,只是中间层里最常见的一层。


二、四层缓存分别负责什么

2.1 浏览器缓存:最便宜,也最容易被浪费

浏览器缓存是距离用户最近的一层。只要命中,请求甚至不需要真正到达你的服务器。

最适合浏览器缓存的内容:

  • 带 hash 的 JS / CSS
  • 版本化图片、字体、图标
  • 不频繁变化的静态 JSON 配置

典型响应头:

Cache-Control: public, max-age=31536000, immutable

这类资源最大的关键不是“缓存时间设多长”,而是内容变了,URL 也必须变

如果你不能保证这一点,长缓存就会变成脏缓存。

常见误区

  • HTML 页面也直接缓存一年
  • 没有文件 hash,却把 max-age 配很长
  • 用户投诉看到旧页面时,只会让他“强刷一下试试”

强刷当然是个办法,但这相当于把你的发布系统设计问题,转嫁给用户手动解决。

2.2 CDN:适合大范围分发,不适合承担复杂业务一致性

CDN 最擅长的是:

  • 静态资源分发
  • 图片、视频、下载文件加速
  • 弱动态页面边缘缓存
  • 吸收大规模重复访问流量

例如活动会场页、商品详情页的部分拼装结果、公开文章详情,都很适合 CDN 做第一道拦截。

一个典型例子:

  • 首页 banner 图:浏览器 + CDN 长缓存
  • 活动页骨架 HTML:CDN 短缓存 30 秒
  • 用户个性化信息:不进 CDN,走后端实时获取

这样做的好处,是把“所有人都一样的数据”和“因人而异的数据”拆开。

2.3 应用本地缓存:最快,但最容易产生一致性问题

本地缓存通常在单个进程内,命中时快得像内存访问。

最适合本地缓存的是:

  • 热点配置
  • 权限模型快照
  • 只读字典
  • 短 TTL 的热点对象
  • 下游接口的轻量结果

一个常见实现:

class LocalCache<V> {
  private store = new Map<string, { value: V; expiresAt: number }>()

  get(key: string): V | null {
    const item = this.store.get(key)
    if (!item) return null

    if (Date.now() > item.expiresAt) {
      this.store.delete(key)
      return null
    }

    return item.value
  }

  set(key: string, value: V, ttlMs: number) {
    this.store.set(key, {
      value,
      expiresAt: Date.now() + ttlMs,
    })
  }
}

看起来简单,但本地缓存有两个天然限制:

  1. 实例之间不共享
  2. 实例重启就丢

所以它适合承担高频、可接受短暂不一致、回源代价不算太高的数据,而不适合作为唯一真相来源。

2.4 Redis:共享缓存层,不是真理之书

Redis 最常见的角色是:

  • 多实例共享缓存
  • 分布式限流与计数
  • 排行榜、会话、短期状态
  • 热点对象缓存
  • 分布式锁辅助

它比本地缓存更一致,比数据库更快,但也更贵、更脆弱、更需要治理。

一个成熟的认知是:

Redis 是用来挡住数据库和下游服务的,不是让你把所有东西都塞进去的。


三、怎样判断一份数据该放哪一层

最有效的判断,不是看技术喜好,而是看 4 个维度:

维度要问的问题影响
变化频率数据多久变一次?决定 TTL 和失效方式
一致性要求能接受几秒旧数据吗?决定能否走 CDN / 本地缓存
数据体积返回结果大不大?决定适不适合边缘缓存
回源成本miss 时会压垮谁?决定是否值得做更靠前的缓存

3.1 一个实用的分类方法

A 类:几乎所有人看到都一样,且变化少

例如:

  • logo、静态资源
  • 文章页正文
  • 营销活动介绍页

优先级:浏览器缓存 + CDN

B 类:热点高、变化有节奏、允许秒级旧数据

例如:

  • 首页推荐位
  • 榜单
  • 商品详情的公开部分
  • 热门搜索词

优先级:CDN / 本地缓存 / Redis 组合

C 类:用户相关,但短时间内重复访问高

例如:

  • 用户资料摘要
  • 权限集
  • 购物车摘要
  • 最近浏览记录

优先级:本地缓存 + Redis

D 类:强一致、强事务

例如:

  • 实时库存
  • 余额
  • 支付状态
  • 核销资格

优先级:谨慎缓存,甚至不缓存

不是所有数据都值得缓存。缓存最怕的一种数据,就是“看起来读很多,实际上每次都必须绝对实时”。


四、一个真实可落地的多层缓存架构

下面给一个电商系统里常见的商品详情页设计。

4.1 目标拆分

商品详情页实际包含 4 类数据:

  1. 商品静态信息:标题、图片、参数
  2. 营销信息:活动标签、文案、会场氛围
  3. 个性化信息:会员价、优惠资格、推荐理由
  4. 强实时信息:库存、限购、秒杀状态

如果你把这 4 类数据都放在一个接口里,并要求“完全实时”,那缓存设计会非常难看。

更合理的拆法是:

数据类型缓存层TTL / 策略
静态信息CDN + Redis10 分钟 + 主动失效
营销信息CDN 短缓存30-60 秒
个性化信息本地缓存 + Redis10-30 秒
强实时信息直查或超短缓存1-3 秒 / 甚至不缓存

4.2 BFF 聚合层示例

type ProductDetail = {
  productId: string
  title: string
  images: string[]
  campaignLabel: string | null
  memberPrice: number | null
  inventory: number
}

export async function getProductDetail(
  productId: string,
  userId?: string,
): Promise<ProductDetail> {
  const staticKey = `product:static:${productId}`
  const userKey = userId ? `product:user:${productId}:${userId}` : null

  const staticPart = await cacheAside(
    staticKey,
    10 * 60,
    () => productService.getStaticPart(productId),
  )

  const campaignPart = await cacheAside(
    `product:campaign:${productId}`,
    60,
    () => campaignService.getCampaignPart(productId),
  )

  const personalizedPart = userKey
    ? await localFirstThenRedis(
        userKey,
        20,
        () => pricingService.getUserPricing(productId, userId),
      )
    : { memberPrice: null }

  const inventory = await inventoryService.getRealtimeInventory(productId)

  return {
    ...staticPart,
    ...campaignPart,
    ...personalizedPart,
    inventory,
  }
}

这段设计的核心不在代码技巧,而在边界:

  • 稳定数据尽量缓存久一点
  • 弱实时数据短缓存
  • 个性化数据只在靠后的层缓存
  • 强实时数据不要为了“统一”而硬塞进缓存

五、缓存读写策略,别只会 cache aside

多数团队最熟悉的,是 Cache Aside

  1. 先查缓存
  2. miss 了查数据库
  3. 再回写缓存

这是默认策略,但不是唯一策略。

5.1 Cache Aside:最通用,但要处理并发 miss

export async function cacheAside<T>(
  key: string,
  ttlSeconds: number,
  loader: () => Promise<T>,
): Promise<T> {
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached) as T

  const value = await loader()
  await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds)
  return value
}

简单、直观,但有两个常见坑:

  • 并发 miss 时会一起打数据库
  • 数据更新后可能短时间读到旧值

5.2 Write Through:适合对一致性和可控性要求更高的场景

写请求先更新缓存,再由缓存系统写后端,或者应用统一写缓存和主存。

优点:

  • 读路径更稳定
  • 热数据更容易持续命中

缺点:

  • 写链路更复杂
  • 一旦缓存层抖动,会影响写入可用性

5.3 Write Back / Write Behind:吞吐高,但适用面窄

先写缓存,异步刷到数据库。

适用于:

  • 日志聚合
  • 计数器
  • 对瞬时一致性要求没那么高的场景

不适用于:

  • 订单、支付、库存主链路

5.4 Refresh Ahead:适合热点且高命中数据

在缓存快过期前,后台主动刷新,避免用户请求触发 miss。

适用于:

  • 热门榜单
  • 大促会场配置
  • 首页推荐块

六、缓存最怕的四种事故

6.1 缓存穿透:请求的数据根本不存在

典型场景:有人不断请求不存在的商品 ID。

如果每次 miss 都打到数据库,缓存形同虚设。

解决办法

  • 对空结果也做短 TTL 缓存
  • 对非法参数先做布隆过滤或规则拦截
  • 对异常高频的 miss 做限流
async function getProductOrNull(productId: string) {
  const key = `product:${productId}`
  const cached = await redis.get(key)
  if (cached) {
    return cached === '__NULL__' ? null : JSON.parse(cached)
  }

  const product = await productRepo.findById(productId)
  if (!product) {
    await redis.set(key, '__NULL__', 'EX', 60)
    return null
  }

  await redis.set(key, JSON.stringify(product), 'EX', 600)
  return product
}

6.2 缓存击穿:热点 key 突然失效

一个超级热点 key 在失效瞬间,大量请求一起回源,把数据库打爆。

解决办法

  • 单飞机制(single flight)
  • 分布式锁保护回源
  • 热点数据提前刷新
  • TTL 加随机抖动,避免同时过期
const inflight = new Map<string, Promise<unknown>>()

export async function singleFlight<T>(
  key: string,
  loader: () => Promise<T>,
): Promise<T> {
  const existing = inflight.get(key)
  if (existing) return existing as Promise<T>

  const task = loader().finally(() => inflight.delete(key))
  inflight.set(key, task)
  return task
}

6.3 缓存雪崩:一批 key 同时失效

这通常不是代码 bug,而是 TTL 设计太整齐。

例如你在整点批量写缓存,统一 TTL 30 分钟,那么 30 分钟后整批一起过期。

解决办法

  • TTL 加随机值
  • 分批预热
  • 分层兜底
  • miss 高峰时自动降级

6.4 缓存污染:把低价值、低复用数据缓存了太多

一个典型错误是:

  • 把每个组合筛选结果都缓存
  • 把低频用户特征也长时间缓存
  • 把大对象整包缓存但只用其中 5% 字段

结果就是:

  • Redis 内存被迅速吃满
  • 淘汰策略开始误伤热点数据
  • 命中率并没有显著上升

缓存不是仓库,缓存是收费很高的高速收费站。


七、本地缓存 + Redis 的组合,什么时候最值

这是很多中大型系统都非常常见的一种搭配。

7.1 为什么需要两层

如果所有请求都直接打 Redis,会有三个问题:

  • 高频热点仍然经过网络
  • Redis 成为共享瓶颈
  • 应用层短时突发没被吸收

所以更合理的路径通常是:

先查本地缓存 → miss 再查 Redis → miss 再查数据库 / 下游服务

7.2 一个更完整的实现示例

export async function localFirstThenRedis<T>(
  key: string,
  ttlSeconds: number,
  loader: () => Promise<T>,
): Promise<T> {
  const local = localCache.get(key)
  if (local) return local as T

  const remote = await redis.get(key)
  if (remote) {
    const parsed = JSON.parse(remote) as T
    localCache.set(key, parsed, 3_000)
    return parsed
  }

  const value = await singleFlight(key, loader)
  await redis.set(
    key,
    JSON.stringify(value),
    'EX',
    ttlSeconds + Math.floor(Math.random() * 15),
  )
  localCache.set(key, value, 3_000)
  return value
}

这里有三个关键点:

  • 本地缓存 TTL 更短,承担瞬时热点
  • Redis TTL 更长,承担跨实例共享
  • TTL 加随机抖动,避免集体过期

7.3 它不适合什么

  • 极强一致场景
  • 单个对象特别大
  • 数据变化非常频繁
  • 本地缓存失效后代价很高,但又没有通知机制

八、CDN 不是只给图片用的

这是一个很常见的认知偏差。

很多团队只把 CDN 当成“静态资源托管器”。实际上,CDN 对弱动态内容同样有巨大价值。

8.1 哪些内容适合 CDN 缓存

  • 文章详情页
  • 活动专题页
  • 不同地区共享的公共配置
  • 商品详情的公共部分
  • 排行榜、榜单接口

8.2 Edge 缓存的核心问题不是能不能缓存,而是怎么回源

如果 CDN 缓存 miss 后直接回源到最重的应用链路,收益会被稀释。

更好的方式通常是:

  • CDN miss → 命中网关或 BFF 的轻量聚合接口
  • BFF 再命中 Redis / 本地缓存
  • 最后才落到数据库

也就是说,边缘缓存和中心缓存不是替代关系,而是串联关系。

8.3 一个活动页的边缘策略例子

Cache-Control: public, s-maxage=60, stale-while-revalidate=30

这组头部表达的意思是:

  • CDN 可缓存 60 秒
  • 过期后短时间内可以先返回旧内容
  • 同时后台重新验证和刷新

它特别适合:

  • 访问量高
  • 允许几十秒旧数据
  • 更新有节奏而不是实时跳变

九、缓存失效设计,决定了你会不会在凌晨被叫醒

缓存方案能不能在线上稳定,关键不只在命中率,还在失效方式。

9.1 三种常见失效方式

方式 A:纯 TTL 到期

最简单,但最被动。

适合:

  • 变化规律稳定
  • 短时间旧数据可接受
  • 失效后回源成本不高

方式 B:事件驱动失效

数据更新后,主动删除或刷新相关 key。

适合:

  • 商品改价
  • 活动规则更新
  • 权限模型变化
async function onProductUpdated(productId: string) {
  await redis.del(`product:static:${productId}`)
  localCache.set(`product:static:${productId}`, null, 1)
  await cdn.purge(`/products/${productId}`)
}

这里故意展示了一个工程细节:

  • Redis 删 key
  • 本地缓存也要同步清理
  • CDN 如果缓存了完整页面,也要一起失效

多层缓存最常见的问题,不是某层没缓存,而是你只删了一层。

方式 C:版本号失效

把 key 设计成:

product:v42:detail:1001

一旦版本升级,老 key 自然失效,不需要大规模扫描删除。

这对:

  • 大批量变更
  • 高并发发布
  • 复杂依赖关联

都非常友好。

9.2 推荐的 key 设计规则

至少包含:

  • 业务域
  • 对象 ID
  • 版本或场景
  • 必要的维度参数

例如:

feed:home:v3:guest
product:campaign:v2:1001
permission:user:9381
search:hotwords:cn

不要把 key 命名写成一团临时字符串。你今天偷懒,三个月后删缓存时会加倍还债。


十、缓存命中率不是越高越好,要看命中的是什么

团队最喜欢展示一个指标:缓存命中率 95%。

这个数字当然好看,但经常会误导。

10.1 为什么高命中率也可能没价值

例如:

  • 命中的全是低成本数据
  • 真正重的热点查询仍然 miss
  • 个性化请求因维度太多根本缓存不住
  • 命中的是旧数据,业务效果反而变差

10.2 更值得看的指标

比起单独看命中率,我更建议同时看:

指标价值
命中率看缓存是否被使用
miss 回源耗时看失败时代价多大
回源 QPS看后端承压情况
热点 key 集中度看风险是否过度集中
失效率峰值看是否存在雪崩风险
降级触发次数看缓存是否承担保护作用

10.3 一个真实的收益对比

某内容站首页做完缓存分层后:

指标优化前优化后
首页 P95780ms120ms
应用层 QPS3200900
Redis QPS1800760
数据库 QPS64075
高峰 CPU82%46%
大促前扩容实例数1810

你会发现,真正的收益不只是“快了”,而是:

  • 应用层压力降了
  • Redis 压力也降了
  • 数据库风险更低
  • 机器成本下降了

这才叫缓存体系。


十一、什么时候不该再继续堆缓存

缓存很好用,但不是万能补丁。

以下几种情况,继续堆缓存通常是错误方向:

11.1 底层查询本身已经烂掉

如果 miss 一次就要跑 2 秒 SQL,那你真正该做的是先修数据库路径,而不是祈祷缓存永远不 miss。

11.2 数据更新频率远高于读取频率

这时缓存不断失效、不断重建,收益有限,甚至负收益。

11.3 个性化维度太多

比如:

  • 用户等级
  • 地区
  • 设备类型
  • 渠道来源
  • 实验桶
  • 实时活动资格

一旦这些维度组合起来,key 爆炸会迅速吃掉缓存系统。

11.4 团队没有治理能力

如果没有:

  • 统一 key 规范
  • 缓存监控
  • 失效流程
  • 热点保护机制
  • 回源告警

那缓存层越复杂,系统越不可控。

复杂度本身就是成本。


十二、一套可执行的缓存分层落地步骤

如果你现在要在团队里推一套缓存体系,我建议按这个顺序来。

第 1 步:先画数据分层表

把核心接口里的数据按下面方式拆出来:

数据项是否个性化变化频率一致性要求推荐缓存层
商品标题/图片CDN + Redis
活动标签CDN 短缓存 + Redis
会员价中高本地缓存 + Redis
实时库存否/半个性化直查 / 超短缓存

第 2 步:先做最值钱的一层

不是所有层都要一次性上齐。

通常收益顺序是:

  1. 静态资源长缓存
  2. 热点接口 Redis 缓存
  3. 热点对象本地缓存
  4. CDN 边缘缓存
  5. 预热、刷新、自动降级

第 3 步:先补防护,再追命中率

先保证:

  • miss 不会打穿后端
  • 热点 key 有保护
  • TTL 有抖动
  • 失败有降级兜底

命中率优化可以后做,防故障必须先做。

第 4 步:给每一层明确所有权

这是组织层很容易忽略的一点。

建议明确:

  • 前端负责浏览器缓存与静态资源版本化
  • 平台 / 网关团队负责 CDN 策略
  • 应用团队负责本地缓存与 Redis key 设计
  • 数据团队负责回源容量评估

缓存体系如果人人都能改,最后往往等于没人负责。


十三、给团队的缓存分层检查清单

浏览器 / CDN 层

  • 静态资源是否带 hash 并设置长缓存
  • HTML / 弱动态内容是否区分缓存策略
  • 是否明确了 CDN 回源路径
  • 是否支持按页面或路径主动失效

应用层 / 本地缓存层

  • 本地缓存是否只缓存高频热点和轻量对象
  • 是否设置了较短 TTL
  • 实例扩缩容后是否能接受短暂不一致
  • 是否有内存上限和淘汰策略

Redis 层

  • key 命名是否统一
  • TTL 是否带随机抖动
  • 是否有热点 key 保护
  • 是否缓存空值防穿透
  • 是否监控命中率、回源 QPS、内存与淘汰

工程治理层

  • 是否明确哪些数据不允许缓存
  • 是否能把接口耗时拆到各缓存层
  • 是否有缓存失效操作手册
  • 是否有降级与回源告警
  • 是否定期清理废弃 key 和无效策略

总结

把缓存分层这件事讲透,可以收敛成 5 句话:

  1. 缓存不是单点工具,而是逐层拦截流量的系统。 越靠前拦住,收益越大。
  2. 本地缓存、Redis、CDN 的职责不同。 不要让一层去承担另一层的工作。
  3. 缓存最难的不是命中,而是失效。 真正的稳定性来自回源保护、主动刷新和一致性边界。
  4. 热点保护比“平均命中率”更重要。 系统大多死在热点 key 失效的那一分钟。
  5. 缓存只是手段,不是遮羞布。 如果数据库和下游链路本身不健康,缓存只能延迟爆炸。

如果你只记住一句话,我希望是这一句:

好的缓存分层,不是让每一层都很忙,而是让最贵的那一层尽量没机会出场。

程序员常说,世界上最难的两件事是:缓存失效和命名。

真到线上你会发现,还得加第三件:

在所有人都说“先加一层缓存”时,仍然保持清醒。