第二部分:缓存击穿——热点key过期引发的“DB瞬间高压”
缓存击穿的本质是“某个热点key(高并发访问)突然过期”,导致大量请求在同一时间穿透缓存,集中冲击DB,形成“瞬间高压”。
案例3:电商秒杀的“库存超卖”惊魂
故障现场
某电商平台“618”秒杀活动中,一款限量1000台的手机采用“Redis缓存+MySQL”架构:
- 缓存key:
seckill:stock:1001
(存储库存数量),过期时间1小时; - 流程:查询缓存→未命中则查DB→扣减库存→更新缓存。
- 故障:活动开始1小时后,缓存key恰好过期,此时2000+用户同时刷新页面,缓存未命中,所有请求直达MySQL查询库存。MySQL因瞬间高并发(2000QPS)出现锁等待,库存更新延迟,最终超卖50台。
根因解剖
- 热点key(
seckill:stock:1001
)过期瞬间,2000+并发请求穿透至MySQL; - MySQL查询库存时加行锁(
SELECT stock FROM seckill WHERE item_id=1001 FOR UPDATE
),并发请求排队等待,导致库存更新延迟; - 前端未做防重放处理,用户多次刷新加剧并发。
三重防御方案落地
方案1:热点数据“逻辑永不过期”
核心逻辑:缓存不设置物理过期时间,而是在value中嵌入“逻辑过期时间”。当逻辑过期时,不直接删除缓存,而是通过后台线程异步更新,当前请求仍返回旧数据。
优势:彻底避免过期瞬间的并发穿透。
实战代码:
@Service
public class SeckillStockService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SeckillMapper seckillMapper;
// 线程池:处理缓存异步更新
private final ExecutorService updatePool = new ThreadPoolExecutor(
5, 10, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// 缓存数据模型(含逻辑过期时间)
@Data
static class StockCache {
private Integer stock; // 库存数量
private long expireTime; // 逻辑过期时间(毫秒)
}
/**
* 查询秒杀库存(逻辑永不过期)
*/
public Integer getStock(Long itemId) {
String cacheKey = "seckill:stock:" + itemId;
// 1. 查询缓存
String cacheVal = redisTemplate.opsForValue().get(cacheKey);
if (cacheVal == null) {
// 2. 缓存未命中(首次加载):加锁查询DB并初始化
return loadStockWithLock(itemId, cacheKey);
}
// 3. 解析缓存数据
StockCache cache = JSON.parseObject(cacheVal, StockCache.class);
// 4. 逻辑未过期:直接返回
if (System.currentTimeMillis() < cache.getExpireTime()) {
return cache.getStock();
}
// 5. 逻辑已过期:异步更新缓存,当前请求返回旧数据
updatePool.submit(() -> refreshStockCache(itemId, cacheKey));
return cache.getStock();
}
// 加锁加载库存(防止缓存击穿)
private Integer loadStockWithLock(Long itemId, String cacheKey) {
// 使用Redisson分布式锁
RLock lock = redissonClient.getLock("lock:seckill:stock:" + itemId);
try {
// 最多等待100ms,持有锁5秒
if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {
// 双重检查:防止重复加载
String cacheVal = redisTemplate.opsForValue().get(cacheKey);
if (cacheVal != null) {
return JSON.parseObject(cacheVal, StockCache.class).getStock();
}
// 查询DB并初始化缓存(逻辑过期1小时)
Integer stock = seckillMapper.selectStock(itemId);
StockCache cache = new StockCache();
cache.setStock(stock);
cache.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));
return stock;
} else {
// 获取锁失败:返回DB查询结果(兜底)
return seckillMapper.selectStock(itemId);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// 刷新缓存(异步执行)
private void refreshStockCache(Long itemId, String cacheKey) {
RLock lock = redissonClient.getLock("lock:seckill:stock:" + itemId);
try {
// 加锁防止并发更新
if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {
Integer newStock = seckillMapper.selectStock(itemId);
StockCache cache = new StockCache();
cache.setStock(newStock);
cache.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
时序图:
正常请求(未过期):
[用户] → 查缓存 → 命中(未过期)→ 返回结果
过期请求(异步更新):
[用户] → 查缓存 → 命中(已过期)→ 返回旧数据
↓
异步线程更新缓存(加锁)
实战效果:缓存过期时无请求穿透至DB,MySQL查询量稳定在50QPS以内,超卖问题彻底解决。
方案2:分布式锁“串行化”查询
核心逻辑:热点key过期时,通过分布式锁保证只有一个线程能查询DB并更新缓存,其他线程等待重试。
适用场景:数据实时性要求高,无法接受旧数据。
实战代码(Redisson实现):
@Service
public class HotItemService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private ItemMapper itemMapper;
/**
* 查询热点商品详情(分布式锁防击穿)
*/
public ItemDTO getHotItem(Long itemId) {
String cacheKey = "item:hot:" + itemId;
// 1. 查询缓存
String cacheVal = redisTemplate.opsForValue().get(cacheKey);
if (cacheVal != null) {
return JSON.parseObject(cacheVal, ItemDTO.class);
}
// 2. 缓存未命中:加分布式锁
RLock lock = redissonClient.getLock("lock:item:hot:" + itemId);
try {
// 最多等待500ms,持有锁3秒
if (lock.tryLock(500, 3000, TimeUnit.MILLISECONDS)) {
// 双重检查:防止锁等待期间已更新缓存
cacheVal = redisTemplate.opsForValue().get(cacheKey);
if (cacheVal != null) {
return JSON.parseObject(cacheVal, ItemDTO.class);
}
// 3. 查询DB并更新缓存(设置过期时间30分钟)
ItemDTO item = itemMapper.selectById(itemId);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(item), 30, TimeUnit.MINUTES);
return item;
} else {
// 4. 获取锁失败:重试(最多3次)
for (int i = 0; i < 3; i++) {
Thread.sleep(50); // 短暂等待
cacheVal = redisTemplate.opsForValue().get(cacheKey);
if (cacheVal != null) {
return JSON.parseObject(cacheVal, ItemDTO.class);
}
}
// 重试失败:返回DB结果(兜底)
return itemMapper.selectById(itemId);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
实战效果:热点key过期时,仅1个线程查询DB,其他线程从缓存获取,MySQL峰值QPS从2000降至5,接口响应时间从500ms降至50ms。
方案3:熔断降级(极端情况保护)
核心逻辑:当DB压力过大时,通过熔断组件(如Resilience4j)临时返回缓存旧值或默认值,避免DB被压垮。
实战代码(Resilience4j配置):
@Configuration
public class CircuitBreakerConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超50%触发熔断
.waitDurationInOpenState(Duration.ofSeconds(10)) // 熔断10秒
.permittedNumberOfCallsInHalfOpenState(5) // 半开状态允许5次调用
.slidingWindowSize(100) // 滑动窗口大小100
.build();
return CircuitBreakerRegistry.of(config);
}
}
@Service
public class ItemService {
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@Autowired
private ItemMapper itemMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 带熔断的DB查询(兜底方案)
*/
public ItemDTO queryFromDBWithFallback(Long itemId) {
CircuitBreaker breaker = circuitBreakerRegistry.circuitBreaker("itemDBQuery");
// 包装DB查询方法,配置熔断降级
return Try.ofSupplier(CircuitBreaker.decorateSupplier(breaker, () ->
itemMapper.selectById(itemId)
)).recover(Exception.class, e -> {
log.warn("DB查询熔断,使用缓存旧值,itemId={}", itemId, e);
// 熔断时返回缓存旧值(即使过期)
String oldVal = redisTemplate.opsForValue().get("item:hot:" + itemId);
return oldVal != null ? JSON.parseObject(oldVal, ItemDTO.class) : buildDefaultItem(itemId);
}).get();
}
// 构建默认商品(极端降级)
private ItemDTO buildDefaultItem(Long itemId) {
ItemDTO defaultItem = new ItemDTO();
defaultItem.setId(itemId);
defaultItem.setName("商品信息加载中");
return defaultItem;
}
}
实战效果:DB压力过大时自动熔断,返回缓存旧值,接口成功率保持99.9%,无服务雪崩。
击穿防御总结
方案 | 适用场景 | 优点 | 缺点 | 实施成本 |
---|---|---|---|---|
逻辑永不过期 | 实时性要求不高 | 无并发穿透,性能好 | 可能返回旧数据 | 中 |
分布式锁 | 实时性要求高 | 数据一致,实现简单 | 锁竞争可能导致延迟 | 中 |
熔断降级 | 极端流量保护 | 兜底保障,防止DB雪崩 | 影响用户体验 | 低 |