多级缓存与 Cache Stampede 应对实战

多级缓存与 Cache Stampede 应对实战 封面配图

本指南教你如何解决多级缓存中的 Cache Stampede(缓存踩踏)问题,从浏览器、CDN(内容分发网络)、应用本地缓存到 Redis 共享缓存逐层梳理。很多团队第一次遇到缓存击穿都是在大促前的压测里:每秒请求一冲到峰值,Redis 的负载还很闲,后端数据库却被打爆了。原因不复杂,热点键同时过期,几千个请求在同一瞬间回源查询,把原本应该被缓存挡下的查询全部压到数据库上。这种现象在英文社区里被叫做 Cache Stampede,中文一般翻译成缓存踩踏或缓存雪崩。本指南将带你从浏览器、内容分发网络、应用层本地缓存一直梳理到 Redis 共享缓存,解决多级缓存的协同与踩踏应对,帮助你在不增加机器的前提下扛住几十倍的瞬时流量。

为什么需要多级缓存

把缓存只放在一层,往往不是设计选择,而是没想清楚架构。一个典型电商详情页的请求路径,至少要经过四层可以缓存的位置:

  • 浏览器:HTTP 缓存与 Service Worker,离用户最近,命中后零网络开销
  • 边缘节点:CDN 缓存静态资源与可缓存的 HTML 片段,覆盖全国甚至全球
  • 应用进程内:本地的 LRU 或 Caffeine 这类内存结构,纳秒级访问
  • 远端共享缓存:Redis 集群,跨实例一致,作为最后一道防线挡住数据库

每多一层缓存,回源比例就能再降一个数量级。问题在于层与层之间的失效要互相协调,否则就会出现上层还没过期、下层已经穿透的诡异情况。多级缓存设计的核心不是把缓存堆得更多,而是把失效路径理清楚。

缓存踩踏是怎么发生的

最朴素的复现方式是开一段多线程脚本,故意制造热点键过期的瞬间。假设有两百个线程同时访问一个刚刚过期的键,每个线程都会发现缓存里没有值,于是各自去查数据库。生产环境里这两百就是两万,数据库瞬间被打死。

问题的根源在于:缓存未命中的判断和回源查询之间是无锁的,而过期是个时间点事件,所有访问者都会在同一毫秒内看到同样的未命中状态。要解决这个问题,要么让回源串行化,要么让过期变得平滑。

互斥锁:最朴素也最有效

最直接的办法是让回源串行化。Redis 提供了 SET NX EX 原语,配合简单的等待重试就能解决:

def get_with_lock(key):
    v = r.get(key)
    if v is not None:
        return v
    if r.set(f"lock:{key}", "1", nx=True, ex=5):
        try:
            value = query_db(key)
            r.setex(key, 30, value)
            return value
        finally:
            r.delete(f"lock:{key}")
    else:
        time.sleep(0.05)
        return get_with_lock(key)

锁的生存时间一定要大于回源耗时但又不能太长,五秒是个不错的起点。注意拿不到锁的请求不要死等,要加退避或者直接返回旧值。锁键与数据键务必分开命名,否则清理数据时连锁一起清,下一波依然会踩踏。

提前刷新与逻辑过期

互斥锁解决了并发回源,但锁持有期间其它请求是卡住的。更优雅的方案是把过期变成逻辑过期——值里带上一个过期时间戳,Redis 的真实生存时间设得更长。读到值时先看时间戳,过期了就异步刷新,同步返回旧值给用户。

这种写法下用户始终能拿到响应,最多看到一两秒的旧数据。对详情页、推荐列表这类容忍度高的场景非常合适。要注意异步刷新需要去重,否则多个线程同时触发刷新,等于没加锁。一般做法是给刷新动作再套一层短时锁,谁抢到谁去刷新,其它线程直接返回旧值。

多级缓存的失效协调

引入本地缓存后,问题从踩踏变成了一致性。常见做法是用 Redis 的发布订阅或 Kafka 广播失效事件。写入端更新数据库后,先删除 Redis 的对应键,再发布一条失效消息。各应用实例订阅这条消息,收到后清掉自己进程内的本地缓存。本地缓存的生存时间设短一些,三十秒到一分钟,作为消息丢失时的兜底。

如果你的应用前面挂了反向代理做了页面缓存,记得一并配置主动清理接口,可以参考 Nginx 反向代理与负载均衡实战 中的 cache key 与 purge 模块用法,把这一层也纳入失效链路,否则页面层会成为一致性的盲区。

边缘节点的配合

边缘层踩踏的影响比 Redis 更严重,同一个边缘节点下成千上万的回源请求会同时打到源站。主流 CDN 都提供回源屏蔽或请求合并的功能,开启即可。如果你用的是 Cloudflare,开启分层缓存就能让大部分回源在上层节点收敛,详细配置可以看 Cloudflare CDN 中国加速 里关于上层屏蔽节点的部分。

源站这边,给可缓存内容加上 stale-while-revalidatestale-if-error 两个响应头,边缘节点在回源失败时会继续返回旧版本,避免雪崩扩散到用户侧。

WordPress 这类场景的实际配置

如果你跑的是 WordPress 站点,前面这些手写的逻辑大部分被缓存插件接管了。但默认配置往往不够,比如对象缓存的键前缀没分租户、页面缓存没开启惰性失效,热点文章一更新就会出现集中回源。这部分建议参考 WordPress 与 W3 Total Cache 调优 把几个关键参数对一遍,再做压测。

部署到生产前,跳板机的 SSH 也建议做基础加固,避免缓存层调试时把端口直接暴露在公网,SSH 与 Fail2ban 加固 这篇里给了一份 jail.local 模板可以直接套用。

常见踩坑

  • 锁键与数据键不分开,导致清理数据时连锁一起清,下一波继续踩踏
  • 逻辑过期的异步刷新没有去重,多个线程同时触发,等于没加锁
  • 本地缓存与 Redis 的生存时间完全一致,过期时两层同时回源
  • 发布订阅消息没有补偿机制,订阅端崩溃重启后本地缓存与 Redis 不一致
  • 压测只测稳态吞吐,没测集中过期瞬间,踩踏风险被掩盖到上线后

总结

缓存踩踏不是某一层的问题,而是多级缓存协同失效的问题。建议先把热点识别和生存时间抖动这两步做好,给每个键的过期时间加上百分之十左右的随机偏移,能消除大部分集中过期,再叠加互斥锁或逻辑过期覆盖剩下的高频热点。多级缓存的失效协调推荐用现成的消息总线,而不是自己写一套,否则后续维护成本会越来越高。如果你需要把这套思路落到具体业务,可以从最热的五个键开始改造,监控回源吞吐与命中率两个指标,量化之后再推广到全站。整体下来你会发现,扛住峰值流量靠的不是更大的 Redis,而是更聪明的过期策略。

发表评论