应用服务器和数据库的资源总是有限的,用户量和访问量却在涨。一个有效办法是引入缓存:在链路各环节直接返回已算好的结果,减少计算量与数据库 I/O,用有限资源服务更多请求。
本篇从命中率与缓存分层讲起,覆盖本地/分布式缓存、更新与淘汰策略,以及穿透、击穿、雪崩的成因与常见解法——也提醒何时不该用缓存。
缓存系列(本篇为开篇总览,下面六篇逐一深挖)
TL;DR
- 缓存打断标准链路:在浏览器、CDN、应用、数据库前等环节命中即可返回,减少计算与 I/O。
- 命中率 = 命中 / (命中 + 未命中),受业务读写比、缓存粒度、容量与淘汰策略影响;读多写少、粒度更细通常更划算。
- 介质:内存最快但不持久;硬盘可兜底;特殊 KV 库(如 Redis)吞吐远高于关系型数据库。
- 淘汰:FIFO / LFU / LRU;工程上常见本地缓存(进程内)与分布式缓存(可共享、易扩展)两类方案。
- 三种更新策略:Cache-Aside(读多写少首选)、Write-Through(写后同步双写,命中率高但写延迟高、且一致性仍受 TTL 与写失败影响,并非绝对强一致)、Write-Behind(异步刷库,写性能最佳但一致性最弱)。
- 延迟双删:写操作后先删缓存、更新库、再延迟删一次——清除并发读写入的旧值,是 Cache-Aside 的常见补偿方案。
- 热 Key:打散为多个副本 Key 或加本地缓存兜底;大 Key:拆分集合或用
UNLINK异步删除。 - 三大经典问题:穿透(空 key 打穿到库,用布隆过滤器/空对象缓存)、击穿(热 key 过期后并发回源,用 singleflight/分布式锁/逻辑过期)、雪崩(集中失效或节点宕机,用 TTL 抖动/多级缓存/限流降级/高可用集群)。
- AdTech 场景:预算快照缓存供竞价在 tmax 内同步读、campaign/创意元数据缓存、用户画像与频控计数放 Redis;竞价路径用「本地 Caffeine(L1)+ 分布式 Redis(L2)」多级缓存,热 key = 热门 campaign / 大广告主。
- 别滥用:频繁修改、无热点、强一致要求过高时,缓存可能变成累赘;预热与集群化能降低冷启动与单点风险。
Table of contents
Open Table of contents
1. 缓存分层与命中率
缓存的核心思路是:在请求链路的各个环节,尽可能早地返回已计算好的结果,避免重复计算和 I/O。
如下图所示,缓存可以嵌入在链路的多个层次,每个层次的缓存方案与特点各不相同。
缓存可嵌在浏览器、CDN、应用、数据库前等链路各层。
1.1 命中率
命中率 = 命中数 / (命中数 + 未命中数)
命中率是衡量缓存有效性的核心指标,受以下因素影响:
1. 业务场景:缓存适合读多写少的场景;写多读少时命中率低,引入缓存意义不大。更本质的一条判据是——实时性要求越低,越适合缓存:能容忍的数据陈旧窗口越大,就能设越长的 TTL、越少的失效与回源,命中率越高、一致性负担越轻;反之实时性要求越高(金额、库存、余额),TTL 只能越短甚至绕过缓存,缓存收益递减、成本递增。这条判据贯穿全系列——缓存一致性 的选型路径正是按「能接受多大的不一致窗口」展开的。
2. 缓存粒度:粒度越细,命中率通常越高。缓存单个对象比缓存整个集合更稳定——集合中任何一个成员变更都会导致缓存整体失效。
3. 缓存容量:容量不足会触发频繁淘汰,降低命中率。需结合目标 QPS 与热点数据体量合理规划容量。
4. 淘汰策略:不同算法对命中率的影响不同,需根据访问模式(热点稳定 vs 时效性强)选型。
1.2 缓存介质
从存储介质来看,主要分为三类:
- 内存:读写最快,无额外 I/O;缺点是数据不持久,进程重启后丢失。
- 硬盘:可持久化,容量大,常与内存结合使用——内存满时溢出到磁盘,或用于缓存快照备份。
- 特殊 KV 数据库:如 Redis、Memcached,将数据保存在内存并支持持久化,吞吐量远高于传统关系型数据库,是工程中最常用的分布式缓存选型。
1.3 淘汰策略:FIFO / LFU / LRU
| 策略 | 淘汰依据 | 适用场景 |
|---|---|---|
| FIFO | 写入时间最早 | 数据时效性优先,保留最新数据 |
| LFU(最近最不常用) | 历史访问次数最少 | 热点固定、高频数据需长期保留 |
| LRU(最近最少使用) | 最后访问时间最早 | 热点会随时间漂移,保留近期活跃数据 |
工程中 LRU 最为常见,Redis 等主流缓存框架均提供 LRU 及其变体(如 LRU-K、近似 LRU)作为默认或可选策略。
1.4 链路中的缓存位置
缓存不只存在于应用层面,系统各层均有其身影:
- 硬件层:CPU 各级 cache、磁盘读取时操作系统会预读附近页到 Page Cache;
- 网络层:DNS 查询结果缓存、HTTP 响应头控制浏览器缓存;
- 应用层:CDN 边缘节点缓存静态资源、数据库 Query Cache、Redis/Memcached 分布式缓存、进程内本地缓存。
理解「缓存无处不在」有助于在排查性能问题时快速定位瓶颈在哪一层。
2. 本地缓存、分布式缓存与更新策略
根据缓存与应用的耦合程度,可以分为本地缓存(local cache)和分布式缓存(remote cache)两大类。
- 本地缓存:运行在应用进程内,读写无网络开销,速度最快;缺点是多节点间无法共享,各节点需各自维护,且受进程内存限制,容量有限。适合单机场景或集群各节点无需互通的少量热点数据。
- 分布式缓存:独立于应用进程,多个应用节点共享同一份缓存,易于扩展,支持大容量数据;代价是多一次网络 RTT。适合多节点共享、容量需求大的场景。
2.1 本地缓存
方式一:成员变量或局部变量
最简单的方式,用 HashMap 等在方法或类内部缓存中间结果,减少重复数据库 I/O:
public void processItems() {
Map<String, Object> localCache = new HashMap<>();
for (Object item : getItemList()) {
if (localCache.containsKey(item)) {
// 命中:直接使用缓存
} else {
Object value = loadFromDB(item.toString());
localCache.put(item.toString(), value);
}
}
}
缺点:作用域仅限于当前方法/实例,类间无法共享。
方式二:静态变量
通过静态 Map 在类加载时一次性预热,适合城市列表、枚举字典等低频变更的基础数据:
public class CityUtils {
private static final Map<Integer, String> CITY_NAME_MAP = new HashMap<>();
static {
// 应用启动时从数据库或远程接口加载,之后直接读内存
loadCityData(CITY_NAME_MAP);
}
public static String getCityName(int cityId) {
return CITY_NAME_MAP.getOrDefault(cityId, "未知");
}
}
缺点:数据实时性差;若需动态更新,通常需结合配置中心(如 Nacos、ZooKeeper)做变更通知与热刷新。
2.2 Ehcache
Ehcache 是 Java 生态中成熟的本地缓存框架,支持内存与磁盘两级存储,提供完善的 TTL、容量限制、监听事件等能力。相比裸 HashMap,Ehcache 内置淘汰策略(LRU/LFU/FIFO)、溢出落盘与序列化支持,适合在单机或少量节点、缓存量稍大且需要持久化的场景中使用。Spring Cache 抽象与 Ehcache 集成开箱即用。
2.3 Guava Cache
Guava Cache 是 Google Guava 提供的进程内轻量级缓存,API 简洁,功能实用:
- 支持基于时间的过期策略(
expireAfterWrite/expireAfterAccess); - 支持最大容量限制与 LRU 淘汰;
- 支持
CacheLoader自动回源,线程安全; - 轻量无需额外部署,适合进程内少量热点数据的缓存。
Guava Cache 的继任者 Caffeine 用 W-TinyLFU 淘汰算法把命中率又拔高了一截,还支持异步加载与堆外缓存——本地缓存的选型、淘汰算法与调优详见 本地缓存深挖:Caffeine、W-TinyLFU 与堆外缓存。
2.4 分布式缓存:Redis 与 Memcached
分布式缓存以 Redis 和 Memcached 为代表。
Redis 是目前最主流的分布式缓存选型,支持丰富的数据结构(String / Hash / List / Set / ZSet / Stream 等),单线程 I/O 模型保证原子性,支持 AOF/RDB 持久化与主从复制/集群高可用,适用于绝大多数场景。
Memcached 纯内存、多线程,数据结构简单(只有 KV),适合纯缓存、不需持久化的高吞吐场景,工程中已逐渐被 Redis 替代。
Redis 作为分布式缓存事实标准,本系列用三篇深挖它:底层数据结构与内存模型见 Redis 深挖(一)、持久化与高可用(RDB/AOF、主从、Sentinel、Cluster)见 Redis 深挖(二)、单线程 + IO 多线程模型与性能见 Redis 深挖(三)。
2.5 更新策略:Cache-Aside / Write-Through / Write-Behind
缓存与数据库的一致性是分布式缓存的核心挑战。常见三种更新模式:
Cache-Aside(旁路缓存)
最常用的模式,应用代码显式管理缓存:
- 读:先查缓存,Miss 则查数据库,将结果写入缓存后返回;
- 写:先更新数据库,再删除(而非直接更新)缓存,让下次读时重新填充。
为什么写时删除而不是更新?更新缓存需要先查数据库再计算再写入,并发时若顺序颠倒(旧值覆盖新值)会导致数据不一致;删除操作简单,只要保证「删了就会触发下次回源拉最新值」即可,代价仅是下一次读有一次 miss。
适用:读多写少、可接受短暂不一致(最终一致)的场景;绝大多数互联网业务的首选。
旁路缓存:读 miss 回源再写入;写通常先更新库再失效/更新缓存。
Write-Through(穿透写)
应用写入时同步更新数据库和缓存,让两者在写成功后尽量保持一致。
- 优点:缓存写后即为最新值,读命中率高、读一致性好;
- 缺点:每次写都需同步双写,延迟较高;对写多读少的场景浪费明显——写进缓存的数据可能在 TTL 内从未被读到。
注意:Write-Through 常被简单标注为”强一致”,但这是过度简化。它只保证「写成功那一刻缓存 = DB」,仍有几处不一致窗口:① 缓存条目通常仍带 TTL,过期后到重新填充前读到的是回源值而非缓存值;② 双写非原子——若”写 DB 成功但写缓存失败”(或反之),两者会短暂背离,需要重试或补偿;③ 绕过缓存直改 DB(如运维批量脚本、其他服务)时缓存不会自动更新。真正的强一致需要事务或分布式一致性协议,缓存层一般只能做到”写后最新 + 最终一致”。
适用:写频率不高、且对读一致性要求较高的场景(如用户配置、配置中心数据)。
Write-Behind / Write-Back(异步写)
应用只写缓存,由后台异步批量刷新到数据库。
- 优点:写延迟极低,吞吐量高;
- 缺点:缓存宕机时未刷入数据库的数据会丢失,一致性最弱,实现复杂。
适用:对写延迟极度敏感、允许短暂数据丢失的场景(如计数器、点赞数,丢几个可接受);通常配合 WAL 或消息队列做补偿。
| 策略 | 读性能 | 写性能 | 一致性 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| Cache-Aside | 高(Miss 有回源延迟) | 中(删缓存) | 最终一致 | 低 | 商品详情、用户信息 |
| Write-Through | 最高(命中率高) | 低(同步双写) | 写后最新(受 TTL/双写失败影响,非绝对强一致) | 中 | 配置数据、用户设置 |
| Write-Behind | 高 | 最高(异步) | 弱一致 | 高 | 计数器、统计数据 |
2.6 延迟双删
Cache-Aside 模式下的经典一致性问题:两个并发请求可能导致旧值重新写入缓存——
- 请求 A:先删缓存;
- 请求 B(并发读):缓存 miss,查库拿到旧值,准备写入缓存;
- 请求 A:更新数据库;
- 请求 B:将旧值写入缓存 → 缓存被污染。
延迟双删 是实践中常用的补偿方案:
1. 删除缓存
2. 更新数据库
3. 延迟 500ms–1s 后再次删除缓存(清除步骤 1–2 期间并发读写入的旧值)
延迟时长需大于一次「数据库读 + 缓存写」的最大时间。延迟删除可通过消息队列异步执行,避免阻塞业务线程。
注意:延迟双删仍有短暂不一致窗口(步骤 2–3 之间);若数据库已配置 binlog → Canal/Debezium 同步链路,也可由消费端触发缓存失效,比手动双删更可靠且可重放。
2.7 热 Key 与大 Key
热 Key(Hot Key)
单个 Key 被极高频率访问(如爆款商品详情页),Redis 单节点可能成为瓶颈,单核 CPU 跑满,其余 Key 的请求也受影响(Redis 单线程)。
解法:
- 本地缓存兜底:在应用内存中缓存一份,TTL 极短(5–10s),大部分请求不出进程;
- Key 拆分打散:将热 Key 复制为
key_0、key_1…key_N,写时全部更新,读时随机选一个,从而将流量打散到不同 Redis 节点; - Redis Cluster:集群模式下各槽位由不同主节点负责(分片路由与负载均衡里的一致性哈希是同一套思想),但单 Key 仍在单节点,热 Key 需配合前两种方案解决。
大 Key(Big Key)
单个 Key 的 Value 体积过大(String > 10KB,或 Hash/List/Set 元素数量超万)。
危害:
- 网络传输慢,占用带宽;
DEL一个大 Key 会阻塞 Redis 主线程(同步删除),引发高延迟或 OOM;- 集群模式下大 Key 所在槽位数据迁移耗时极长。
解法:
- 序列化前压缩 Value(gzip/snappy);
- 拆分大集合为多个小集合(Hash 分桶:
key:{hash(field) % N}); - 删除大 Key 时使用
UNLINK(异步删除)而非DEL。
2.8 多级缓存一致性
本地缓存 + 分布式缓存的多级架构能大幅提升吞吐,但带来了一致性联动的挑战:
当数据库数据更新时,需要同时失效:
- 所有应用节点的进程内缓存(L1 本地缓存);
- 分布式缓存(L2,如 Redis)。
常见方案:
- Redis Pub/Sub:更新数据库后向 Redis 发布失效消息,各应用节点订阅并清除本地缓存;简单但不可靠(节点宕机时漏消息无法重放);
- 消息队列广播:通过 Kafka/RabbitMQ 广播缓存失效事件,可重放,可靠性更高;
- 本地缓存设极短 TTL:简单粗暴,本地 TTL 设为 1–5s,接受极短的不一致窗口,适合对一致性要求不高的场景;
- 版本号比对:每次读本地缓存时携带版本号,若版本落后则主动失效并重新拉取。
缓存与 DB、L1 与 L2 之间的一致性是本系列最难啃的一块——延迟双删的竞态边界、Binlog/Canal 订阅驱动失效、多级缓存广播失效的可靠性对比,详见 缓存一致性深挖:双删、Binlog 订阅与多级失效。
多级缓存的读取遵循「L1 本地 → L2 分布式 → 回源」逐层下探、逐层回填的顺序:
public Product getProduct(long id) {
String key = "product:" + id;
// L1:进程内本地缓存(Caffeine),TTL 极短,吸收绝大多数读
Product p = localCache.getIfPresent(key);
if (p != null) return p;
// L2:分布式缓存(Redis),多节点共享
p = redisGet(key);
if (p != null) {
localCache.put(key, p); // 回填 L1
return p;
}
// 回源:查库并逐层回填(此处应叠加 §3.2 的 singleflight/锁防击穿)
p = loadFromDB(id);
if (p != null) {
redisSet(key, p, Duration.ofMinutes(10));
localCache.put(key, p);
}
return p;
}
多级缓存带来的性能收益显著,但一致性方案复杂度成倍增加;一致性要求越高,多级缓存的实现成本越高,需权衡是否值得。广告竞价路径正是「L1 Caffeine + L2 Redis」多级缓存的典型场景,详见 §4。
2.9 注解式缓存(Spring Cache)
Spring Cache 提供统一的缓存抽象,通过注解与业务逻辑解耦,底层可替换为 Ehcache、Redis、Caffeine 等任意实现:
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product getById(Long id) {
return productRepository.findById(id).orElseThrow();
}
@CacheEvict(value = "products", key = "#product.id")
public void update(Product product) {
productRepository.save(product);
}
}
@Cacheable:方法执行前查缓存,命中则直接返回,未命中则执行方法并将结果写入缓存;@CacheEvict:方法执行后删除指定缓存键;@CachePut:方法执行后更新缓存(不跳过方法执行)。
注解式缓存简化了代码,但需注意自调用失效(Spring AOP 代理限制)和缓存 key 设计。
3. 穿透、击穿与雪崩
穿透、击穿、雪崩是缓存三大经典失效模式:它们的共同后果都是缓存这道防线被绕过,流量完整地压到数据库上(数据库扛不住时的扩展手段见 数据库性能与扩展),区别只在于失效的是「不存在的 key」「单个热 key」还是「大批 key / 整个节点」。
三者都让流量绕过缓存打到 DB,区别在于失效的是「不存在的 key」「单个热 key」还是「大批 key / 整个节点」。
3.1 缓存穿透
问题描述:查询一个数据库中也不存在的 key,缓存无法命中,每次请求都落到数据库;若这类请求量大,数据库承受无意义的查询压力,严重时引发崩溃。
常见解法:
- 缓存空对象:对查询结果为空的 key,也在缓存中存入一个空值(如
null或特殊标记),并设置较短的 TTL。成本低,适合命中率低但可能被频繁查询的 key。 - 布隆过滤器:在缓存前置一个 Bloom Filter,对所有合法 key 做预建。请求到来时先过滤——不在布隆过滤器中的 key 直接拒绝,不打到缓存或数据库。误判率可调,但无法删除元素,适合 key 集合相对稳定的场景。
布隆过滤器可用 Redisson 的 RBloomFilter(分布式共享)或 Guava 的 BloomFilter(进程内)实现。以 Redisson 为例:
RBloomFilter<String> filter = redisson.getBloomFilter("product:exists");
// 初始化:预计元素数 1000 万,期望误判率 1%
filter.tryInit(10_000_000L, 0.01);
// 全量合法 key 预建(新增数据时同步 add)
for (long id : allProductIds()) filter.add("product:" + id);
public Product getProduct(long id) {
String key = "product:" + id;
// 不在过滤器中 → 库里必然不存在,直接短路,不打缓存/DB
if (!filter.contains(key)) return null;
// 可能存在(含误判)→ 走正常缓存 + 回源流程
Product p = cacheGet(key);
if (p != null) return p;
p = loadFromDB(id);
cacheSet(key, p == null ? EMPTY : p, p == null ? Duration.ofSeconds(60) : Duration.ofMinutes(10));
return p;
}
布隆过滤器有假阳性(判定”可能存在”但实际不存在)而无假阴性——被它放行的少量不存在 key 仍需靠”缓存空对象”兜底,两者常配合使用。
布隆过滤器在 Redis 上到底怎么实现(原生 bitmap 手写 / RedisBloom 模块 / Redisson)、误判率与 m/k 参数怎么算、不支持删除怎么破(计数布隆 / 布谷鸟过滤器),详见 Redis 布隆过滤器实现:位图、RedisBloom 与缓存穿透。
3.2 缓存击穿
问题描述:某个热点 key 在高并发访问期间恰好过期,瞬间有大量请求同时穿透缓存,全部打到数据库,形成并发回源冲击。
常见解法:
- 互斥锁(分布式锁):缓存 miss 后,仅允许一个请求回源数据库并写入缓存,其余请求等待或短路返回兜底数据;
- 逻辑过期:缓存中存储「数据 + 过期时间」,实际不设 TTL;读到数据后判断逻辑时间,若已过期则异步刷新,其余请求继续使用旧值;
- 热点 key 永不过期:对真正的热点 key 主动维护(更新时同步刷新缓存),避免 TTL 到期带来的并发冲击。
互斥锁 / singleflight 实现:缓存 miss 时用分布式锁(或单机 singleflight)保证同一 key 只有一个请求回源,其余请求短暂等待后复用结果,把”并发回源 N 次”收敛为”回源 1 次”:
public Product getProductWithLock(long id) {
String key = "product:" + id;
Product p = cacheGet(key);
if (p != null) return p;
String lockKey = "lock:" + key;
// SET NX PX:只有一个请求能抢到锁去回源
boolean locked = redis.set(lockKey, "1", "NX", "PX", 3000);
if (!locked) {
Thread.sleep(50); // 未抢到锁,短暂等待后重试读缓存
return getProductWithLock(id); // 此时大概率已被持锁者回填
}
try {
p = cacheGet(key); // double-check,避免刚好被别人填过
if (p != null) return p;
p = loadFromDB(id);
cacheSet(key, p, Duration.ofMinutes(10));
return p;
} finally {
redis.del(lockKey); // 生产中应校验持有者并用 Lua 原子释放
}
}
Java 单机可用 Guava/Caffeine 的
LoadingCache(自带 per-key 加载互斥),Go 直接用golang.org/x/sync/singleflight——核心都是同一 key 的并发回源只放行一个。分布式锁释放务必校验持有者(用 Lua 原子比对 value 再删),否则可能误删他人的锁。
3.3 缓存雪崩
问题描述:大量缓存 key 同时过期,或缓存节点集中宕机,导致大量请求同时打到数据库,引发系统整体崩溃。
一句话:穿透、击穿、雪崩——任一失控都可能将流量压力完整地压在数据库上;限流、降级、熔断与多级缓存是架构侧的关键兜底。
常见解法:
- 错开过期时间:在基准 TTL 上叠加随机抖动(如
TTL ± rand(0, N)),避免大量 key 同时失效; - 多级缓存:本地缓存 + 分布式缓存双层兜底,即使 Redis 节点异常,本地缓存仍可吸收部分流量;
- 限流与熔断:对回源请求做速率限制,超出阈值降级(返回默认值或缓存快照);
- 高可用部署:Redis 主从 + Sentinel 或 Cluster,避免单节点故障引发雪崩;
- 压力测试:在测试环境模拟缓存集中失效场景,提前验证降级策略是否生效。
三大问题各有主攻方向,也共享「限流 / 降级 / 熔断 + 多级缓存」这道最后兜底:
穿透防「查不存在」、击穿防「热 key 过期」、雪崩防「集中失效」;限流、降级、熔断与多级缓存是三者共同的最后兜底。
4. 广告系统中的缓存(AdTech)
广告竞价系统是缓存的极端练兵场:单机数万到数十万 QPS,一次广告请求(tmax 通常 80–150ms)里要读预算、频控、创意、画像等多份数据,任何一项慢一点都可能超时导致 no-bid(放弃这次竞价 = 直接的收入损失)。这里缓存不是”锦上添花的加速”,而是竞价能否在 tmax 内完成的前提。
4.1 缓存了什么
| 数据 | 用途 | 缓存位置 | 特点 |
|---|---|---|---|
| 预算快照(budget snapshot) | 竞价前校验 campaign 是否还有预算、是否超投 | L1 Caffeine + L2 Redis | 读极频繁、要求 tmax 内返回;短 TTL + 变更主动刷新 |
| campaign / 创意元数据 | 定向、出价策略、素材信息 | L1 Caffeine + L2 Redis | 读多写少,变更走配置中心广播失效 |
| 用户画像 / 定向标签 | 人群定向、相关性打分 | Redis(大容量) | key 多、value 中等;命中率决定召回质量 |
| 频控计数(frequency cap) | 限制同一用户对同一广告的曝光次数 | Redis(原子 INCR + TTL) | 写频繁、需原子性;天然适合 Redis 计数 |
4.2 竞价路径的多级缓存:Caffeine(L1)+ Redis(L2)
竞价读预算/元数据必须在 tmax 内完成,因此走「本地 Caffeine(L1)→ 分布式 Redis(L2)→ 回源配置中心 / DB」的多级缓存:L1 亚毫秒吸收绝大多数读,L2 兜底跨节点共享,回源只在冷启动或未命中时发生。
竞价读预算必须在 tmax 内完成:L1 亚毫秒吸收绝大多数读,L2 兜底,回源仅冷启动/未命中;本地 TTL 极短 + 预算变更主动刷新避免超投。
一组有代表性的量级(因业务而异,用于建立直觉):
- L1 Caffeine:命中率 ~90%,
<1ms,TTL 仅 1–5s(宁可短也不敢读到旧预算快照导致超投); - L2 Redis 集群:吸收 L1 未命中的 ~9%,1–3ms RTT;
- 回源:
<1%,数十 ms,异步预热,绝不放在竞价关键路径上同步等待。
预算快照的一致性尤其敏感:读到旧快照会导致已花光预算的 campaign 继续参竞、造成超投(超出广告主预算 = 平台亏损)。因此 L1 的 TTL 必须极短,且预算扣减到阈值时要主动推送失效给各竞价节点,而不是被动等 TTL 到期。
4.3 热 key = 热门 campaign / 大广告主
广告场景的热 key 不是抽象概念,而是具体的爆量广告主或定向过宽的 campaign:它相关的预算、频控、元数据 key 会被绝大多数竞价请求同时读,把承载它的单个 Redis 节点 CPU 打满(Redis 单线程),进而拖慢所有落在该节点的请求。应对手段就是 §2.7 与 §3.2 的组合:
- L1 本地缓存兜底:热门 campaign 的元数据在每个竞价节点内存里存一份(TTL 极短),绝大多数读不出进程;
- 热 key 打散:把
budget:{campaignId}复制为budget:{campaignId}:{0..N},读时随机选一个副本,把单节点压力摊到多个分片; - 逻辑过期防击穿:热门 campaign 的元数据用”逻辑过期”而非 TTL,过期时异步刷新、其余请求继续用旧值,避免 TTL 到期瞬间的并发回源冲击。
5. 什么时候不该用缓存
缓存不是银弹,以下场景需谨慎评估:
6.1 频繁修改的数据
若数据写入速度接近读取速度(读写比低于 2:1),缓存数据刚写入就过期,命中率极低,反而徒增系统复杂度。
反例:实时库存扣减——每笔订单都修改库存,若同时缓存库存值,读到的几乎总是旧值,且写时还要维护缓存一致性,得不偿失。此类场景应直接操作数据库,配合乐观锁或 Redis DECR 做原子扣减。
6.2 没有热点的访问
若访问分布均匀,不符合「少量 key 承担大部分流量」的规律,引入缓存收益有限。
反例:用户档案系统,1000 万用户中每天均匀访问约 100 万不同用户的数据——命中率约 10%,缓存只能覆盖少量热点用户,其余请求仍打到数据库;此时优先考虑数据库索引优化与读副本(参见 数据库性能与扩展),而非缓存。若冷热差异明显,更适合走 数据冷热分离 而非一层通用缓存。
6.3 强一致性要求
缓存天然存在短暂的数据延迟窗口。若业务对一致性要求极高,需仔细权衡是否适合引入缓存。
典型场景:
- 金融账户余额:扣款后必须读到最新值,不能读到缓存旧值导致超额授信——核心交易链路通常绕过缓存,直查主库;
- 库存强一致:秒杀场景虽高并发,但库存超卖比缓存命中率更重要——常用 Redis
DECR原子操作替代传统缓存方案,而非加一层可能过期的 KV 缓存; - 审计/对账数据:对账要求每一条记录都准确,任何延迟写入都不可接受。
6.4 缓存可用性
缓存会承担数据库的大部分访问压力。一旦缓存服务故障,数据库将承受全量流量,极易宕机。通过分布式缓存集群(多分片 + 副本)分散风险,单节点故障时仅影响部分数据,不至于引发整体雪崩。同时配合限流熔断(参见 服务限流)做最后防线。
6.5 缓存预热
新部署或重启后,缓存中无任何数据,所有请求都会打到数据库(冷启动)。对于已知的热点数据(如商品列表、城市字典),在服务启动时主动预加载到缓存(warm up),可显著缓解冷启动压力。
5. 生产落地清单
给某个读接口引入缓存前逐项确认:
- 确认场景适合缓存:读多写少 + 有热点,读写比高、访问集中;频繁修改 / 无热点 / 强一致场景先排除(§5)
- 选对更新策略:通用读多写少用 Cache-Aside(写时删缓存而非更新),配置类可用 Write-Through,计数类可用 Write-Behind(§2.5)
- Cache-Aside 写路径「先更新 DB 再删缓存」,并发一致性敏感时叠加延迟双删或 binlog(Canal/Debezium)订阅失效(§2.6)
- 别把 Write-Through 当”强一致”:它仍受 TTL、双写失败、绕过缓存直改 DB 影响,只能做到”写后最新 + 最终一致”(§2.5)
- 防穿透:布隆过滤器拦非法 key + 空对象短 TTL 兜底(§3.1)
- 防击穿:热点 key 用互斥锁 / singleflight 收敛并发回源,或逻辑过期 / 永不过期 + 主动刷新(§3.2)
- 防雪崩:TTL 叠加随机抖动错开过期、多级缓存兜底、回源限流降级、缓存高可用集群(§3.3)
- 热 key 主动探测(
--hotkeys/ 监控热点 slot)并打散为多副本 + L1 本地缓存兜底(§2.7 / §4.3) - 大 key 拆分或压缩,删除用
UNLINK而非DEL(§2.7) - 多级缓存明确 L1/L2 失效联动方案(Pub/Sub、MQ 广播、极短 TTL 或版本号),并评估一致性成本(§2.8)
- 缓存故障要降级而非雪崩:回源加限流熔断,缓存不可用时保护 DB(§5.4)
- 上线前预热已知热点数据,避免冷启动打垮 DB(§5.5)
- 命中率、回源 QPS、热 key、Redis 节点 CPU 纳入监控告警(竞价场景还需盯 no-bid 率与 tmax 超时率)