促销开始那一分钟,Redis 命中率断崖式下跌,数据库连接池瞬间打满,接口 RT 从毫秒级跳到秒级——这类事故的共同点是:缓存没坏,是缓存没能挡住流量。挡住流量靠的不是把 TTL 调长,而是把"失效瞬间"从架构上拆开。
先分清三种异常,它们的成因完全不同,解法也互相不能替代:
1. 穿透、击穿、雪崩
| 异常 | 成因 | 表现 | 主要解法 |
|---|---|---|---|
| 穿透 | 查一个库里也不存在的 key | 每次请求都落到库,缓存永远不命中 | 空值缓存、布隆过滤器、参数校验 |
| 击穿 | 单个热点 key 过期的瞬间 | 大量并发同时回源,压垮一个查询 | 互斥重建、逻辑过期、单飞 |
| 雪崩 | 大批 key 同时过期或 Redis 整体不可用 | 全量请求打到库,库被打挂 | TTL 抖动、多级缓存、限流降级 |
穿透是"数据不存在",击穿是"数据存在但缓存刚没",雪崩是"一大片缓存同时没"。三者会叠加:一次 Redis 重启既造成雪崩,也让热点 key 全部击穿,同时把不存在的 id 全放进来穿透。
2. 穿透:空值缓存与布隆过滤器
空值缓存最直接:查到 null 也写进 Redis,用很短的 TTL,只挡住"反复查同一个不存在的 id"。
| 做法 | 挡住什么 | 代价 |
|---|---|---|
| 空值哨兵(短 TTL,典型几十秒量级) | 同 id 的重复查询 | 内存被垃圾 key 占据;写路径必须删哨兵 |
| 布隆过滤器 | 不存在 id 的查询(零回源) | 有假阳性、不支持删除、需重建 |
| 参数校验(id 范围、格式) | 明显非法的请求 | 只能挡低级攻击 |
public Item get(Long id) {
String key = "item:" + id;
String cached = redis.opsForValue().get(key);
if (NULL_SENTINEL.equals(cached)) return null; // 哨兵命中,直接返回
if (cached != null) return Json.decode(cached, Item.class);
if (!bloom.mightContain(id)) return null; // 一定不存在,不回源
Item db = itemMapper.select(id);
if (db == null) {
redis.opsForValue().set(key, NULL_SENTINEL, Duration.ofSeconds(45)); // 短 TTL
return null;
}
redis.opsForValue().set(key, Json.encode(db), Duration.ofMinutes(30));
return db;
}
布隆过滤器的假阳性率随元素数上升,容量要按预估 key 数一次性给足;它不支持删除,商品下架后旧 id 仍会"可能存在"并回源——所以布隆过滤器之后还必须有空值缓存兜第二层。
3. 击穿:三种重建方式与代价
| 解法 | 机制 | 代价 | 适用 |
|---|---|---|---|
| 互斥锁重建 | 只让一个线程回源,其余等待 | 等待线程堆积,锁超时处理不好会死锁 | 强一致要求、能接受短暂排队 |
| 逻辑过期 | 值里带过期时间,过期仍返回旧值,异步重建 | 返回的是旧数据,需业务接受 | 热点商品、详情页 |
| 单飞 singleflight | 同 key 并发请求合并成一次 | 只对同进程有效,多实例仍各自回源 | 单机热点、本地缓存回源 |
逻辑过期的关键是永不过期 + 值内带时间戳,让"过期"不再等于"不可用":
record CacheVal(String json, long expireAt) {}
public Item getWithLogicalExpire(Long id) {
String key = "item:logic:" + id;
CacheVal v = Json.decode(redis.opsForValue().get(key), CacheVal.class);
if (v == null) { // 冷启动:走互斥重建,避免并发回源
return rebuildWithLock(id);
}
if (v.expireAt() > System.currentTimeMillis()) {
return Json.decode(v.json(), Item.class);
}
// 已逻辑过期:仍返回旧值,后台只让一个线程重建
if (redis.opsForValue().setIfAbsent(key + ":rebuild", "1", Duration.ofSeconds(10))) {
executor.execute(() -> {
try { rebuildAndSet(key, id); } finally { redis.delete(key + ":rebuild"); }
});
}
return Json.decode(v.json(), Item.class);
}
互斥锁重建的两个硬性细节:锁必须有过期时间(否则重建线程挂掉就是永久死锁),拿不到锁的线程要快速失败或短暂等待,不能无限 sleep 轮询——那会把 Tomcat 线程池耗光,比打穿数据库更快出事。
4. 雪崩:抖动、分级与限流
TTL 加随机抖动是最便宜的一招,作用是把"同一时刻"摊成"一段时间":
// 基础 TTL ± 随机偏移,避免同批 key 同一秒集体失效
long ttl = baseSeconds + ThreadLocalRandom.current().nextLong(0, jitterSeconds);
redis.opsForValue().set(key, json, Duration.ofSeconds(ttl));
但抖动的边界要说清:它只解决"同一批写入、同一批过期",解决不了 Redis 整体不可用。真正的雪崩防护是三件事叠加:
| 层 | 作用 | 不做会怎样 |
|---|---|---|
| 本地缓存 | Redis 挂了仍有兜底读 | 全部请求直接进库 |
| 限流 / 熔断 | 把超过库承受能力的流量挡在入口 | 库被打满,所有请求一起超时 |
| 降级返回 | 返回默认值或静态页,保住可用性 | 用户看到 500 |
5. 热点 key 怎么发现
| 手段 | 精度 | 代价 |
|---|---|---|
| 代理层(如 Twemproxy / 自研网关)统计 key 频次 | 高,可按 key 聚合 | 需改链路或加代理 |
MONITOR |
全量命令,最准 | 严重拖慢 Redis,只能短时开、只能用在非核心实例 |
--hotkeys(redis-cli --hotkeys) |
基于 LFU 抽样,近似 | 需要 maxmemory-policy 为 LFU 系列 |
| 客户端埋点 | 只覆盖自家应用 | 埋点成本,易漏其他写入方 |
SLOWLOG |
只能看到慢命令 | 热点但快的命令看不到 |
MONITOR 的代价必须说清:它把每条命令复制一份发给监听连接,QPS 高时这条连接本身成为瓶颈,还会额外占内存缓冲。真要开,限制时长(秒级)、限定实例、并接受这段时间内的性能下降。
探测到热点后的处理顺序是:先加本地缓存(把读打散到各进程),再考虑 key 分片(hotkey:{0..N} 拆成 N 份,读时随机取一份),最后才是扩容。分片的代价是写入要写 N 份、且聚合读要合并结果,只在读远多于写时划算。
6. 本地缓存必然带来的三个问题
| 问题 | 表现 | 处理 |
|---|---|---|
| 容量上限 | 无上限的 map 跟着 QPS 涨到 OOM | maximumSize 必须显式设置 |
| 失效广播 | 各实例各自一份,删不到别人 | Redis pub/sub + 短 TTL 兜底 |
| 一致性范围 | 从"一份数据"变成"N 份 + 共享层" | 接受秒级不一致,或只缓存不变数据 |
Cache<Long, Item> local = Caffeine.newBuilder()
.maximumSize(10_000) // 必须有上限
.expireAfterWrite(Duration.ofSeconds(30)) // 必须有兜底 TTL
.recordStats()
.build();
本地缓存的定位是削峰工具,不是存储。给它上限、给它短 TTL、给它广播通道,三样缺一样都会在某个时刻变成事故。
7. 预热的三条路径
| 路径 | 触发 | 覆盖 | 风险 |
|---|---|---|---|
| 启动预热 | 应用启动时批量加载 | 只覆盖"已知热点清单" | 拉长启动时间,清单过期即无效 |
| 定时刷新 | 定时任务重算热点 | 跟随访问分布变化 | 刷新间隔内的新热点无缓存 |
| 按访问触发 | 首次访问回源并写入 | 自然覆盖真实热点 | 冷启动瞬间仍会击穿一次 |
@EventListener(ApplicationReadyEvent.class)
public void warmUp() {
List<Long> hotIds = hotKeyConfig.topN(500); // 清单来自离线统计,不是拍脑袋
for (List<Long> batch : Lists.partition(hotIds, 100)) {
Map<Long, Item> rows = itemMapper.selectBatch(batch);
rows.forEach((id, item) -> redis.opsForValue()
.set("item:" + id, Json.encode(item), Duration.ofMinutes(30)));
}
}
预热的取舍很直接:它把流量峰值的一部分搬到启动时刻。清单越大启动越慢,滚动发布时新实例就绪时间变长,健康检查可能先判失败。实践上限制批量大小、异步执行、并在预热未完成时允许正常回源,比"必须预热完才接流量"更稳。
8. 降级:三种出口
- 返回默认值:非核心字段(推荐位、计数)返回空或缓存里的旧值,保住主流程;
- 走库但限速:用信号量限制回源并发,超出的请求快速失败而不是排队;
- 限流:入口按 key 维度限速,热点 key 的 QPS 直接被削到库能承受的量级。
降级必须可观测:降级次数、降级时长、降级期间的错误率,三项都要有指标,否则降级会悄悄变成常态。
9. 排错:六种现场
| 现象 | 根因 | 处理 |
|---|---|---|
| 命中率周期性掉坑 | 大批 key 同一时刻过期 | 加随机抖动,检查批量写入的 TTL 是否同值 |
| 明明有缓存还是打库 | 空值哨兵挡住了真实数据,或布隆误判 | 写路径必须删哨兵;核对布隆容量 |
| 重建期间接口全超时 | 互斥锁无超时,等待线程堆积 | 锁加 TTL,等待方快速失败 |
| 重启后第一波流量打挂库 | 冷启动无缓存,预热未完成就接流量 | 异步预热 + 入口限流 |
| 单实例 CPU 高、其他正常 | 本地缓存未命中分布不均或 key 分片倾斜 | 核对分片键与本地缓存统计 |
| 内存持续上涨 | 本地缓存无 maximumSize |
立即加上限并观察 eviction 计数 |
10. 边界:什么情况不该上缓存层
| 场景 | 建议 | 理由 |
|---|---|---|
| 强一致读(余额、库存) | 直接读库或 Redis 作权威源 | 缓存只会增加不一致窗口 |
| 低频、长尾查询 | 不缓存 | 命中率低,白占内存 |
| 结果集很大(分页深、导出) | 缓存 id 列表而非整页数据 | 大 value 阻塞与带宽成本高 |
| 写多读少 | 不缓存或极短 TTL | 失效次数超过命中次数 |
| 已经过 CDN 的静态内容 | 交给 CDN,不再套 Redis | 重复一层反而增加失效复杂度 |
11. 自测
- 穿透、击穿、雪崩各自的触发条件是什么?为什么一次 Redis 重启能同时引发三种?
- 空值缓存的 TTL 为什么必须短?写路径还需要做什么配套动作?
- 布隆过滤器为什么不支持删除?不支持删除会带来什么后果,第二层兜底是什么?
- 逻辑过期返回旧值,业务上要接受什么?重建锁为什么必须有超时?
MONITOR为什么不能在生产常开?除了它还有哪些发现热点的手段?- 预热把什么成本搬到了启动时刻?滚动发布时这个成本会表现成什么?