- 第一篇:从订单雪崩到异步削峰
- 第二篇:JVM调优实录------如何把异常率从90%打到4%
- 第三篇:慢SQL治理------分页查询从1.2秒到80毫秒
- 第四篇:多级缓存架构------Caffeine + Redis + MySQL 三级协同
前三篇解决了异步削峰、JVM调优和慢SQL治理,系统吞吐量已经稳定在3200 QPS。但压测时发现一个新问题:秒杀开始前,用户疯狂刷新活动页面,每次都查数据库,再强的SQL优化也扛不住十几万人同时刷。 更致命的是,热点课程信息被几十万人反复查询,Redis单节点CPU飙到95%,响应时间从1ms涨到50ms------Redis也成了瓶颈。
单级Redis不是银弹。本文复盘如何用Caffeine本地缓存 + Redis分布式缓存 + MySQL数据库构建三级缓存体系,把热点数据响应压到毫秒级。
本文核心问题:
- 为什么有了Redis还需要本地缓存?单级Redis的瓶颈在哪?
- Caffeine为什么比Guava Cache快?W-TinyLFU算法强在哪?
- 缓存只针对读请求------如何保证写请求不被缓存拦截?
- 三级缓存的数据流转是如何设计的?L1、L2、L3各自存什么、过期多久?
- 热点数据怎么自动探测?缓存预热是怎么做的?
- 多级缓存的数据一致性怎么保证?
- Redis宕机后如何恢复数据?流水表在这里起到什么作用?
- 这套多级缓存架构能扛多大并发?压测数据如何?
读完本文你将掌握从本地缓存到分布式缓存的完整多级缓存设计方法。
一、单级Redis的性能天花板
疑问:Redis单机QPS能到10万+,秒杀系统日均才几千QPS,单Redis还不够吗?
回答:三个问题同时爆发时,单Redis就捉襟见肘了。
1.1 热点Key把单节点打满
秒杀活动页接口: GET /api/courses/1001
压测2000并发访问同一个课程ID:
Redis单节点:
单核CPU: 95%(Redis是单线程处理命令)
QPS: 8万(接近单节点极限)
RT: 从1ms涨到50ms
上游服务RT: 从15ms涨到80ms
根因:Redis单线程模型下,热点Key的所有请求在同一节点串行处理,单核CPU瓶颈无法水平扩展。
1.2 网络开销吃掉大部分时间
客户端 → Redis Server 网络RT = 0.5ms ~ 2ms(取决于机房距离)
2000并发 × 2ms → 大量线程排队等网络IO
Redis本身处理只要0.1ms,网络来回吃掉了95%的时间
1.3 缓存雪崩风险
缓存过期瞬间的数据流:
1. 缓存失效 → 所有请求同时打到数据库
2. 数据库返回 → 每个请求都尝试更新缓存
3. Redis收到2000个SET → CPU再次飙高
结论:单级Redis在热点集中、高并发、网络延迟三重压力下不堪重负。需要在更靠近应用的地方加缓存层------Caffeine本地缓存。
二、读写分离------缓存设计的首要原则
疑问:如果库存扣减请求来了,会被Caffeine拦截住吗?
回答:绝对不会。这是多级缓存最核心的设计原则------读写分离。缓存只拦截读请求,写请求直接穿透所有缓存层。
2.1 两条数据通道
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
通道一:写请求(秒杀下单 - 修改库存)
Controller → Service(扣减逻辑)→ 直接穿透所有缓存 → Redis
核心:代码逻辑是直接调用 decrStock 方法,不走任何缓存注解
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
通道二:读请求(查看库存 / 商品详情)
Controller → Caffeine → Redis → MySQL
核心:标记了 @CaffeineCache 和 @RedisCache 注解的方法
请求被层层拦截,命中即返回
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2.2 代码层面的区分
java
// ❌ 写操作:扣减接口 ------ 不加任何缓存注解
@PostMapping("/seckill/deduct")
public Result deductStock(@RequestBody SeckillReq req) {
// 直接调 Redis Lua 脚本扣减库存
Long remain = redisLuaUtil.deductStock(req.getSkuId(), req.getUserId(), req.getCount());
// 写流水表
// 发 MQ 消息
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) {
// 实际查Redis
String stock = redisTemplate.opsForValue().get("seckill:stock:" + skuId);
// 兜底查MySQL...
return Result.success(Integer.parseInt(stock));
}
本质 :@CaffeineCache 注解是 AOP 切面,只在标注了的方法上生效。扣减接口没有标注,所以毫发无伤地穿透所有缓存层。
三、三级缓存架构设计
疑问:每一层缓存分别做什么?数据怎么流转?
回答:越靠近应用的缓存越快,但容量越小、一致性越弱。三级缓存各司其职。
用户请求(读操作)
↓
Caffeine本地缓存(L1:JVM内部,热点数据,每3-30秒刷新)
├── 命中 → 返回(<0.1ms)
└── 未命中
↓
Redis分布式缓存(L2:独立服务器,共享状态,1-5分钟过期)
├── 命中 → 回写Caffeine → 返回(~2ms)
└── 未命中
↓
MySQL数据库(L3:磁盘,全量数据,强一致性)
└── 命中 → 回写Redis → 回写Caffeine → 返回(~15ms)
各级职责与性能指标:
| 缓存层 | 位置 | 容量 | 响应时间 | 数据特点 | 过期策略 |
|---|---|---|---|---|---|
| Caffeine本地 | JVM堆内存 | 几百MB | <0.1ms | 热点数据、课程信息 | 3-30秒 |
| Redis集群 | 独立服务器 | 几十GB | 1~3ms | 共享状态(库存) | 1~5分钟 |
| MySQL | 磁盘 | TB级 | 10~100ms | 全量数据 | 永久 |
数据流转示例(查询课程详情)
请求: 查询课程详情(ID=1001)
Caffeine.get(1001)
├── 命中 → 返回(<0.1ms)
└── 未命中
↓
Redis.get("course:1001")
├── 命中 → 回写Caffeine → 返回(~2ms)
└── 未命中
↓
MySQL查询
├── 命中 → 回写Redis(5min) → 回写Caffeine(30s) → 返回(~15ms)
└── 未命中 → 缓存空值(10s防穿透)
关于库存数据的特别说明
库存扣减使用的是 Redis Lua 脚本直接操作,不经过 Caffeine。但查询库存的接口会经过 Caffeine 和 Redis:
java
// 查询库存接口 ------ 有缓存注解,经过L1→L2→L3
@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) { ... }
// 扣减库存接口 ------ 无缓存注解,直接穿透到Redis
@PostMapping("/seckill/deduct")
public Result deductStock(@RequestBody SeckillReq req) { ... }
用户体验分析:我参与秒杀时,刷新一下界面就会显示一下库存。如果全部查询都打到Redis上,Redis压力还是大。所以查询库存接口加了Caffeine本地缓存,设置3秒短过期。用户3秒内多次刷新看到的库存是一样的,这在抢购时完全可以接受------很多秒杀App都是这样设计的。
为什么Caffeine设置3秒而不是更久? 库存扣减是秒级的,如果Caffeine缓存30秒,用户看到的库存严重滞后。3秒是一个平衡点------既大幅降低了Redis的读压力(3秒内的重复刷新走本地),又不会让用户看到过于过时的数据。如果业务要求极致准确,可以将库存查询接口的Caffeine去掉,只保留Redis层。
四、Caffeine本地缓存------JVM内的性能王者
疑问:本地缓存有Guava Cache、Ehcache、Caffeine,为什么选Caffeine?
回答:Caffeine的读写QPS碾压竞品,核心在于它的淘汰算法W-TinyLFU。
4.1 性能对比
| 缓存库 | 读QPS(单线程) | 写QPS(单线程) | 淘汰算法 | 内存占用 |
|---|---|---|---|---|
| Guava Cache | 6万 | 5万 | LRU | 中 |
| Ehcache 3 | 12万 | 8万 | LFU | 高 |
| Caffeine | 30万+ | 20万+ | W-TinyLFU | 低 |
4.2 W-TinyLFU算法------时间和频率的最佳平衡
传统LRU:只看最近是否被访问。"最近"这个词是双刃剑------突发流量涌进来,会把真正的热数据冲掉。
传统LFU:只看访问频率。历史热数据永远占着位置,新热数据上不来。
W-TinyLFU = Window TinyLFU,结合两者优点:
一个Key是否值得留在缓存 =
访问频率(高频 > 低频)
× 时间衰减(最近访问权重更高)
× Count-Min Sketch(用4bit近似统计,省内存)
实际效果:
- 突发流量不会冲掉老热数据(频率保护)
- 长期不访问的热数据会被衰减淘汰(时间衰减)
- 新热数据能快速上升(Window缓存区优先给新数据机会)
4.3 实战集成
java
@Configuration
public class CaffeineConfig {
// 课程信息缓存(热点数据,30秒刷新)
@Bean
public Cache<Long, Course> courseCache() {
return Caffeine.newBuilder()
.maximumSize(1000) // 最多1000门课程
.expireAfterWrite(30, TimeUnit.SECONDS) // 写后30秒过期
.recordStats() // 开启统计
.build();
}
// 秒杀活动配置缓存(高频但极少变化)
@Bean
public Cache<Long, SeckillActivity> activityCache() {
return Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.SECONDS)
.recordStats()
.build();
}
}
4.4 三级缓存查询实现
java
@Service
public class CourseService {
@Autowired
private Cache<Long, Course> courseCache; // L1: Caffeine
@Autowired
private StringRedisTemplate redisTemplate; // L2: Redis
@Autowired
private CourseMapper courseMapper; // L3: MySQL
public Course getCourse(Long courseId) {
// Level 1: Caffeine本地缓存
Course course = courseCache.getIfPresent(courseId);
if (course != null) {
return course;
}
// Level 2: Redis分布式缓存
String redisKey = "course:" + courseId;
String json = redisTemplate.opsForValue().get(redisKey);
if (json != null && !"NULL".equals(json)) {
course = JSON.parseObject(json, Course.class);
courseCache.put(courseId, course); // 回写L1
return course;
}
if ("NULL".equals(json)) {
return null; // 缓存的空值,防穿透
}
// Level 3: MySQL数据库
course = courseMapper.selectById(courseId);
if (course != null) {
// 回写L2和L1,带随机过期时间防雪崩
int expire = 300 + ThreadLocalRandom.current().nextInt(60);
redisTemplate.opsForValue().set(redisKey,
JSON.toJSONString(course), expire, TimeUnit.SECONDS);
courseCache.put(courseId, course);
} else {
// 缓存空值,10秒过期(防穿透)
redisTemplate.opsForValue().set(redisKey, "NULL", 10, TimeUnit.SECONDS);
}
return course;
}
}
4.5 监控缓存命中率
java
@Scheduled(fixedRate = 60000) // 每分钟输出一次
public void reportCacheStats() {
CacheStats stats = courseCache.stats();
log.info("Caffeine命中率: {}%, 命中: {}, 未命中: {}",
String.format("%.2f", stats.hitRate() * 100),
stats.hitCount(), stats.missCount());
// 命中率 < 80% 说明参数有问题
if (stats.hitRate() < 0.8) {
alertService.send("Caffeine命中率过低: " + stats.hitRate());
}
}
五、缓存预热------秒杀开始前先把数据准备好
疑问:缓存里的数据从哪来?如果秒杀一开始缓存是空的,第一个请求岂不是要穿透到MySQL?
回答:这就是缓存预热要做的事------秒杀开始前,提前把库存和商品信息加载到Redis和Caffeine中。
5.1 预热流程
java
@Component
@Slf4j
public class CachePreheatJob {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private CourseMapper courseMapper;
@Autowired
private Cache<Long, Course> courseCache; // Caffeine
/**
* 秒杀开始前5分钟执行
*/
@XxlJob("cachePreheatJob")
public void preheat() {
log.info("开始缓存预热");
// 查询今日秒杀课程
List<Course> seckillCourses = courseMapper.selectList(
Wrappers.<Course>lambdaQuery()
.eq(Course::getIsSeckill, 1)
.eq(Course::getSeckillDate, LocalDate.now())
);
for (Course course : seckillCourses) {
// 1. 库存加载到Redis
String stockKey = "seckill:stock:" + course.getId();
redisTemplate.opsForValue().set(
stockKey,
String.valueOf(course.getSeckillStock()),
2, TimeUnit.HOURS
);
// 2. 课程信息加载到Redis
String courseKey = "course:" + course.getId();
redisTemplate.opsForValue().set(
courseKey,
JSON.toJSONString(course),
2, TimeUnit.HOURS
);
// 3. 主动加载到Caffeine(可选:如果集群部署且热点集中)
// 这样秒杀第一个请求甚至不用走Redis,直接JVM内部返回
courseCache.put(course.getId(), course);
log.info("预热完成:{}, 库存:{}", course.getName(), course.getSeckillStock());
}
log.info("缓存预热完成,共预热{}个课程", seckillCourses.size());
}
}
5.2 预热的意义
| 场景 | 有预热 | 无预热 |
|---|---|---|
| 第一个请求 | 命中Caffeine或Redis,<3ms | 穿透到MySQL,>15ms |
| 后续请求 | 层层命中,<1ms | 第一个请求回写缓存后才快 |
| 秒杀瞬间 | 所有缓存就绪 | 缓存击穿风险大增 |
配置:使用XXL-Job的分片广播任务,在秒杀开始前5分钟执行预热。预热完成后,所有服务节点的Caffeine和Redis都已有数据。
六、热点数据自动探测
疑问:如果预热时不知道哪些是热点怎么办?
回答:运行时动态探测------用滑动窗口统计 + 优先队列自动发现热点。
java
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<Long, LongAdder> counter = new ConcurrentHashMap<>();
@Scheduled(fixedRate = 10000) // 每10秒统计一次
public void detectHotKeys() {
PriorityQueue<KeyCount> topK = new PriorityQueue<>(
Comparator.comparingLong(KeyCount::getCount)
);
counter.forEach((key, count) -> {
long c = count.sumThenReset();
if (c > 100) { // 10秒内超过100次访问 → 热点
topK.offer(new KeyCount(key, c));
if (topK.size() > 50) topK.poll(); // 保留Top-50
}
});
// 主动预热热点数据到Caffeine
topK.forEach(kc -> {
Course course = courseMapper.selectById(kc.getKey());
if (course != null) {
courseCache.put(kc.getKey(), course);
}
});
}
public void recordAccess(Long courseId) {
counter.computeIfAbsent(courseId, k -> new LongAdder()).increment();
}
}
七、多级缓存一致性------更新策略
疑问:三层缓存各存各的,缓存不一致怎么办?
回答:核心思路------写操作只更新Redis,读操作通过缓存过期自动刷新,必要时主动失效。
7.1 策略一:阶梯过期时间(最终一致,默认方案)
Caffeine: 3~30秒 ← 快速过期,基本保障用户体验
Redis: 5分钟 ← 中间层,扛住大部分读
MySQL: 永久 ← 真实数据源
最差情况:数据更新后,3秒内可能读到旧值(库存查询场景完全可接受)
7.2 策略二:主动失效通知(关键操作)
java
@Service
public class CacheInvalidationService {
@Autowired
private Cache<Long, Course> courseCache;
// 库存扣减时,主动通知所有服务实例清除本地缓存
public void onStockChanged(Long courseId, int newStock) {
// 1. 更新Redis(立即生效)
redisTemplate.opsForValue().set("seckill:stock:" + courseId,
String.valueOf(newStock));
// 2. 通过Redis Pub/Sub广播失效通知
redisTemplate.convertAndSend("cache:invalidate",
JSON.toJSONString(Map.of("type", "course", "id", courseId)));
}
// 每个服务实例订阅失效消息
@PostConstruct
public void subscribe() {
new Thread(() -> {
redisTemplate.getConnectionFactory().getConnection()
.subscribe((message, pattern) -> {
Map<String, Object> msg = JSON.parseObject(
new String(message.getBody()), Map.class);
Long courseId = ((Number) msg.get("id")).longValue();
courseCache.invalidate(courseId); // 立即清除Caffeine
}, "cache:invalidate".getBytes());
}).start();
}
}
八、Redis宕机后的数据恢复
疑问:Redis突然宕机了,内存中的库存数据全部丢失,怎么恢复?
回答:这正是之前架构设计中"流水表"发挥作用的时刻。
8.1 恢复流程
java
// 查询库存接口的兜底逻辑
@GetMapping("/stock/{skuId}")
@CaffeineCache(name = "stock", key = "#skuId", expireAfterWrite = 3, timeUnit = TimeUnit.SECONDS)
public Result<Integer> queryStock(@PathVariable Long skuId) {
String stockKey = "seckill:stock:" + skuId;
String stock = redisTemplate.opsForValue().get(stockKey);
if (stock == null) {
// Redis中没有这个key,有两种可能:
// 1. 秒杀还没开始(正常)
// 2. Redis重启导致数据丢失(异常)
// 从MySQL课程表获取原始总库存
Integer totalStock = courseMapper.selectById(skuId).getSeckillStock();
// 减去流水表中已成功扣减的库存数量
Integer deductedCount = deductLogMapper.getDeductedCount(skuId);
// 计算真实库存
Integer realStock = totalStock - deductedCount;
// 重新写入Redis
redisTemplate.opsForValue().set(stockKey, String.valueOf(realStock), 2, TimeUnit.HOURS);
log.warn("Redis库存丢失,已从MySQL+流水表恢复:skuId={}, realStock={}", skuId, realStock);
return Result.success(realStock);
}
return Result.success(Integer.parseInt(stock));
}
恢复公式:
真实库存 = MySQL原始总库存 - 流水表中已扣减的数量
为什么流水表在这里至关重要? 如果没有流水表,我们只能从MySQL拿到原始总库存,但不知道已经被扣了多少。有了流水表,就可以精确计算:原始库存1000件,流水表显示已成功扣减200件,则真实库存是800件。
8.2 定时对账兜底
即使查询接口有兜底,仍可能有微小偏差。加一个30分钟级别的定时对账:
java
@XxlJob("redisReconciliationJob")
public void reconcileRedisStock() {
// 对比Redis库存和MySQL理论库存
// 不一致时自动修复并发送告警
}
九、缓存三大经典问题的防御
疑问:穿透、击穿、雪崩,在多级缓存下怎么防?
回答:多层防线,逐级过滤。
9.1 防穿透
| 防线 | 手段 |
|---|---|
| 参数校验 | ID<=0直接拒绝 |
| 布隆过滤器 | 启动时加载所有合法ID |
| 缓存空值 | Redis和Caffeine都缓存NULL,10秒过期 |
9.2 防击穿
java
// 分布式锁:只有一个线程能查数据库
String lockKey = "lock:course:" + courseId;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 查数据库,回写缓存
course = courseMapper.selectById(courseId);
if (course != null) {
redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(course), 5, TimeUnit.MINUTES);
courseCache.put(courseId, course);
}
} else {
// 没抢到锁,休眠重试
Thread.sleep(50);
return getCourse(courseId);
}
9.3 防雪崩
| 手段 | 实现 |
|---|---|
| 过期时间加随机值 | expire = 300 + random(0,60) |
| 多级缓存降级 | Caffeine过期还有Redis,Redis过期还有MySQL |
| 限流兜底 | Sentinel在数据库压力过大时直接限流 |
十、压测验证:多级缓存的极限性能
压测条件:
工具: JMeter
线程: 5000
时长: 10分钟
接口: GET /api/courses/1001(热点课程)
优化前(直查Redis+MySQL):
QPS: 8500
RT P99: 45ms
CPU: 75%
Redis CPU: 95%
优化后(Caffeine + Redis + MySQL):
QPS: 45000+
RT P99: <3ms
CPU: 30%
Redis CPU: 20%
Caffeine命中率: 96.8%
分层命中率分析:
| 缓存层 | 请求数 | 命中数 | 命中率 |
|---|---|---|---|
| Caffeine (L1) | 450万 | 435万 | 96.8% |
| Redis (L2) | 15万 | 12.3万 | 82% |
| MySQL (L3) | 2.7万 | 2.7万 | 100% |
96.8%的请求被Caffeine在0.1ms内消化,MySQL压力仅为原始的0.6%。
总结
- 单级Redis的瓶颈在热点Key集中、网络开销和雪崩风险,Caffeine本地缓存解决这两个问题
- 读写分离是缓存设计的首要原则:扣减库存的写操作不走缓存,直接穿透到Redis;查询接口才走L1→L2→L3三级缓存
- Caffeine的W-TinyLFU算法在时间和频率之间取得最优平衡,读QPS达30万+
- 三级缓存数据流转:L1(Caffeine 3-30s) → L2(Redis 5min) → L3(MySQL永久)
- 缓存预热在秒杀前5分钟执行,将库存和商品信息加载到Redis和Caffeine
- 热点自动探测用滑动窗口+Top-K优先队列,无需人工标注
- 一致性靠阶梯过期+Redis Pub/Sub主动失效保障,库存查询容忍3秒延迟
- Redis宕机后通过MySQL原始库存减去流水表已扣减数量恢复真实库存,定时对账兜底
- 穿透用布隆过滤器+空值缓存,击穿用分布式锁,雪崩用随机过期+多级降级
- 压测验证:多级缓存命中率96.8%,QPS从8500提升到45000+