Skip to content

多级缓存与纵深防御 ​

单层 Redis 缓存有两个瓶颈:网络延迟(每次读都要跨网络)和单点压力(所有请求打到同一个 Redis)。本章先讲多级缓存——在 Redis 之前加一层本地缓存,形成「本地 → Redis → 数据库」三级架构;再把缓存预热、熔断降级、限流组合成一套纵深防御,兜住 Redis 宕机等极端场景。

1. 为什么需要多级缓存 ​

问题单层 Redis多级缓存
网络延迟每次读 0.5~2ms本地缓存命中 < 0.01ms
Redis 宕机全部穿透到数据库本地缓存仍能兜底
热点 key 压力单点承受全部 QPS本地缓存拦截大部分请求
带宽成本高(每次都跨网络)低(大部分本地返回)

核心思想:把最热的数据放在离请求最近的地方。

2. 架构设计 ​

txt
请求 → L1 本地缓存(Caffeine,进程内,最快)
         ↓ 未命中
       L2 Redis(分布式,共享,容量大)
         ↓ 未命中
       L3 数据库(MySQL,最终数据源)

每一层的职责:

层级技术选型容量速度特点
L1Caffeine / Guava Cache小(千级~万级)微秒各实例独立,不共享
L2Redis中(十万~百万级)毫秒分布式共享
L3MySQL大十毫秒持久化存储

2.1 各层参数设计原则 ​

参数L1 本地缓存L2 Redis
容量maximumSize(1000~10000)根据内存预算设置 maxmemory
TTL短(1~5 分钟)中(5~30 分钟 + 随机偏移)
淘汰策略W-TinyLFU(默认)allkeys-lru / volatile-lru
数据一致性弱(实例间不共享)中等

L1 的 TTL 必须短于 L2。如果 L1 TTL 比 L2 还长,会出现 L1 还有旧值但 L2 已经过期更新了的情况。

3. Spring Boot 实现 ​

3.1 依赖 ​

xml
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

3.2 核心实现 ​

java
@Service
public class MultiLevelCacheService {

    private final Cache<String, String> localCache;
    private final StringRedisTemplate redis;
    private final UserMapper userMapper;

    public MultiLevelCacheService(StringRedisTemplate redis, UserMapper userMapper) {
        this.redis = redis;
        this.userMapper = userMapper;

        // L1:本地缓存
        this.localCache = Caffeine.newBuilder()
            .maximumSize(5000)                    // 最多 5000 条
            .expireAfterWrite(2, TimeUnit.MINUTES) // 2 分钟过期
            .recordStats()                         // 开启统计
            .build();
    }

    public User getUser(Long userId) {
        String key = "user:" + userId;

        // L1:查本地缓存
        String cached = localCache.getIfPresent(key);
        if (cached != null) {
            return JSON.parseObject(cached, User.class);
        }

        // L2:查 Redis
        cached = redis.opsForValue().get(key);
        if (cached != null) {
            localCache.put(key, cached);  // 回填 L1
            return JSON.parseObject(cached, User.class);
        }

        // L3:查数据库
        User user = userMapper.selectById(userId);
        if (user != null) {
            String json = JSON.toJSONString(user);
            // 回填 L2(带随机 TTL)
            int ttl = 300 + ThreadLocalRandom.current().nextInt(61);
            redis.opsForValue().set(key, json, ttl, TimeUnit.SECONDS);
            localCache.put(key, json);  // 回填 L1
        }
        return user;
    }

    /**
     * 写入时清除所有层级
     */
    @Transactional
    public void updateUser(Long userId, User user) {
        userMapper.updateById(user);
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    redis.delete("user:" + userId);
                    localCache.invalidate("user:" + userId);
                }
            }
        );
    }

    /**
     * 仅查询本地缓存,不触发回源,供降级路径使用
     */
    public User getLocal(Long userId) {
        String cached = localCache.getIfPresent("user:" + userId);
        return cached != null ? JSON.parseObject(cached, User.class) : null;
    }

    /**
     * 获取 L1 命中率统计
     */
    public CacheStats getLocalCacheStats() {
        return localCache.stats();
    }
}

3.3 L1 命中率监控 ​

Caffeine 的 recordStats() 开启后可以获取命中率:

java
@RestController
@RequestMapping("/cache")
public class CacheMonitorController {

    @Autowired
    private MultiLevelCacheService cacheService;

