2847 字
约 9 分钟
17
S12-X12 · 本地缓存与逻辑过期:多级缓存的工程细节

促销开始那一分钟,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. 自测

  1. 穿透、击穿、雪崩各自的触发条件是什么?为什么一次 Redis 重启能同时引发三种?
  2. 空值缓存的 TTL 为什么必须短?写路径还需要做什么配套动作?
  3. 布隆过滤器为什么不支持删除?不支持删除会带来什么后果,第二层兜底是什么?
  4. 逻辑过期返回旧值,业务上要接受什么?重建锁为什么必须有超时?
  5. MONITOR 为什么不能在生产常开?除了它还有哪些发现热点的手段?
  6. 预热把什么成本搬到了启动时刻?滚动发布时这个成本会表现成什么?
S12-X12 · 本地缓存与逻辑过期:多级缓存的工程细节
https://www.clxhxhhr.top/posts/5981/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。