1. 缓存基础概念:什么是缓存、缓存的作用与价值、缓存穿透/击穿/雪崩的定义
1.1 什么是缓存
缓存(Cache)是一种将数据存储在临时存储介质中的技术,目的是在后续请求中能够更快地访问这些数据,而无需重新计算或从原始数据源(如数据库、磁盘文件、远程服务)重新获取。在计算机系统中,缓存无处不在:从 CPU 的 L1/L2/L3 缓存、操作系统的页缓存,到浏览器缓存、CDN 缓存,再到应用层的 Redis、Memcached,都是缓存思想的体现。
在 Spring Boot 微服务架构中,缓存通常指应用层缓存,即使用内存数据库(如 Redis)或本地内存(如 Caffeine)暂存热点数据,以减少对后端数据库或外部服务的访问压力。
核心公式: 缓存命中率 = 缓存命中的请求数 / 总请求数。命中率越高,缓存带来的性能收益越大。
1.2 缓存的作用与价值
缓存的价值可以从三个维度衡量:
| 维度 | 说明 | 典型收益 |
|---|---|---|
| 性能 | 减少数据访问延迟,提升系统响应速度 | 读操作延迟从 10~50ms(DB)降至 1~5ms(缓存) |
| 吞吐量 | 降低后端系统负载,支持更高并发 | 数据库 QPS 从 1000 提升至 10000+ |
| 成本 | 减少对昂贵资源(如数据库连接、计算资源)的消耗 | 同等硬件下支撑更多业务请求 |
在 Spring Boot 项目中,缓存最常见的应用场景包括:
- 热点数据查询:如用户信息、商品详情、配置参数等频繁读取但变化不频繁的数据。
- 复杂计算结果:如报表统计、推荐算法结果,避免重复计算。
- 会话状态:使用 Redis 缓存用户 Session,实现分布式会话共享。
- 限流与防重:利用缓存原子操作实现接口限流计数器或幂等令牌。
1.3 缓存穿透 / 击穿 / 雪崩的定义
这三个概念是缓存系统中最高频的故障模式,理解它们的区别是设计缓存一致性方案的前提。
| 故障模式 | 定义 | 触发条件 | 影响范围 |
|---|---|---|---|
| 缓存穿透 | 请求查询一个缓存和数据库中都不存在的数据,导致每次请求都穿透缓存直达数据库 | 恶意攻击或查询不存在的 key(如 -1、空字符串) | 数据库压力骤增,可能引发宕机 |
| 缓存击穿 | 一个热点 key 在缓存失效的瞬间,大量并发请求同时访问该 key,全部穿透到数据库 | 热点 key 过期 + 高并发访问 | 数据库单点压力暴增,可能引发连锁故障 |
| 缓存雪崩 | 大量缓存 key 在同一时间集中过期失效,导致所有请求全部落到数据库 | 缓存设置相同的过期时间、缓存服务宕机 | 数据库被瞬间打垮,系统整体不可用 |
常见误区: 缓存穿透和缓存击穿容易混淆。穿透是"数据不存在"导致的持续穿透;击穿是"数据存在但 key 刚好过期"导致的瞬时穿透。穿透是"一直不存在",击穿是"曾经存在但刚过期"。
1.4 三种故障模式的对比流程图
下图展示了缓存穿透、击穿、雪崩在请求链路中的不同表现:
1.5 缓存穿透的代码示例
以下是一个典型的缓存穿透场景及空值缓存解决方案的伪代码:
// 缓存穿透:查询不存在的用户
public User getUserById(Long id) {
// 1. 尝试从缓存获取
User user = redisTemplate.opsForValue().get("user:" + id);
if (user != null) {
return user;
}
// 2. 缓存未命中,查询数据库
user = userMapper.selectById(id);
// 3. 缓存穿透处理:如果数据库也不存在,缓存空值
if (user == null) {
// 缓存空值,过期时间设置较短(如 60 秒)
redisTemplate.opsForValue().set("user:" + id, new User(), 60, TimeUnit.SECONDS);
return null;
}
// 4. 数据库存在,写入缓存
redisTemplate.opsForValue().set("user:" + id, user, 3600, TimeUnit.SECONDS);
return user;
}
最佳实践: 空值缓存虽然能解决穿透问题,但会占用缓存空间。建议配合布隆过滤器使用:布隆过滤器判断 key 不存在时直接拒绝请求,避免无效缓存写入。布隆过滤器的误判率通常控制在 1% 以内。
1.6 缓存击穿的互斥锁方案
对于缓存击穿,最常用的方案是互斥锁(Mutex Key),确保同一时刻只有一个线程去加载数据:
// 缓存击穿:互斥锁方案
public Product getProductById(Long id) {
String cacheKey = "product:" + id;
String lockKey = "lock:" + id;
// 1. 先查缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 2. 尝试获取分布式锁
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 3. 双重检查:获取锁后再次查询缓存
product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 4. 查询数据库
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 3600, TimeUnit.SECONDS);
}
return product;
} finally {
// 5. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 6. 未获取到锁,等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductById(id); // 递归重试
}
}
1.7 缓存雪崩的过期时间随机化
防止缓存雪崩的核心策略是避免大量 key 在同一时刻过期:
// 缓存雪崩:过期时间加随机值
public void setCacheWithRandomExpire(String key, Object value, long baseExpire) {
// 基础过期时间上增加 0~300 秒的随机偏移
long randomExpire = baseExpire + ThreadLocalRandom.current().nextLong(0, 300);
redisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.SECONDS);
}
// 批量初始化缓存时使用
public void initProductCaches(List<Product> products) {
for (Product product : products) {
String key = "product:" + product.getId();
// 每个 key 的过期时间都不同,避免集中失效
setCacheWithRandomExpire(key, product, 3600);
}
}
关键总结: 缓存穿透、击穿、雪崩是缓存系统中最常见的三类故障。穿透是"数据不存在"导致的持续穿透;击穿是"热点 key 过期"导致的瞬时穿透;雪崩是"大量 key 同时过期"导致的集体穿透。理解这三者的本质区别,是设计缓存一致性方案的第一步,也是后续章节中缓存更新策略、双写一致性、分布式锁等高级主题的基础。