当流量洪峰撞上数据库:Redis缓存穿透、击穿、雪崩全解析

149 阅读8分钟

当流量洪峰撞上数据库:Redis缓存穿透、击穿、雪崩全解析

一、数据库为什么会不堪重负?

场景设定

某电商平台正在进行"双11秒杀活动",某款热门手机(product_id=1001)的库存信息缓存在Redis中,缓存过期时间设置为5分钟。在缓存过期的瞬间,突然有10万用户同时刷新商品页面,触发缓存击穿。所有请求直接涌向数据库查询库存,最终导致数据库崩溃,商品页面无法加载。

数据库崩溃的核心原因:资源耗尽 + 连锁反应

1. 连接池耗尽(第一波冲击)
-- 每个请求对应的SQL
SELECT stock, price FROM products WHERE id=1001;

数据库配置

  • 最大连接数:500
  • 每秒处理请求:2000 QPS(理想情况)

问题爆发过程

  1. 缓存失效瞬间涌入10万请求
  2. 数据库连接池瞬间被500个连接占满
  3. 剩余99,500个请求进入排队状态
  4. 连接等待队列迅速堆积,超出最大等待时间(如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;

问题演进

  1. 库存查询请求尚未处理完
  2. 下单请求又涌入更新库存
  3. 行锁(Row Lock)竞争导致阻塞
  4. 事务超时(如超过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秒

后果

  1. 所有200个应用服务器线程被挂起
  2. 新请求进入排队队列
  3. 最终触发OutOfMemoryErrorRejectedExecutionException

2. 数据库主从同步延迟

架构

  • 主库处理写操作
  • 从库处理读操作

问题演进

  1. 主库CPU满载导致binlog同步延迟
  2. 从库数据严重滞后(如延迟5分钟)
  3. 用户看到"库存还有100件"实际已售罄
  4. 最终导致超卖纠纷

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不存在且未缓存空值,数据库可能被瞬间击垮。

解决方案概述
  1. 布隆过滤器 (Bloom Filter)
    • 原理:预加载有效键到布隆过滤器,拦截无效请求。
    • 优点:内存高效,拦截准确。
    • 缺点:存在误判率,需定期维护。
  2. 缓存空值 (Null Caching)
    • 原理:将查询结果为空的键缓存,避免重复查询数据库。
    • 优点:实现简单,快速生效。
    • 缺点:内存占用可能较高。
  3. 请求校验 (Validation)
    • 原理:在业务层校验参数合法性(如ID格式)。
    • 优点:直接拦截无效参数。
    • 缺点:依赖业务规则,覆盖面有限。
代码示例:
  1. 初始化布隆过滤器
    @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);
            }
        }
    }
    
  2. 查询逻辑实现

    @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;
}

优势:

  1. 内置看门狗机制自动续期
  2. 完善的锁竞争处理
  3. 支持可重入锁特性
  4. 避免自旋消耗CPU

缓存雪崩

大量缓存数据同时失效缓存服务单宕机, 导致所有请求直接访问数据库,引发数据库压力激增甚至崩溃

典型场景与案例分析

场景1:批量缓存同时过期

  • 案例:某电商平台将商品信息的缓存过期时间统一设置为2小时,大促期间大量商品缓存同时失效,数据库每秒请求量从1k激增至10k,最终导致数据库崩溃。
  • 根因:缓存过期时间过于集中,缺乏随机性。

场景2:Redis集群宕机

  • 案例:某社交平台因Redis主节点故障,从节点未能及时切换,所有查询请求直接访问数据库,导致服务不可用长达10分钟。
  • 根因:缓存层高可用架构缺失,故障转移机制不完善。
解决方案与代码实现
  1. 分散缓存过期时间

    通过为缓存过期时间添加随机值,避免批量失效。

    // 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
        );
    }
    
  2. 分布式锁防止击穿

  3. 缓存预热+定时更新

    通过定时任务提前加载热点数据。

    // 示例:缓存预热
    @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);
        });
    }