1. 当缓存遇见穿透时会发生什么?

最近我们在电商大促监控中发现,每当秒杀活动开启后,PolarDB的CPU使用率会突然飙升到90%以上,但此时Redis缓存命中率却只有20%。经过日志分析发现,这是由于大量请求都在查询"当前用户是否拥有抢购资格"的状态,而实际上很多请求对应的是从未参与活动的无效用户ID。

这就是典型的缓存穿透场景:当一个不存在的数据被高频请求时,缓存层和数据库都会被穿透。没有防护的情况下,每个请求都会直接落到数据库。根据我们的测试,10万次/s的无效请求就能让PolarDB主节点CPU负载增加50%。

2. 布隆过滤器方案实现

示例技术栈:Java + Spring Boot + Guava BloomFilter + Jedis

// 初始化布隆过滤器(预期包含1亿元素,误判率0.1%)
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()), 
    100_000_000, 
    0.001
);

// 预热过滤器(从PolarDB加载有效用户ID)
String sql = "SELECT user_id FROM active_users";
List<String> activeUserIds = jdbcTemplate.query(sql, (rs, rowNum) -> rs.getString("user_id"));
activeUserIds.forEach(userId -> bloomFilter.put(userId));

// 查询拦截逻辑
public boolean checkUserExists(String userId) {
    if (!bloomFilter.mightContain(userId)) {
        return false; // 快速拦截不存在请求
    }
    // 存在则继续后续逻辑...
}

布隆过滤器的内存占用优势非常明显:存储1亿元素仅需约114MB内存,相较于存储完整键值,空间节省率超过90%。不过需要注意误判率的累积效应,当实际元素数量超过预设值时,误判率会成指数级上升。

3. 空值缓存动态补偿机制实现

当布隆过滤器发生误判时(存在性判断错误),需要通过空值缓存作为第二道防线:

public String getProductDetail(String productId) {
    // 优先尝试获取缓存
    String cacheKey = "product:" + productId;
    String value = jedis.get(cacheKey);
    
    if (value != null) {
        return "NONE".equals(value) ? null : value; // 处理空值标记
    }
    
    // 穿透情况下查询数据库
    Product product = productMapper.selectById(productId);
    if (product == null) {
        // 设置短周期的空值缓存(5分钟)
        jedis.setex(cacheKey, 300, "NONE");
        return null;
    }
    
    // 正常缓存数据(1小时)
    jedis.setex(cacheKey, 3600, product.toJson());
    return product;
}

这里存在一个关键的时间平衡:空值缓存的过期时间设置过长会导致数据更新不及时,设置过短会削弱防护效果。我们建议结合业务场景动态调整,例如商品信息设置5-10分钟,用户信息设置为30分钟。

4. 双重防护联合方案

将两种方案组合使用时,完整的查询流程应该包含三级校验:

public Product getProductWithProtection(String productId) {
    // 第一层:内存级布隆过滤器
    if (!bloomFilter.mightContain(productId)) {
        log.warn("BloomFilter阻断非法请求:{}", productId);
        return null;
    }
    
    // 第二层:空值缓存检查
    String cachedData = jedis.get("product:" + productId);
    if ("NONE".equals(cachedData)) {
        return null;
    } else if (cachedData != null) {
        return parseProduct(cachedData);
    }
    
    // 第三层:数据库查询
    Product dbResult = productMapper.selectById(productId);
    if (dbResult == null) {
        // 回填空值缓存
        jedis.setex("product:" + productId, 300, "NONE");
        // 注意此时不应更新布隆过滤器
        return null;
    }
    
    // 数据存在时回填布隆过滤器
    if (!bloomFilter.mightContain(productId)) {
        // 异步更新布隆过滤器
        updateExecutor.execute(() -> bloomFilter.put(productId));
    }
    
    // 设置正常缓存
    jedis.setex("product:" + productId, 3600, dbResult.toJson());
    return dbResult;
}

