当流量洪峰撞上数据库:Redis缓存穿透、击穿、雪崩全解析
一、数据库为什么会不堪重负?
场景设定
某电商平台正在进行"双11秒杀活动",某款热门手机(product_id=1001)的库存信息缓存在Redis中,缓存过期时间设置为5分钟。在缓存过期的瞬间,突然有10万用户同时刷新商品页面,触发缓存击穿。所有请求直接涌向数据库查询库存,最终导致数据库崩溃,商品页面无法加载。
数据库崩溃的核心原因:资源耗尽 + 连锁反应
1. 连接池耗尽(第一波冲击)
-- 每个请求对应的SQL
SELECT stock, price FROM products WHERE id=1001;
数据库配置:
- 最大连接数:500
- 每秒处理请求:2000 QPS(理想情况)
问题爆发过程:
- 缓存失效瞬间涌入10万请求
- 数据库连接池瞬间被500个连接占满
- 剩余99,500个请求进入排队状态
- 连接等待队列迅速堆积,超出最大等待时间(如30秒)
结果:
- 前端开始出现
504 Gateway Timeout错误 - 部分用户反复刷新页面,产生更多请求
2. CPU过载(第二波冲击)
假设服务器配置:
- 4核CPU
- 每次查询消耗3ms CPU时间
计算峰值压力:
- 单核处理能力:1000ms / 3ms ≈ 333 QPS
- 4核理论最大:4 × 333 ≈ 1332 QPS
实际场景:
- 瞬时10万请求涌入
- CPU使用率瞬间飙升至100%
- 上下文切换(context switching)开销暴增
现象:
- 监控面板出现
CPU Load Avg > 50(正常应< CPU核数) - 单次查询耗时从3ms暴增至300ms
3. 磁盘I/O瓶颈(第三波冲击)
假设场景:
- 该商品数据未正确索引
- 每次查询触发全表扫描
-- 没有索引的查询(假设products表有1000万条记录)
EXPLAIN SELECT * FROM products WHERE id=1001; -- 显示"type=ALL"
后果:
- 每次查询需要读取10MB数据(假设)
- 机械磁盘的随机IOPS约200次/秒
- 10万请求需要读取:10万 × 10MB = 1TB数据
现象:
- 磁盘利用率100%
- 查询耗时从毫秒级升至秒级
4. 锁竞争加剧(第四波冲击)
并发更新场景:
-- 用户下单时触发的更新
UPDATE products SET stock=stock-1 WHERE id=1001;
问题演进:
- 库存查询请求尚未处理完
- 下单请求又涌入更新库存
- 行锁(Row Lock)竞争导致阻塞
- 事务超时(如超过30秒)自动回滚
结果:
- 出现大量
Deadlock found错误日志 - 库存出现超卖或无法扣减
崩溃的连锁反应
1. 应用服务器雪崩
// 伪代码示例
@GetMapping("/product/{id}")
public Product getProduct(@PathVariable Long id) {
// 数据库查询超时设置为2秒
Product product = productService.getProduct(id); // 这里开始堆积线程
return product;
}
线程池配置:
- Tomcat最大线程数:200
- 数据库响应时间:从50ms → 5秒
后果:
- 所有200个应用服务器线程被挂起
- 新请求进入排队队列
- 最终触发
OutOfMemoryError或RejectedExecutionException
2. 数据库主从同步延迟
架构:
- 主库处理写操作
- 从库处理读操作
问题演进:
- 主库CPU满载导致
binlog同步延迟 - 从库数据严重滞后(如延迟5分钟)
- 用户看到"库存还有100件"实际已售罄
- 最终导致超卖纠纷
3. 自动故障转移失败
高可用配置:
- 主从切换时间:30秒
- 哨兵(Sentinel)检测间隔:10秒
致命问题:
- 在切换过程中持续涌入新请求
- 新主库刚启动即被再次打挂
- 最终进入
主库↔从库无限循环切换
二、解决方案
缓存穿透
缓存穿透是指大量请求查询不存在的数据,导致请求直接穿透缓存层,频繁访问数据库,引发性能问题 .
案例:
根据商品ID查询商品详情,例如:GET /product/{productId}
代码逻辑:
public Product getProduct(String productId) {
// 1. 查缓存
Product product = redis.get("product:" + productId);
if (product != null) return product;
// 2. 查数据库
product = productDao.findById(productId);
if (product != null) {
redis.set("product:" + productId, product, 30, TimeUnit.MINUTES);
}
return product;
}
缓存穿透场景:
- 恶意攻击:攻击者用脚本批量生成随机ID(如
1000001,1000002...)高频请求。 - 爬虫探测:爬虫遍历猜测商品ID,尝试抓取未公开的商品数据。
- 用户输入错误:用户手动输入了不存在的ID(如
123abc)。
后果
每次请求都穿透到数据库,若商品ID不存在且未缓存空值,数据库可能被瞬间击垮。
解决方案概述
- 布隆过滤器 (Bloom Filter)
- 原理:预加载有效键到布隆过滤器,拦截无效请求。
- 优点:内存高效,拦截准确。
- 缺点:存在误判率,需定期维护。
- 缓存空值 (Null Caching)
- 原理:将查询结果为空的键缓存,避免重复查询数据库。
- 优点:实现简单,快速生效。
- 缺点:内存占用可能较高。
- 请求校验 (Validation)
- 原理:在业务层校验参数合法性(如ID格式)。
- 优点:直接拦截无效参数。
- 缺点:依赖业务规则,覆盖面有限。
代码示例:
-
初始化布隆过滤器
@Component public class BloomFilterInitializer { @Autowired private RedissonClient redissonClient; @Autowired private ProductDao productDao; @PostConstruct public void initBloomFilter() { RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productBloomFilter"); // 初始化布隆过滤器:预期数据量100万,误判率1% bloomFilter.tryInit(1000000L, 0.01); // 加载数据库中的有效键 List<String> allProductIds = productDao.findAllProductIds(); for (String id : allProductIds) { bloomFilter.add(id); } } } -
查询逻辑实现
@Service public class ProductService { @Autowired private RedissonClient redissonClient; @Autowired private RedisTemplate<String, Product> redisTemplate; @Autowired private ProductDao productDao; public Product getProductById(String productId) { // 1. 参数校验(请求校验) if (!isValidProductId(productId)) { throw new IllegalArgumentException("Invalid product ID"); } // 2. 布隆过滤器检查 RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productBloomFilter"); if (!bloomFilter.contains(productId)) { return null; // 直接拦截不存在的数据 } // 3. 查询Redis缓存 Product product = redisTemplate.opsForValue().get(productId); if (product != null) { return product instanceof NullProduct ? null : product; // 处理空值 } // 4. 查询数据库 product = productDao.findById(productId); if (product != null) { // 写入缓存 redisTemplate.opsForValue().set(productId, product, 30, TimeUnit.MINUTES); } else { // 缓存空值(防止穿透),过期时间5分钟 redisTemplate.opsForValue().set(productId, new NullProduct(), 5, TimeUnit.MINUTES); } return product; } private boolean isValidProductId(String productId) { // 示例:ID必须为数字且长度在6-10位之间 return productId != null && productId.matches("\\d{6,10}"); } // 空值标记对象 private static class NullProduct extends Product {} }
缓存击穿
缓存击穿是指一个热点key在缓存过期瞬间,大量请求直接打到数据库,导致数据库压力激增的现象。
问题复现代码:
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductDao productDao; // 假设是数据库访问组件
// 存在缓存击穿风险的代码
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product == null) {
// 缓存失效瞬间,大量并发请求进入此处
product = productDao.getById(productId); // 查询数据库
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
return product;
}
}
解决方案:Redisson分布式锁
public Product getProductWithRedisson(Long productId) {
String key = "product:" + productId;
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) return product;
RLock lock = redissonClient.getLock("productLock:" + productId);
try {
if (lock.tryLock(2, 30, TimeUnit.SECONDS)) { // 等待2秒,锁30秒
// 双重检查
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) return product;
product = productDao.getById(productId);
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return product;
}
优势:
- 内置看门狗机制自动续期
- 完善的锁竞争处理
- 支持可重入锁特性
- 避免自旋消耗CPU
缓存雪崩
大量缓存数据同时失效或缓存服务单宕机, 导致所有请求直接访问数据库,引发数据库压力激增甚至崩溃
典型场景与案例分析
场景1:批量缓存同时过期
- 案例:某电商平台将商品信息的缓存过期时间统一设置为2小时,大促期间大量商品缓存同时失效,数据库每秒请求量从1k激增至10k,最终导致数据库崩溃。
- 根因:缓存过期时间过于集中,缺乏随机性。
场景2:Redis集群宕机
- 案例:某社交平台因Redis主节点故障,从节点未能及时切换,所有查询请求直接访问数据库,导致服务不可用长达10分钟。
- 根因:缓存层高可用架构缺失,故障转移机制不完善。
解决方案与代码实现
-
分散缓存过期时间
通过为缓存过期时间添加随机值,避免批量失效。
// Java示例:设置带随机过期时间的缓存 public void setProductCache(String productId, Product product) { int baseExpire = 2 * 60 * 60; // 基础过期时间2小时 int randomOffset = new Random().nextInt(600); // 0-10分钟随机偏移 redisTemplate.opsForValue().set( "product:" + productId, product, baseExpire + randomOffset, TimeUnit.SECONDS ); } -
分布式锁防止击穿
-
缓存预热+定时更新
通过定时任务提前加载热点数据。
// 示例:缓存预热 @Scheduled(fixedRate = 30 * 60 * 1000) // 每30分钟执行 public void preloadHotProducts() { List<String> hotProductIds = database.fetchHotProductIds(); // 获取热点商品ID hotProductIds.forEach(id -> { Product product = database.fetchProduct(id); redisTemplate.opsForValue().set("product:" + id, product, 2 * 60 * 60, TimeUnit.SECONDS); }); }