引言
在高并发系统中,缓存是提升性能的银弹,而Redis早已成为缓存层的事实标准。然而,缓存的使用并非无脑存取这么简单,设计不当极易引发缓存穿透 、缓存击穿 、缓存雪崩 等经典问题,同时缓存与数据库的双写一致性也是面试和实战中的高频难题。
本文将从真实业务场景出发,结合 Spring Boot + Redis 的完整可运行代码,系统地讲解上述问题的原理与解决方案,包括布隆过滤器 、分布式互斥锁 、随机过期时间 以及先更新数据库再删除缓存的一致性策略。无论你是准备面试还是落地生产环境,都能找到直接可用的参考。
1. 核心概念:缓存三大"灾星"
1.1 缓存穿透
现象 :查询一个数据库中根本不存在的 key,由于缓存中也没有,每次请求都会穿过缓存直接打到数据库上,造成数据库压力过大。
常见原因 :恶意攻击、业务误传的非法ID。
解决思路 :
-
缓存空值:将不存在的结果也缓存起来,设置较短过期时间。
-
布隆过滤器:在缓存之前加一层概率性判无的过滤器,直接拦截不存在的 key。
1.2 缓存击穿
现象 :一个热点 key 在过期的一瞬间,大量并发请求同时查询该 key,导致全部请求落到数据库,瞬间压垮 DB。
解决思路 :
-
加互斥锁:仅让一个请求去加载数据库并回写缓存,其余请求等待或快速失败。
-
逻辑过期(永不过期):热点 key 不设物理过期,而是利用逻辑过期时间,异步刷新缓存。
1.3 缓存雪崩
现象 :大量缓存在同一时刻过期,或 Redis 宕机,导致所有请求直接请求数据库,造成 DB 层瞬时压力过高。
解决思路 :
-
添加随机过期时间,避免同时过期。
-
高可用架构:Redis 哨兵或集群。
-
限流与降级。
2. 实战准备:项目搭建
我们创建一个简单的商品查询服务,使用 Spring Boot 2.7 + Redis,采用 Lettuce 客户端。
依赖(pom.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.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
配置(application.yml)
yaml
spring:
redis:
host: localhost
port: 6379
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
实体类
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
private Integer stock;
}
模拟数据库访问层
java
@Component
public class ProductRepository {
private final Map<Long, Product> db = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
db.put(1L, new Product(1L, "iPhone 15", new BigDecimal("7999"), 100));
db.put(2L, new Product(2L, "MacBook Pro", new BigDecimal("14999"), 50));
db.put(3L, new Product(3L, "AirPods Pro", new BigDecimal("1899"), 200));
}
public Product findById(Long id) {
// 模拟数据库查询延迟
try { Thread.sleep(200); } catch (InterruptedException ignored) {}
return db.get(id);
}
public void update(Product product) {
db.put(product.getId(), product);
}
}
3. 缓存穿透解决方案:布隆过滤器 + 空值缓存
我们采用 Google Guava 的布隆过滤器,在 Redis 缓存前做一次快速判断。
布隆过滤器初始化与产品ID注册
java
@Configuration
public class BloomFilterConfig {
@Bean
public BloomFilter<Long> productBloomFilter() {
// 预计插入1000个元素,误判率0.01
BloomFilter<Long> filter = BloomFilter.create(Funnels.longFunnel(), 1000, 0.01);
// 初始化已知存在的ID
filter.put(1L);
filter.put(2L);
filter.put(3L);
return filter;
}
}
完整Service实现
java
@Service
@Slf4j
public class ProductService {
private final RedisTemplate<String, Object> redisTemplate;
private final ProductRepository productRepository;
private final BloomFilter<Long> bloomFilter;
// 空值缓存过期时间
private static final long NULL_TTL = 60; // 秒
// 正常商品缓存过期时间
private static final long PRODUCT_TTL = 600;
public ProductService(RedisTemplate<String, Object> redisTemplate,
ProductRepository productRepository,
BloomFilter<Long> bloomFilter) {
this.redisTemplate = redisTemplate;
this.productRepository = productRepository;
this.bloomFilter = bloomFilter;
}
public Product getProduct(Long id) {
// 1. 布隆过滤器预判
if (!bloomFilter.mightContain(id)) {
log.warn("布隆过滤器拦截不存在的ID: {}", id);
return null; // 直接返回空,不查缓存与DB
}
String cacheKey = "product:" + id;
// 2. 查询缓存
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
if (cached instanceof Product) {
return (Product) cached;
}
// 如果缓存的空对象标识(如字符串"NULL"),表示之前查过不存在
log.info("命中空值缓存,ID: {}", id);
return null;
}
// 3. 缓存未命中,查询数据库(加分布式锁防止击穿,见下一节)
Product product = productRepository.findById(id);
if (product != null) {
// 写入缓存
redisTemplate.opsForValue().set(cacheKey, product, PRODUCT_TTL, TimeUnit.SECONDS);
} else {
// 数据库也没有,缓存空对象防止穿透
redisTemplate.opsForValue().set(cacheKey, "NULL", NULL_TTL, TimeUnit.SECONDS);
}
return product;
}
}
注意:布隆过滤器不支持删除元素,如果商品下架或新增,需要同步更新过滤器(可以定期全量重建)。空值缓存的 key 需同步清理。
4. 缓存击穿解决方案:分布式互斥锁
当热点 key 过期时,使用 Redis 的 SETNX 命令实现简单的分布式锁,保证只有一个线程去加载数据库。
分布式锁工具类
java
@Component
public class RedisDistributedLock {
private final StringRedisTemplate stringRedisTemplate;
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁,非阻塞
* @param lockKey 锁的key
* @param requestId 请求标识(用于释放锁时校验)
* @param expireSec 锁的过期时间(秒)
* @return 是否获取成功
*/
public boolean tryLock(String lockKey, String requestId, long expireSec) {
return Boolean.TRUE.equals(stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, expireSec, TimeUnit.SECONDS));
}
/**
* 释放锁(Lua脚本保证原子性)
*/
public boolean releaseLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId);
return result != null && result == 1L;
}
}
结合击穿防护的商品查询方法
java
public Product getProductWithLock(Long id) {
// ... 布隆过滤器判断同上 ...
String cacheKey = "product:" + id;
String lockKey = "lock:product:" + id;
String requestId = UUID.randomUUID().toString();
Product product = null;
try {
// 1. 尝试从缓存获取
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
if (cached instanceof Product) return (Product) cached;
return null; // 空对象缓存
}
// 2. 缓存未命中,尝试获取锁
if (distributedLock.tryLock(lockKey, requestId, 10)) {
// 双重检查
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
if (cached instanceof Product) return (Product) cached;
return null;
}
// 3. 查询数据库
product = productRepository.findById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, PRODUCT_TTL + ThreadLocalRandom.current().nextInt(60), TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(cacheKey, "NULL", NULL_TTL, TimeUnit.SECONDS);
}
} else {
// 未获取到锁,短暂等待后重试(实际可用自旋或返回降级数据)
TimeUnit.MILLISECONDS.sleep(50);
return getProductWithLock(id); // 递归重试,注意防止栈溢出
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 释放锁
if (product != null || ... ) // 确保是加锁的线程释放
distributedLock.releaseLock(lockKey, requestId);
}
return product;
}
说明 :上述代码中对 PRODUCT_TTL 添加了 ThreadLocalRandom.current().nextInt(60) 秒的随机值,正是雪崩的随机过期时间方案。
5. 缓存雪崩的精细化防控
除了随机过期时间,生产环境还需:
- 多级缓存:本地缓存(如Caffeine) + Redis,即使Redis故障,本地缓存还能扛一阵。
- 熔断降级:使用Hystrix/Sentinel,在Redis不可用时快速返回兜底数据。
- Redis高可用:采用Cluster或Sentinel模式,避免单点故障。
随机过期示例 (已在上方代码体现):
设置过期时间时加入一个随机偏移量,确保不会在同一秒大量过期。
java
int baseTtl = 600; // 10分钟
int randomOffset = ThreadLocalRandom.current().nextInt(120); // 0~2分钟
redisTemplate.opsForValue().set(cacheKey, product, baseTtl + randomOffset, TimeUnit.SECONDS);
6. 缓存更新策略与数据库一致性
6.1 经典方案:Cache Aside(先更新DB,再删除缓存)
- 为何不是先删缓存,再更新DB?
并发下可能出现:A请求删了缓存,B请求查询发现缓存不存在,从DB读到旧值并写入缓存,然后A才更新DB。此时缓存中依然是旧值,造成脏数据。 - 为何不是先更新DB,再更新缓存?
两个并发写操作可能导致缓存与DB不一致,且更新缓存的成本可能较高。 - Cache Aside 最稳妥 :更新数据库成功后,删除缓存;下次读请求会重建缓存。
6.2 延迟双删
在某些极端情况下,即使先更新DB再删除缓存,也可能因为主从复制延迟导致读到旧数据。可以在更新DB后,延迟(如1秒)再删一次缓存,称为"延迟双删"。
一致性代码实践(商品更新接口)
java
@Transactional
public Product updateProduct(Product product) {
// 1. 更新数据库
productRepository.update(product);
// 2. 删除缓存(立即)
String cacheKey = "product:" + product.getId();
redisTemplate.delete(cacheKey);
// 3. 延迟双删(异步执行,确保最终一致性)
executorService.schedule(() -> {
redisTemplate.delete(cacheKey);
}, 500, TimeUnit.MILLISECONDS);
return product;
}
对于要求强一致性的场景,可以引入Canal监听binlog,由消息队列驱动缓存更新,实现最终一致性。
7. 完整Controller测试
java
@RestController
@RequestMapping("/product")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public ResponseEntity<?> getProduct(@PathVariable Long id) {
Product product = productService.getProductWithLock(id);
if (product == null) {
return ResponseEntity.ok("商品不存在");
}
return ResponseEntity.ok(product);
}
@PutMapping
public ResponseEntity<?> update(@RequestBody Product product) {
Product updated = productService.updateProduct(product);
return ResponseEntity.ok(updated);
}
}
使用JMeter或并发请求工具,可以验证击穿、穿透、雪崩防护是否生效。
8. 常见问题与注意事项
- 布隆过滤器数据变更:商品新增时需将ID加入过滤器,下架则无法删除。可采用定期全量重建过滤器(从DB加载所有有效ID)。
- 分布式锁的可靠性:上文给出的锁实现简单,但生产建议使用Redisson的RLock,支持自动续期和可重入。
- 缓存序列化问题:RedisTemplate默认使用JDK序列化,可读性差,建议配置为Jackson2JsonRedisSerializer,方便调试。
- 大Key与热Key:避免单个key过大(如存储整个列表),可拆分;热key可通过多副本、本地缓存等分摊压力。
- 内存淘汰策略 :设置为
allkeys-lru或volatile-lru,避免内存满后写入失败。
9. 总结
本文从实战出发,详细解析了Redis缓存设计中的三大类问题------穿透、击穿、雪崩的成因与解决方案,并给出了Spring Boot下可直接运行的代码示例。同时,对缓存与数据库的一致性策略进行了深入对比,推荐采用"先更新数据库再删除缓存"的Cache Aside模式,辅以延迟双删保障最终一致性。
核心要点回顾 :
-
穿透:布隆过滤器 + 空值缓存
-
击穿:分布式互斥锁 + 逻辑过期
-
雪崩:随机过期时间 + 多级缓存 + 限流
-
一致性:Cache Aside + 延迟双删 / Canal异步同步
缓存设计没有银弹,需要根据业务场景灵活取舍。希望本文能成为你应对高并发缓存难题的一块坚实拼图。
完整代码已托管至 GitHub示例仓库 (示例地址,实际请自行搭建)。