多级缓存、预热与热点数据:缓存层怎么设计才不会反噬
加缓存很容易,难的是回答几个具体问题:这一层缓存要挡住多少流量、数据失效了怎么办、只有 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-Control、ETag | 发布后用户拿到旧版本,需要文件名带哈希 |
| CDN | 静态资源、公共页面 | TTL、主动刷新 | 刷新有延迟;动态内容不适合 |
| 网关 / Nginx | 短时间内的相同请求 | proxy_cache TTL | 用户维度的数据不能缓存在这里 |
| 应用本地缓存 | 极热的少量数据 | TTL、容量淘汰、消息广播失效 | 多实例之间不一致;重启后失效 |
| 分布式缓存 Redis | 数据库的大部分读流量 | 主动删除、TTL | 网络往返;缓存与数据库一致性 |
选择时问三个问题:
- 这层能挡住多少比例的请求? 命中率低于 50% 的缓存层,通常只是增加复杂度。
- 数据变化后怎么让它失效? 本地缓存的失效最麻烦,需要广播或者接受短暂不一致。
- 不一致窗口业务能不能接受? 商品价格和库存不能容忍,商品描述和分类通常可以。
2.1 本地缓存什么时候值得加
本地缓存的收益是省掉一次网络往返(同机实测 Redis p50 约 0.05ms,跨主机还要更多)和 Redis 的一次序列化,代价是多实例之间的不一致。值得加的典型场景:
- 访问量极高、变化极少:配置、字典、地区列表、开关。
- 极热的少量 key:比如首页的几十个商品,用本地缓存吸收热点,避免单个 Redis 分片被打爆。
Java 侧一般用 Caffeine,要点是必须设置容量上限和 TTL,并且 TTL 要短于业务能容忍的不一致窗口:
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 只回源一次
缓存未命中时,同一时刻的大量请求会一起回源。单机上用本地互斥,集群上用分布式锁或「请求合并」:
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 万条时,思路不是「挑出热数据再放进去」,而是让缓存自己筛选:
- 所有 key 设置 TTL,让冷数据自然消失。 TTL 越短,缓存里留下的越是近期访问过的数据。
- 淘汰策略设为
allkeys-lru或allkeys-lfu,内存满时自动淘汰最不常用的。LFU 更抗一次性批量扫描,详见 Redis 内存满了会怎样。 - 用时加载:被访问到的数据才进缓存,天然符合访问分布。
- 对突发热点做主动探测:统计访问频次(本地计数 + 定期上报,或用 Redis 的
OBJECT FREQ),识别出新的热 key 后提前加载到本地缓存。
要观察的指标是命中率:
redis-cli INFO stats | grep keyspace
# keyspace_hits / (keyspace_hits + keyspace_misses)命中率长期低于 80%,通常说明缓存的数据选错了,或者 TTL 太短;命中率高但数据库压力仍然大,说明流量集中在未命中的那部分,要看具体是哪些 key。
五、Redis 挂了怎么办
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 和淘汰策略筛选热点,而故障预案的价值只有在演练过之后才成立。把「这一层挡住多少流量、失效怎么处理、坏掉怎么办」这三个问题回答清楚,缓存层才不会在关键时刻反噬业务。
缓存与数据库的一致性写法,见 缓存一致性的四种写法。
配套实验
- codesphere-labs/cache/cache-layer-latency:Caffeine 本地缓存、Redis
GET、MySQL 主键查询在同一个客户端里的访问延迟(验证记录)
参考资料