MySQL + Redis + Caffeine:Java后端通用三级缓存架构实战

MySQL + Redis + Caffeine:Java后端通用三级缓存架构实战

一句话定位:让十万并发打到你的服务时,90%的请求在纳秒级本地内存就被消化掉,数据库安坐钓鱼台。


一、为什么单级Redis不够用?先看一组残酷的压测数据

很多团队的架构长这样:Controller → Redis → MySQL。看起来很美,但一上压测就现原形------

场景 Redis单节点表现
热点Key打满 单核CPU飙到95%,QPS卡在8万
网络RT损耗 客户端↔Redis往返0.5~2ms,Redis处理仅0.1ms------95%的时间浪费在路上
缓存击穿 热点Key过期瞬间,2000并发同时穿透,MySQL直接跪
缓存雪崩 10万Key同一秒过期,所有请求直涌数据库,系统雪崩

结论是冰冷的:Redis单线程模型下,热点Key就是阿喀琉斯之踵。 你需要一层"贴着CPU的缓存"------这就是Caffeine存在的意义。


二、三级缓存架构全景:分层扛压,逐级兜底

scss 复制代码
┌─────────────────────────────────────────────────────────┐
│  L1 本地缓存 (Caffeine)                                   │
│  · 存储介质:JVM堆内存                                    │
│  · 访问延迟:纳秒级(<0.1ms)                             │
│  · 容量:有限(建议单实例≤10000条)                       │
│  · 作用:拦截超级热点(首页Banner、秒杀商品、热门课程)      │
├─────────────────────────────────────────────────────────┤
│  L2 分布式缓存 (Redis)                                    │
│  · 存储介质:独立Redis集群                                 │
│  · 访问延迟:毫秒级(~2ms)                               │
│  · 容量:可扩展(集群分片)                                │
│  · 作用:全局共享数据(用户会话、商品详情、库存计数)        │
├─────────────────────────────────────────────────────────┤
│  L3 数据库 (MySQL)                                        │
│  · 存储介质:磁盘                                         │
│  · 访问延迟:十毫秒级(~15ms)                            │
│  · 容量:无限                                             │
│  · 作用:数据最终源头,强一致性保障                        │
└─────────────────────────────────────────────────────────┘

核心设计原则:读写分离,写请求穿透所有缓存层。

less 复制代码
java
// ❌ 写操作:扣减库存------不加任何缓存注解,直接穿透
@PostMapping("/seckill/deduct")
public Result deductStock(@RequestBody SeckillReq req) {
    Long remain = redisLuaUtil.deductStock(req.getSkuId(), req.getUserId(), req.getCount());
    writeFlowTable(req);
    mqProducer.send(new StockDeductedEvent(req));
    return Result.ok("排队中");
}

// ✅ 读操作:查询库存------双层缓存注解
@GetMapping("/stock/{skuId}")
@CaffeineCache(name = "stock", key = "#skuId", expireAfterWrite = 3, timeUnit = TimeUnit.SECONDS)
@RedisCache(name = "stock", key = "#skuId", expire = 5, timeUnit = TimeUnit.SECONDS)
public Result<Integer> getStock(@PathVariable Long skuId) {
    String stock = redisTemplate.opsForValue().get("seckill:stock:" + skuId);
    if (stock == null) {
        stock = String.valueOf(stockMapper.selectById(skuId));
        redisTemplate.opsForValue().set("seckill:stock:" + skuId, stock, 5, TimeUnit.MINUTES);
    }
    return Result.success(Integer.parseInt(stock));
}

三、技术选型与依赖配置

基于 Spring Boot 3.2.5 + JDK 17 的生产级技术栈:

组件 版本 选型理由
Caffeine 3.1.8 W-TinyLFU算法,命中率比Guava高15%~20%
Redis 7.2.4 IO多路复用+集群模式,单节点10万QPS
MySQL 8.0.36 事务+MVCC,ACID兜底
MyBatis-Plus 3.5.5.1 简化CRUD,聚焦业务逻辑
Lettuce 默认 Spring Boot 2.x起默认Redis客户端,支持异步
xml 复制代码
xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.5.1</version>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
        <version>3.1.8</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

四、核心代码:装饰器模式实现多级缓存

别用继承------会炸出DBQuery → RedisDBQuery → CaffeineRedisDBQuery的类爆炸。用装饰器模式,动态组合,优雅扩展。

4.1 抽象组件

vbnet 复制代码
java
public interface DataProvider {
    Object getData(String key);
}

4.2 具体组件:数据库查询

typescript 复制代码
java
@Component
public class MySQLDataProvider implements DataProvider {
    @Autowired private UserMapper userMapper;
    
    @Override
    public Object getData(String key) {
        Long userId = Long.parseLong(key.split(":")[1]);
        return userMapper.selectById(userId);
    }
}

4.3 装饰器:Redis缓存层

vbnet 复制代码
java
@Component
public class RedisCacheDecorator implements DataProvider {
    @Autowired private RedisTemplate<String, Object> redisTemplate;
    @Autowired private DataProvider delegate;
    
