Redis 缓存设计:Cache Aside、穿透、击穿与雪崩(四)
系列导航:
- 上一篇:Redis 数据结构总览:String、Hash、List、Set、ZSet、Stream 怎么选(三)
- 当前篇:Redis 缓存设计:Cache Aside、穿透、击穿与雪崩(四)(本文)
- 下一篇:Redis 数据结构实战:计数器、排行榜、去重与会话(五)
很多团队说自己“用了 Redis 做缓存”,实际只做了两件事:查不到就回源、查到了就直接返回。系统刚上线时,这样通常已经够用;真正的问题会在流量起来、热点变集中、写入变频繁之后出现。此时麻烦往往不是 Redis 命令不会写,而是缓存模式、失效策略和一致性窗口根本没有设计清楚。
这一篇只讲四件事:
Cache Aside到底解决什么,为什么它仍然是大多数业务的默认选择- 缓存穿透、击穿、雪崩分别是什么,常见症状有什么不同
- 数据更新时为什么一定会出现双写窗口,窗口该怎么缩小
- TTL、淘汰与主动失效应该怎样配合,而不是只会“统一写 300 秒”
本文刻意不展开分布式锁、限流、排行榜、消息流这些其他主题,只把缓存设计这条主线讲清楚。
Cache Aside 为什么仍然是默认模式
Cache Aside 也常被叫作“旁路缓存”。它的基本流程很朴素:
- 先查缓存。
- 缓存未命中时查数据库。
- 把数据库结果写回缓存,并设置过期时间。
写路径通常对应另一套动作:
- 先更新数据库。
- 再删除缓存,或按约定刷新缓存。
这个模式之所以常见,不是因为它最完美,而是因为它把职责分得足够清楚:
- 数据库仍然是主存和最终真相源
- Redis 负责加速热点读取
- 应用层负责定义“什么时候回源、什么时候失效”
一个最简化的读路径可以写成这样:
async function getUserProfile(userId: string) {
const cacheKey = `user:profile:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const profile = await db.user.findById(userId);
if (!profile) {
return null;
}
await redis.set(cacheKey, JSON.stringify(profile), "EX", 300);
return profile;
}
看起来很简单,但这里面已经包含三个重要前提:
- 不是所有数据都值得缓存,只有高频读、可接受短暂旧值的数据才适合
- 缓存命中与否不能改变业务真相,缓存只是性能层
- 回源写回不是“永远正确”,而是带着时间窗口和并发风险的工程折中
也正因为如此,Cache Aside 不是“写几行 Redis 代码”那么简单,而是一套围绕失败路径设计的模式。
缓存穿透、击穿、雪崩不是一回事
线上经常把三个词混用,结果排障时一直对不上症状。更准确的区分方式是:问题是来自不存在的数据、单个热点数据,还是大批数据同时失效。
缓存穿透:请求的就是不存在的数据
缓存穿透的典型特征是:
- 请求的 Key 在缓存里没有
- 去数据库查,数据库里也没有
- 这类请求持续出现,导致每次都绕过缓存直接打到数据库
最常见场景有两个:
- 恶意构造大量不存在的 ID
- 业务本身存在高频查询空结果的情况
它的危险不在于“Redis 没命中”,而在于缓存层完全没有挡住无效请求。常见缓解方式包括:
- 对不存在的结果做短 TTL 的空值缓存
- 在入口层先做参数校验,过滤明显非法请求
- 对可枚举 ID 或热点查询做布隆过滤器等前置判断
这里最容易犯的错,是把空值缓存写得过长。空值 TTL 太长,会让后来真实创建的数据在一段时间内仍被误判为不存在。
缓存击穿:单个热点 Key 失效后大量并发回源
缓存击穿关注的是“热点”和“同一时刻”。
典型症状是:
- 某个 Key 非常热,比如首页配置、热门商品、活动库存摘要
- 这个 Key 刚好过期或被删除
- 大量并发请求同时回源,把数据库或下游服务瞬间打高
击穿的重点不是“有多少 Key 失效”,而是一个很热的 Key 在一个很差的时间点失效。
常见应对思路有:
- 对极热点数据做主动预热,而不是完全依赖自然过期
- 使用互斥重建,让同一时间只有一个请求负责回源和回填
- 对热点数据采用逻辑过期,让旧值短时间兜底,而不是立即放所有流量穿透
如果你明知道某个 Key 是活动页、首页聚合、爆款详情,却仍然把它和普通长尾数据一样配统一 TTL,那么击穿基本只是时间问题。
缓存雪崩:一批缓存集中失效
缓存雪崩的特征是“成片地失效”。
常见触发方式包括:
- 大量缓存使用相同 TTL,在同一秒或同一分钟过期
- Redis 实例整体不可用,导致整个缓存层同时失效
- 发布、脚本或误操作一次性删掉大量热点 Key
雪崩和击穿的区别在于:
- 击穿通常围绕一个或少数热点 Key
- 雪崩是大面积缓存保护同时消失
应对雪崩时,不能只盯着单个 Key,而要从整体容量和降级设计入手:
- TTL 加随机抖动,避免同批数据同时过期
- 把超热点数据和普通缓存分层,不要混成一锅
- 在应用侧保留降级、限流和兜底返回能力
- 明确 Redis 不可用时哪些接口允许回源,哪些接口必须直接熔断
很多所谓“数据库突然扛不住”的事故,本质上不是数据库突然变差,而是缓存层在几秒钟内集体失守。
双写窗口为什么一定存在
只要你的数据同时出现在数据库和缓存里,就不可避免会面对“双写窗口”。
最典型的写路径有两种。
方案一:先更新数据库,再删除缓存
这是最常见的 Cache Aside 写法:
- 更新数据库中的真实数据
- 删除对应缓存
它的优点是简单,也避免了“把新值先写到缓存,但数据库更新失败”的直接脏写问题。
但它仍然有窗口:
- 线程 A 更新数据库成功
- 缓存删除前,线程 B 正好读到旧缓存
- 或者缓存删除后,线程 C 回源又把旧值写回去
这里真正要记住的是:删除缓存不是魔法,它只能缩小脏数据停留时间,不能让窗口消失。
方案二:先更新数据库,再刷新缓存
有些团队会改成“数据库更新后,直接把新值写入缓存”,想借此减少冷启动 miss。
这个方案的问题是:
- 你需要确保写缓存时拿到的就是最终正确值
- 并发写入顺序错位时,旧值可能覆盖新值
- 一旦缓存写失败,还得决定要不要补偿、重试和兜底
所以“更新 DB + 刷新缓存”并不是天然比“更新 DB + 删除缓存”更一致,它只是把风险从“短暂 miss”换成了“错误值覆盖”。
工程上该怎么缩小窗口
双写窗口无法消灭,但可以控制:
- 优先让数据库做唯一真相源,不把 Redis 当主存
- 写路径默认采用“更新数据库,再删除缓存”
- 对回填缓存的值携带版本、时间戳或变更序号,减少旧值覆盖新值的概率
- 极端热点写场景用消息异步失效或订阅 binlog/CDC 做补偿同步
一句更实用的话是:不要追求“缓存和数据库永远零偏差”,而要追求“偏差时间短、范围可控、补偿路径明确”。
失效策略不只是一个 TTL 数字
很多文章谈缓存策略,只剩一句“给缓存加过期时间”。这远远不够。真正可用的失效策略,至少要回答三个问题:
- 数据多久变一次?
- 数据错多久业务还能接受?
- 失效时系统有没有能力承接回源流量?
TTL 该怎么定
TTL 本质上是在“新鲜度”和“回源成本”之间做平衡。
- 变化快、对准确性敏感的数据,TTL 应更短
- 变化慢、回源昂贵的数据,TTL 可以更长
- 极热点数据不要只靠自然过期,还要配合预热或主动刷新
比 TTL 长短更重要的是避免“整站一把梭”:
- 不要所有对象都 300 秒
- 不要所有热点都在整点刷新
- 不要把业务分类、热点级别和失效方式混为一类
随机抖动为什么重要
给 TTL 增加随机抖动,目的不是“更高级”,而是避免同一批键在同一时刻到期。
例如:
const baseTtl = 300;
const jitter = Math.floor(Math.random() * 60);
await redis.set(cacheKey, value, "EX", baseTtl + jitter);
这种做法不能解决所有雪崩问题,但能明显降低“同批创建、同批过期”的风险。
主动失效什么时候更合适
并不是所有缓存都应该等 TTL 自然到期。对于这些场景,主动失效通常更关键:
- 资料更新后希望尽快看到新值
- 配置变更后需要尽快全站生效
- 活动状态、库存摘要这类高关注数据不能长期停留旧值
主动失效的方式可以是:
- 写库后删除对应 Key
- 批量变更后按前缀或索引定位相关 Key 做定向清理
- 用消息通知应用实例清本地缓存,再由 Redis 承接共享缓存
这里的关键不是“删缓存”三个字,而是你是否知道到底哪些 Key 受这次变更影响。如果键设计本身没有映射关系,后续失效策略再精细也会很难落地。
设计缓存时最容易被忽略的判断
真正决定缓存是否稳,不在于 Redis 用得多复杂,而在于你是否先回答过下面这几件事:
- 这个接口没命中缓存时,数据库能扛住多少并发回源
- 这个数据允许多久不一致,能不能接受几秒旧值
- 热点 Key 是哪些,是否需要和普通数据分开设计 TTL
- Redis 不可用时,接口是回源、降级、限流,还是直接失败
如果这些问题没有答案,那么“加 Redis 缓存”往往只是把正常时期的平均延迟压低了,却没有降低故障时期的系统脆弱性。
本篇解决了什么:
- 解释了
Cache Aside为什么仍然是大多数业务缓存的默认模式 - 区分了缓存穿透、击穿、雪崩三类问题的触发条件和治理重点
- 说明了数据库与缓存双写时窗口一定存在,关键在于如何缩小和补偿
- 梳理了 TTL、随机抖动、主动失效三类常见失效策略的使用边界