Skip to content

本地缓存 + Redis 二级缓存架构

问题:为什么单层 Redis 缓存扛不住?

先算一笔账。一个商品详情页 QPS 10 万,全部走 Redis 读取:

  • 单次网络往返:服务→Redis 平均 0.5ms(同机房)~ 2ms(跨可用区)
  • Redis 单线程处理:10 万 QPS 下,单个热 Key 的请求排队,响应时间从 0.5ms 涨到 50ms+
  • 服务端线程阻塞:Tomcat 200 线程池,每个线程等 50ms,很快就满了,新请求排队等线程

生产事故案例:某电商大促期间,商品详情页 Redis 热 Key(某个爆款 SKU)被 300+ 线程同时请求,Redis 单线程处理,P99 延迟从 2ms 飙升到 800ms,服务端线程池打满,连非热 Key 的请求也拿不到线程,最终导致整个商品服务雪崩。

二级缓存架构就是要在服务进程内把热数据先挡一层:本地缓存(L1,进程内缓存) + Redis 缓存(L2,分布式缓存)。读请求优先查本地,命中直接返回,0 网络开销;未命中再查 Redis 并回填本地。

核心流程:读请求的完整路径

时序图(文字版)

Client → Service Thread

  ├─ 1. localCache.get(key) ──→ 命中 → 返回(0ms 延迟)

  └─ 未命中 →
       ├─ 2. redisCache.get(key) ──→ 命中 →
       │    └─ 回填 localCache(设置 TTL 5min)
       │    └─ 返回(0.5~2ms 延迟)

       └─ 未命中 →
            ├─ 3. DB.query(key)(5~20ms 延迟)
            ├─ 4. 回填 redisCache(TTL 1h)
            └─ 5. 回填 localCache(TTL 5min)
            └─ 返回

代码实现

java
public class MultiLevelCache {
    // Caffeine 本地缓存,配置见下方
    private final Cache<String, Object> localCache;
    private final RedisTemplate<String, Object> redisCache;

    public Object get(String key) {
        // 1. 查本地缓存——零网络开销
        Object val = localCache.getIfPresent(key);
        if (val != null) return val;

        // 2. 查 Redis——一次网络往返
        val = redisCache.opsForValue().get(key);
        if (val != null) {
            // 回填本地缓存,设置短过期时间兜底
            localCache.put(key, val);
            return val;
        }

        // 3. 查数据库——最慢的一步,加互斥锁防止击穿
        synchronized (key.intern()) {
            // 双重检查:第一个线程查完 DB 回填后,第二个线程直接拿本地缓存
            val = localCache.getIfPresent(key);
            if (val != null) return val;
            
            val = queryDB(key);
            redisCache.opsForValue().set(key, val, 1, TimeUnit.HOURS);
            localCache.put(key, val);
        }
        return val;
    }
}

关键细节synchronized (key.intern()) 做互斥锁,防止同一时刻多个线程同时查 DB。但注意 String.intern() 在大量不同 key 时会有性能问题,生产上可以用 StripedLock 替代。

Caffeine 配置:为什么选 W-TinyLFU?

Caffeine 内部使用 W-TinyLFU 淘汰算法,比 LRU 好在哪?

指标LRUW-TinyLFU
突发流量大量新 key 涌入会淘汰老热点频率窗口过滤,突发 key 不会冲掉热 key
扫描抵抗全表扫描时缓存被污染频率统计 + 衰减,扫描数据很快被淘汰
命中率随机访问场景下降维持 90%+ 命中率
内存开销无额外开销额外 10KB 左右频率布隆

生产配置示例

java
Cache<String, Object> localCache = Caffeine.newBuilder()
    .initialCapacity(5000)          // 预分配,减少扩容
    .maximumSize(10_000)            // 最多 1 万条,按业务估算
    .expireAfterWrite(5, TimeUnit.MINUTES)  // 写后 5 分钟过期——一致性兜底
    .recordStats()                  // 开启命中率统计
    .build();
  • initialCapacity:预分配多少槽位,减少扩容开销。按业务预估:比如商品详情最多 5000 个 SKU,设 5000。
  • maximumSize:限制最大条目数,防止 OOM。1 万条对象 ≈ 几十 MB,合理。
  • expireAfterWrite:最关键的一致性兜底——即使广播失效没收到,最多 5 分钟自动过期。
  • recordStats():监控命中率,线上可以配 Grafana 面板看本地缓存命中率,低于 80% 说明配置不合理。

