Administrator
发布于 2022-03-15 / 8489 阅读
227

多级缓存架构设计:本地缓存 + Redis

大促压测:Redis 被打到 12 万 QPS,还是扛不住

3 月大促前做全链路压测,商品详情页的接口目标 5 万 QPS。单 Redis 集群(3 主 3 从,Redis 6.2)在 12 万 QPS 时 CPU 跑满,P99 从 3 ms 飙到 47 ms,再往上就开始超时。加节点成本太高,而且延迟敏感链路(APP 首屏)不该每次都跨机房的 Redis 网络往返。

架构评审上我提了方案:在应用进程内加一层 Caffeine 本地缓存,和 Redis 组成两级缓存。这篇把设计、一致性处理和最终数据写出来。

为什么需要本地缓存这一级

商品详情这种读多写少、容忍秒级不一致的场景,绝大多数请求其实命中的是同一批热点数据。压测抽样看:Top 2000 个 SKU 占了 73% 的读流量。这些 SKU 放本地,能挡掉大部分 Redis 访问。

多级结构长这样:

读路径:
  1. 查 Caffeine(本地,~50ns)
  2. 未命中查 Redis(同机房,~1ms)
  3. 未命中查 DB(~10ms),回填两级
写路径:
  1. 更新 DB
  2. 删 Redis
  3. 删本地(本机直接删;其他机靠"版本广播"或"短 TTL"兜底)

本地读比 Redis 快两个数量级,这是压测里最直观的收益。

一致性问题:本地缓存怎么失效

多级缓存最头疼的是一致性。Redis 好办,写后 DEL 就行;但本地缓存分散在几十个应用实例上,怎么让它们都失效?

我们对比了三种策略:

策略一致性复杂度适用
短 TTL(如 30s)最终一致,最多脏 30s最低容忍秒级延迟
Redis 发布订阅广播失效近实时一致性要求高
Binlog(Canal)监听失效近实时,与业务解耦多系统共用

商品详情容忍 30 秒以内的旧数据(价格变更没那么敏感),我们选了短 TTL + Redis 主动失效广播的组合:本地 TTL 设 30 秒兜底,写的那台实例再通过 Redis pub/sub 给所有实例发一条失效消息,让本地的脏数据在毫秒级被清掉。

// 写完成后广播失效
stringRedisTemplate.convertAndSend("cache:invalidate",
    objectMapper.writeValueAsString(new InvalidateMsg("sku", skuId)));

// 各实例监听
@RedisListener(channel = "cache:invalidate")
public void onInvalidate(InvalidateMsg msg) {
    caffeineCache.invalidate(msg.key());   // 只删本机
}

一个坑:pub/sub 不保证送达。某次网络抖动丢了一条失效消息,那台机器就脏了 30 秒(等 TTL 兜底)。所以我们把广播当"加速",TTL 当"保底",两者叠加才稳。

热点探测与缓存击穿防护

大促最怕的是热点 key 击穿:某个爆款 SKU 本地和 Redis 同时失效,瞬间几万请求打到 DB。我们用两个手段防:

  • 本地缓存用 Caffeine 的 refreshAfterWrite,到期前异步刷新,不阻塞读线程,避免集体回源。
  • 回源 DB 加分布式锁(Redisson),同 key 同时只有一个线程查库,其余等着拿缓存。
Caffeine<String, Sku> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(30, TimeUnit.SECONDS)
    .refreshAfterWrite(10, TimeUnit.SECONDS)     // 提前异步刷新
    .build(key -> loadFromRedisOrDb(key));

// 回源加锁,防击穿
public Sku get(String key) {
    Sku v = cache.get(key, k -> {
        RLock lock = redisson.getLock("sku:lock:" + k);
        if (lock.tryLock()) {
            try { return loadFromRedisOrDb(k); }
            finally { lock.unlock(); }
        }
        return cache.getIfPresent(k);   // 没抢到锁就拿旧值
    });
    return v;
}

Caffeine 的 W-TinyLFU 算法在这里很关键:它用窗口 + 过滤器统计频率,能识别真正的热点。我们做过对比,LRU 在爆款突增时命中率掉得比 W-TinyLFU 快,因为 LRU 容易被一次性扫描流量冲掉热点。

压测数据:命中率从 0 到 91%

上线前按 5 万 QPS 压了三轮,本地缓存大小设为 1 万条(覆盖 Top SKU),TTL 30 秒:

方案到 Redis 的 QPS到 DB 的 QPSP99本地命中率
仅 Redis120,00040047 ms(CPU 满)
两级缓存9,800354 ms91.3%

Redis 的 QPS 从 12 万降到 9800,降了 92%。本地命中率 91.3%,剩下的 8.7% 里大部分是长尾 SKU 和 TTL 刷新期间的回源。P99 从 47 ms 降到 4 ms,APP 首屏时间从 320 ms 降到 140 ms。

还有个意外收获:DB 的读压力从峰值 400 QPS 降到 35,给大促期间写库留了充足余量。

上线后遇到的两个真实问题

  • 本地缓存内存占用。1 万个 SKU 对象,每个约 2.4 KB,单实例占约 24 MB。我们 40 个实例,总本地内存 960 MB,可接受。但 maximumSize 别拍脑袋,要结合单条大小算,否则容易 OOM。
  • 广播风暴。有次批量改价,1 秒内发了 8000 条失效消息,Redis pub/sub 把网络打满。后来改成按 key 哈希分桶,批量失效合并成一条,单实例只处理属于自己的桶。

小结

  • 多级缓存适合"读多写少、容忍秒级不一致"的热点场景,本地缓存挡掉 90% 以上的 Redis 流量。
  • 一致性靠"短 TTL 兜底 + 主动失效广播加速",广播可能丢,TTL 必须留着当保底。
  • 热点 key 用 refreshAfterWrite 异步刷新 + 回源分布式锁,防击穿。W-TinyLFU 比 LRU 更扛得住突发热点。
  • 压测实测:Redis QPS 降 92%,P99 从 47 ms 到 4 ms,首屏 320 ms 到 140 ms。
  • maximumSize 要结合单条对象大小算内存,失效广播要分桶合并防风暴。

多级缓存不是银弹,它把"一致性"这个难题从数据库搬到了缓存层。我们能用,是因为商品详情容忍 30 秒旧数据。换成库存、余额这类强一致场景,本地缓存就是坑,千万别用。

参考