Skip to content

多级缓存、预热与热点数据:缓存层怎么设计才不会反噬 ​

加缓存很容易,难的是回答几个具体问题:这一层缓存要挡住多少流量、数据失效了怎么办、只有 20 万条内存放得下而数据库有 2000 万条时放哪些、以及 Redis 挂了系统还能不能活。

本文把缓存层的设计拆成四个决策:分几层、怎么预热、放哪些数据、故障时怎么降级。文中的延迟数字来自本机 Docker 实测(Redis 8.10.1、MySQL 8.4.11、Caffeine 3.3.0,同一台机器、单连接顺序请求),仅用于说明数量级,见文末配套实验。

一、先说结论 ​

  • 缓存的层数由「要挡住什么」决定,不是越多越好。每多一层,就多一份不一致的可能和一套失效逻辑。
  • 本地缓存和 Redis 的差距,比 Redis 和数据库的差距大。 实测 Redis p50 为 0.049ms,MySQL 主键查询 0.071ms,只差 1.4 倍;而本地缓存平均 87ns,比 Redis 快约 560 倍——引入 Redis 主要是为了保护数据库和共享数据,不只是为了快。
  • 预热的核心是「知道哪些是热数据」,而不是「把所有数据都灌进去」。冷启动时的回源风暴要靠限流和分批控制。
  • 内存放不下全部数据时,靠 TTL 加淘汰策略自然筛选热点,配合热点探测补齐突发流量。
  • Redis 故障预案要提前演练:本地缓存兜底、限流保护数据库、降级开关一键生效。

二、分几层:每一层挡住什么 ​

浏览器 / 客户端Cache-Control0CDN静态资源 · 边缘节点~10ms网关 / Nginxproxy_cache~1ms应用本地缓存Caffeine · 堆内87ns分布式缓存 Redis跨实例共享0.049ms数据库最终数据源0.071ms每一层都要回答:命中率、失效方式、不一致窗口能不能接受
图 1 · 越靠近用户的缓存越快、越省资源,但一致性越难保证;同一台机器上实测本地缓存 87ns,Redis p50 0.049ms,MySQL 主键查询 p50 0.071ms,后两者的差距远小于直觉(CDN 与网关为量级示意)
层挡住的流量失效方式主要风险
浏览器 / 客户端重复请求同一资源Cache-Control、ETag发布后用户拿到旧版本,需要文件名带哈希
CDN静态资源、公共页面TTL、主动刷新刷新有延迟;动态内容不适合
网关 / Nginx短时间内的相同请求proxy_cache TTL用户维度的数据不能缓存在这里
应用本地缓存极热的少量数据TTL、容量淘汰、消息广播失效多实例之间不一致;重启后失效
分布式缓存 Redis数据库的大部分读流量主动删除、TTL网络往返;缓存与数据库一致性

选择时问三个问题:

  1. 这层能挡住多少比例的请求? 命中率低于 50% 的缓存层,通常只是增加复杂度。
  2. 数据变化后怎么让它失效? 本地缓存的失效最麻烦,需要广播或者接受短暂不一致。
  3. 不一致窗口业务能不能接受? 商品价格和库存不能容忍,商品描述和分类通常可以。

2.1 本地缓存什么时候值得加 ​

本地缓存的收益是省掉一次网络往返(同机实测 Redis p50 约 0.05ms,跨主机还要更多)和 Redis 的一次序列化,代价是多实例之间的不一致。值得加的典型场景:

  • 访问量极高、变化极少:配置、字典、地区列表、开关。
  • 极热的少量 key:比如首页的几十个商品,用本地缓存吸收热点,避免单个 Redis 分片被打爆。

Java 侧一般用 Caffeine,要点是必须设置容量上限和 TTL,并且 TTL 要短于业务能容忍的不一致窗口:

java
Cache<Long, Product> local = Caffeine.newBuilder()
        .maximumSize(10_000)                       // 必须有上限,否则就是内存泄漏
        .expireAfterWrite(Duration.ofSeconds(30))  // 上限即最大不一致窗口
        .recordStats()                             // 命中率要能被观测
        .build();

数据变更时,除了删 Redis,还要通过消息广播让各实例清理本地缓存;做不到就把 TTL 调到足够短,用时间兜底。

三、预热:冷启动不要打垮数据库 ​

缓存为空时,所有请求都会回源。发布、重启、Redis 故障恢复都会造成这种局面。

3.1 四种预热方式 ​

方式做法适合注意
启动时加载应用启动时把热数据灌入缓存,加载完再对外提供服务数据量小、启动时间可控拖慢发布;多实例同时加载会压垮数据库
定时任务预热低峰期按热度列表提前加载热点相对稳定的场景需要维护热度数据
用时加载(懒加载)第一次访问时回源并回填大多数业务的默认做法冷启动时集中回源,需要防击穿
异步加载器缓存即将过期时后台刷新(refreshAfterWrite)热点数据,避免过期瞬间的抖动实现复杂,要控制刷新并发

多数系统用「用时加载 + 少量关键数据启动预热」就够了。

3.2 多实例同时预热的风险 ​

这是一个真实发生过的故障模式:灰度发布时一百多个 Pod 几乎同时启动,各自从数据库全量加载几十万条数据,瞬间叠加的读请求把数据库拖垮,引发主从切换。

控制手段:

  • 限制同时预热的实例数,用分布式信号量让一次只有 N 个实例加载,其余等待。具体实现见 基于 Redis 的分布式并发控制器。
  • 分批加载:每批几百条,批次之间留出间隔,给数据库喘息时间。
  • 错开启动时间:滚动发布本身就是一种错峰,要避免「一次性替换全部实例」。

