大促压测: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 的 QPS | P99 | 本地命中率 |
|---|---|---|---|---|
| 仅 Redis | 120,000 | 400 | 47 ms(CPU 满) | — |
| 两级缓存 | 9,800 | 35 | 4 ms | 91.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 秒旧数据。换成库存、余额这类强一致场景,本地缓存就是坑,千万别用。