    @GetMapping("/stats")
    public Map<String, Object> stats() {
        CacheStats stats = cacheService.getLocalCacheStats();
        Map<String, Object> result = new HashMap<>();
        result.put("hitRate", String.format("%.2f%%", stats.hitRate() * 100));
        result.put("missRate", String.format("%.2f%%", stats.missRate() * 100));
        result.put("hitCount", stats.hitCount());
        result.put("missCount", stats.missCount());
        result.put("evictionCount", stats.evictionCount());
        return result;
    }
}

命中率低于 50% 说明 L1 容量太小或 TTL 太短,需要调优。理想情况下 L1 命中率应在 80% 以上。

4. 多实例一致性问题 ​

本地缓存的最大问题是:实例 A 更新了数据,实例 B 的本地缓存还是旧值。解决方式有三种:

方案一致性复杂度说明
缩短 L1 TTL弱低最简单,接受短暂不一致
消息广播清除强中写入时广播所有实例清除 L1
Canal + MQ强高Binlog 驱动,完全解耦

4.1 消息广播清除 ​

写入时通过 Redis Pub/Sub 或 Kafka 广播清除指令:

java
@Component
public class LocalCacheEvictionListener implements MessageListener {

    private final Cache<String, String> localCache;

    public LocalCacheEvictionListener(Cache<String, String> localCache,
                                      RedisConnectionFactory connectionFactory) {
        this.localCache = localCache;

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(this, new ChannelTopic("cache:evict"));
        container.start();
    }