    @Override
    public Object getData(String key) {
        Object cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            log.info("Redis hit: {}", key);
            return cached;
        }
        Object dbData = delegate.getData(key);
        if (dbData != null) {
            redisTemplate.opsForValue().set(key, dbData, 30, TimeUnit.MINUTES);
        }
        return dbData;
    }
}

4.4 装饰器:Caffeine本地缓存层

typescript 复制代码
java
@Component
public class CaffeineCacheDecorator implements DataProvider {
    private final Cache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(30, TimeUnit.SECONDS)
        .recordStats()
        .build();
    
    @Autowired private DataProvider delegate;
    
    @Override
    public Object getData(String key) {
        Object cached = cache.getIfPresent(key);
        if (cached != null) {
            log.info("Caffeine hit: {}", key);
            return cached;
        }
        Object data = delegate.getData(key);
        if (data != null) {
            cache.put(key, data);
        }
        return data;
    }
}

4.5 组合:构建缓存链

typescript 复制代码
java
@Configuration
public class MultiLevelCacheConfig {
    @Autowired private MySQLDataProvider mysqlProvider;
    @Autowired private RedisCacheDecorator redisDecorator;
    @Autowired private CaffeineCacheDecorator caffeineDecorator;
    
    @Bean
    public DataProvider multiLevelCache() {
        // CaffeineDecorator(RedisDecorator(MySQLDataProvider))
        return new CaffeineCacheDecorator() {{
            // 通过Setter注入delegate
        }};
    }
}

更优雅的方式:Spring Cache抽象 + 自定义CacheManager

scss 复制代码
java
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {
    
    @Bean
    @Override
    public CacheManager cacheManager() {
        MultiLevelCacheManager manager = new MultiLevelCacheManager(
            caffeineCacheManager(),   // L1
            redisCacheManager()       // L2
        );
        return manager;
    }
    
    @Bean
    public CacheManager caffeineCacheManager() {
        CaffeineCacheManager cm = new CaffeineCacheManager();
        cm.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(30, TimeUnit.SECONDS)
            .build());
        return cm;
    }
    
    @Bean
    public CacheManager redisCacheManager() {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer())
            );
        return RedisCacheManager.builder(redisConnectionFactory())
            .cacheDefaults(config)
            .build();
    }
}

使用时只需一个注解:

kotlin 复制代码
java
@Service
public class UserService {
    
    @Cacheable(value = {"users"}, key = "#id")  // 自动走 Caffeine → Redis → MySQL
    public User getUser(Long id) {
        return userMapper.selectById(id);
    }
    
    @CacheEvict(value = {"users"}, key = "#user.id")  // 自动删除 Redis + Caffeine
    public void updateUser(User user) {
        userMapper.updateById(user);
    }
}

五、数据一致性:这才是三级缓存的生死线

三级缓存最致命的问题不是性能,是数据不一致。一个节点更新了价格,其他节点的Caffeine里还是旧价格------用户看到的是脏数据。

5.1 写操作:Cache Aside + 延迟双删