一致性问题:多实例部署,本地缓存怎么同步?

多实例部署下,写操作的流程是:更新数据库 → 删除/更新 Redis 缓存。但本地缓存呢?A 实例更新了数据,B 实例的本地缓存还是旧值。

三种方案,从简单到可靠

方案一:短过期兜底(默认方案,推荐)

本地缓存 TTL 设 3~5 分钟,脏数据最多存活 5 分钟。适合大部分业务场景:商品详情、文章内容、用户信息,5 分钟不一致完全能接受。

优点:零额外代码,零额外基础设施。 缺点:一致性延迟 5 分钟,实时性要求高的场景不行。

方案二:Redis Pub/Sub 广播失效(中等方案)

java
// 写操作之后,发布失效消息
public void updateData(String key, Object newVal) {
    db.update(key, newVal);
    redisCache.delete(key);
    // 广播本地缓存失效
    redisTemplate.convertAndSend("cache:invalidate", key);
}

// 所有实例启动时订阅
@PostConstruct
public void subscribeInvalidation() {
    redisTemplate.getConnectionFactory().getConnection()
        .subscribe(new MessageListener() {
            @Override
            public void onMessage(Message msg, byte[] pattern) {
                String key = new String(msg.getBody());
                log.info("收到失效通知,清除本地缓存: {}", key);
                localCache.invalidate(key);
            }
        }, "cache:invalidate".getBytes());
}

注意坑

  • Redis Pub/Sub 是即发即忘,不持久化。如果订阅者当时不在线,消息就丢了。
  • 网络抖动会导致订阅临时断开,重连后中间的消息全丢。
  • 生产上**必须配合方案一(短过期兜底)**一起用,Pub/Sub 作为辅助,不能依赖它做唯一保证。

方案三:MQ 广播(最可靠)

用 RocketMQ/Kafka 广播模式,每个实例消费一条失效消息:

java
// 生产者
rocketMQTemplate.syncSend("cache-invalidate-topic", 
    MessageBuilder.withPayload(key).build());

// 消费者——每个实例都会消费
@RocketMQMessageListener(topic = "cache-invalidate-topic", 
    consumerGroup = "cache-invalidate-group", 
    messageModel = MessageModel.BROADCASTING)
public class CacheInvalidateConsumer implements RocketMQListener<String> {
    @Override
    public void onMessage(String key) {
        localCache.invalidate(key);
    }
}

对比

方案一致性延迟消息可靠性额外依赖推荐场景
短过期3~5min无(靠 TTL)默认首选
Pub/Sub毫秒级低(丢消息不持久化)Redis本身辅助手段
MQ广播毫秒级高(持久化+重试)RocketMQ/Kafka对一致性要求高

有一条原则记牢强一致性场景不做二级缓存。订单状态、库存扣减、余额变更——这些直接走 Redis + DB,不走本地缓存。二级缓存只适合「最终一致性」的业务。

缓存击穿在二级缓存下的表现

单层 Redis 缓存击穿(热 Key 过期瞬间,大量并发请求打穿到 DB)在二级缓存下反而被缓解了——因为本地缓存还在

但极端情况:所有实例的本地缓存同时过期,所有请求同时打到 Redis,Redis 再一起打到 DB。

如何避免

  1. 本地缓存加随机过期时间expireAfterWrite(3, 7, TimeUnit.MINUTES) 随机 3~7 分钟,各实例过期时间错开。

  2. refreshAfterWrite:在过期前异步刷新,不是等过期了再同步加载:

java
LoadingCache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .refreshAfterWrite(1, TimeUnit.MINUTES)  // 1 分钟后开始异步刷新
    .build(key -> loadFromDB(key));           // 查不到时调用

refreshAfterWriteexpireAfterWrite 配合:前者在过期前做异步刷新,后者做兜底过期。如果刷新失败,数据还能用(还没过期)。

  1. 布隆过滤器:查询前先用 BloomFilter 判断 key 是否存在,不存在直接返回 null,不查 Redis 和 DB。J2Cache 内置了这个。

热 Key 场景:本地缓存收益最大化的地方

极端案例:微博热搜第一条,每秒 50 万次查询。如果全部走 Redis,Redis 节点带宽打满(50万 × 1KB = 500MB/s),CPU 单线程 100%,响应时间 200ms+。

