最近公司有个项目遇到了高峰期数据库压力爆表的情况,我带着团队排查三天三夜后发现问题的罪魁祸首竟然是——缓存穿透。你可能听说过缓存雪崩、缓存击穿这些常见的缓存问题,但缓存穿透这种不起眼的问题一旦发作,真的能让你的数据库瞬间崩溃。今天我们就来重点聊聊,怎么用正确姿势给MySQL数据库穿上防弹衣。


一、当数据库变成筛子的时候发生了什么

上周三凌晨三点,我们的订单系统突然收到大量"查无此单"的请求。这些请求有个共同特点:都在查询不存在的订单号。Redis缓存层被这些非法请求轻松绕过,直接穿透到MySQL数据库,导致数据库CPU飙到98%,查询响应时间从平时的20ms变成恐怖的3秒。

![示意图:大量无效请求穿透缓存攻击数据库]

(这里原要求不放图片,故以文字描述示意图内容)

这种情况就叫做典型的缓存穿透攻击——大量不存在的请求导致缓存层失效,直击数据库。这时我们采取了三步走策略,在半小时内成功止损:

  1. 即时扩容MySQL集群节点
  2. 临时启用空值缓存策略
  3. 部署布隆过滤器防护层

二、救命的三板斧方案

2.1 布隆过滤器:第一道防火墙

(技术栈:Java + Spring Boot + Guava BloomFilter)

布隆过滤器就像高速路口的电子扫描仪,能瞬间识别可疑车辆。我们在网关层部署的这个解决方案,可以有效拦截98%的非法请求。

// 初始化布隆过滤器(这里使用Guava实现)
BloomFilter<String> orderFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.forName("UTF-8")),
    1000000,  // 预期元素量
    0.01      // 误判率
);

// 系统启动时加载有效订单号
List<String> validOrders = orderDao.getAllOrderIds();
validOrders.forEach(orderFilter::put);

// 查询前的过滤检查
public Order getOrder(String orderId) {
    if (!orderFilter.mightContain(orderId)) {
        log.warn("非法订单号拦截:{}", orderId);
        return null;
    }
    // 正常查询流程...
}

这个方案的优缺点十分明显:

  • ✅ 优点:内存消耗极低(百万数据仅需1MB),拦截效率高达万次/秒
  • ❌ 缺点:存在1%的误判率需要兜底方案配合,初始化需要全量数据

2.2 互斥锁方案:给缓存上锁

(技术栈:Redis + Redisson分布式锁)

当多个请求同时查询同一个不存在的数据时,用分布式锁确保只有一个请求能访问数据库,其他请求等待缓存重建。

public Order getOrderWithLock(String orderId) {
    // 尝试从缓存获取
    Order order = redisTemplate.opsForValue().get(orderId);
    if (order != null) return order;

    // 获取分布式锁
    RLock lock = redissonClient.getLock("ORDER_LOCK:" + orderId);
    try {
        if (lock.tryLock(2, 5, TimeUnit.SECONDS)) {
            // 双重检查缓存
            order = redisTemplate.opsForValue().get(orderId);
            if (order == null) {
                // 查数据库
                order = orderDao.getById(orderId);
                // 即使为空也要缓存
                redisTemplate.opsForValue().set(orderId, 
                    order == null ? "NULL" : order, 
                    5, TimeUnit.MINUTES);
            }
        }
    } finally {
        lock.unlock();
    }
    return order;
}

这个方案需要注意锁的粒度——建议使用订单号级别的细粒度锁,避免影响正常查询。在实测中,这种方案将QPS从600降到了150,但数据库压力明显缓解。

2.3 空值缓存:给不存在的请求留位置

(技术栈:Redis + Spring Cache)

给查询结果为空的请求也建立短期缓存,设置较短的过期时间来应对突发流量。

# application.yml配置
spring:
  cache:
    redis:
      time-to-live: 300s    # 正常缓存5分钟
      cache-null-values: true  # 允许缓存空值
      additional-key-prefix: "NULL_"  # 空值缓存前缀

在代码实现时需要注意:

  1. 空值缓存的过期时间要设置随机抖动(如5分钟±随机30秒)
  2. 需要配套的缓存清理机制,在数据真实产生时及时清除空值标记

三、进阶防御:组合拳才是王道

3.1 混合防护体系

在实际生产环境中,我们构建了这样的防护体系:

请求 -> API网关(BloomFilter) -> 本地缓存 -> Redis缓存 -> 分布式锁 -> MySQL

这种层级防护结构,使我们的系统在618大促期间,面对峰值15万QPS的订单查询请求时,MySQL的CPU始终稳定在30%以下。

3.2 智能限流策略

(技术栈:Sentinel + Nginx)

对于频繁请求同一非法ID的情况,我们通过以下规则进行熔断:

// Sentinel规则配置
List<FlowRule> rules = new ArrayList<>();
FlowRule rule = new FlowRule();
rule.setResource("orderQuery");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(50); // 单个ID每秒最大请求量
rules.add(rule);
FlowRuleManager.loadRules(rules);

同时配合Nginx的limit_req模块,实现全局速率限制:

http {
    limit_req_zone $arg_orderId zone=order_limit:10m rate=30r/s;
    
    server {
        location /order {
            limit_req zone=order_limit burst=50;
        }
    }
}

四、血的教训:这些坑千万别踩

  1. 布隆过滤器不是万能的
    去年双十一就发生过新订单无法查询的故障,原因是布隆过滤器没有及时更新。现在我们采用异步更新机制,订单创建成功后通过RabbitMQ消息更新过滤器。

  2. 空值缓存的幽灵
    曾出现用户下单后查询不到订单,结果发现是空值缓存未及时清除。现在我们采用双重删除策略:下单成功后立即删除缓存,并通过监听binlog进行二次校验。

  3. 锁的超时陷阱
    早期出现过线程获取锁后因Full GC导致锁超时释放,其他线程拿到锁后又重复查询。现在我们的锁超时时间设置为业务时间的3倍,并增加了重试次数限制。


五、性能对比报告

我们针对三种方案进行了压力测试(8核16G服务器集群):

方案 1000QPS数据库负载 10000QPS响应时间 内存占用
裸奔方案 CPU 95% 3200ms -
布隆过滤器单层防护 CPU 45% 120ms 12MB
混合防护方案 CPU 22% 80ms 58MB

测试数据清晰表明,混合方案虽然增加了30%的内存消耗,但整体性能提升显著。


六、总结与展望

经过半年多的实战检验,我们的防护体系经受住了各种突发流量的考验。但缓存穿透防护永远没有终点,下一步我们计划:

  1. 引入机器学习算法识别异常请求模式
  2. 实现动态布隆过滤器自动扩容
  3. 开发智能热点预测系统

记住,好的防护系统就像洋葱——要有很多层。当单一防护失效时,其他层次仍然能提供保护。别等到数据库崩溃了才想起做防护,现在就动手给你的系统穿上盔甲吧!