    /**
     * 监听 Redis Pub/Sub 的缓存清除消息
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = new String(message.getBody(), StandardCharsets.UTF_8);
        localCache.invalidate(key);
    }
}

写入时发布清除消息:

java
public void updateUser(Long userId, User user) {
    userMapper.updateById(user);
    redis.delete("user:" + userId);
    localCache.invalidate("user:" + userId);

    // 广播给其他实例
    redis.convertAndSend("cache:evict", "user:" + userId);
}

5. 自定义 Spring CacheManager ​

如果项目使用 @Cacheable 注解,可以把多级缓存封装进自定义 CacheManager,让注解自动走「L1 → L2」链路。

先澄清一个常见误区。Spring 自带的 CompositeCacheManager 做不了多级缓存,它的职责是聚合多个 CacheManager,getCache(name) 时按顺序返回第一个命中该 name 的 Cache。当 L1 和 L2 注册了同一个 name,它永远只返回 L1,根本不会级联到 L2。真正的多级缓存需要自定义 Cache,在内部先查 L1,未命中再查 L2 并回填 L1。

java
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        // L1:Caffeine 本地缓存
        CaffeineCacheManager l1 = new CaffeineCacheManager("users");
        l1.setCaffeine(Caffeine.newBuilder()
            .maximumSize(5000)
            .expireAfterWrite(2, TimeUnit.MINUTES));

        // L2:Redis 分布式缓存
        RedisCacheConfiguration redisConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        CacheManager l2 = RedisCacheManager.builder(factory)
            .cacheDefaults(redisConfig)
            .build();

        return new MultiLevelCacheManager(l1, l2);
    }
}

自定义 CacheManager 和包装 Cache:

java
public class MultiLevelCacheManager implements CacheManager {

    private final CacheManager l1;
    private final CacheManager l2;

    public MultiLevelCacheManager(CacheManager l1, CacheManager l2) {
        this.l1 = l1;
        this.l2 = l2;
    }

    @Override
    public Cache getCache(String name) {
        return new MultiLevelCache(name, l1.getCache(name), l2.getCache(name));
    }

    @Override
    public Collection<String> getCacheNames() {
        return l1.getCacheNames();
    }
}

class MultiLevelCache implements Cache {

    private final String name;
    private final Cache l1;
    private final Cache l2;

    MultiLevelCache(String name, Cache l1, Cache l2) {
        this.name = name;
        this.l1 = l1;
        this.l2 = l2;
    }

    @Override
    public ValueWrapper get(Object key) {
        ValueWrapper v = l1.get(key);
        if (v != null) return v;
        v = l2.get(key);
        if (v != null) l1.put(key, v.get());
        return v;
    }

    @Override
    public <T> T get(Object key, Class<T> type) {
        T value = l1.get(key, type);
        if (value != null) return value;
        value = l2.get(key, type);
        if (value != null) l1.put(key, value);
        return value;
    }

    @Override
    public void put(Object key, Object value) {
        l1.put(key, value);
        l2.put(key, value);
    }

    @Override
    public void evict(Object key) {
        l1.evict(key);
        l2.evict(key);
    }

    @Override
    public void clear() {
        l1.clear();
        l2.clear();
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public Object getNativeCache() {
        return this;
    }
}

使用注解:

java
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
    return userMapper.selectById(userId);
}

这个自定义 Cache 才是真正意义上的多级缓存。读时先查 L1,未命中查 L2 并回填 L1;写和删除时两层同时处理。相比 §3.2 的手动实现,它的好处是业务代码只需 @Cacheable 注解,代价是要维护一套 Cache 接口的委托方法。二选一即可,不必两套都上。

6. 纵深防御:把多级缓存变成完整防线 ​

多级缓存解决「读性能」和「单点压力」,但生产环境还要面对两个极端场景:冷启动时本地缓存没有数据、Redis 整体宕机。纵深防御就是把这些手段组合起来,让任何单一层级的故障都不直接打垮数据库。

6.1 纵深防御的思路 ​

单一防御手段都有盲区:

手段局限性
TTL 随机化只防「同时过期」,不防 Redis 宕机
多级缓存本地缓存容量有限,冷启动时无数据
熔断降级已经在降级了,用户体验受损

纵深防御的核心思想:不指望一层挡住所有攻击,而是每一层拦住一部分,层层递减,最终到达数据库的请求量在可控范围内。

txt
                    ┌─────────────────┐
  请求洪峰 ──────────┤  L1 本地缓存      │ 拦截 80% 热点请求
                    └────────┬────────┘
                             ↓ 20% 未命中
                    ┌─────────────────┐
                    │ L2 Redis        │ 拦截 95% 剩余请求
                    └────────┬────────┘
                             ↓ 5% 未命中
                    ┌─────────────────┐
                    │ L3 熔断降级      │ Redis 不可用时兜底
                    └────────┬────────┘
                             ↓ 正常请求
                    ┌─────────────────┐
                    │ L4 数据库        │ 最终承受 <1% 流量
                    └─────────────────┘

其中 TTL 随机化、逻辑过期的实现见 缓存失效:穿透·击穿·雪崩 的 §4.2 与 §3.3,本章不再重复。

6.2 第一层:缓存预热 ​

缓存预热指在系统启动或流量高峰前,主动把热点数据加载到缓存中,避免冷启动时的全量穿透。

启动时预热:

java
@Component
public class CacheWarmer implements ApplicationRunner {

    private final StringRedisTemplate redis;
    private final UserMapper userMapper;
    private final HotKeyDetector hotKeyDetector;

    @Override
    public void run(ApplicationArguments args) {
        log.info("开始缓存预热...");

        // 方式一:加载热门用户(按访问量排序,取 Top 1000)
        List<User> hotUsers = userMapper.selectTopUsers(1000);
        for (User user : hotUsers) {
            String key = "user:" + user.getId();
            int ttl = 300 + ThreadLocalRandom.current().nextInt(61);
            redis.opsForValue().set(key,
                JSON.toJSONString(user), ttl, TimeUnit.SECONDS);
        }

        log.info("缓存预热完成,加载 {} 条数据", hotUsers.size());
    }
}

定时预热:对于周期性热点(如每天早高峰的首页数据),可以用定时任务提前预热:

java
@Component
public class ScheduledCacheWarmer {

    /**
     * 每天早上 7:30 预热当天的热点数据
     */
    @Scheduled(cron = "30 7 * * *")
    public void warmDailyHotData() {
        // 加载今日推荐商品、热门文章等
        loadHotProducts();
        loadTrendingArticles();
    }

    /**
     * 每 10 分钟刷新实时热 key
     */
    @Scheduled(fixedRate = 600_000)
    public void refreshHotKeys() {
        List<String> hotKeys = hotKeyDetector.getHotKeys();
        for (String key : hotKeys) {
            // 重新加载,续期 TTL
            refreshCache(key);
        }
    }
}

6.3 兜底:熔断降级与限流 ​

当 Redis 整体不可用或数据库压力过大时,主动降级保护数据库。

Redis 健康检查:

java
@Component
public class RedisHealthChecker {

    private final StringRedisTemplate redis;
    private volatile boolean redisHealthy = true;

    @Scheduled(fixedRate = 5000)
    public void check() {
        try (RedisConnection connection = redis.getConnectionFactory().getConnection()) {
            connection.ping();
            redisHealthy = true;
        } catch (Exception e) {
            redisHealthy = false;
            log.error("Redis 不可用,触发降级", e);
        }
    }

