云选科普

高并发详情页的缓存击穿防护:从分层缓存到故障降级

面向商品详情、内容页等读多写少场景,梳理本地缓存、Redis 与数据库的分层链路,并用互斥回源、TTL 抖动、空值缓存和布隆过滤器应对缓存击穿、雪崩与穿透。

商品详情页、文章页和配置查询接口通常有一个共同特点:读请求远多于写请求,而且流量会集中到少数热点对象。把所有请求直接交给数据库,平时可能没有问题,一到活动、推送或爬虫集中访问,数据库连接池、CPU 和磁盘 I/O 就可能同时承压。

缓存并不是把数据放进 Redis 就结束了。真正需要设计的是:请求经过哪些层、热点 Key 失效时谁负责回源、大量 Key 同时过期怎么办,以及请求一个不存在的 ID 时如何阻止它反复访问数据库。下面以商品详情为例,给出一套可以迁移到内容站和 API 服务的缓存击穿防护方案。

先按数据特征设计缓存链路

缓存策略应先区分数据的访问量和一致性要求。商品图文描述通常允许短时间延迟;价格、库存和上下架状态则更需要及时更新。把这些字段全部使用同一套 TTL,往往会在性能和一致性之间留下不必要的冲突。

一个常见的读取链路如下:

浏览器或 CDN → 应用本地缓存 → Redis → 数据库
  • 浏览器/CDN:适合缓存变化不频繁的静态资源和可公开缓存的页面片段。
  • 应用本地缓存:保存少量最热的数据,减少每次请求访问 Redis 的网络开销。
  • Redis:保存跨实例共享的详情数据,承担大部分缓存命中。
  • 数据库:只处理缓存未命中、数据回源和写入。

本地缓存容量和时间应受到限制。多实例部署时,每个实例都有自己的副本,TTL 过长会扩大实例之间的数据差异窗口。Redis 也不能被当作永久数据库使用,需要明确淘汰策略、容量上限和故障时的降级路径。

多级缓存的实现要点

L1 本地缓存只保留热点

可以使用 Caffeine 等进程内缓存保存少量热点商品。命中 L1 时不需要访问网络;未命中再查询 Redis,并把结果回填到本地缓存。示意代码如下:

Cache<String, ProductDetail> localCache = Caffeine.newBuilder()
    .maximumSize(5000)
    .expireAfterWrite(Duration.ofSeconds(30))
    .recordStats()
    .build();

ProductDetail getDetail(long skuId) {
    String key = "product:detail:" + skuId;
    ProductDetail value = localCache.getIfPresent(key);
    if (value != null) return value;

    value = redis.get(key);
    if (value != null) {
        localCache.put(key, value);
        return value;
    }
    return loadAndCache(skuId, key);
}

这里的 30 秒只是示例,不是通用参数。应结合更新频率、实例数量、对象大小和可接受的不一致时间,通过监控命中率与回源量调整。热点也可以在活动开始前预热,而不是等首个请求触发数据库查询。

L2 Redis 保存共享副本

Redis 的 Key 应包含明确的业务前缀和版本信息,例如 product:detail:v2:10001。对象写入时设置过期时间,并加入小幅随机抖动,避免同一批预热数据在相同时间失效:

import random