本地缓存解法:100 个实例各存一份,读取无网络开销。每个实例只需要处理 5000 QPS,完全没问题。

热 Key 检测 + 主动刷新

java
@Component
public class HotKeyManager {
    private final Cache<String, Object> localCache;
    private final ConcurrentHashMap<String, AtomicLong> accessCount = new ConcurrentHashMap<>();
    
    // 每个请求进来,计数+1
    public Object get(String key) {
        accessCount.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
        return localCache.get(key, k -> loadFromRedis(key));
    }
    
    // 定时任务:每 10 秒检测热 Key
    @Scheduled(fixedRate = 10_000)
    public void detectAndRefresh() {
        accessCount.entrySet().stream()
            .filter(e -> e.getValue().get() > 1000)  // 10 秒内超过 1000 次访问
            .forEach(e -> {
                // 热 Key 永不过期 + 主动刷新
                Object val = redisCache.opsForValue().get(e.getKey());
                if (val != null) {
                    localCache.put(e.getKey(), val);
                }
                log.info("热 Key 刷新: {}, 10s 内访问 {} 次", e.getKey(), e.getValue().get());
            });
        accessCount.clear();
    }
}

生产注意accessCount 是 ConcurrentHashMap,高并发下 computeIfAbsent 可能成为瓶颈。可以用 LongAdder + 分段数组替代,或者直接用 Redis 的 hotkeys 命令辅助检测。

开源方案对比:J2Cache vs JetCache

特性J2CacheJetCache(阿里巴巴)
L1 实现Caffeine / EhcacheCaffeine / LinkedHashMap
L2 实现Redis / MemcachedRedis / Tair
缓存穿透防护内置布隆过滤器无原生支持
广播机制Redis Pub/SubTTL 兜底 + 可选广播
消息可靠性低(不持久化)中(可配 MQ)
注解支持@Cacheable 风格@Cached 多级注解
监控集成 Micrometer
学习成本低,半小时上手中,需理解注解体系
适用场景中小型项目快速集成大型项目灵活配置与监控

选型建议

  • 项目小、不想引太多依赖 → J2Cache,内置 Bloom Filter 是加分项
  • 已经用了 Spring Boot + AliCloud → JetCache,注解开发体验好
  • 两者核心思路一致:消息通知 + 短过期兜底

面试常见追问

Q:本地缓存 OOM 怎么办? A:Caffeine 的 maximumSize 限制条目数,配合 weigher 控制总内存大小。另外监控 localCache.stats().evictionCount() 看淘汰率,如果频繁淘汰说明容量不够。

Q:多级缓存下的数据一致性如何测试? A:写一个测试:A 实例写数据,B 实例读数据,验证一段时间后 B 实例读到新值。配合 chaos engineering:杀掉某个实例的 Pub/Sub 订阅,验证 TTL 兜底生效。

Q:为什么不直接用 Caffeine 的 loadingCache 做自动刷新? A:loadingCacherefreshAfterWrite 只在访问时触发刷新。如果某个 key 长时间没人访问,数据不会自动刷新。所以 refreshAfterWrite 必须配合 expireAfterWrite 使用——前者做访问时预刷新,后者做兜底过期。

总结

  • 二级缓存 = Caffeine(L1)+ Redis(L2),读请求优先本地,零网络延迟
  • 一致性兜底:短过期时间(3~5 分钟)是默认方案,Pub/Sub 或 MQ 广播作为辅助
  • 热 Key 收益最大:本地缓存天然扛热点,几十个实例各存一份,Redis 压力骤降
  • 强一致性场景不做二级缓存:订单、库存、余额,直连 Redis + DB
  • Caffeine 选 W-TinyLFU 不选 LRU:扫描抵抗好,热点维持高
  • 开源方案:J2Cache 适合快速集成,JetCache 适合大型系统

面试话术:『先画一个 Caffeine + Redis 的读写流程图,然后说一致性方案——短过期兜底是默认的,实时性要求高的场景用 Redis Pub/Sub 广播失效,但必须配合 MQ 做可靠补偿。最后强调:强一致性数据不放进二级缓存,这是架构取舍。』

参考

Caffeine GitHub Wiki(expireAfterWrite / refreshAfterWrite);J2Cache 源码(oschina/j2cache);JetCache 文档(alibaba/jetcache);Redis Pub/Sub 官方文档

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。