    public boolean isHealthy() {
        return redisHealthy;
    }
}

降级查询:

java
@Service
public class ResilientUserService {

    private final MultiLevelCacheService cacheService;
    private final RedisHealthChecker healthChecker;
    private final UserMapper userMapper;

    public User getUser(Long userId) {
        // Redis 不健康时,跳过 L2,直接查本地缓存或数据库
        if (!healthChecker.isHealthy()) {
            return getUserWithFallback(userId);
        }
        return cacheService.getUser(userId);
    }

    /**
     * 降级路径:L1 → 默认值
     */
    private User getUserWithFallback(Long userId) {
        // 尝试本地缓存
        User user = cacheService.getLocal(userId);
        if (user != null) return user;

        // 返回兜底数据
        User fallback = new User();
        fallback.setId(userId);
        fallback.setName("用户信息暂时不可用");
        return fallback;
    }
}

限流保护数据库:即使有缓存层,也要对数据库做限流保护,防止极端情况下缓存全部失效:

java
@Configuration
public class DbRateLimiterConfig {

    @Bean
    public RateLimiter dbRateLimiter() {
        // Guava RateLimiter:每秒最多 1000 个请求到达数据库
        return RateLimiter.create(1000);
    }
}

@Service
public class UserService {

    @Autowired
    private RateLimiter dbRateLimiter;

    public User getUserFromDb(Long userId) {
        if (!dbRateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
            throw new RuntimeException("数据库限流,请稍后重试");
        }
        return userMapper.selectById(userId);
    }
}

6.4 完整防御链路 ​

把四层防御串联起来:

java
@Service
public class FullDefenseUserService {

    private final Cache<String, String> localCache;       // L1
    private final StringRedisTemplate redis;               // L2
    private final RedisHealthChecker healthChecker;        // 健康检查
    private final RateLimiter dbRateLimiter;               // 数据库限流
    private final UserMapper userMapper;

    public User getUser(Long userId) {
        String key = "user:" + userId;

        // ── 第一层:本地缓存 ──
        String cached = localCache.getIfPresent(key);
        if (cached != null) {
            return JSON.parseObject(cached, User.class);
        }

        // ── 第二层:Redis(仅在健康时访问)──
        if (healthChecker.isHealthy()) {
            try {
                cached = redis.opsForValue().get(key);
                if (cached != null) {
                    localCache.put(key, cached);
                    return JSON.parseObject(cached, User.class);
                }
            } catch (Exception e) {
                log.warn("Redis 查询失败,降级到数据库", e);
            }
        }

        // ── 第三层:数据库(限流保护)──
        if (!dbRateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
            // 限流触发,返回兜底
            User fallback = new User();
            fallback.setId(userId);
            fallback.setName("系统繁忙,请稍后重试");
            return fallback;
        }

        User user = userMapper.selectById(userId);
        if (user != null) {
            String json = JSON.toJSONString(user);
            // 回填各层(仅在 Redis 健康时回填 L2)
            localCache.put(key, json);
            if (healthChecker.isHealthy()) {
                int ttl = 300 + ThreadLocalRandom.current().nextInt(61);
                redis.opsForValue().set(key, json, ttl, TimeUnit.SECONDS);
            }
        }
        return user;
    }
}

6.5 各层防御效果 ​

假设 QPS 10000 的请求洪峰:

层级拦截率到达下一层的 QPS说明
L1 本地缓存80%2000热点数据本地返回
L2 Redis95%100正常缓存命中
L3 熔断/限流90%10异常时兜底
L4 数据库-10最终承受 0.1% 流量

纵深防御不是银弹,它的价值在于:任何单一层级的故障都不会直接打垮数据库。即使 Redis 完全宕机,本地缓存 + 熔断降级仍然能保护数据库。

7. 小结 ​

要点说明
L1 TTL < L2 TTL避免 L1 旧值覆盖 L2 新值
监控命中率L1 命中率低于 50% 需要调优
写入时清除所有层级避免脏读
多实例一致性缩短 TTL 或消息广播
容量规划L1 存热点(千级),L2 存温数据(万级~十万级)

纵深防御的分层:

层级手段防御目标
预防缓存预热 + TTL 随机化避免冷启动和同时过期
缓冲多级缓存(L1 + L2)拦截大部分请求
兜底熔断降级 + 数据库限流保护数据库不被打垮

判别口诀:预热防冷启动、随机防同时过期、多级防穿透、熔断防雪崩。

Released under the MIT License.