scss 复制代码
java
@Transactional
public void updateUser(User user) {
    // ① 先更新数据库
    userMapper.updateById(user);
    
    // ② 删除Redis(不是更新!)
    redisTemplate.delete("user:" + user.getId());
    
    // ③ 延迟双删:等其他节点从DB读完旧数据后,再删一次
    try {
        Thread.sleep(1000);  // 延迟 > 业务接口平均耗时
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    redisTemplate.delete("user:" + user.getId());
    
    // ④ 发送消息,通知所有节点清理本地Caffeine
    rocketMQTemplate.send("cache-invalidate-topic",
        MessageBuilder.withPayload("user:" + user.getId()).build());
}

5.2 消费消息:清理本地缓存

typescript 复制代码
java
@Service
@RocketMQMessageListener(topic = "cache-invalidate-topic", consumerGroup = "cache-group")
public class CacheInvalidateConsumer implements RocketMQListener<String> {
    
    @Autowired private CaffeineCache caffeineCache;
    
    @Override
    public void onMessage(String cacheKey) {
        String[] parts = cacheKey.split(":");
        if (parts.length == 2 && "user".equals(parts[0])) {
            Long userId = Long.parseLong(parts[1]);
            caffeineCache.invalidate(userId);
            log.info("Invalidated local cache for user: {}", userId);
        }
    }
}

5.3 架构总览

复制代码
更新请求
  │
  ▼
更新MySQL ──→ 删除Redis ──→ 延迟1s ──→ 再次删除Redis
  │                              │
  │                              ▼
  │                    发送RocketMQ消息
  │                              │
  ▼                              ▼
写流水表              所有服务节点消费消息 → 清理本地Caffeine

六、三大缓存灾难的防护方案

灾难 现象 解决方案
缓存穿透 查询不存在的id,每次打DB 布隆过滤器拦截 + 缓存空值(5分钟短TTL)
缓存击穿 热点Key过期,万请求打DB 互斥锁 + 超级热点Key永不过期(只在更新时删除)
缓存雪崩 大量Key同时过期,DB压力骤增 过期时间加随机值(1h±30min)+ Redis集群高可用 + 熔断降级

布隆过滤器实战

kotlin 复制代码
java
@Bean
public BloomFilter<Long> userIdBloomFilter() {
    BloomFilter<Long> filter = BloomFilter.create(
        Funnels.longFunnel(),
        10_000_000,  // 预期元素数量
        0.01         // 误判率1%
    );
    // 启动时预热:加载所有用户ID
    userMapper.selectAllIds().forEach(filter::put);
    return filter;
}

public User getUser(Long id) {
    if (!bloomFilter.mightContain(id)) {
        return null;  // 布隆过滤器说不存在,直接返回,不查任何缓存
    }
    // ... 走 Caffeine → Redis → MySQL 流程
}

七、缓存预热:别让冷启动成为系统的墓志铭

系统刚启动时,Caffeine和Redis都是空的。十万用户同时进来,所有请求直打MySQL------这不是高并发,这是DDoS。

预热策略

typescript 复制代码
java
@Component
public class CacheWarmupRunner implements ApplicationRunner {
    
    @Autowired private UserMapper userMapper;
    @Autowired private RedisTemplate<String, Object> redisTemplate;
    @Autowired private CaffeineCache caffeineCache;
    
    @Override
    public void run(ApplicationArguments args) {
        log.info("🔥 开始缓存预热...");
        
        // 第一层:从MySQL加载Top1000热门数据到Redis
        List<User> hotUsers = userMapper.selectTopHot(1000);
        hotUsers.forEach(u -> 
            redisTemplate.opsForValue().set("user:" + u.getId(), u, 60, TimeUnit.MINUTES)
        );
        
        // 第二层:Top10超级热点加载到每台服务器的Caffeine
        List<User> superHotUsers = userMapper.selectSuperHot(10);
        superHotUsers.forEach(u -> 
            caffeineCache.put(String.valueOf(u.getId()), u)
        );
        
        log.info("✅ 预热完成:Redis {}条,Caffeine {}条", hotUsers.size(), superHotUsers.size());
    }
}

八、压测数据说话

在Spring Boot 3.2.5 + JDK 17环境下,对同一课程查询接口压测:

架构 并发 平均RT QPS 数据库QPS
单MySQL 2000 150ms 130 130
MySQL + Redis 2000 8ms 2500 50
MySQL + Redis + Caffeine 2000 0.3ms 6500 12
MySQL + Redis + Caffeine 10000 0.5ms 18000 35
MySQL + Redis + Caffeine 50000 0.8ms 42000 80

数据库QPS从130降到12------降低90%。 这就是三级缓存的威力。


九、最佳实践Checklist

  • 读写分离:写接口不加缓存注解,直接穿透
  • 过期时间分层:Caffeine 30s < Redis 5min < MySQL永久
  • 过期加随机值1h + Random(0~30min),避免雪崩
  • 超级热点永不过期:首页Top10数据只在更新时主动删除
  • 布隆过滤器前置:拦截99%的无效查询
  • 缓存空值:不存在的Key也缓存5分钟,防止穿透
  • 延迟双删 + MQ广播:保证多实例Caffeine一致性
  • 启动预热:Top1000进Redis,Top10进Caffeine
  • 监控埋点:Caffeine.recordStats() + Redis INFO stats,实时看命中率

十、写在最后

三级缓存不是银弹,它是一套分层抗压的哲学------让热点数据尽可能靠近CPU,让无效请求尽可能在门口就被拦住。Caffeine负责纳秒级的最后一公里,Redis负责毫秒级的分布式共享,MySQL负责十毫秒级的最终真相。

当十万用户同时涌来,你的服务不是在"扛",而是在"卸"------90%的请求在本地内存就被消化,Redis只承10%,数据库只看到零星的冷数据查询。

这,才是高并发架构该有的样子。

相关推荐
乘风破浪酱524361 小时前
别再乱用Redisson分布式锁了!这可能是你见过最标准的教程(附完整代码)
后端
兔子零10241 小时前
当 Codex 成为主力,软件工程的重心已经变了
前端·后端·架构
用户6757049885021 小时前
别再死记硬背了!一文扒光 I/O 多路复用的底裤(Epoll/Select/Poll)
后端
牛奶1 小时前
网关是怎么当"门卫"的?
前端·后端·负载均衡
悟空聊架构1 小时前
100多G数据同步引发的MySQL集群“连环炸”,我是如何一步步恢复的? - 墨天轮
后端·架构
Hemy082 小时前
tauri + rust 创建初始项目
开发语言·后端·rust
锋行天下2 小时前
后端golang项目一键打包部署方案
后端
用户6757049885022 小时前
90%的人都不知道:Docker 容器 apt 报错 404 的幕后黑手竟是它!
后端·docker·容器