def ttl_with_jitter(base_seconds: int) -> int:
    return base_seconds + random.randint(0, base_seconds // 5)

redis.set(key, serialize(product), ex=ttl_with_jitter(600))

TTL 的作用是回收失效数据,不是数据一致性的全部保障。更新商品时通常采用 Cache Aside:先更新数据库,再删除对应缓存;读取请求发现缓存不存在时从数据库加载并回填。对于并发更新、删除失败和消息重试,还要配合重试、延迟删除或变更消息,避免旧值长期留在缓存中。

缓存击穿:保护单个热点 Key 的回源

缓存击穿指一个访问非常集中的 Key 在某个时刻失效,大量并发请求同时发现缓存为空,随后一起查询数据库。它与缓存雪崩的区别在于:击穿通常针对单个或少数热点 Key,雪崩则是大量 Key 集中失效。

一种常见做法是 Redis 分布式锁加双重检查:

  1. 先读缓存,命中立即返回。
  2. 未命中时尝试以 SET key value NX EX seconds 获取锁。
  3. 抢到锁的请求再次检查缓存,确认仍为空后才查询数据库。
  4. 查询结果写回缓存并释放锁。
  5. 未抢到锁的请求短暂等待后重新读缓存,并限制重试次数。

伪代码:

def get_hot_product(sku_id):
    key = f"product:detail:{sku_id}"
    lock_key = f"lock:product:{sku_id}"

    cached = redis.get(key)
    if cached is not None:
        return decode(cached)

    token = random_token()
    if redis.set(lock_key, token, nx=True, ex=10):
        try:
            cached = redis.get(key)  # 双重检查
            if cached is not None:
                return decode(cached)
            value = query_database(sku_id)
            if value is not None:
                redis.set(key, encode(value), ex=ttl_with_jitter(600))
            return value
        finally:
            release_lock_if_owner(lock_key, token)

    for _ in range(3):
        sleep(0.05)
        cached = redis.get(key)
        if cached is not None:
            return decode(cached)
    return fallback_or_limited_reload(sku_id)

锁必须设置过期时间,防止持锁进程崩溃后造成永久阻塞;释放锁时应校验随机 token,不能无条件 DEL,否则可能误删后来请求持有的新锁。若数据库查询时间可能超过锁 TTL,应设计续租或使用更可靠的锁实现。

对于允许返回短暂旧数据的详情内容,还可以使用逻辑过期:缓存值中记录业务过期时间,过期后先返回旧值,再由后台任务异步刷新。它降低了热点 Key 失效瞬间的等待,但需要接受短时旧数据,并处理刷新任务重复执行和失败重试。

缓存雪崩:避免大量请求同时回源

雪崩有两类常见诱因:大量 Key 设置了相同 TTL,几乎同时过期;或者 Redis 整体不可用,多个服务同时失去缓存层。对应措施应分层处理:

  • 打散过期时间:基础 TTL 上增加随机值,批量预热时也不要使用完全相同的过期时间。
  • 限制回源并发:为数据库查询设置并发上限、队列或舱壁,避免缓存故障把数据库连接池耗尽。
  • 保留可用的本地副本:本地缓存可以作为短时兜底,但要控制容量和旧数据风险。
  • 准备降级响应:对非核心字段返回静态摘要、默认状态或稍后重试;价格和库存等关键数据不能无条件使用旧值。
  • 监控 Redis 依赖:观察命中率、回源 QPS、连接池等待、命令延迟和内存淘汰,提前发现缓存层退化。

高可用部署能降低 Redis 故障概率,但不能代替应用层限流。故障演练时应分别验证:单节点异常、网络超时、集群不可用和大量 Key 同时过期。

缓存穿透:拦截不存在的数据请求

缓存穿透是请求的数据在数据库中也不存在,因此每次都经历“缓存未命中→查询数据库→仍然为空”。恶意扫描不存在的 ID 时,这条路径会反复消耗数据库资源。

可组合使用两种手段:

空值缓存

查询结果为空时,写入一个短 TTL 的占位值,例如 __NULL__。后续相同请求命中占位值后直接返回,不再访问数据库。空值 TTL 不宜过长,否则新商品刚上线时可能继续被旧占位值挡住。

布隆过滤器

布隆过滤器适合做“是否可能存在”的前置判断:它可以确定某些 ID 不在集合中,但判断为存在时仍需继续查询,因为概率型结构可能产生误判。商品上架、删除和批量导入时要同步维护集合;如果无法可靠同步,就不能把它当作唯一的数据正确性来源。

典型读取顺序是:

参数校验 → 布隆过滤器否定判断 → 空值/正常缓存 → 数据库 → 回填缓存

参数范围校验、接口鉴权和访问限流仍然需要保留。布隆过滤器主要减少无效查询,不能替代业务权限检查。

生产落地前的检查清单

  1. 按字段区分一致性要求,不要给价格、库存和图文描述套用同一个 TTL。
  2. 为热点 Key 设计互斥回源或逻辑过期,并对等待和重试设置上限。
  3. 所有锁都设置过期时间,释放时校验持有者身份。
  4. TTL 加随机抖动,批量预热和批量删除也要避免瞬时尖峰。
  5. 对不存在的对象使用短 TTL 空值缓存,必要时增加布隆过滤器。
  6. 为数据库回源设置限流、超时和熔断,Redis 故障时不要让请求无限重试。
  7. 用压测分别模拟热点 Key 失效、批量过期、Redis 超时和不存在 ID 扫描。
  8. 监控 L1 命中率、Redis 命中率、数据库回源 QPS、锁等待、缓存延迟和错误率。

结语

高并发读场景的重点不是简单增加缓存,而是让不同类型的风险由不同机制处理:多级缓存减少正常请求到达数据库,互斥回源和逻辑过期处理热点 Key 失效,TTL 抖动与回源限流处理批量失效,空值缓存和布隆过滤器处理不存在数据。

如果你在云服务器上自建 Redis 和数据库,小团队更应先把超时、限流、备份、监控和故障降级补齐,再考虑复杂的缓存拓扑。只有在压测和故障演练中验证回源量确实受控,这套方案才算真正可用。

继续浏览

还想继续看,可以再看这些文章

适合想继续在同一主题下横向阅读、对比不同切入角度的用户。