这里的并发控制需要特别注意:当多个线程同时遇到缓存穿透时,应该使用Redis的SETNX命令防止缓存击穿。我们通过引入分布式锁机制来保证数据一致性:

// 数据库查询前获取锁
String lockKey = "lock:product:" + productId;
if (jedis.setnx(lockKey, "1") == 1) {
    jedis.expire(lockKey, 10); // 设置锁有效期
    try {
        // 二次校验缓存
        String doubleCheck = jedis.get("product:" + productId);
        if (doubleCheck == null) {
            // 执行数据库查询
        }
    } finally {
        jedis.del(lockKey);
    }
} else {
    // 等待锁释放后重试
    Thread.sleep(50);
    return getProductWithProtection(productId); 
}

5. 性能压测对比

使用JMeter对三种场景进行测试(测试环境:PolarDB 8核32G,Redis集群3节点):

场景 QPS P99延迟 DB查询次数/s
无防护 1200 850ms 980
单独布隆过滤器 4500 120ms 45
双重防护 6800 65ms 12

双重防护方案在缓存穿透场景下表现优异,特别是对于突发的大规模无效请求(模拟5万/s的随机UUID查询),数据库负载始终稳定在10%以下。

6. 应用场景分析

6.1 典型适用场景

  • 高并发查询场景:如电商中的商品库存查询、票务系统的座位状态查询
  • 动态数据校验场景:社交平台的用户关系检查(如A是否关注B)
  • 时序性数据场景:物联网设备的状态查询(当设备未激活时)

6.2 特殊场景处理

对于需要频繁变更的列表型数据(如热门话题排行榜),建议采用以下优化方案:

// 布隆过滤器版本管理
AtomicInteger filterVersion = new AtomicInteger(0);

void refreshFilter() {
    BloomFilter<String> newFilter = createNewFilter();
    // 原子切换版本
    int currentVersion = filterVersion.get();
    filterVersion.compareAndSet(currentVersion, currentVersion + 1);
}

boolean checkValid(String key) {
    int versionSnapshot = filterVersion.get();
    return bloomFilterMap.get(versionSnapshot).mightContain(key);
}

这种版本化设计可以实现过滤器热更新,避免在更新期间出现服务中断。

7. 技术优缺点对比

7.1 布隆过滤器优势边界

  • 内存效率:存储1亿元素仅需114MB(0.1%误判率)
  • 查询性能:内存级响应(100ns内完成判断)
  • 但存在假阳性率,且不支持删除操作

7.2 空值缓存的适应范围

  • 准确拦截已确认的无效请求
  • 实现简单,无需预加载数据
  • 但可能引发短期数据不一致

8. 五大实施注意事项

  1. 容量规划:布隆过滤器的预期元素数量建议按实际业务量的2倍预估
  2. 误判监控:在过滤器后增加审核日志,统计实际误判率
  3. 缓存雪崩防护:空值缓存的过期时间应加入随机因子(如300±60秒)
  4. 数据更新策略:数据库写入时需要同步更新布隆过滤器
  5. 监控埋点:重点关注穿透率、布隆过滤器命中率、空值缓存占比

9. 运维最佳实践

生产环境建议采用Redis的RedisBloom模块(原生支持布隆过滤器),对比测试发现其性能比Guava实现提升3倍以上:

# RedisBloom操作示例
BF.RESERVE user_filter 0.001 100000000
BF.ADD user_filter user123
BF.EXISTS user_filter user123

集群环境下需要进行key分片设计,比如按业务前缀进行哈希分片,避免单个过滤器过大导致性能下降。

10. 最终方案总结

经过三个月的生产环境验证,在PolarDB的缓存穿透防护中,双重方案相比单一方案效果显著:

  • 数据库无效查询减少99.8%
  • Redis内存占用仅增加15%(主要来自空值缓存)
  • 99.9%的请求在5ms内完成校验

未来的优化方向包括:基于机器学习的动态参数调整,自适应调整布隆过滤器参数;以及研究新型数据结构如Cuckoo Filter(支持删除操作)的应用可能。