REDIS · 缓存高可用专题

Redis 缓存穿透、击穿与雪崩:从一次未命中到保护数据库

缓存穿透是反复查询根本不存在的数据;缓存击穿是一个热点键失效后大量请求同时回源;缓存雪崩是许多键或整个缓存服务同时失效。本文基于 Cache-Aside(旁路缓存)模式:应用先查缓存,未命中才查数据库并写回。

前置:旁路缓存重点:未命中来源 · 回源放大 · 降级边界:Redis 作为缓存副本
一、先建立正常流程:缓存只是副本,数据库才是来源

Cache-Aside(旁路缓存)是应用先查缓存,未命中时再查数据库并把结果写回缓存的模式。Redis 保存的是可丢失、可重建的副本;TTL(Time To Live,存活时间)负责给键设置自动过期边界。

  1. 请求读取商品 42,先执行 Redis GET。
  2. 命中就直接返回,数据库承担 0 次读取。
  3. 未命中才读取数据库。
  4. 查到后写回 Redis,并设置 TTL。
  5. 数据库更新后删除缓存,下一次读取重新装载。
核心指标:命中率只能告诉你“多少请求没回源”。还要看未命中原因、同一键并发数、数据库连接池、回源耗时和缓存服务健康度。
二、一张全链路图:相同结果,三条完全不同的入口
请求入口 → 缓存判断 → 回源放大 → 数据库出口 候选请求 1…1000先经过缓存 Redis 查询命中 → 直接返回 未命中 为什么未命中?按原因分成 3 条线 穿透:对象本来不存在1000 个恶意/错误 ID缓存和数据库都查不到 击穿:1 个热点键刚过期1000 个请求查同一商品瞬间并发回源 1000 次 雪崩:许多键/节点一起失效1000 个请求覆盖大量键数据库承受成片流量 共同出口:数据库、连接池和下游被放大流量压住但入口不同,所以不能只用“加机器”统一处理
最应记住:判断题型先问“数据是否存在、失效的是一个热点还是一大片”。常见误读是把任何大量未命中都叫缓存雪崩。
问题数据存在吗影响范围第一防线
穿透通常不存在大量不同无效键参数校验、空值缓存、布隆过滤器
击穿存在,是热点单个或少量热点键单飞重建、逻辑过期
雪崩多数存在大量键或缓存服务过期打散、高可用、限流降级
三、缓存穿透:为什么“不存在”也值得短暂记住

如果商品编号 999999 根本不存在,每次请求都会先错过缓存,再访问数据库。攻击者随机生成编号时,缓存几乎没有复用价值。

入口校验

拒绝格式、范围明显非法的 ID。成本最低,但无法判断格式合法却不存在的数据。

空值缓存

用短 TTL 缓存“未找到”哨兵。精确、简单,但大量随机键可能占空间,并带来短暂不一致。

布隆过滤器

Bloom Filter 是用少量位判断“肯定不存在或可能存在”的概率结构。否定可靠,肯定仍要查库。

新增数据边界:空值 TTL 过长会让刚创建的对象仍显示不存在;布隆过滤器有假阳性,还要同步新增键并考虑重建。二者都不是鉴权和限流的替代品。
四、缓存击穿:一千个请求只能让一个请求回源

单飞重建表示同一个键同时未命中时,只允许一个请求加载数据库,其他请求等待、读旧值或快速失败。互斥锁只是实现方式之一。

热点键过期后的完整候选、竞争和出口 候选共 10 个请求,编号 0…9;本例编号 3 赢得重建资格 0123456789 原子竞争 lock:product:42SET NX + 有限 TTL 请求 3:唯一获胜者查数据库 1 次 → 写回缓存 其余 9 个请求短暂等待 / 返回旧值 / 降级 缓存写好后再读取 代价:等待者增加延迟;锁超时过短仍会出现重复回源,过长会延迟恢复。
最应记住:候选请求有十个,但数据库读取必须收敛为一次;其余请求也要画出等待、旧值或降级出口。常见误读是“加一把锁就结束”,却没有锁超时和失败分支。
五、缓存雪崩:不只是不小心把 TTL 写成一样

大量键同刻过期

批量导入时统一设置 30 分钟 TTL,半小时后回源峰值同步出现。做法:基础 TTL 加随机抖动,热点提前刷新,分批预热。

Redis 整体不可用

网络隔离、故障切换、资源耗尽都会让所有键像同时未命中。做法:高可用、超时、熔断、本地兜底与数据库限流。

随机 TTL 只能打散计划性过期,无法修复 Redis 整体不可用;主从或集群能提高可用性,也不能免除客户端超时、隔离和降级策略。

保护顺序:数据库快撑不住时,先限流、熔断和降级,避免“为了保证每个请求都有结果”把整个系统拖垮。缓存恢复后还要限速预热,防止第二次雪崩。
六、组合防线:从入口到数据库逐层削峰
层次手段解决什么代价/边界
入口鉴权、参数校验、按用户/IP 限流过滤恶意或异常请求要防误伤
存在性空值缓存、布隆过滤器减少穿透短暂不一致或假阳性
单键并发单飞重建、逻辑过期减少击穿等待或短暂旧值
过期计划TTL 抖动、分批预热减少集中失效需要容量和发布计划
基础设施复制、集群、监控提高缓存可用性故障切换仍有窗口
数据库前熔断、降级、连接池隔离阻止级联故障部分请求得不到完整结果
七、可运行思路:旁路缓存与单飞重建
func GetProduct(ctx context.Context, id int64) (*Product, error) {
    key := fmt.Sprintf("cache:product:%d", id)
    if p, ok := readCache(ctx, key); ok { return p, nil }

    // 同一进程可用 singleflight;跨实例可用短租约锁或逻辑过期。
    value, err, _ := group.Do(key, func() (any, error) {
        if p, ok := readCache(ctx, key); ok { return p, nil } // 二次检查
        p, err := loadDatabase(ctx, id)
        if errors.Is(err, sql.ErrNoRows) {
            writeNotFound(ctx, key, 30*time.Second)
            return nil, ErrNotFound
        }
        if err != nil { return nil, err }
        ttl := 10*time.Minute + randomJitter()
        writeCache(ctx, key, p, ttl)
        return p, nil
    })
    if err != nil { return nil, err }
    return value.(*Product), nil
}
代码边界:进程内 singleflight 只能合并同一实例的并发;多实例要结合 Redis 锁、数据库层控制或允许返回旧值。所有等待都必须服从请求 Context 的超时。
八、坑点、面试话术与应用场景

高频坑点

  • 所有未命中统一查询数据库,不缓存不存在结果。
  • 互斥重建没有二次检查,排队者拿锁后又查一次库。
  • 所有键使用完全相同 TTL。
  • Redis 超时比业务总超时还长,故障时线程全部堆积。
  • 只扩数据库,不限制恢复期回源速度。
90 秒回答:穿透是请求不存在的数据,击穿是单个热点键失效,雪崩是大量键或缓存整体失效。穿透用校验、短期空值和布隆过滤器;击穿用单飞重建、合理租约或逻辑过期;雪崩用 TTL 抖动、高可用、限流熔断、降级和分批预热。三者共同目标是控制回源放大,但必须先按“数据是否存在、影响一个键还是一大片”定位原因,并给失败和恢复过程设置明确出口。

应用场景

商品详情、用户资料、配置读取等读多写少场景适合旁路缓存,因为数据可从数据库重建并能容忍短暂陈旧;强一致余额、库存最终扣减不能只相信缓存。

资料依据