缓存分层策略完全指南:本地缓存、Redis、CDN 到底该怎么配
缓存不是‘加个 Redis’就结束。本文从真实高并发场景出发,系统拆解本地缓存、Redis、CDN 的职责边界、回源策略、失效治理、热点保护与成本权衡,帮你建立一套可落地的多层缓存体系。
缓存分层策略完全指南:本地缓存、Redis、CDN 到底该怎么配
很多团队第一次遇到高并发,脑子里冒出来的第一句往往是:
上 Redis。
第二次再出问题,会说:
再加一层 CDN。
第三次故障复盘时,会议室里通常会出现一句更诚实的话:
为什么我们明明有缓存,系统还是被打穿了?
这句话很关键。
因为缓存从来不是“有”或“没有”的问题,而是:你的每一层缓存,到底在拦截什么流量,承受什么一致性要求,以及在失效时会把压力转嫁给谁。
很多线上事故都不是因为没有缓存,而是因为缓存体系不成体系:
- 浏览器缓存、CDN、网关缓存、应用本地缓存、Redis 各自为战
- 缓存 key 命名混乱,删不干净
- 热点 key 失效后瞬间回源,把数据库打满
- 静态资源明明适合长缓存,却每次都重新请求
- 本地缓存命中率很高,但实例扩容后一致性全乱了
缓存真正难的地方,不在于 API 调用,而在于边界设计。
这篇文章想解决的,不是“Redis 怎么 set/get”,而是更接近真实系统的几个问题:
- 什么数据该放浏览器 / CDN,什么该放本地缓存,什么该放 Redis
- 多层缓存的职责怎么拆,才不会互相打架
- 怎样处理热点 key、缓存穿透、击穿、雪崩
- 怎样设计失效、预热、回源和降级策略
- 什么时候缓存值得做,什么时候只是在掩盖底层设计问题
如果你正在做一个访问量持续上涨的系统,这篇比“缓存面试八股”更接近实际战场。
一、先把问题说透:缓存不是优化插件,而是流量分流系统
很多人把缓存理解成“让数据读取更快”。
这句话不能算错,但不够完整。
在工程上,缓存最核心的价值其实是两件事:
- 把重复请求挡在更靠前的地方
- 把昂贵依赖保护在更靠后的地方
所谓“更靠前”,是离用户更近;所谓“更靠后”,是离数据库、搜索引擎、对象存储这些昂贵资源更近。
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,
})
}
}
看起来简单,但本地缓存有两个天然限制:
- 实例之间不共享
- 实例重启就丢
所以它适合承担高频、可接受短暂不一致、回源代价不算太高的数据,而不适合作为唯一真相来源。
2.4 Redis:共享缓存层,不是真理之书
Redis 最常见的角色是:
- 多实例共享缓存
- 分布式限流与计数
- 排行榜、会话、短期状态
- 热点对象缓存
- 分布式锁辅助
它比本地缓存更一致,比数据库更快,但也更贵、更脆弱、更需要治理。
一个成熟的认知是:
Redis 是用来挡住数据库和下游服务的,不是让你把所有东西都塞进去的。
三、怎样判断一份数据该放哪一层
最有效的判断,不是看技术喜好,而是看 4 个维度:
| 维度 | 要问的问题 | 影响 |
|---|---|---|
| 变化频率 | 数据多久变一次? | 决定 TTL 和失效方式 |
| 一致性要求 | 能接受几秒旧数据吗? | 决定能否走 CDN / 本地缓存 |
| 数据体积 | 返回结果大不大? | 决定适不适合边缘缓存 |
| 回源成本 | miss 时会压垮谁? | 决定是否值得做更靠前的缓存 |
3.1 一个实用的分类方法
A 类:几乎所有人看到都一样,且变化少
例如:
- logo、静态资源
- 文章页正文
- 营销活动介绍页
优先级:浏览器缓存 + CDN
B 类:热点高、变化有节奏、允许秒级旧数据
例如:
- 首页推荐位
- 榜单
- 商品详情的公开部分
- 热门搜索词
优先级:CDN / 本地缓存 / Redis 组合
C 类:用户相关,但短时间内重复访问高
例如:
- 用户资料摘要
- 权限集
- 购物车摘要
- 最近浏览记录
优先级:本地缓存 + Redis
D 类:强一致、强事务
例如:
- 实时库存
- 余额
- 支付状态
- 核销资格
优先级:谨慎缓存,甚至不缓存
不是所有数据都值得缓存。缓存最怕的一种数据,就是“看起来读很多,实际上每次都必须绝对实时”。
四、一个真实可落地的多层缓存架构
下面给一个电商系统里常见的商品详情页设计。
4.1 目标拆分
商品详情页实际包含 4 类数据:
- 商品静态信息:标题、图片、参数
- 营销信息:活动标签、文案、会场氛围
- 个性化信息:会员价、优惠资格、推荐理由
- 强实时信息:库存、限购、秒杀状态
如果你把这 4 类数据都放在一个接口里,并要求“完全实时”,那缓存设计会非常难看。
更合理的拆法是:
| 数据类型 | 缓存层 | TTL / 策略 |
|---|---|---|
| 静态信息 | CDN + Redis | 10 分钟 + 主动失效 |
| 营销信息 | CDN 短缓存 | 30-60 秒 |
| 个性化信息 | 本地缓存 + Redis | 10-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:
- 先查缓存
- miss 了查数据库
- 再回写缓存
这是默认策略,但不是唯一策略。
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 一个真实的收益对比
某内容站首页做完缓存分层后:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首页 P95 | 780ms | 120ms |
| 应用层 QPS | 3200 | 900 |
| Redis QPS | 1800 | 760 |
| 数据库 QPS | 640 | 75 |
| 高峰 CPU | 82% | 46% |
| 大促前扩容实例数 | 18 | 10 |
你会发现,真正的收益不只是“快了”,而是:
- 应用层压力降了
- Redis 压力也降了
- 数据库风险更低
- 机器成本下降了
这才叫缓存体系。
十一、什么时候不该再继续堆缓存
缓存很好用,但不是万能补丁。
以下几种情况,继续堆缓存通常是错误方向:
11.1 底层查询本身已经烂掉
如果 miss 一次就要跑 2 秒 SQL,那你真正该做的是先修数据库路径,而不是祈祷缓存永远不 miss。
11.2 数据更新频率远高于读取频率
这时缓存不断失效、不断重建,收益有限,甚至负收益。
11.3 个性化维度太多
比如:
- 用户等级
- 地区
- 设备类型
- 渠道来源
- 实验桶
- 实时活动资格
一旦这些维度组合起来,key 爆炸会迅速吃掉缓存系统。
11.4 团队没有治理能力
如果没有:
- 统一 key 规范
- 缓存监控
- 失效流程
- 热点保护机制
- 回源告警
那缓存层越复杂,系统越不可控。
复杂度本身就是成本。
十二、一套可执行的缓存分层落地步骤
如果你现在要在团队里推一套缓存体系,我建议按这个顺序来。
第 1 步:先画数据分层表
把核心接口里的数据按下面方式拆出来:
| 数据项 | 是否个性化 | 变化频率 | 一致性要求 | 推荐缓存层 |
|---|---|---|---|---|
| 商品标题/图片 | 否 | 低 | 中 | CDN + Redis |
| 活动标签 | 否 | 中 | 中 | CDN 短缓存 + Redis |
| 会员价 | 是 | 中 | 中高 | 本地缓存 + Redis |
| 实时库存 | 否/半个性化 | 高 | 高 | 直查 / 超短缓存 |
第 2 步:先做最值钱的一层
不是所有层都要一次性上齐。
通常收益顺序是:
- 静态资源长缓存
- 热点接口 Redis 缓存
- 热点对象本地缓存
- CDN 边缘缓存
- 预热、刷新、自动降级
第 3 步:先补防护,再追命中率
先保证:
- miss 不会打穿后端
- 热点 key 有保护
- TTL 有抖动
- 失败有降级兜底
命中率优化可以后做,防故障必须先做。
第 4 步:给每一层明确所有权
这是组织层很容易忽略的一点。
建议明确:
- 前端负责浏览器缓存与静态资源版本化
- 平台 / 网关团队负责 CDN 策略
- 应用团队负责本地缓存与 Redis key 设计
- 数据团队负责回源容量评估
缓存体系如果人人都能改,最后往往等于没人负责。
十三、给团队的缓存分层检查清单
浏览器 / CDN 层
- 静态资源是否带 hash 并设置长缓存
- HTML / 弱动态内容是否区分缓存策略
- 是否明确了 CDN 回源路径
- 是否支持按页面或路径主动失效
应用层 / 本地缓存层
- 本地缓存是否只缓存高频热点和轻量对象
- 是否设置了较短 TTL
- 实例扩缩容后是否能接受短暂不一致
- 是否有内存上限和淘汰策略
Redis 层
- key 命名是否统一
- TTL 是否带随机抖动
- 是否有热点 key 保护
- 是否缓存空值防穿透
- 是否监控命中率、回源 QPS、内存与淘汰
工程治理层
- 是否明确哪些数据不允许缓存
- 是否能把接口耗时拆到各缓存层
- 是否有缓存失效操作手册
- 是否有降级与回源告警
- 是否定期清理废弃 key 和无效策略
总结
把缓存分层这件事讲透,可以收敛成 5 句话:
- 缓存不是单点工具,而是逐层拦截流量的系统。 越靠前拦住,收益越大。
- 本地缓存、Redis、CDN 的职责不同。 不要让一层去承担另一层的工作。
- 缓存最难的不是命中,而是失效。 真正的稳定性来自回源保护、主动刷新和一致性边界。
- 热点保护比“平均命中率”更重要。 系统大多死在热点 key 失效的那一分钟。
- 缓存只是手段,不是遮羞布。 如果数据库和下游链路本身不健康,缓存只能延迟爆炸。
如果你只记住一句话,我希望是这一句:
好的缓存分层,不是让每一层都很忙,而是让最贵的那一层尽量没机会出场。
程序员常说,世界上最难的两件事是:缓存失效和命名。
真到线上你会发现,还得加第三件:
在所有人都说“先加一层缓存”时,仍然保持清醒。