3.3 防击穿:同一个 key 只回源一次 ​

缓存未命中时,同一时刻的大量请求会一起回源。单机上用本地互斥,集群上用分布式锁或「请求合并」:

java
public Product get(long id) {
    Product cached = redis.get(id);
    if (cached != null) {
        return cached;
    }
    // 同一 JVM 内对同一 key 只放行一个线程回源,其余等待结果
    return loadingCache.get(id, key -> {
        Product db = productMapper.selectById(key);
        redis.set(key, db, ttlWithJitter());
        return db;
    });
}

配合两点:空值也要缓存(用短 TTL,防止穿透),TTL 加随机偏移(防止雪崩)。

四、只能放 20 万条:怎么保证放的是热数据 ​

数据库 2000 万条、Redis 只放得下 20 万条时,思路不是「挑出热数据再放进去」,而是让缓存自己筛选:

  1. 所有 key 设置 TTL,让冷数据自然消失。 TTL 越短,缓存里留下的越是近期访问过的数据。
  2. 淘汰策略设为 allkeys-lru 或 allkeys-lfu,内存满时自动淘汰最不常用的。LFU 更抗一次性批量扫描,详见 Redis 内存满了会怎样。
  3. 用时加载:被访问到的数据才进缓存,天然符合访问分布。
  4. 对突发热点做主动探测:统计访问频次(本地计数 + 定期上报,或用 Redis 的 OBJECT FREQ),识别出新的热 key 后提前加载到本地缓存。

要观察的指标是命中率:

bash
redis-cli INFO stats | grep keyspace
# keyspace_hits / (keyspace_hits + keyspace_misses)

命中率长期低于 80%,通常说明缓存的数据选错了,或者 TTL 太短;命中率高但数据库压力仍然大,说明流量集中在未命中的那部分,要看具体是哪些 key。

五、Redis 挂了怎么办 ​

请求读缓存Redis 不可用超时 / 连接失败本地缓存兜底限流 / 熔断只放行部分请求数据库降级开关返回默认值 / 空列表异步补偿恢复后回填缓存预案关键是提前演练:主动断开 Redis,观察数据库 QPS 与接口成功率
图 2 · 缓存不可用时,先用本地缓存与限流保护数据库,再按重要性放行部分请求;预案要能一键开关

5.1 降低故障概率 ​

  • 部署高可用:哨兵或集群模式,至少三个节点分布在不同机器上。它们缩短的是恢复时间,异步复制下切换仍会丢失已确认的写入,实测见 Redis Sentinel 故障切换;重启后能恢复多少数据取决于持久化配置,见 Redis 持久化与恢复。
  • 设置 maxmemory 与合适的淘汰策略,避免被 OOM Killer 杀掉。
  • 禁用危险命令:KEYS、FLUSHALL 在生产实例上重命名或禁用。
  • 慢查询与延迟监控:SLOWLOG GET 定期采集,大 key 操作会阻塞整个实例;集中过期、fork 这类不属于命令的阻塞要打开 LATENCY 监控才能看到,见 Redis 为什么突然变慢。

5.2 故障时的三道防线 ​

第一道:客户端超时要短。 连接和读取超时设成几十到几百毫秒,让请求快速失败进入降级,而不是耗尽 Tomcat 线程。这是最容易被忽略的一点——Redis 挂掉之后压垮系统的往往不是数据库,而是等待超时的线程。

第二道:本地缓存兜底。 关键数据在本地保留一份短 TTL 的副本,Redis 不可用时直接用本地数据,允许一定的陈旧。

第三道:限流与降级开关。 缓存全部失效后,数据库最多能承受多少 QPS 要提前压测出来,超过就快速失败或返回降级结果(默认值、空列表、兜底页面)。开关要能在不发布的情况下打开。

5.3 恢复之后 ​

Redis 重启后缓存是空的,此时全部流量会打到数据库,比故障期间还危险。恢复流程应该是:先限流,再逐步放开,配合预热把热数据灌回去,观察数据库负载再撤掉限流。

5.4 演练 ​

上面这些只有演练过才算数。演练方式很简单:在预发环境断开 Redis 连接,观察三件事——接口成功率、数据库 QPS、应用线程池状态。多数团队第一次演练都会发现超时设置过长或者某处没有降级。

六、常见误区 ​

  • 「加缓存就能扛住高并发」:缓存失效、穿透、雪崩时,压力会一次性转移到数据库。
  • 「本地缓存和 Redis 都要加才够快」:Redis 与数据库的延迟差距没有想象中大,本地缓存主要用于极热数据和保护下游。
  • 「预热就是启动时把数据全灌进去」:多实例同时全量加载是典型的自我攻击。
  • 「Redis 挂了就降级到数据库」:数据库通常扛不住全部流量,必须先限流。
  • 「命中率高就说明缓存设计得好」:要同时看数据库的实际压力和未命中请求的分布。

小结 ​

缓存设计的每个决策都有对应的代价:多一层缓存多一份不一致,预热太激进会打垮数据库,内存有限时靠 TTL 和淘汰策略筛选热点,而故障预案的价值只有在演练过之后才成立。把「这一层挡住多少流量、失效怎么处理、坏掉怎么办」这三个问题回答清楚,缓存层才不会在关键时刻反噬业务。

缓存与数据库的一致性写法,见 缓存一致性的四种写法。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权